Files
koreader-mirror/plugins/wallabag.koplugin/main.lua
2025-09-03 07:26:19 +02:00

1766 lines
74 KiB
Lua

--[[--
This plugin downloads a set number of the newest arcticles in your Wallabag "Unread" list. As epubs,
or in their original formats. It can archive or delete articles from Wallabag when you finish them
in KOReader. And it will delete or archive them locally when you finish them elsewhere.
@todo Integrate comments from https://github.com/koreader/koreader/pull/12949
@todo Translate the new menu labels? See https://github.com/koreader/koreader-translations
@todo Make sure all menu labels and message texts are wrapped in _() for translation
@todo An option to parse comma-separated reviews as tags, full text as review?
@module koplugin.wallabag
]]
local BD = require("ui/bidi")
local DataStorage = require("datastorage")
local Dispatcher = require("dispatcher")
local DocSettings = require("docsettings")
local DocumentRegistry = require("document/documentregistry")
local Event = require("ui/event")
local FFIUtil = require("ffi/util")
local FileManager = require("apps/filemanager/filemanager")
local InfoMessage = require("ui/widget/infomessage")
local InputDialog = require("ui/widget/inputdialog")
local JSON = require("json")
local LuaSettings = require("luasettings")
local Math = require("optmath")
local MultiConfirmBox = require("ui/widget/multiconfirmbox")
local MultiInputDialog = require("ui/widget/multiinputdialog")
local NetworkMgr = require("ui/network/manager")
local ReadHistory = require("readhistory")
local UIManager = require("ui/uimanager")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local filemanagerutil = require("apps/filemanager/filemanagerutil")
local http = require("socket.http")
local lfs = require("libs/libkoreader-lfs")
local logger = require("logger")
local ltn12 = require("ltn12")
local socket = require("socket")
local socketutil = require("socketutil")
local util = require("util")
local _ = require("gettext")
local N_ = _.ngettext
local T = FFIUtil.template
-- constants
local article_id_prefix = "[w-id_"
local article_id_postfix = "] "
local failed, skipped, downloaded = 1, 2, 3
local Wallabag = WidgetContainer:extend{
name = "wallabag",
}
function Wallabag:onDispatcherRegisterActions()
Dispatcher:registerAction("wallabag_download", {
category = "none",
event = "SynchronizeWallabag",
title = _("Wallabag retrieval"),
general = true,
})
Dispatcher:registerAction("wallabag_queue_upload", {
category = "none",
event = "UploadWallabagQueue",
title = _("Wallabag queue upload"),
general = true,
})
Dispatcher:registerAction("wallabag_status_upload", {
category = "none",
event = "UploadWallabagStatuses",
title = _("Wallabag statuses upload"),
general = true,
})
end
function Wallabag:init()
self.token_expiry = 0
self:onDispatcherRegisterActions()
self.ui.menu:registerToMainMenu(self)
self.wb_settings = self:readSettings()
-- These settings do not have defaults and need to be set by the user
self.server_url = self.wb_settings.data.wallabag.server_url
self.client_id = self.wb_settings.data.wallabag.client_id
self.client_secret = self.wb_settings.data.wallabag.client_secret
self.username = self.wb_settings.data.wallabag.username
self.password = self.wb_settings.data.wallabag.password
self.directory = self.wb_settings.data.wallabag.directory
-- These settings do have defaults
self.filter_tag = self.wb_settings.data.wallabag.filter_tag or ""
self.filter_starred = self.wb_settings.data.wallabag.filter_starred or false
self.ignore_tags = self.wb_settings.data.wallabag.ignore_tags or ""
self.auto_tags = self.wb_settings.data.wallabag.auto_tags or ""
if self.wb_settings.data.wallabag.archive_finished == nil then
self.archive_finished = true
else
self.archive_finished = self.wb_settings.data.wallabag.archive_finished
end
self.archive_read = self.wb_settings.data.wallabag.archive_read or false
self.archive_abandoned = self.wb_settings.data.wallabag.archive_abandoned or false
self.delete_instead = self.wb_settings.data.wallabag.delete_instead or false
self.auto_archive = self.wb_settings.data.wallabag.auto_archive or false
self.sync_remote_archive = self.wb_settings.data.wallabag.sync_remote_archive or false
self.articles_per_sync = self.wb_settings.data.wallabag.articles_per_sync or 30
self.send_review_as_tags = self.wb_settings.data.wallabag.send_review_as_tags or false
self.remove_finished_from_history = self.wb_settings.data.wallabag.remove_finished_from_history or false
self.remove_read_from_history = self.wb_settings.data.wallabag.remove_read_from_history or false
self.remove_abandoned_from_history = self.wb_settings.data.wallabag.remove_abandoned_from_history or false
self.download_original_document = self.wb_settings.data.wallabag.download_original_document or false
self.offline_queue = self.wb_settings.data.wallabag.offline_queue or {}
self.use_local_archive = self.wb_settings.data.wallabag.use_local_archive or false
self.file_block_timeout = self.wb_settings.data.wallabag.file_block_timeout or socketutil.FILE_BLOCK_TIMEOUT
self.file_total_timeout = self.wb_settings.data.wallabag.file_total_timeout or socketutil.FILE_TOTAL_TIMEOUT
self.large_block_timeout = self.wb_settings.data.wallabag.large_block_timeout or socketutil.LARGE_BLOCK_TIMEOUT
self.large_total_timeout = self.wb_settings.data.wallabag.large_total_timeout or socketutil.LARGE_TOTAL_TIMEOUT
-- archive_directory only has a default if directory is set
self.archive_directory = self.wb_settings.data.wallabag.archive_directory
if not self.archive_directory or self.archive_directory == "" then
if self.directory and self.directory ~= "" then
self.archive_directory = FFIUtil.joinPath(self.directory, "archive")
end
end
-- workaround for dateparser only available if newsdownloader is active
self.is_dateparser_available = false
self.is_dateparser_checked = false
-- workaround for dateparser, only once
-- the parser is in newsdownloader.koplugin, check if it is available
if not self.is_dateparser_checked then
local res
res, self.dateparser = pcall(require, "lib.dateparser")
if res then self.is_dateparser_available = true end
self.is_dateparser_checked = true
end
if self.ui and self.ui.link then
self.ui.link:addToExternalLinkDialog("25_wallabag", function(this, link_url)
return {
text = _("Add to Wallabag"),
callback = function()
UIManager:close(this.external_link_dialog)
this.ui:handleEvent(Event:new("AddWallabagArticle", link_url))
end,
}
end)
end
end
--- Add Wallabag to the Tools menu in both the file manager and the reader.
function Wallabag:addToMainMenu(menu_items)
menu_items.wallabag = {
text = _("Wallabag"),
sub_item_table = {
{
text_func = function()
if self.auto_archive then
return _("Synchronize articles with server")
else
return _("Download new articles from server")
end
end,
callback = function()
self.ui:handleEvent(Event:new("SynchronizeWallabag"))
end,
},
{
text = _("Upload queue of locally added articles to server"),
callback = function()
self.ui:handleEvent(Event:new("UploadWallabagQueue"))
end,
enabled_func = function()
return #self.offline_queue > 0
end,
},
{
text = _("Upload article statuses to server"),
callback = function()
self.ui:handleEvent(Event:new("UploadWallabagStatuses"))
end,
enabled_func = function()
return self.archive_finished or self.archive_read or self.archive_abandoned
end,
},
{
text = _("Go to download folder"),
callback = function()
self.ui:handleEvent(Event:new("GoToWallabagDirectory"))
end,
},
{
text = _("Settings"),
callback_func = function()
return nil
end,
separator = true,
sub_item_table = {
{
text = _("Configure Wallabag server"),
keep_menu_open = true,
callback = function()
self:editServerSettings()
end,
},
{
text = _("Download settings"),
sub_item_table = {
{
text_func = function()
local path
if not self.directory or self.directory == "" then
path = _("not set")
else
path = filemanagerutil.abbreviate(self.directory)
end
return T(_("Download folder: %1"), BD.dirpath(path))
end,
keep_menu_open = true,
callback = function(touchmenu_instance)
self:setDownloadDirectory(touchmenu_instance)
end,
},
{
text_func = function()
return T(_("Number of articles to keep locally: %1"), self.articles_per_sync)
end,
keep_menu_open = true,
callback = function(touchmenu_instance)
self:setArticlesPerSync(touchmenu_instance)
end,
},
{
text_func = function()
return T(_("Only download articles with tag: %1"), self.filter_tag or "")
end,
keep_menu_open = true,
callback = function(touchmenu_instance)
self:setTagsDialog(
touchmenu_instance,
_("Tag to include"),
_("Enter a single tag to filter articles on"),
self.filter_tag,
function(tag)
self.filter_tag = tag
end
)
end,
},
{
text_func = function()
return T(_("Do not download articles with tags: %1"), self.ignore_tags or "")
end,
keep_menu_open = true,
callback = function(touchmenu_instance)
self:setTagsDialog(
touchmenu_instance,
_("Tags to ignore"),
_("Enter a comma-separated list of tags to ignore"),
self.ignore_tags,
function(tags)
self.ignore_tags = tags
end
)
end,
},
{
text = _("Only download starred articles"),
keep_menu_open = true,
checked_func = function()
return self.filter_starred or false
end,
callback = function()
self.filter_starred = not self.filter_starred
self:saveSettings()
end,
},
{
text = _("Prefer original non-HTML document"),
keep_menu_open = true,
checked_func = function()
return self.download_original_document or false
end,
callback = function()
self.download_original_document = not self.download_original_document
self:saveSettings()
end,
},
},
},
{
text = _("Remote mark-as-read settings"),
sub_item_table = {
{
text = _("Mark finished articles as read"),
checked_func = function() return self.archive_finished end,
callback = function()
self.archive_finished = not self.archive_finished
self:saveSettings()
end,
},
{
text = _("Mark 100% read articles as read"),
checked_func = function() return self.archive_read end,
callback = function()
self.archive_read = not self.archive_read
self:saveSettings()
end,
},
{
text = _("Mark articles on hold as read"),
checked_func = function() return self.archive_abandoned end,
callback = function()
self.archive_abandoned = not self.archive_abandoned
self:saveSettings()
end,
separator = true,
},
{
text = _("Auto-upload article statuses when downloading"),
checked_func = function() return self.auto_archive end,
callback = function()
self.auto_archive = not self.auto_archive
self:saveSettings()
end,
},
{
text = _("Delete instead of marking as read"),
checked_func = function() return self.delete_instead end,
callback = function()
self.delete_instead = not self.delete_instead
self:saveSettings()
end,
},
},
},
{
text = _("Local file removal settings"),
sub_item_table = {
{
text = _("Delete remotely archived and deleted articles locally"),
checked_func = function() return self.sync_remote_archive end,
callback = function()
self.sync_remote_archive = not self.sync_remote_archive
self:saveSettings()
end,
},
{
text = _("Move to archive folder instead of deleting"),
checked_func = function() return self.use_local_archive end,
callback = function()
self.use_local_archive = not self.use_local_archive
self:saveSettings()
end,
},
{
text_func = function()
local path
if not self.archive_directory or self.archive_directory == "" then
path = _("not set")
else
path = filemanagerutil.abbreviate(self.archive_directory)
end
return T(_("Archive folder: %1"), BD.dirpath(path))
end,
keep_menu_open = true,
callback = function(touchmenu_instance)
self:setArchiveDirectory(touchmenu_instance)
end,
enabled_func = function()
return self.use_local_archive
end,
},
},
},
{
text = _("Network timeout settings"),
sub_item_table = {
{
text_func = function()
return T(_("Article download connection timeout: %1 s"), self.file_block_timeout)
end,
keep_menu_open = true,
callback = function(touchmenu_instance)
self:setTimeoutValue(
touchmenu_instance,
_("Article download connection timeout (seconds)"),
self.file_block_timeout,
function(value) self.file_block_timeout = value end
)
end,
},
{
text_func = function()
return T(_("Article download total timeout: %1 s"), self.file_total_timeout)
end,
keep_menu_open = true,
callback = function(touchmenu_instance)
self:setTimeoutValue(
touchmenu_instance,
_("Article download total timeout (seconds)"),
self.file_total_timeout,
function(value) self.file_total_timeout = value end
)
end,
},
{
text_func = function()
return T(_("API request connection timeout: %1 s"), self.large_block_timeout)
end,
keep_menu_open = true,
callback = function(touchmenu_instance)
self:setTimeoutValue(
touchmenu_instance,
_("API request connection timeout (seconds)"),
self.large_block_timeout,
function(value) self.large_block_timeout = value end
)
end,
},
{
text_func = function()
return T(_("API request total timeout: %1 s"), self.large_total_timeout)
end,
keep_menu_open = true,
callback = function(touchmenu_instance)
self:setTimeoutValue(
touchmenu_instance,
_("API request total timeout (seconds)"),
self.large_total_timeout,
function(value) self.large_total_timeout = value end
)
end,
},
}
},
{
text = _("History settings"),
sub_item_table = {
{
text = _("Remove finished articles from history"),
keep_menu_open = true,
checked_func = function()
return self.remove_finished_from_history or false
end,
callback = function()
self.remove_finished_from_history = not self.remove_finished_from_history
self:saveSettings()
end,
},
{
text = _("Remove 100% read articles from history"),
keep_menu_open = true,
checked_func = function()
return self.remove_read_from_history or false
end,
callback = function()
self.remove_read_from_history = not self.remove_read_from_history
self:saveSettings()
end,
},
{
text = _("Remove articles on hold from history"),
keep_menu_open = true,
checked_func = function()
return self.remove_abandoned_from_history or false
end,
callback = function()
self.remove_abandoned_from_history = not self.remove_abandoned_from_history
self:saveSettings()
end,
separator = true,
},
},
separator = true,
},
{
text_func = function()
return T(_("Tags to add to new articles: %1"), self.auto_tags or "")
end,
keep_menu_open = true,
callback = function(touchmenu_instance)
self:setTagsDialog(
touchmenu_instance,
_("Tags to add to new articles"),
_("Enter a comma-separated list of tags to add when submitting a new article to Wallabag."),
self.auto_tags,
function(tags)
self.auto_tags = tags
end
)
end,
},
{
text = _("Send review as tags"),
help_text = _("This allows you to write tags in the review field, separated by commas, which will be applied to the article on Wallabag."),
keep_menu_open = true,
checked_func = function()
return self.send_review_as_tags or false
end,
callback = function()
self.send_review_as_tags = not self.send_review_as_tags
self:saveSettings()
end,
separator = true,
},
{
text = _("Help"),
keep_menu_open = true,
callback = function()
UIManager:show(InfoMessage:new{
text = _([[Download folder: use a folder that is exclusively used by the Wallabag plugin. Existing files in this folder risk being deleted.
Articles marked as finished, on hold or 100% read can be marked as read (or deleted) on the server. This is done automatically when retrieving new articles with the 'Auto-upload article statuses when downloading' setting.
The 'Delete remotely archived and deleted articles locally' option will allow deletion of local files that are archived or deleted on the server.]])
})
end,
}
}
},
{
text = _("Info"),
keep_menu_open = true,
callback = function()
UIManager:show(InfoMessage:new{
text = T(_([[Wallabag is an open source read-it-later service. This plugin synchronizes with a Wallabag server.
More details: https://wallabag.org
Downloads to folder: %1]]), BD.dirpath(filemanagerutil.abbreviate(self.directory)))
})
end,
},
},
}
end
--- Validate server settings and request an OAuth bearer token.
-- Do not request a new token if the saved one is valid for more than 5 minutes.
function Wallabag:getBearerToken()
local function isEmpty(s)
return s == nil or s == ""
end
-- check if the configuration is complete
local server_empty = isEmpty(self.server_url) or isEmpty(self.username) or isEmpty(self.password) or isEmpty(self.client_id) or isEmpty(self.client_secret)
local directory_empty = isEmpty(self.directory)
if server_empty or directory_empty then
logger.warn("Wallabag:getBearerToken: showing dialog because server_empty =", server_empty, "or directory_empty =", directory_empty)
UIManager:show(MultiConfirmBox:new{
text = _("Please configure the server settings and set a download folder."),
choice1_text_func = function()
if server_empty then
return _("Server (★)")
else
return _("Server")
end
end,
choice1_callback = function() self:editServerSettings() end,
choice2_text_func = function()
if directory_empty then
return _("Folder (★)")
else
return _("Folder")
end
end,
choice2_callback = function() self:setDownloadDirectory() end,
})
return false
end
-- Check if the download directory is valid
local dir_mode = lfs.attributes(self.directory, "mode")
if dir_mode ~= "directory" then
logger.err("Wallabag:getBearerToken:", self.directory, "is not a directory")
UIManager:show(InfoMessage:new{
text = _("The download folder is not valid.\nPlease configure it in the settings.")
})
return false
end
-- Check if token is valid for at least 5 minutes. If so, no need to renew
local now = os.time()
if self.token_expiry - now > 300 then
logger.dbg("Wallabag:getBearerToken: token valid for another", self.token_expiry - now, "s")
return true
end
-- Construct and make API call
local login_url = "/oauth/v2/token"
local body = {
grant_type = "password",
client_id = self.client_id,
client_secret = self.client_secret,
username = self.username,
password = self.password,
}
local body_json = JSON.encode(body)
local headers = {
["Content-type"] = "application/json",
["Accept"] = "application/json, */*",
["Content-Length"] = tostring(#body_json),
}
logger.dbg("Wallabag:getBearerToken: making API call")
local ok, result = self:callAPI("POST", login_url, headers, body_json)
if ok then
self.access_token = result.access_token
self.token_expiry = now + result.expires_in
logger.dbg("Wallabag:getBearerToken: new access token is valid for another", result.expires_in, "s")
return true
else
logger.err("Wallabag:getBearerToken: could not login to Wallabag server")
UIManager:show(InfoMessage:new{ text = _("Could not login to Wallabag server.") })
return false
end
end
--- Get a JSON formatted list of articles from the server.
-- The list should have self.article_per_sync item, or less if an error occurred.
-- If filter_tag is set, only articles containing this tag are queried.
-- If ignore_tags is defined, articles containing any of the tags are skipped.
-- @treturn table List of article tables
function Wallabag:getArticleList()
local filtering = ""
if self.filter_tag ~= "" then
filtering = "&tags=" .. self.filter_tag
end
if self.filter_starred then
filtering = filtering .. "&starred=1"
end
local article_list = {}
local page = 1
-- query the server for articles until we hit our target number
while #article_list < self.articles_per_sync do
-- get the JSON containing the article list
local articles_url = "/api/entries.json?archive=0"
.. "&page=" .. page
.. "&perPage=" .. self.articles_per_sync
.. filtering
local ok, result, code = self:callAPI("GET", articles_url, nil, nil, nil, true)
if not ok and result == "http_error" and code == 404 then
logger.dbg("Wallabag:getArticleList: requesting page", page, "failed with", result, code)
if #article_list == 0 then
UIManager:show(InfoMessage:new{ text = _("Requesting article list failed with a 404 error.") })
return
end
-- Assume we have gone past the last page, do return articles from previous pages
break
elseif not ok then
-- Some other error has occurred. Don't proceed with downloading or deleting articles
logger.warn("Wallabag:getArticleList: requesting page", page, "failed with", result, code)
UIManager:show(InfoMessage:new{ text = _("Requesting article list failed.") })
return
elseif result == nil or result._embedded == nil or result._embedded.items == nil or #result._embedded.items == 0 then
-- No error occurred, but no items were returned either
logger.warn("Wallabag:getArticleList: requesting page", page, "did not return anything")
if #article_list == 0 then
UIManager:show(InfoMessage:new{ text = _("Requesting article list did not return anything.") })
return
end
-- Articles were returned from a previous page, do return those
break
end
-- We're only interested in the actual articles in the JSON
-- build an array of those so it's easier to manipulate later
local page_article_list = {}
for _, article in ipairs(result._embedded.items) do
table.insert(page_article_list, article)
end
-- Remove articles that have any of the tags in self.ignore_tags
page_article_list = self:filterIgnoredTags(page_article_list)
-- Append this page's filtered list to the final article_list
for _, article in ipairs(page_article_list) do
table.insert(article_list, article)
if #article_list == self.articles_per_sync then
logger.dbg("Wallabag:getArticleList: #article_list == self.articles_per_sync ==", self.articles_per_sync)
break
end
end
if result.pages ~= nil and page < result.pages then
page = page + 1
else
logger.dbg("Wallabag:getArticleList: reached the last page")
break
end
end
return article_list
end
--- Remove all the articles from the list containing one of the ignored tags.
-- @tparam table article_list Array containing a JSON formatted list of articles
-- @treturn table Same array, but without any articles that contain an ignored tag.
function Wallabag:filterIgnoredTags(article_list)
-- decode all tags to ignore
local ignoring = {}
if self.ignore_tags ~= "" then
for tag in util.gsplit(self.ignore_tags, "[,]+", false) do
ignoring[tag] = true
end
end
-- rebuild a list without the ignored articles
local filtered_list = {}
for _, article in ipairs(article_list) do
local skip_article = false
for _, tag in ipairs(article.tags) do
if ignoring[tag.label] then
skip_article = true
logger.dbg("Wallabag:filterIgnoredTags: skipping", article.id, article.title, "because it is tagged", tag.label)
break -- no need to look for other tags
end
end
if not skip_article then
table.insert(filtered_list, article)
end
end
return filtered_list
end
--- Download a single article from the Wallabag server given by id in the article table.
-- @tparam table A list of article tables, see https://doc.wallabag.org/developer/api/methods/#getting-existing-entries
-- @treturn int 1 failed, 2 skipped, 3 downloaded
function Wallabag:downloadArticle(article)
local skip_article = false
logger.dbg("Wallabag:downloadArticle: article.title =", article.title)
local title = util.getSafeFilename(article.title, self.directory, 230, 0)
logger.dbg("Wallabag:downloadArticle: local title =", title)
local file_ext = ".epub"
local item_url = "/api/entries/" .. article.id .. "/export.epub"
-- The mimetype is actually an HTTP Content-Type, so it can include a semicolon with stuff after it.
-- Just in case we also trim it, though that shouldn't be necessary.
-- A function represents `null` in our JSON.decode, because `nil` would just disappear.
-- We can simplify that to 'not a string'.
local mimetype = type(article.mimetype) == "string" and util.trim(article.mimetype:match("^[^;]*")) or nil
if self.download_original_document then
logger.dbg("Wallabag:downloadArticle: local mimetype =", mimetype)
logger.dbg("Wallabag:downloadArticle: article.url =", article.url)
if mimetype == "text/html" then
logger.dbg("Wallabag:downloadArticle: not ignoring EPUB, because", article.url, "is HTML")
elseif mimetype == nil then -- base ourselves on the file extension
logger.dbg("Wallabag:downloadArticle: mimetype = nil, using article.url instead")
if util.getFileNameSuffix(article.url):lower():find("^html?$") then
logger.dbg("Wallabag:downloadArticle: not ignoring EPUB, because", article.url, "appears to be HTML")
elseif DocumentRegistry:hasProvider(article.url) then
logger.dbg("Wallabag:downloadArticle: ignoring EPUB in favor of original", article.url)
file_ext = "." .. util.getFileNameSuffix(article.url)
item_url = article.url
-- If an article does not have a title in its metadata (e.g. txt files),
-- its filename (including extension) is used instead. This would cause it to be
-- saved with a duplicate extension. So we remove the extension from the title
title = util.trim(title:gsub("%" .. file_ext .. "$", ""))
else
logger.dbg("Wallabag:downloadArticle: not ignoring EPUB, because there is no provider for", article.url)
end
elseif DocumentRegistry:hasProvider(nil, mimetype) then
logger.dbg("Wallabag:downloadArticle: ignoring EPUB in favor of mimetype", mimetype)
file_ext = "." .. DocumentRegistry:mimeToExt(mimetype)
item_url = article.url
else
logger.dbg("Wallabag:downloadArticle: not ignoring EPUB, because there is no provider for", mimetype)
end
end
local local_path = FFIUtil.joinPath(self.directory, article_id_prefix..article.id..article_id_postfix..title..file_ext)
logger.dbg("Wallabag:downloadArticle: downloading", article.id, "to", local_path)
local attr = lfs.attributes(local_path)
if attr then
-- File already exists, skip it. Preferably only skip if the date of local file is newer than server's.
-- newsdownloader.koplugin has a date parser but it is available only if the plugin is activated.
--- @todo find a better solution
if self.is_dateparser_available then
local server_date = self.dateparser.parse(article.updated_at)
if server_date < attr.modification then
skip_article = true
logger.dbg("Wallabag:downloadArticle: skipping download because local copy at", local_path, "is newer")
end
else
skip_article = true
logger.dbg("Wallabag:downloadArticle: skipping download because local copy exists at", local_path)
end
end
if skip_article == false then
if self:callAPI("GET", item_url, nil, nil, local_path) then
return downloaded -- = 3
else
return failed -- = 1
end
end
return skipped -- = 2
end
--- Call the Wallabag API.
-- See https://app.wallabag.it/api/doc/ for methods and parameters.
-- @param method GET, POST, DELETE, PATCH, etc…
-- @param url URL endpoint on Wallabag server without hostname, or full URL for external link
-- @param[opt] headers Defaults to Authorization for API endpoints, none for external
-- @param[opt] body Body to include in the request, if needed
-- @param[opt] filepath Downloads the file if provided, returns JSON otherwise
-- @param[opt=false] quiet
-- @treturn bool Whether the request was successful
-- @treturn string Error type if unsuccessful, filepath if success with path, JSON if without
-- @treturn int HTTP response code if unsuccessful (e.g. 404, 503, …)
function Wallabag:callAPI(method, url, headers, body, filepath, quiet)
quiet = quiet or false
local sink = {}
local request = {
method = method
}
-- Is it an API call, or a regular file direct download?
--- @todo Separate call to internal API from the download on external server
if url:sub(1, 1) == "/" then
-- API call to our server, has the form "/random/api/call"
request.url = self.server_url .. url
request.headers = headers or {
["Authorization"] = "Bearer " .. self.access_token,
}
else -- Assume full URL (e.g. https://…)
request.url = url
request.headers = headers or {}
end
if filepath ~= nil then
request.sink = ltn12.sink.file(io.open(filepath, "w"))
socketutil:set_timeout(self.file_block_timeout, self.file_total_timeout)
else
request.sink = ltn12.sink.table(sink)
socketutil:set_timeout(self.large_block_timeout, self.large_total_timeout)
end
if body ~= nil then
request.source = ltn12.source.string(body)
end
logger.dbg("Wallabag:callAPI:", request.method, request.url)
local code, resp_headers, status = socket.skip(1, http.request(request))
socketutil:reset_timeout()
-- Raise error if network is unavailable
if resp_headers == nil then
if filepath then
self:removeFailedDownload(filepath)
end
logger.err("Wallabag:callAPI: network error", status or code)
return false, "network_error"
end
-- If the request returned successfully
if code == 200 then
if filepath then
logger.dbg("Wallabag:callAPI: file downloaded to", filepath)
return true, filepath
else
local content = table.concat(sink)
-- If any JSON was downloaded
if content ~= "" and string.sub(content, 1, 1) == "{" then
local ok, result = pcall(JSON.decode, content)
-- If the downloaded JSON could be parsed
if ok and result then
logger.dbg("Wallabag:callAPI: JSON downloaded")
-- Only enable this log when needed, the output can be large
-- logger.dbg("Wallabag:callAPI: result =", result)
return true, result
else
logger.err("Wallabag:callAPI: response was no valid JSON", content)
UIManager:show(InfoMessage:new{ text = _("Server response is not valid.") })
end
else
logger.err("Wallabag:callAPI: response was no JSON", content)
UIManager:show(InfoMessage:new{ text = _("Server response is not valid.") })
end
return false, "json_error"
end
else
if filepath then
self:removeFailedDownload(filepath)
elseif not quiet then
UIManager:show(InfoMessage:new{ text = _("Communication with server failed.") })
end
logger.err("Wallabag:callAPI: HTTP error", status or code, resp_headers)
return false, "http_error", code
end
end
function Wallabag:removeFailedDownload(filepath)
if filepath then
local entry_mode = lfs.attributes(filepath, "mode")
if entry_mode == "file" then
os.remove(filepath)
logger.dbg("Wallabag:removeFailedDownload: removed", filepath)
end
end
end
--- Add articles from local queue to Wallabag, then download new articles.
-- If self.auto_archive is true, then local article statuses are uploaded before downloading.
-- @treturn bool Whether the synchronization process reached the end (with or without errors)
function Wallabag:downloadArticles()
local info = InfoMessage:new{ text = _("Connecting to Wallabag server…") }
UIManager:show(info)
local del_count_remote = 0
local del_count_local = 0
-- Update bearer token if needed
if not self:getBearerToken() or self.access_token == "" then
UIManager:close(info)
return false
end
UIManager:close(info)
-- Add articles from queue to remote
local queue_count = self:uploadQueue()
-- Upload local article statuses to remote
if self.auto_archive == true then
logger.dbg("Wallabag:downloadArticles: uploading statuses automatically")
del_count_remote, del_count_local = self:uploadStatuses()
else
logger.dbg("Wallabag:downloadArticles: skipping status upload")
end
local remote_article_ids = {}
local download_count = 0
local fail_count = 0
local skip_count = 0
-- Get a list of articles to download
info = InfoMessage:new{ text = _("Getting list of newest articles on Wallabag…") }
UIManager:show(info)
UIManager:forceRePaint()
local articles = self:getArticleList()
UIManager:close(info)
if articles then
logger.dbg("Wallabag:downloadArticles: got a list of", #articles, "articles")
info = InfoMessage:new{
text = T(N_("Received a list of 1 article.", "Received a list of %1 articles.", #articles), #articles),
timeout = 3
}
UIManager:show(info)
UIManager:forceRePaint()
for i, article in ipairs(articles) do
logger.dbg("Wallabag:downloadArticles: downloading", article.id)
remote_article_ids[ tostring(article.id) ] = true
local res = self:downloadArticle(article)
if res == downloaded then
logger.dbg("Wallabag:downloadArticles: downloading", article.id, "succeeded")
download_count = download_count + 1
info = InfoMessage:new{
text = T(
_("Downloaded article %1 of %2…"),
download_count,
#articles
),
timeout = 3
}
UIManager:show(info)
UIManager:forceRePaint()
elseif res == failed then
logger.err("Wallabag:downloadArticles: downloading", article.id, "failed")
fail_count = fail_count + 1
else -- res == skipped
logger.err("Wallabag:downloadArticles: downloading", article.id, "skipped")
skip_count = skip_count + 1
end
end
-- Synchronize remote deletions to local
if self.sync_remote_archive then
logger.dbg("Wallabag:downloadArticles: processing remote deletes…")
del_count_local = del_count_local + self:processRemoteDeletes(remote_article_ids)
else
logger.dbg("Wallabag:downloadArticles: processing remote deletes skipped")
end
logger.info("Wallabag:downloadArticles: sync finished")
local msg = _("Sync finished:")
logger.info("Wallabag:downloadArticles: - queue_count =", queue_count)
if queue_count > 0 then
msg = msg.."\n"..T(_("- added from queue: %1"), queue_count)
end
logger.info("Wallabag:downloadArticles: - download_count =", download_count)
logger.dbg("Wallabag:downloadArticles: - skip_count =", skip_count)
msg = msg.."\n"..T(_("- downloaded: %1\n- skipped: %2"), download_count, skip_count)
logger.info("Wallabag:downloadArticles: - fail_count =", fail_count)
if fail_count > 0 then
msg = msg.."\n"..T(_("- failed: %1"), fail_count)
end
logger.info("Wallabag:downloadArticles: - del_count_local =", del_count_local)
if del_count_local > 0 then
if self.use_local_archive then
msg = msg.."\n"..T(_("- archived in KOReader: %1"), del_count_local)
else
msg = msg.."\n"..T(_("- deleted from KOReader: %1"), del_count_local)
end
end
logger.info("Wallabag:downloadArticles: - del_count_remote =", del_count_remote)
if del_count_remote > 0 then
if self.delete_instead then
msg = msg.."\n"..T(_("- deleted from Wallabag: %1"), del_count_remote)
else
msg = msg.."\n"..T(_("- archived in Wallabag: %1"), del_count_remote)
end
end
UIManager:close(info)
info = InfoMessage:new{ text = msg }
UIManager:show(info)
UIManager:forceRePaint()
end -- articles
return true
end
--- Upload any articles that were added to the queue.
-- In case there was no network connection to upload them at the time.
-- @tparam[opt=true] bool quiet Whether to supress the info message or not
-- @treturn int Number of article URLs added to the server
function Wallabag:uploadQueue(quiet)
quiet = quiet or true
local count = 0
if self.offline_queue and next(self.offline_queue) ~= nil then
local msg = T(N_("Adding 1 article from queue…", "Adding %1 articles from queue…", #self.offline_queue), #self.offline_queue)
local info = InfoMessage:new{ text = msg }
UIManager:show(info)
for _, articleUrl in ipairs(self.offline_queue) do
if self:addArticle(articleUrl) then
count = count + 1
--- @todo Add error handling
end
end
self.offline_queue = {}
self:saveSettings()
UIManager:close(info)
end
if not quiet then
local msg = T(N_("Added 1 article from queue to Wallabag", "Added %1 articles from queue to Wallabag", count), count)
local info = InfoMessage:new{ text = msg }
UIManager:show(info)
end
logger.info("Wallabag:uploadQueue: uploaded", count, "articles from queue to Wallabag")
return count
end
--- Compare local IDs with remote_article_ids and delete or archive any that are missing.
-- @tparam table remote_article_ids Article IDs of articles downloaded this sync run
-- @treturn int Number of locally deleted or archived articles
function Wallabag:processRemoteDeletes(remote_ids)
logger.dbg("Wallabag:processRemoteDeletes: remote_ids =", remote_ids)
local info = InfoMessage:new{ text = _("Synchronizing remote archivals and deletions…") }
UIManager:show(info)
UIManager:forceRePaint()
local count = 0
for entry in lfs.dir(self.directory) do
local entry_path = FFIUtil.joinPath(self.directory, entry)
if entry ~= "." and entry ~= ".." and lfs.attributes(entry_path, "mode") == "file" then
local local_id = self:getArticleID(entry_path)
if not remote_ids[ local_id ] then
if self.use_local_archive then
logger.dbg("Wallabag:processRemoteDeletes: archiving", local_id, "at", entry_path)
count = count + self:archiveLocalArticle(entry_path)
else
logger.dbg("Wallabag:processRemoteDeletes: deleting", local_id, "at", entry_path)
count = count + self:deleteLocalArticle(entry_path)
end
else
logger.dbg("Wallabag:processRemoteDeletes: local_id", local_id, "found in remote_ids; not archiving/deleting")
end
end
end
UIManager:close(info)
return count
end
--- Archive (or delete) locally finished articles on the Wallabag server.
-- @tparam[opt] bool quiet Whether to supress the info message or not
function Wallabag:uploadStatuses(quiet)
if quiet == nil then
quiet = true
end
local count_remote = 0
local count_local = 0
-- Update bearer token if needed
if self:getBearerToken() == false then
logger.warn("Wallabag:uploadStatuses: could not update bearer token, skipping upload of statuses")
return count_remote, count_local
end
if self.archive_finished or self.archive_read or self.archive_abandoned then
local info = InfoMessage:new{ text = _("Syncing local article statuses…") }
UIManager:show(info)
UIManager:forceRePaint()
for entry in lfs.dir(self.directory) do
local skip = false
if entry ~= "." and entry ~= ".." then
local entry_path = FFIUtil.joinPath(self.directory, entry)
if DocSettings:hasSidecarFile(entry_path) then
logger.dbg("Wallabag:uploadStatuses:", entry_path, "has sidecar file")
if self.send_review_as_tags then
self:addTagsFromReview(entry_path)
end
local doc_settings = DocSettings:open(entry_path)
local summary = doc_settings:readSetting("summary")
local status = summary and summary.status
local percent_finished = doc_settings:readSetting("percent_finished")
if (
(status == "complete" and self.archive_finished)
or (status == "abandoned" and self.archive_abandoned)
or (percent_finished == 1 and self.archive_read)
) then
logger.dbg("Wallabag:uploadStatuses: - has been finished, so archiving/deleting on remote…")
if self:archiveArticle(entry_path) then
count_remote = count_remote + 1
logger.dbg("Wallabag:uploadStatuses: - archived/deleted on remote")
else
logger.warn("Wallabag:uploadStatuses: - could not archive/delete on remote")
skip = true
end
if skip then
logger.dbg("Wallabag:uploadStatuses: - skipping local archiving/deleting")
else
if self.use_local_archive then
logger.dbg("Wallabag:uploadStatuses: - archiving locally as well")
count_local = count_local + self:archiveLocalArticle(entry_path)
else
logger.dbg("Wallabag:uploadStatuses: - deleting locally as well")
count_local = count_local + self:deleteLocalArticle(entry_path)
end -- if use local archive
end -- if not skip
else -- not finished
logger.dbg("Wallabag:uploadStatuses: - but has not been finished yet")
end -- if finished
end -- if has sidecar
end -- if not . or ..
end -- for entry
UIManager:close(info)
end -- if self.archive
logger.info("Wallabag:uploadStatuses: upload finished")
logger.info("Wallabag:uploadStatuses: - count_remote =", count_remote)
logger.info("Wallabag:uploadStatuses: - count_local =", count_local)
logger.dbg("Wallabag:uploadStatuses: - quiet =", quiet)
if not quiet then
local msg = _("Upload finished:")
if self.delete_instead then
msg = msg.."\n"..T(_("- deleted from Wallabag: %1"), count_remote)
else
msg = msg.."\n"..T(_("- archived on Wallabag: %1"), count_remote)
end
if self.use_local_archive then
msg = msg.."\n"..T(_("- archived in KOReader: %1"), count_local)
else
msg = msg.."\n"..T(_("- deleted from KOReader: %1"), count_local)
end
local info = InfoMessage:new{ text = msg }
UIManager:show(info)
UIManager:forceRePaint()
end -- if not quiet
return count_remote, count_local
end
--- Add a new article (including any auto_tags) to the Wallabag server.
-- @tparam string article_url Full URL of the article
-- @treturn bool Whether the API call could be made successfully
function Wallabag:addArticle(article_url)
logger.dbg("Wallabag:addArticle: adding", article_url)
-- Update bearer token if needed
if not article_url or self:getBearerToken() == false then
return false
end
local body = {
url = article_url,
tags = self.auto_tags,
}
local body_JSON = JSON.encode(body)
local headers = {
["Content-type"] = "application/json",
["Accept"] = "application/json, */*",
["Content-Length"] = tostring(#body_JSON),
["Authorization"] = "Bearer " .. self.access_token,
}
return self:callAPI("POST", "/api/entries.json", headers, body_JSON) == true
end
--- Add tags from the local review to the article on Wallabag.
-- @tparam string path Local path of the article
-- @treturn nil
function Wallabag:addTagsFromReview(path)
logger.dbg("Wallabag:addTagsFromReview: managing tags for", path)
local id = self:getArticleID(path)
if id then
local doc_settings = DocSettings:open(path)
local summary = doc_settings:readSetting("summary")
local tags = summary and summary.note
if tags and tags ~= "" then
logger.dbg("Wallabag:addTagsFromReview: sending tags", tags, "for", path)
local body = {
tags = tags,
}
local bodyJSON = JSON.encode(body)
local headers = {
["Content-type"] = "application/json",
["Accept"] = "application/json, */*",
["Content-Length"] = tostring(#bodyJSON),
["Authorization"] = "Bearer " .. self.access_token,
}
self:callAPI("POST", "/api/entries/" .. id .. "/tags.json", headers, bodyJSON)
else
logger.dbg("Wallabag:addTagsFromReview: no tags to send for", path)
end
end
end
--- Archive an article on Wallabag, or if delete_instead, then delete.
-- @tparam string path Local path of the article
-- @treturn bool Whether archiving or deleting was completed
function Wallabag:archiveArticle(path)
logger.dbg("Wallabag:archiveArticle: getting Wallabag ID from", path)
local id = self:getArticleID(path)
if id then
if self.delete_instead then
logger.dbg("Wallabag:archiveArticle: deleting", path, "on remote")
if self:callAPI("DELETE", "/api/entries/" .. id .. ".json") then
return true
end
else
local body = { archive = 1 }
local bodyJSON = JSON.encode(body)
local headers = {
["Content-type"] = "application/json",
["Accept"] = "application/json, */*",
["Content-Length"] = tostring(#bodyJSON),
["Authorization"] = "Bearer " .. self.access_token,
}
logger.dbg("Wallabag:archiveArticle: archiving", path, "on remote")
if self:callAPI("PATCH", "/api/entries/" .. id .. ".json", headers, bodyJSON) then
return true
end
end -- if delete_instead
end -- if id
return false
end
--- Move an article and its sidecar to archive_directory.
-- @tparam string path Local path of the article
-- @treturn int 1 if successful, 0 if not
function Wallabag:archiveLocalArticle(path)
local result = 0
-- Check if the archive directory is valid
local dir_mode = lfs.attributes(self.archive_directory, "mode")
if dir_mode == nil then
logger.dbg("Wallabag:archiveLocalArticle: archive_directory does not exist, creating at", self.archive_directory)
util.makePath(self.archive_directory)
elseif dir_mode ~= "directory" then
UIManager:show(InfoMessage:new{
text = _("The archive folder is not valid.\nPlease configure it in the settings."),
})
return result
end
if lfs.attributes(path, "mode") == "file" then
local _, file = util.splitFilePathName(path)
local new_path = FFIUtil.joinPath(self.archive_directory, file)
if FileManager:moveFile(path, new_path) then
result = 1
end
DocSettings.updateLocation(path, new_path, false) -- move sdr
--- @todo Why is sdr copied instead of moved?
end
return result
end
--- Delete an article and its sidecar locally.
-- @tparam string path Local path of the article
-- @treturn int 1 if successful, 0 if not
function Wallabag:deleteLocalArticle(path)
local result = 0
if lfs.attributes(path, "mode") == "file" then
FileManager:deleteFile(path, true)
result = 1
end
return result
end
--- Extract the Wallabag ID from the file name.
-- @tparam string path Local path of the article
-- @return ID as string if successful, nil if not
function Wallabag:getArticleID(path)
local _, filename = util.splitFilePathName(path)
local prefix_len = article_id_prefix:len()
logger.dbg("Wallabag:getArticleID: getting id from", filename)
if filename:sub(0, prefix_len) ~= article_id_prefix then
logger.warn(filename:sub(0, prefix_len), "~=", article_id_prefix)
return
end
local endpos = filename:find(article_id_postfix, prefix_len)
if endpos == nil then
logger.warn("Wallabag:getArticleID:", article_id_postfix, "was not found in", filename)
return
end
local id = filename:sub(prefix_len + 1, endpos - 1)
logger.dbg("Wallabag:getArticleID: got id", id, "from", filename)
return id
end
function Wallabag:refreshFileManager()
if FileManager.instance then
FileManager.instance:onRefresh()
end
end
--- A dialog used for setting filter_tag, ignore_tags and auto_tags.
function Wallabag:setTagsDialog(touchmenu_instance, title, description, value, callback)
self.tags_dialog = InputDialog:new{
title = title,
description = description,
input = value,
buttons = {
{
{
text = _("Cancel"),
id = "close",
callback = function()
UIManager:close(self.tags_dialog)
end,
},
{
text = _("Save"),
is_enter_default = true,
callback = function()
callback(self.tags_dialog:getInputText())
self:saveSettings()
touchmenu_instance:updateItems()
UIManager:close(self.tags_dialog)
end,
}
}
},
}
UIManager:show(self.tags_dialog)
self.tags_dialog:onShowKeyboard()
end
--- The dialog shown when clicking "Configure Wallabag server".
-- Or automatically, when getBearerToken is run with an incomplete server configuration.
function Wallabag:editServerSettings()
local text_info = T(_([[
Enter the details of your Wallabag server and account.
Client ID and client secret are long strings so you might prefer to save the empty settings and edit the config file directly in your installation folder:
%1/wallabag.lua
Restart KOReader after editing the config file.]]), BD.dirpath(DataStorage:getSettingsDir()))
self.settings_dialog = MultiInputDialog:new{
title = _("Wallabag settings"),
fields = {
{
text = self.server_url,
--description = T(_("Server URL:")),
hint = _("Server URL")
},
{
text = self.client_id,
--description = T(_("Client ID and secret")),
hint = _("Client ID")
},
{
text = self.client_secret,
hint = _("Client secret")
},
{
text = self.username,
--description = T(_("Username and password")),
hint = _("Username")
},
{
text = self.password,
text_type = "password",
hint = _("Password")
},
},
buttons = {
{
{
text = _("Cancel"),
id = "close",
callback = function()
UIManager:close(self.settings_dialog)
end
},
{
text = _("Info"),
callback = function()
UIManager:show(InfoMessage:new{ text = text_info })
end
},
{
text = _("Apply"),
callback = function()
local myfields = self.settings_dialog:getFields()
self.server_url = myfields[1]:gsub("/*$", "") -- remove all trailing slashes
self.client_id = myfields[2]
self.client_secret = myfields[3]
self.username = myfields[4]
self.password = myfields[5]
self:saveSettings()
UIManager:close(self.settings_dialog)
end
},
},
},
}
UIManager:show(self.settings_dialog)
self.settings_dialog:onShowKeyboard()
end
--- The dialog shown when clicking "Number of articles to keep locally".
function Wallabag:setArticlesPerSync(touchmenu_instance)
self.articles_dialog = InputDialog:new{
title = _("Number of articles to keep locally"),
input = tostring(self.articles_per_sync),
buttons = {
{
{
text = _("Cancel"),
id = "close",
callback = function()
UIManager:close(self.articles_dialog)
end,
},
{
text = _("Apply"),
callback = function()
self.articles_per_sync = math.max(1, tonumber(self.articles_dialog:getInputText()) or self.articles_per_sync)
self:saveSettings()
touchmenu_instance:updateItems()
UIManager:close(self.articles_dialog)
end,
}
}
},
}
UIManager:show(self.articles_dialog)
self.articles_dialog:onShowKeyboard()
end
--- The dialog shown when clicking "Download folder".
-- Or automatically, when getBearerToken is run with an incomplete server configuration.
function Wallabag:setDownloadDirectory(touchmenu_instance)
require("ui/downloadmgr"):new{
onConfirm = function(path)
self.directory = path
self:saveSettings()
logger.dbg("Wallabag:setDownloadDirectory: set download directory to", self.directory)
if touchmenu_instance then
touchmenu_instance:updateItems()
end
end,
}:chooseDir()
end
--- The dialog shown when clicking "Archive folder"
function Wallabag:setArchiveDirectory(touchmenu_instance)
require("ui/downloadmgr"):new{
onConfirm = function(path)
self.archive_directory = path
self:saveSettings()
logger.dbg("Wallabag:setArchiveDirectory: set archive directory to", self.archive_directory)
if touchmenu_instance then
touchmenu_instance:updateItems()
end
end,
}:chooseDir()
end
function Wallabag:setTimeoutValue(touchmenu_instance, title_text, current_value, setter_func)
self.timeout_dialog = InputDialog:new{
title = title_text,
input = tostring(current_value),
input_type = "number", -- For numeric keyboard
buttons = {
{
{
text = _("Cancel"),
id = "close",
callback = function()
UIManager:close(self.timeout_dialog)
end,
},
{
text = _("Set timeout"),
is_enter_default = true,
callback = function()
local new_value = tonumber(self.timeout_dialog:getInputText())
if new_value and new_value > 0 then
setter_func(new_value)
self:saveSettings()
touchmenu_instance:updateItems()
UIManager:close(self.timeout_dialog)
else
UIManager:show(InfoMessage:new{ text = _("Invalid input. Please enter a positive number greater than 0.")})
-- Keep dialog open by not closing it here.
end
end,
}
}
},
}
UIManager:show(self.timeout_dialog)
self.timeout_dialog:onShowKeyboard()
end
function Wallabag:saveSettings()
local tempsettings = {
server_url = self.server_url,
client_id = self.client_id,
client_secret = self.client_secret,
username = self.username,
password = self.password,
directory = self.directory,
filter_tag = self.filter_tag,
filter_starred = self.filter_starred,
ignore_tags = self.ignore_tags,
auto_tags = self.auto_tags,
archive_finished = self.archive_finished,
archive_read = self.archive_read,
archive_abandoned = self.archive_abandoned,
delete_instead = self.delete_instead,
auto_archive = self.auto_archive,
sync_remote_archive = self.sync_remote_archive,
articles_per_sync = self.articles_per_sync,
send_review_as_tags = self.send_review_as_tags,
remove_finished_from_history = self.remove_finished_from_history,
remove_read_from_history = self.remove_read_from_history,
remove_abandoned_from_history = self.remove_abandoned_from_history,
download_original_document = self.download_original_document,
offline_queue = self.offline_queue,
use_local_archive = self.use_local_archive,
archive_directory = self.archive_directory,
file_block_timeout = self.file_block_timeout,
file_total_timeout = self.file_total_timeout,
large_block_timeout = self.large_block_timeout,
large_total_timeout = self.large_total_timeout,
}
self.wb_settings:saveSetting("wallabag", tempsettings)
self.wb_settings:flush()
end
function Wallabag:readSettings()
local wb_settings = LuaSettings:open(DataStorage:getSettingsDir().."/wallabag.lua")
wb_settings:readSetting("wallabag", {})
return wb_settings
end
--- Handler for addWallabagArticle event.
-- Uploads a new article to Wallabag directly if there is a network connection, or add it to the
-- local upload queue.
function Wallabag:onAddWallabagArticle(article_url)
if not NetworkMgr:isOnline() then
self:addToOfflineQueue(article_url)
UIManager:show(InfoMessage:new{
text = T(_("Article will be added to Wallabag in the next sync:\n%1"), BD.url(article_url)),
timeout = 1,
})
return
end
if self:addArticle(article_url) then
UIManager:show(InfoMessage:new{
text = T(_("Article added to Wallabag:\n%1"), BD.url(article_url)),
timeout = 1,
})
else
UIManager:show(InfoMessage:new{
text = T(_("Error adding link to Wallabag:\n%1"), BD.url(article_url)),
})
end
return true
end
function Wallabag:onSynchronizeWallabag()
local connect_callback = function()
logger.dbg("Wallabag:onSynchronizeWallabag:connect_callback: downloading articles…")
self:downloadArticles()
logger.dbg("Wallabag:onSynchronizeWallabag:connect_callback: refreshing file manager…")
self:refreshFileManager()
end
NetworkMgr:runWhenOnline(connect_callback)
return true
end
function Wallabag:onUploadWallabagQueue()
local connect_callback = function()
self:uploadQueue(false)
self:refreshFileManager()
end
NetworkMgr:runWhenOnline(connect_callback)
return true
end
function Wallabag:onUploadWallabagStatuses()
local connect_callback = function()
self:uploadStatuses(false)
self:refreshFileManager()
end
NetworkMgr:runWhenOnline(connect_callback)
return true
end
function Wallabag:onGoToWallabagDirectory()
if self.ui.document then
self.ui:onClose()
logger.dbg("Wallabag:onGoToWallabagDirectory: closed document")
end
if FileManager.instance then
FileManager.instance:reinit(self.directory)
logger.dbg("Wallabag:onGoToWallabagDirectory: reinitialized file manager at", self.directory)
else
FileManager:showFiles(self.directory)
logger.dbg("Wallabag:onGoToWallabagDirectory: opened file manager at", self.directory)
end
return true
end
--- Get percent read of the opened article.
function Wallabag:getLastPercent()
local percent = self.ui.paging and self.ui.paging:getLastPercent() or self.ui.rolling:getLastPercent()
return Math.roundPercent(percent)
end
function Wallabag:addToOfflineQueue(article_url)
table.insert(self.offline_queue, article_url)
self:saveSettings()
logger.dbg("Wallabag:addToOfflineQueue: added", article_url, "to queue")
end
--- Handler for the CloseDocument event.
-- If the opened article/book is saved in the Wallabag directory, and if any of the
-- remove_from_history settings are set and matching, then remove it from history
function Wallabag:onCloseDocument()
if self.remove_finished_from_history or self.remove_read_from_history or self.remove_abandoned_from_history then
local document_full_path = self.ui.document.file
local summary = self.ui.doc_settings:readSetting("summary")
local status = summary and summary.status
local is_finished = status == "complete"
local is_read = self:getLastPercent() == 1
local is_abandoned = status == "abandoned"
if document_full_path
and self.directory
and ( (self.remove_finished_from_history and is_finished)
or (self.remove_read_from_history and is_read)
or (self.remove_abandoned_from_history and is_abandoned) )
and self.directory == string.sub(document_full_path, 1, string.len(self.directory)) then
ReadHistory:removeItemByPath(document_full_path)
self.ui:setLastDirForFileBrowser(self.directory)
end
end
end
return Wallabag