--[[--
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
