You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
koreader/plugins/evernote.koplugin/main.lua

703 lines
27 KiB
Lua

local BD = require("ui/bidi")
local InputContainer = require("ui/widget/container/inputcontainer")
local LoginDialog = require("ui/widget/logindialog")
local InfoMessage = require("ui/widget/infomessage")
local NetworkMgr = require("ui/network/manager")
local DataStorage = require("datastorage")
local DocSettings = require("docsettings")
local UIManager = require("ui/uimanager")
local ConfirmBox = require("ui/widget/confirmbox")
local Screen = require("device").screen
local util = require("ffi/util")
local Device = require("device")
local DEBUG = require("dbg")
local JoplinClient = require("JoplinClient")
local T = require("ffi/util").template
local _ = require("gettext")
local N_ = _.ngettext
local slt2 = require('slt2')
local MyClipping = require("clip")
local realpath = require("ffi/util").realpath
local EvernoteExporter = InputContainer:new{
name = "evernote",
login_title = _("Login to Evernote"),
notebook_name = _("KOReader Notes"),
evernote_domain = nil,
notemarks = _("Note: "),
clipping_dir = DataStorage:getDataDir() .. "/clipboard",
evernote_token = nil,
notebook_guid = nil,
}
function EvernoteExporter:init()
self.text_clipping_file = self.clipping_dir .. "/KOReaderClipping.txt"
local settings = G_reader_settings:readSetting("evernote") or {}
self.evernote_domain = settings.domain
self.evernote_username = settings.username or ""
self.evernote_token = settings.token
self.notebook_guid = settings.notebook
self.joplin_IP = settings.joplin_IP or "localhost"
self.joplin_port = settings.joplin_port or 41185
self.joplin_token = settings.joplin_token -- or your token
self.joplin_notebook_guid = settings.joplin_notebook_guid or nil
self.html_export = settings.html_export or false
self.joplin_export = settings.joplin_export or false
self.txt_export = settings.txt_export or false
--- @todo Is this if block necessarry? Nowhere in the code they are assigned both true.
-- Do they check against external modifications to settings file?
if self.html_export then
self.txt_export = false
self.joplin_export = false
elseif self.txt_export then
self.joplin_export = false
end
self.parser = MyClipping:new{
my_clippings = "/mnt/us/documents/My Clippings.txt",
history_dir = "./history",
}
self.template = slt2.loadfile(self.path.."/note.tpl")
self:migrateClippings()
self.config = DocSettings:open(util.joinPath(self.clipping_dir, "evernote.sdr"))
self.ui.menu:registerToMainMenu(self)
end
function EvernoteExporter:isDocless()
return self.ui == nil or self.ui.document == nil or self.view == nil
end
function EvernoteExporter:readyToExport()
return self.evernote_token ~= nil or
self.html_export ~= false or
self.txt_export ~= false or
self.joplin_export ~= false
end
function EvernoteExporter:migrateClippings()
local old_dir = util.joinPath(util.realpath(util.joinPath(self.path, "..")),
"evernote.sdr")
if lfs.attributes(old_dir, "mode") == "directory" then
local mv_bin = Device:isAndroid() and "/system/bin/mv" or "/bin/mv"
return util.execute(mv_bin, old_dir, self.clipping_dir) == 0
end
end
function EvernoteExporter:addToMainMenu(menu_items)
menu_items.evernote = {
text = _("Evernote"),
sub_item_table = {
{
text_func = function()
local domain
if self.evernote_domain == "sandbox" then
domain = "Sandbox"
elseif self.evernote_domain == "yinxiang" then
domain = "Yinxiang"
else
domain = "Evernote"
end
return self.evernote_token and (_("Logout") .. " " .. domain)
or _("Login")
end,
callback_func = function()
return self.evernote_token and function() self:logout() end
or nil
end,
sub_item_table_func = function()
return not self.evernote_token and {
{
text = "Evernote",
callback = function()
self.evernote_domain = nil
self:login()
end
},
{
text = "印象笔记",
callback = function()
self.evernote_domain = "yinxiang"
self:login()
end
}
} or nil
end,
},
{
text = _("Joplin") ,
checked_func = function() return self.joplin_export end,
sub_item_table ={
{
text = _("Set Joplin IP and Port"),
keep_menu_open = true,
callback = function()
local MultiInputDialog = require("ui/widget/multiinputdialog")
local url_dialog
url_dialog = MultiInputDialog:new{
title = _("Set Joplin IP and port number"),
fields = {
{
text = self.joplin_IP,
input_type = "string"
},
{
text = self.joplin_port,
input_type = "number"
}
},
buttons = {
{
{
text = _("Cancel"),
callback = function()
UIManager:close(url_dialog)
end
},
{
text = _("OK"),
callback = function()
local fields = url_dialog:getFields()
local ip = fields[1]
local port = tonumber(fields[2])
if ip ~= "" then
if port and port < 65355 then
self.joplin_IP = ip
self.joplin_port = port
end
self:saveSettings()
end
UIManager:close(url_dialog)
end
}
}
}
}
UIManager:show(url_dialog)
url_dialog:onShowKeyboard()
end
},
{
text = _("Set authorization token"),
keep_menu_open = true,
callback = function()
local MultiInputDialog = require("ui/widget/multiinputdialog")
local auth_dialog
auth_dialog = MultiInputDialog:new{
title = _("Set authorization token for Joplin"),
fields = {
{
text = self.joplin_token,
input_type = "string"
}
},
buttons = {
{
{
text = _("Cancel"),
callback = function()
UIManager:close(auth_dialog)
end
},
{
text = _("Set token"),
callback = function()
local auth_field = auth_dialog:getFields()
self.joplin_token = auth_field[1]
self:saveSettings()
UIManager:close(auth_dialog)
end
}
}
}
}
UIManager:show(auth_dialog)
auth_dialog:onShowKeyboard()
end
},
{
text = _("Export to Joplin"),
checked_func = function() return self.joplin_export end,
callback = function()
self.joplin_export = not self.joplin_export
if self.joplin_export then
self.html_export = false
self.txt_export = false
end
self:saveSettings()
end
},
{
text = _("Help"),
keep_menu_open = true,
callback = function()
UIManager:show(InfoMessage:new{
text = T(_([[You can enter your auth token on your computer by saving an empty token. Then quit KOReader, edit the evernote.joplin_token field in %1/settings.reader.lua after creating a backup, and restart KOReader once you're done.
To export to Joplin, you must forward the IP and port used by this plugin to the localhost:port on which Joplin is listening. This can be done with socat or a similar program. For example:
For Windows: netsh interface portproxy add listeningaddress:0.0.0.0 listeningport:41185 connectaddress:localhost connectport:41184
For Linux: $socat tcp-listen:41185,reuseaddr,fork tcp:localhost:41184
For more information, please visit https://github.com/koreader/koreader/wiki/Evernote-export.]])
, BD.dirpath(DataStorage:getDataDir()))
})
end
}
}
},
{
text = _("Export all notes in this book"),
enabled_func = function()
return not self:isDocless() and self:readyToExport() and not self.txt_export
end,
callback = function()
UIManager:scheduleIn(0.5, function()
self:exportCurrentNotes(self.view)
end)
UIManager:show(InfoMessage:new{
text = _("Exporting may take several seconds…"),
timeout = 1,
})
end
},
{
text = _("Export all notes in your library"),
enabled_func = function()
return self:readyToExport()
end,
callback = function()
UIManager:scheduleIn(0.5, function()
self:exportAllNotes()
end)
UIManager:show(InfoMessage:new{
text = _("Exporting may take several minutes…"),
timeout = 1,
})
end
},
{
text = _("Export to local HTML files"),
checked_func = function() return self.html_export end,
callback = function()
self.html_export = not self.html_export
if self.html_export then
self.txt_export = false
self.joplin_export = false
end
self:saveSettings()
end
},
{
text = _("Export to local clipping text file"),
checked_func = function() return self.txt_export end,
callback = function()
self.txt_export = not self.txt_export
if self.txt_export then
self.html_export = false
self.joplin_export = false
end
self:saveSettings()
end
},
{
text = _("Purge history records"),
callback = function()
self.config:purge()
UIManager:show(ConfirmBox:new{
text = _("History records have been purged.\nAll notes will be exported again next time.\nWould you like to remove the existing KOReaderClipping.txt file to avoid duplication?\nRecords will be appended to KOReaderClipping.txt instead of being overwritten."),
ok_text = _("Remove file"),
ok_callback = function()
os.remove(self.text_clipping_file)
end,
cancel_text = _("Keep file"),
})
end
}
}
}
end
function EvernoteExporter:login()
if not NetworkMgr:isOnline() then
NetworkMgr:promptWifiOn()
return
end
self.login_dialog = LoginDialog:new{
title = self.login_title,
username = self.evernote_username or "",
buttons = {
{
{
text = _("Cancel"),
enabled = true,
callback = function()
self:closeDialog()
end,
},
{
text = _("Login"),
enabled = true,
callback = function()
local username, password = self:getCredential()
self:closeDialog()
UIManager:scheduleIn(0.5, function()
self:doLogin(username, password)
end)
UIManager:show(InfoMessage:new{
text = _("Logging in. Please wait…"),
timeout = 1,
})
end,
},
},
},
width = Screen:getWidth() * 0.8,
height = Screen:getHeight() * 0.4,
}
UIManager:show(self.login_dialog)
self.login_dialog:onShowKeyboard()
end
function EvernoteExporter:closeDialog()
self.login_dialog:onClose()
UIManager:close(self.login_dialog)
end
function EvernoteExporter:getCredential()
return self.login_dialog:getCredential()
end
function EvernoteExporter:doLogin(username, password)
local EvernoteOAuth = require("EvernoteOAuth")
local EvernoteClient = require("EvernoteClient")
local oauth = EvernoteOAuth:new{
domain = self.evernote_domain,
username = username,
password = password,
}
self.evernote_username = username
local ok, token = pcall(oauth.getToken, oauth)
-- prompt users to turn on Wifi if network is unreachable
if not ok and token then
UIManager:show(InfoMessage:new{
text = _("An error occurred while logging in:") .. "\n" .. token,
})
return
end
local client = EvernoteClient:new{
domain = self.evernote_domain,
authToken = token,
}
local guid
ok, guid = pcall(self.getExportNotebook, self, client)
if not ok and guid and guid:find("Transport not open") then
NetworkMgr:promptWifiOn()
return
elseif not ok and guid then
UIManager:show(InfoMessage:new{
text = _("An error occurred while logging in:") .. "\n" .. guid,
})
elseif ok and guid then
self.evernote_token = token
self.notebook_guid = guid
UIManager:show(InfoMessage:new{
text = _("Logged in to Evernote."),
})
end
self:saveSettings()
end
function EvernoteExporter:logout()
self.evernote_token = nil
self.notebook_guid = nil
self.evernote_domain = nil
self:saveSettings()
end
function EvernoteExporter:saveSettings()
local settings = {
domain = self.evernote_domain,
username = self.evernote_username,
token = self.evernote_token,
notebook = self.notebook_guid,
html_export = self.html_export,
txt_export = self.txt_export,
joplin_IP = self.joplin_IP,
joplin_port = self.joplin_port,
joplin_token = self.joplin_token,
joplin_notebook_guid = self.joplin_notebook_guid,
joplin_export = self.joplin_export
}
G_reader_settings:saveSetting("evernote", settings)
end
function EvernoteExporter:getExportNotebook(client)
local name = self.notebook_name
return client:findNotebookByTitle(name) or client:createNotebook(name).guid
end
function EvernoteExporter:exportCurrentNotes(view)
local clippings = self.parser:parseCurrentDoc(view)
self:exportClippings(clippings)
end
function EvernoteExporter:updateHistoryClippings(clippings, new_clippings)
-- update clippings from history clippings
for title, booknotes in pairs(new_clippings) do
for chapter_index, chapternotes in ipairs(booknotes) do
for note_index, note in ipairs(chapternotes) do
if clippings[title] == nil or clippings[title][chapter_index] == nil
or clippings[title][chapter_index][note_index] == nil
or clippings[title][chapter_index][note_index].page ~= note.page
or clippings[title][chapter_index][note_index].time ~= note.time
or clippings[title][chapter_index][note_index].text ~= note.text
or clippings[title][chapter_index][note_index].note ~= note.note then
DEBUG("found new notes in history", booknotes.title)
clippings[title] = booknotes
end
end
end
end
return clippings
end
function EvernoteExporter:updateMyClippings(clippings, new_clippings)
-- only new titles or new notes in My clippings are updated to clippings
-- since appending is the only way to modify notes in My Clippings
for title, booknotes in pairs(new_clippings) do
if clippings[title] == nil or #clippings[title] < #booknotes then
DEBUG("found new notes in MyClipping", booknotes.title)
clippings[title] = booknotes
end
end
return clippings
end
function EvernoteExporter:exportAllNotes()
-- Flush highlights of current document.
if not self:isDocless() then
self.ui:saveSettings()
end
local clippings = self.config:readSetting("clippings") or {}
clippings = self:updateHistoryClippings(clippings, self.parser:parseHistory())
clippings = self:updateMyClippings(clippings, self.parser:parseMyClippings())
-- remove blank entries
for title, booknotes in pairs(clippings) do
-- chapter number is zero
if #booknotes == 0 then
clippings[title] = nil
end
end
--DEBUG("clippings", clippings)
self:exportClippings(clippings)
self.config:saveSetting("clippings", clippings)
self.config:flush()
end
function EvernoteExporter:exportClippings(clippings)
local client = nil
local exported_stamp
local joplin_client
if not (self.html_export or self.txt_export or self.joplin_export) then
client = require("EvernoteClient"):new{
domain = self.evernote_domain,
authToken = self.evernote_token,
}
exported_stamp = self.notebook_guid
elseif self.html_export then
exported_stamp= "html"
elseif self.txt_export then
exported_stamp = "txt"
elseif self.joplin_export then
exported_stamp = "joplin"
joplin_client = JoplinClient:new{
server_ip = self.joplin_IP,
server_port = self.joplin_port,
auth_token = self.joplin_token
}
---@todo Check if user deleted our notebook, in that case note
-- will end up in random folder in Joplin.
if not self.joplin_notebook_guid then
self.joplin_notebook_guid = joplin_client:createNotebook(self.notebook_name)
self:saveSettings()
end
else
assert("an exported_stamp is expected for a new export type")
end
local export_count, error_count = 0, 0
local export_title, error_title
for title, booknotes in pairs(clippings) do
if type(booknotes.exported) ~= "table" then
booknotes.exported = {}
end
-- check if booknotes are exported in this notebook
-- so that booknotes will still be exported after switching user account
if booknotes.exported[exported_stamp] ~= true then
local ok, err
if self.html_export then
ok, err = pcall(self.exportBooknotesToHTML, self, title, booknotes)
elseif self.txt_export then
ok, err = pcall(self.exportBooknotesToTXT, self, title, booknotes)
elseif self.joplin_export then
ok, err = pcall(self.exportBooknotesToJoplin, self, joplin_client, title, booknotes)
else
ok, err = pcall(self.exportBooknotesToEvernote, self, client, title, booknotes)
end
-- error reporting
if not ok and err and err:find("Transport not open") then
NetworkMgr:promptWifiOn()
return
elseif not ok and err then
DEBUG("Error occurs when exporting book:", title, err)
error_count = error_count + 1
error_title = title
elseif ok then
DEBUG("Exported notes in book:", title)
export_count = export_count + 1
export_title = title
booknotes.exported[exported_stamp] = true
end
end
end
local msg = "Nothing was exported."
local all_count = export_count + error_count
if export_count > 0 and error_count == 0 then
if all_count == 1 then
msg = T(
N_("Exported notes from the book:\n%1",
"Exported notes from the book:\n%1\nand %2 others.",
all_count-1),
export_title,
all_count-1
)
end
elseif error_count > 0 then
if all_count == 1 then
msg = T(
N_("An error occurred while trying to export notes from the book:\n%1",
"Multiple errors occurred while trying to export notes from the book:\n%1\nand %2 others.",
error_count-1),
error_title,
error_count-1
)
end
end
if (self.html_export or self.txt_export) and export_count > 0 then
msg = msg .. T(_("\nNotes can be found in %1/."), BD.dirpath(realpath(self.clipping_dir)))
end
UIManager:show(InfoMessage:new{ text = msg })
end
function EvernoteExporter:exportBooknotesToEvernote(client, title, booknotes)
local content = slt2.render(self.template, {
booknotes = booknotes,
notemarks = self.notemarks,
})
--DEBUG("content", content)
local note_guid = client:findNoteByTitle(title, self.notebook_guid)
local resources = {}
for _, chapter in ipairs(booknotes) do
for _, clipping in ipairs(chapter) do
if clipping.image then
table.insert(resources, {
image = clipping.image
})
-- nullify clipping image after passing it to evernote client
clipping.image = nil
end
end
end
if not note_guid then
client:createNote(title, content, resources, {}, self.notebook_guid)
else
client:updateNote(note_guid, title, content, resources, {}, self.notebook_guid)
end
end
function EvernoteExporter:exportBooknotesToHTML(title, booknotes)
local content = slt2.render(self.template, {
booknotes = booknotes,
notemarks = self.notemarks,
})
--DEBUG("content", content)
local html = io.open(self.clipping_dir .. "/" .. title .. ".html", "w")
if html then
html:write(content)
html:close()
end
end
function EvernoteExporter:exportBooknotesToTXT(title, booknotes)
-- Use wide_space to avoid crengine to treat it specially.
local wide_space = "\227\128\128"
local file_modification = lfs.attributes(self.text_clipping_file, "modification") or 0
local file = io.open(self.text_clipping_file, "a")
if file then
file:write(title .. "\n" .. wide_space .. "\n")
for _ignore1, chapter in ipairs(booknotes) do
if chapter.title then
file:write(wide_space .. chapter.title .. "\n" .. wide_space .. "\n")
end
for _ignore2, clipping in ipairs(chapter) do
-- If this clipping has already been exported, we ignore it.
if clipping.time >= file_modification then
file:write(wide_space .. wide_space ..
T(_("-- Page: %1, added on %2\n"),
clipping.page, os.date("%c", clipping.time)))
if clipping.text then
file:write(clipping.text)
end
if clipping.image then
file:write(_("<An image>"))
end
file:write("\n-=-=-=-=-=-\n")
end
end
end
file:write("\n")
file:close()
end
end
function EvernoteExporter:exportBooknotesToJoplin(client, title, booknotes)
if not client:ping() then
error("Cannot reach Joplin server")
end
local note_guid = client:findNoteByTitle(title, self.joplin_notebook_guid)
local note = ""
for _, chapter in ipairs(booknotes) do
if chapter.title then
note = note .. "\n\t*" .. chapter.title .. "*\n\n * * *"
end
for _, clipping in ipairs(chapter) do
note = note .. os.date("%Y-%m-%d %H:%M:%S \n", clipping.time)
note = note .. clipping.text .. "\n * * *\n"
end
end
if note_guid then
client:updateNote(note_guid, note)
else
client:createNote(title, note, self.joplin_notebook_guid)
end
end
return EvernoteExporter