From b0ceab5b2082c15211f8d08d1078a05fba6061b6 Mon Sep 17 00:00:00 2001 From: roygbyte <82218266+roygbyte@users.noreply.github.com> Date: Mon, 13 Sep 2021 16:04:06 -0300 Subject: [PATCH] [plugin] NewsDownloader: fix XML, better error messages, change default feed, and more (#8145) * Fix XML, introduce some better error messages, etc. See roygbyte/newsdownloader.koplugin for more info * Fix feed attribute saving snafu; Change menu labels; etc. Also: - Change default feed - Change "remove" label to "delete" - Hide menu after feed sync --- .../newsdownloader.koplugin/feed_config.lua | 7 +- plugins/newsdownloader.koplugin/feed_view.lua | 210 ++++ plugins/newsdownloader.koplugin/main.lua | 1027 ++++++++++++++--- 3 files changed, 1064 insertions(+), 180 deletions(-) create mode 100644 plugins/newsdownloader.koplugin/feed_view.lua diff --git a/plugins/newsdownloader.koplugin/feed_config.lua b/plugins/newsdownloader.koplugin/feed_config.lua index 9e864acfb..2948942da 100644 --- a/plugins/newsdownloader.koplugin/feed_config.lua +++ b/plugins/newsdownloader.koplugin/feed_config.lua @@ -33,10 +33,7 @@ return {--do NOT change this line -- LIST YOUR FEEDS HERE: - { "http://feeds.reuters.com/Reuters/worldNews?format=xml", limit = 2, download_full_article=true, include_images=true, enable_filter=true}, - - { "https://www.pcworld.com/index.rss", limit = 7 , download_full_article=false}, - --- { "http://www.football.co.uk/international/rss.xml", limit = 2}, + { "https://github.com/koreader/koreader/releases.atom", limit = 3, download_full_article=true, include_images=false, enable_filter=true, filter_element = "div.release-main-section"}, + { "https://ourworldindata.org/atom.xml", limit = 5 , download_full_article=true, include_images=true, enable_filter=false, filter_element = ""}, }--do NOT change this line diff --git a/plugins/newsdownloader.koplugin/feed_view.lua b/plugins/newsdownloader.koplugin/feed_view.lua new file mode 100644 index 000000000..ac6cdbef7 --- /dev/null +++ b/plugins/newsdownloader.koplugin/feed_view.lua @@ -0,0 +1,210 @@ +local logger = require("logger") +local _ = require("gettext") + +local FeedView = { + URL = "url", + LIMIT = "limit", + DOWNLOAD_FULL_ARTICLE = "download_full_article", + INCLUDE_IMAGES = "include_images", + ENABLE_FILTER = "enable_filter", + FILTER_ELEMENT = "filter_element" +} + +function FeedView:getList(feed_config, callback, edit_feed_attribute_callback, delete_feed_callback) + local view_content = {} + -- Loop through the feed. + for idx, feed in ipairs(feed_config) do + local feed_item_content = {} + local feed_list_content = {} + + local vc_feed_item = FeedView:getItem( + idx, + feed, + edit_feed_attribute_callback, + delete_feed_callback + ) + + if not vc_feed_item then + logger.warn('NewsDownloader: invalid feed config entry', feed) + else + feed_item_content = FeedView:flattenArray(feed_item_content, vc_feed_item) + local url = feed[1] + table.insert( + view_content, + { + url, + "", + callback = function() + -- Here is where we trigger the single feed item display + callback(feed_item_content) + end + } + ) + -- Insert a divider. + table.insert( + view_content, + "---" + ) + end + end + return view_content +end + +function FeedView:getItem(id, feed, edit_feed_callback, delete_feed_callback) + + logger.dbg("NewsDownloader:", feed) + + local url = feed[1] + local limit = feed.limit + + -- If there's no URL or limit we don't care about this + -- because we can't use it. + if not url and limit then + return nil + end + + -- Collect this stuff for later, with the single view. + local download_full_article = feed.download_full_article == nil + or feed.download_full_article + local include_images = not never_download_images + and feed.include_images + local enable_filter = feed.enable_filter + or feed.enable_filter == nil + local filter_element = feed.filter_element + or feed.filter_element == nil + + --- @todo: Strip the http:// or https:// from the URL + local sPre, sLink, sPost = url:match( "(.+)%s+(https?%S+)%s+(.*)$" ) + local pretty_url = sLink + + local view_content = {} + local vc = { + { + _("URL"), + url, + callback = function() + edit_feed_callback( + id, + FeedView.URL, + url + ) + end + }, + { + _("Limit"), + limit, + callback = function() + edit_feed_callback( + id, + FeedView.LIMIT, + limit + ) + end + }, + { + _("Download full article"), + download_full_article, + callback = function() + edit_feed_callback( + id, + FeedView.DOWNLOAD_FULL_ARTICLE, + download_full_article + ) + end + }, + { + _("Include images"), + include_images, + callback = function() + edit_feed_callback( + id, + FeedView.INCLUDE_IMAGES, + include_images + ) + end + }, + { + _("Enable filter"), + enable_filter, + callback = function() + edit_feed_callback( + id, + FeedView.ENABLE_FILTER, + enable_filter + ) + end + + }, + { + _("Filter element"), + filter_element, + callback = function() + edit_feed_callback( + id, + FeedView.FILTER_ELEMENT, + filter_element + ) + end + }, + } + + -- We don't always display this. For instance: if a feed + -- is being created, this button is not necessary. + if delete_feed_callback then + table.insert( + vc, + "---" + ) + table.insert( + vc, + { + _("Delete feed"), + "", + callback = function() + delete_feed_callback( + id + ) + end + } + ) + end + + return vc +end + +-- +-- KeyValuePage doesn't like to get a table with sub tables. +-- This function flattens an array, moving all nested tables +-- up the food chain, so to speak +-- +function FeedView:flattenArray(base_array, source_array) + for key, value in pairs(source_array) do + if value[2] == nil then + -- If the value is empty, then it's probably supposed to be a line + table.insert( + base_array, + "---" + ) + else + if value["callback"] then + table.insert( + base_array, + { + value[1], value[2], callback = value["callback"] + } + ) + else + table.insert( + base_array, + { + value[1], value[2] + } + ) + end + end + end + return base_array +end + + +return FeedView diff --git a/plugins/newsdownloader.koplugin/main.lua b/plugins/newsdownloader.koplugin/main.lua index 95dddf7fb..15b1279aa 100644 --- a/plugins/newsdownloader.koplugin/main.lua +++ b/plugins/newsdownloader.koplugin/main.lua @@ -5,11 +5,16 @@ local DataStorage = require("datastorage") local DownloadBackend = require("epubdownloadbackend") local ReadHistory = require("readhistory") local FFIUtil = require("ffi/util") +local FeedView = require("feed_view") local InfoMessage = require("ui/widget/infomessage") local InputDialog = require("ui/widget/inputdialog") local LuaSettings = require("frontend/luasettings") local UIManager = require("ui/uimanager") +local KeyValuePage = require("ui/widget/keyvaluepage") +local InputDialog = require("ui/widget/inputdialog") +local MultiConfirmBox = require("ui/widget/multiconfirmbox") local NetworkMgr = require("ui/network/manager") +local Persist = require("persist") local WidgetContainer = require("ui/widget/container/widgetcontainer") local dateparser = require("lib.dateparser") local logger = require("logger") @@ -18,21 +23,37 @@ local _ = require("gettext") local T = FFIUtil.template local NewsDownloader = WidgetContainer:new{ - name = "newsdownloader", + name = "news_downloader", + initialized = false, + feed_config_file = "feed_config.lua", + feed_config_path = nil, + news_config_file = "news_settings.lua", + settings = nil, + download_dir_name = "news", + download_dir = nil, + file_extension = ".epub", + config_key_custom_dl_dir = "custom_dl_dir", + empty_feed = { + [1] = "https://", + limit = 5, + download_full_article = true, + include_images = true, + enable_filter = false, + filter_element = "" + }, + kv = {} } -local initialized = false -local feed_config_file_name = "feed_config.lua" -local news_downloader_config_file = "news_downloader_settings.lua" -local news_downloader_settings -local config_key_custom_dl_dir = "custom_dl_dir" -local file_extension = ".epub" -local news_download_dir_name = "news" -local news_download_dir_path, feed_config_path - --- if a title looks like blabla it'll just be feed.title --- if a title looks like blabla then we get a table --- where [1] is the title string and the attributes are also available +local FEED_TYPE_RSS = "rss" +local FEED_TYPE_ATOM = "atom" + +--local initialized = false +--local feed_config_file_name = "feed_config.lua" +--local news_downloader_config_file = "news_downloader_settings.lua + +-- If a title looks like blabla it'll just be feed.title. +-- If a title looks like blabla then we get a table. +-- Where [1] is the title string and the attributes are also available. local function getFeedTitle(possible_title) if type(possible_title) == "string" then return util.htmlEntitiesToUtf8(possible_title) @@ -41,12 +62,12 @@ local function getFeedTitle(possible_title) end end --- there can be multiple links --- for now we just assume the first link is probably the right one --- @todo write unit tests --- some feeds that can be used for unit test --- http://fransdejonge.com/feed/ for multiple links --- https://github.com/koreader/koreader/commits/master.atom for single link with attributes +-- There can be multiple links +-- for now we just assume the first link is probably the right one. +--- @todo Write unit tests. +-- Some feeds that can be used for unit test. +-- http://fransdejonge.com/feed/ for multiple links. +-- https://github.com/koreader/koreader/commits/master.atom for single link with attributes. local function getFeedLink(possible_link) local E = {} if type(possible_link) == "string" then @@ -63,122 +84,168 @@ function NewsDownloader:init() end function NewsDownloader:addToMainMenu(menu_items) - self:lazyInitialization() menu_items.news_downloader = { - text = _("News (RSS/Atom) downloader"), - sub_item_table = { - { - text = _("Download news"), - keep_menu_open = true, - callback = function() - NetworkMgr:runWhenOnline(function() self:loadConfigAndProcessFeedsWithUI() end) - end, - }, - { - text = _("Go to news folder"), - callback = function() - local FileManager = require("apps/filemanager/filemanager") - if self.ui.document then - self.ui:onClose() - end - if FileManager.instance then - FileManager.instance:reinit(news_download_dir_path) - else - FileManager:showFiles(news_download_dir_path) - end - end, - }, - { - text = _("Remove news"), - keep_menu_open = true, - callback = function() self:removeNewsButKeepFeedConfig() end, - }, - { - text = _("Never download images"), - keep_menu_open = true, - checked_func = function() - return news_downloader_settings:isTrue("never_download_images") - end, - callback = function() - news_downloader_settings:toggle("never_download_images") - news_downloader_settings:flush() - end, - }, - { - text = _("Settings"), - sub_item_table = { - { - text = _("Change feeds configuration"), - keep_menu_open = true, - callback = function() self:changeFeedConfig() end, - }, - { - text = _("Set custom download folder"), - keep_menu_open = true, - callback = function() self:setCustomDownloadDirectory() end, - }, + text = _("News downloader (RSS/Atom)"), + sub_item_table_func = function() + return self:getSubMenuItems() + end, + } +end + +function NewsDownloader:getSubMenuItems() + self:lazyInitialization() + local sub_item_table + sub_item_table = { + { + text = _("Go to news folder"), + callback = function() + self:openDownloadsFolder() + end, + }, + { + text = _("Sync news feeds"), + keep_menu_open = true, + callback = function(touchmenu_instance) + NetworkMgr:runWhenOnline(function() self:loadConfigAndProcessFeedsWithUI(touchmenu_instance) end) + end, + }, + { + text = _("Edit news feeds"), + keep_menu_open = true, + callback = function() + local Trapper = require("ui/trapper") + Trapper:wrap(function() + self:viewFeedList() + end) + + end, + }, + { + text = _("Settings"), + sub_item_table = { + { + text = _("Set download folder"), + keep_menu_open = true, + callback = function() self:setCustomDownloadDirectory() end, + }, + { + text = _("Never download images"), + keep_menu_open = true, + checked_func = function() + return self.settings:isTrue("never_download_images") + end, + callback = function() + self.settings:toggle("never_download_images") + self.settings:flush() + end, + }, + { + text = _("Delete all downloaded items"), + keep_menu_open = true, + callback = function() + local Trapper = require("ui/trapper") + Trapper:wrap(function() + local should_delete = Trapper:confirm( + _("Are you sure you want to delete all downloaded items?"), + _("Cancel"), + _("Delete") + ) + if should_delete then + self:removeNewsButKeepFeedConfig() + Trapper:reset() + else + Trapper:reset() + end + end) + end, }, }, - { - text = _("Help"), - keep_menu_open = true, - callback = function() - UIManager:show(InfoMessage:new{ - text = T(_("News downloader retrieves RSS and Atom news entries and stores them to:\n%1\n\nEach entry is a separate html file, that can be browsed by KOReader file manager.\nItems download limit can be configured in Settings."), - BD.dirpath(news_download_dir_path)) - }) - end, - }, + }, + { + text = _("About"), + keep_menu_open = true, + callback = function() + UIManager:show(InfoMessage:new{ + text = T(_("News downloader retrieves RSS and Atom news entries and stores them to:\n%1\n\nEach entry is a separate EPUB file that can be browsed by KOReader.\nFeeds can be configured with download limits and other customization through the Edit Feeds menu item."), + BD.dirpath(self.download_dir)) + }) + end, }, } + return sub_item_table end - +-- lazyInitialization sets up variables that point to the +-- downloads folder and the feeds configuration file. function NewsDownloader:lazyInitialization() - if not initialized then + if not self.initialized then logger.dbg("NewsDownloader: obtaining news folder") - news_downloader_settings = LuaSettings:open(("%s/%s"):format(DataStorage:getSettingsDir(), news_downloader_config_file)) - if news_downloader_settings:has(config_key_custom_dl_dir) then - news_download_dir_path = news_downloader_settings:readSetting(config_key_custom_dl_dir) + self.settings = LuaSettings:open(("%s/%s"):format(DataStorage:getSettingsDir(), self.news_config_file)) + -- Check to see if a custom download directory has been set. + if self.settings:has(self.config_key_custom_dl_dir) then + self.download_dir = self.settings:readSetting(self.config_key_custom_dl_dir) else - news_download_dir_path = ("%s/%s/"):format(DataStorage:getFullDataDir(), news_download_dir_name) + self.download_dir = + ("%s/%s/"):format( + DataStorage:getFullDataDir(), + self.download_dir_name) end - - if not lfs.attributes(news_download_dir_path, "mode") then + logger.dbg("NewsDownloader: Custom directory set to:", self.download_dir) + -- If the directory doesn't exist we will create it. + if not lfs.attributes(self.download_dir, "mode") then logger.dbg("NewsDownloader: Creating initial directory") - lfs.mkdir(news_download_dir_path) + lfs.mkdir(self.download_dir) end - feed_config_path = news_download_dir_path .. feed_config_file_name - - if not lfs.attributes(feed_config_path, "mode") then + -- Now set the path to the feed configuration file. + self.feed_config_path = self.download_dir .. self.feed_config_file + -- If the configuration file doesn't exist create it. + if not lfs.attributes(self.feed_config_path, "mode") then logger.dbg("NewsDownloader: Creating initial feed config.") - FFIUtil.copyFile(FFIUtil.joinPath(self.path, feed_config_file_name), - feed_config_path) + FFIUtil.copyFile(FFIUtil.joinPath(self.path, self.feed_config_file), + self.feed_config_path) end - initialized = true + self.initialized = true end end -function NewsDownloader:loadConfigAndProcessFeeds() +function NewsDownloader:loadConfigAndProcessFeeds(touchmenu_instance) local UI = require("ui/trapper") - UI:info("Loading news feed config…") logger.dbg("force repaint due to upcoming blocking calls") - local ok, feed_config = pcall(dofile, feed_config_path) + local ok, feed_config = pcall(dofile, self.feed_config_path) if not ok or not feed_config then UI:info(T(_("Invalid configuration file. Detailed error message:\n%1"), feed_config)) return end - + -- If the file contains no table elements, then the user hasn't set any feeds. if #feed_config <= 0 then - logger.err('NewsDownloader: empty feed list.', feed_config_path) + logger.err("NewsDownloader: empty feed list.", self.feed_config_path) + local should_edit_feed_list = UI:confirm( + T(_("Feed list is empty. If you want to download news, you'll have to add a feed first.")), + _("Close"), + _("Edit feed list") + ) + if should_edit_feed_list then + -- Show the user a blank feed view so they can + -- add a feed to their list. + local feed_item_vc = FeedView:getItem( + 1, + self.empty_feed, + function(id, edit_key, value) + self:editFeedAttribute(id, edit_key, value) + end + ) + self:viewFeedItem( + feed_item_vc + ) + end return end - local never_download_images = news_downloader_settings:isTrue("never_download_images") - + local never_download_images = self.settings:isTrue("never_download_images") local unsupported_feeds_urls = {} - local total_feed_entries = #feed_config + local feed_message + for idx, feed in ipairs(feed_config) do local url = feed[1] local limit = feed.limit @@ -186,66 +253,179 @@ function NewsDownloader:loadConfigAndProcessFeeds() local include_images = not never_download_images and feed.include_images local enable_filter = feed.enable_filter or feed.enable_filter == nil local filter_element = feed.filter_element or feed.filter_element == nil + -- Check if the two required attributes are set. if url and limit then - local feed_message = T(_("Processing %1/%2:\n%3"), idx, total_feed_entries, BD.url(url)) + feed_message = T(_("Processing %1/%2:\n%3"), idx, total_feed_entries, BD.url(url)) UI:info(feed_message) - NewsDownloader:processFeedSource(url, tonumber(limit), unsupported_feeds_urls, download_full_article, include_images, feed_message, enable_filter, filter_element) + -- Process the feed source. + self:processFeedSource( + url, + tonumber(limit), + unsupported_feeds_urls, + download_full_article, + include_images, + feed_message, + enable_filter, + filter_element) else - logger.warn('NewsDownloader: invalid feed config entry', feed) + logger.warn("NewsDownloader: invalid feed config entry.", feed) end end if #unsupported_feeds_urls <= 0 then - UI:info("Downloading news finished.") + -- When no errors are present, we get a happy message. + feed_message = _("Downloading news finished. ") else + -- When some errors are present, we get a sour message that includes + -- information about the source of the error. local unsupported_urls = "" - for k,url in pairs(unsupported_feeds_urls) do - unsupported_urls = unsupported_urls .. url - if k ~= #unsupported_feeds_urls then + for key, value in pairs(unsupported_feeds_urls) do + -- Create the error message. + unsupported_urls = unsupported_urls .. " " .. value[1] .. " " .. value[2] + -- Not sure what this does. + if key ~= #unsupported_feeds_urls then unsupported_urls = BD.url(unsupported_urls) .. ", " end end - UI:info(T(_("Downloading news finished. Could not process some feeds. Unsupported format in: %1"), unsupported_urls)) + -- Tell the user there were problems. + feed_message = _("Downloading news finished with errors. ") + -- Display a dialogue that requires the user to acknowledge + -- that errors occured. + UI:confirm( + T(_([[ +Could not process some feeds. +Unsupported format in: %1. Please +review your feed configuration file.]]) + , unsupported_urls), + _("Continue"), + "" + ) + end + -- Clear the info widgets before displaying the next ui widget. + UI:clear() + -- Check to see if this method was called from the menu. If it was, + -- we will have gotten a touchmenu_instance. This will context gives the user + -- two options about what to do next, which are handled by this block. + if touchmenu_instance then + -- Ask the user if they want to go to their downloads folder + -- or if they'd rather remain at the menu. + feed_message = feed_message .. _("Go to downloaders folder?") + local should_go_to_downloads = UI:confirm( + feed_message, + _("Close"), + _("Go to downloads") + ) + if should_go_to_downloads then + -- Go to downloads folder. + UI:clear() + self:openDownloadsFolder() + touchmenu_instance:closeMenu() + NetworkMgr:afterWifiAction() + return + else + -- Return to the menu. + NetworkMgr:afterWifiAction() + return + end end - NetworkMgr:afterWifiAction() + return end -function NewsDownloader:loadConfigAndProcessFeedsWithUI() +function NewsDownloader:loadConfigAndProcessFeedsWithUI(touchmenu_instance) local Trapper = require("ui/trapper") Trapper:wrap(function() - self.loadConfigAndProcessFeeds() + self:loadConfigAndProcessFeeds(touchmenu_instance) end) end function NewsDownloader:processFeedSource(url, limit, unsupported_feeds_urls, download_full_article, include_images, message, enable_filter, filter_element) - local ok, response = pcall(function() - return DownloadBackend:getResponseAsString(url) + return DownloadBackend:getResponseAsString(url) end) local feeds + -- Check to see if a response is available to deserialize. if ok then feeds = self:deserializeXMLString(response) end - + -- If the response is not available (for a reason that we don't know), + -- add the URL to the unsupported feeds list. if not ok or not feeds then - table.insert(unsupported_feeds_urls, url) + local error_message + if not ok then + error_message = _("(Reason: Failed to download content)") + else + error_message = _("(Reason: Error during feed deserialization)") + end + table.insert( + unsupported_feeds_urls, + { + url, + error_message + } + ) return end - - local is_rss = feeds.rss and feeds.rss.channel and feeds.rss.channel.title and feeds.rss.channel.item and feeds.rss.channel.item[1] and feeds.rss.channel.item[1].title and feeds.rss.channel.item[1].link - local is_atom = feeds.feed and feeds.feed.title and feeds.feed.entry[1] and feeds.feed.entry[1].title and feeds.feed.entry[1].link - + -- Check to see if the feed uses RSS. + local is_rss = feeds.rss + and feeds.rss.channel + and feeds.rss.channel.title + and feeds.rss.channel.item + and feeds.rss.channel.item[1] + and feeds.rss.channel.item[1].title + and feeds.rss.channel.item[1].link + -- Check to see if the feed uses Atom. + local is_atom = feeds.feed + and feeds.feed.title + and feeds.feed.entry[1] + and feeds.feed.entry[1].title + and feeds.feed.entry[1].link + -- Process the feeds accordingly. if is_atom then ok = pcall(function() - return self:processAtom(feeds, limit, download_full_article, include_images, message, enable_filter, filter_element) + return self:processFeed( + FEED_TYPE_ATOM, + feeds, + limit, + download_full_article, + include_images, + message, + enable_filter, + filter_element + ) end) elseif is_rss then ok = pcall(function() - return self:processRSS(feeds, limit, download_full_article, include_images, message, enable_filter, filter_element) + return self:processFeed( + FEED_TYPE_RSS, + feeds, + limit, + download_full_article, + include_images, + message, + enable_filter, + filter_element + ) end) end + -- If the feed can't be processed, or it is neither + -- Atom or RSS, then add it to the unsupported feeds list + -- and return an error message. if not ok or (not is_rss and not is_atom) then - table.insert(unsupported_feeds_urls, url) + local error_message + if not ok then + error_message = _("(Reason: Failed to download content)") + elseif not is_rss then + error_message = _("(Reason: Couldn't process RSS)") + elseif not is_atom then + error_message = _("(Reason: Couldn't process Atom)") + end + table.insert( + unsupported_feeds_urls, + { + url, + error_message + } + ) end end @@ -256,72 +436,148 @@ function NewsDownloader:deserializeXMLString(xml_str) -- see: koreader/plugins/newsdownloader.koplugin/lib/LICENSE_LuaXML local treehdl = require("lib/handler") local libxml = require("lib/xml") - - --Instantiate the object the states the XML file as a Lua table + -- Instantiate the object that parses the XML file as a Lua table. local xmlhandler = treehdl.simpleTreeHandler() - --Instantiate the object that parses the XML to a Lua table + -- Instantiate the object that parses the XML to a Lua table. local ok = pcall(function() - libxml.xmlParser(xmlhandler):parse(xml_str) + libxml.xmlParser(xmlhandler):parse(xml_str) end) if not ok then return end return xmlhandler.root end function NewsDownloader:processAtom(feeds, limit, download_full_article, include_images, message, enable_filter, filter_element) - local feed_output_dir = string.format("%s%s/", - news_download_dir_path, - util.getSafeFilename(getFeedTitle(feeds.feed.title))) - if not lfs.attributes(feed_output_dir, "mode") then + -- Get the path to the output directory. + local feed_output_dir = string.format( + "%s%s/", + self.download_dir, + util.getSafeFilename(getFeedTitle(feeds.feed.title)) + ) + -- Create the output directory if it doesn't exist. + if not lfs.attributes(feed_outpnnut_dir, "mode") then lfs.mkdir(feed_output_dir) end - + -- Download the feed. for index, feed in pairs(feeds.feed.entry) do + -- If limit has been met, stop downloading feed. if limit ~= 0 and index - 1 == limit then break end - local article_message = T(_("%1\n\nFetching article %2/%3:"), message, index, limit == 0 and #feeds.rss.channel.item or limit) + local total_articles = limit == 0 + and #feeds.rss.channel.item + or limit + -- Create a message to display during processing. + local article_message = T( + _("%1\n\nFetching article %2/%3:"), + message, + index, + total + ) + -- Download the feed. if download_full_article then - self:downloadFeed(feed, feed_output_dir, include_images, article_message, enable_filter, filter_element) + self:downloadFeed( + feed, + feed_output_dir, + include_images, + article_message, + enable_filter, + filter_element + ) else - self:createFromDescription(feed, feed.content[1], feed_output_dir, include_images, article_message) + self:createFromDescription( + feed, + feed.content[1], + feed_output_dir, + include_images, + article_message + ) end end end -function NewsDownloader:processRSS(feeds, limit, download_full_article, include_images, message, enable_filter, filter_element) +function NewsDownloader:processFeed(feed_type, feeds, limit, download_full_article, include_images, message, enable_filter, filter_element) + local feed_title + local feed_item + local total_feeds + -- Setup the above vars based on feed type. + if feed_type == FEED_TYPE_RSS then + feed_title = util.htmlEntitiesToUtf8(feeds.rss.channel.title) + feed_item = feeds.rss.channel.item + total_items = (limit == 0) + and #feeds.rss.channel.item + or limit + else + feed_title = getFeedTitle(feeds.feed.title) + feed_item = feeds.feed.entry + total_items = (limit == 0) + and #feeds.feed.entry + or limit + end + -- Get the path to the output directory. local feed_output_dir = ("%s%s/"):format( - news_download_dir_path, util.getSafeFilename(util.htmlEntitiesToUtf8(feeds.rss.channel.title))) + self.download_dir, + util.getSafeFilename(util.htmlEntitiesToUtf8(feed_title))) + -- Create the output directory if it doesn't exist. if not lfs.attributes(feed_output_dir, "mode") then lfs.mkdir(feed_output_dir) end - - for index, feed in pairs(feeds.rss.channel.item) do + -- Download the feed + for index, feed in pairs(feed_item) do + -- If limit has been met, stop downloading feed. if limit ~= 0 and index - 1 == limit then break end - local article_message = T(_("%1\n\nFetching article %2/%3:"), message, index, limit == 0 and #feeds.rss.channel.item or limit) + -- Create a message to display during processing. + local article_message = T( + _("%1\n\nFetching article %2/%3:"), + message, + index, + total_items + ) + -- Get the feed description. + local feed_description + if feed_type == FEED_TYPE_RSS then + feed_description = feed.description + else + feed_description = feed.summary + end + -- Download the article. if download_full_article then - self:downloadFeed(feed, feed_output_dir, include_images, article_message, enable_filter, filter_element) + self:downloadFeed( + feed, + feed_output_dir, + include_images, + article_message, + enable_filter, + filter_element + ) else - self:createFromDescription(feed, feed.description, feed_output_dir, include_images, article_message) + self:createFromDescription( + feed, + feed_description, + feed_output_dir, + include_images, + article_message + ) end end end local function parseDate(dateTime) - -- uses lua-feedparser https://github.com/slact/lua-feedparser + -- Uses lua-feedparser https://github.com/slact/lua-feedparser -- feedparser is available under the (new) BSD license. -- see: koreader/plugins/newsdownloader.koplugin/lib/LICENCE_lua-feedparser local date = dateparser.parse(dateTime) return os.date("%y-%m-%d_%H-%M_", date) end +-- This appears to be used by Atom feeds in processFeed. local function getTitleWithDate(feed) local title = util.getSafeFilename(getFeedTitle(feed.title)) if feed.updated then - title = parseDate(feed.updated) .. title + title = parseDate(feed.updated) .. title elseif feed.pubDate then - title = parseDate(feed.pubDate) .. title + title = parseDate(feed.pubDate) .. title elseif feed.published then title = parseDate(feed.published) .. title end @@ -332,7 +588,7 @@ function NewsDownloader:downloadFeed(feed, feed_output_dir, include_images, mess local title_with_date = getTitleWithDate(feed) local news_file_path = ("%s%s%s"):format(feed_output_dir, title_with_date, - file_extension) + self.file_extension) local file_mode = lfs.attributes(news_file_path, "mode") if file_mode == "file" then @@ -350,7 +606,7 @@ function NewsDownloader:createFromDescription(feed, content, feed_output_dir, in local title_with_date = getTitleWithDate(feed) local news_file_path = ("%s%s%s"):format(feed_output_dir, title_with_date, - file_extension) + self.file_extension) local file_mode = lfs.attributes(news_file_path, "mode") if file_mode == "file" then logger.dbg("NewsDownloader:", news_file_path, "already exists. Skipping") @@ -372,10 +628,10 @@ function NewsDownloader:createFromDescription(feed, content, feed_output_dir, in end function NewsDownloader:removeNewsButKeepFeedConfig() - logger.dbg("NewsDownloader: Removing news from :", news_download_dir_path) - for entry in lfs.dir(news_download_dir_path) do - if entry ~= "." and entry ~= ".." and entry ~= feed_config_file_name then - local entry_path = news_download_dir_path .. "/" .. entry + logger.dbg("NewsDownloader: Removing news from :", self.download_dir) + for entry in lfs.dir(self.download_dir) do + if entry ~= "." and entry ~= ".." and entry ~= self.feed_config_file then + local entry_path = self.download_dir .. "/" .. entry local entry_mode = lfs.attributes(entry_path, "mode") if entry_mode == "file" then os.remove(entry_path) @@ -385,33 +641,409 @@ function NewsDownloader:removeNewsButKeepFeedConfig() end end UIManager:show(InfoMessage:new{ - text = _("All news removed.") + text = _("All downloaded news feed items deleted.") }) end function NewsDownloader:setCustomDownloadDirectory() require("ui/downloadmgr"):new{ - onConfirm = function(path) - logger.dbg("NewsDownloader: set download directory to: ", path) - news_downloader_settings:saveSetting(config_key_custom_dl_dir, ("%s/"):format(path)) - news_downloader_settings:flush() - - logger.dbg("NewsDownloader: Coping to new download folder previous feed_config_file_name from: ", feed_config_path) - FFIUtil.copyFile(feed_config_path, ("%s/%s"):format(path, feed_config_file_name)) - - initialized = false - self:lazyInitialization() - end, - }:chooseDir() + onConfirm = function(path) + logger.dbg("NewsDownloader: set download directory to: ", path) + self.settings:saveSetting(self.config_key_custom_dl_dir, ("%s/"):format(path)) + self.settings:flush() + + logger.dbg("NewsDownloader: Coping to new download folder previous self.feed_config_file from: ", self.feed_config_path) + FFIUtil.copyFile(self.feed_config_path, ("%s/%s"):format(path, self.feed_config_file)) + + self.initialized = false + self:lazyInitialization() + end, + }:chooseDir() +end + +function NewsDownloader:viewFeedList() + local UI = require("ui/trapper") + UI:info(_("Loading news feed list…")) + -- Protected call to see if feed config path returns a file that can be opened. + local ok, feed_config = pcall(dofile, self.feed_config_path) + if not ok or not feed_config then + change_feed_config = UI:confirm( + _("Could not open feed list. Feeds configuration file is invalid. "), + _("Close"), + _("View file") + ) + if change_feed_config then + self:changeFeedConfig() + end + return + end + UI:clear() + -- See if the config file contains any feed items + if #feed_config <= 0 then + logger.err("NewsDownloader: empty feed list.", self.feed_config_path) + -- Why not ask the user if they want to add one? + -- Or, in future, move along to our list UI with an entry for new feeds + + --return + end + + local view_content = FeedView:getList( + feed_config, + function(feed_item_vc) + self:viewFeedItem( + feed_item_vc + ) + end, + function(id, edit_key, value) + self:editFeedAttribute(id, edit_key, value) + end, + function(id) + self:deleteFeed(id) + end + ) + -- Add a "Add new feed" button with callback + table.insert( + view_content, + { + _("Add new feed"), + "", + callback = function() + -- Prepare the view with all the callbacks for editing the attributes + local feed_item_vc = FeedView:getItem( + #feed_config + 1, + self.empty_feed, + function(id, edit_key, value) + self:editFeedAttribute(id, edit_key, value) + end + ) + self:viewFeedItem( + feed_item_vc + ) + end + } + ) + -- Show the list of feeds. + local kv = self.kv + if #self.kv ~= 0 then + UIManager:close(self.kv) + end + self.kv = KeyValuePage:new{ + title = _("RSS/Atom Feeds List"), + value_overflow_align = "right", + kv_pairs = view_content, + callback_return = function() + UIManager:close(self.kv) + end + } + UIManager:show(self.kv) +end + +function NewsDownloader:viewFeedItem(data) + local kv = self.kv + if #self.kv ~= 0 then + UIManager:close(self.kv) + end + self.kv = KeyValuePage:new{ + title = _("Edit Feed"), + value_overflow_align = "left", + kv_pairs = data, + callback_return = function() + self:viewFeedList() + end + } + UIManager:show(self.kv) +end + +function NewsDownloader:editFeedAttribute(id, key, value) + local kv = self.kv + -- There are basically two types of values: string (incl. numbers) + -- and booleans. This block chooses what type of value our + -- attribute will need and displays the corresponding dialog. + if key == FeedView.URL + or key == FeedView.LIMIT + or key == FeedView.FILTER_ELEMENT then + + local title + local input_type + local description + + if key == FeedView.URL then + title = _("Edit feed URL") + input_type = "string" + elseif key == FeedView.LIMIT then + title = _("Edit feed limit") + description = _("Set to 0 for no limit to how many items are downloaded") + input_type = "number" + elseif key == FeedView.FILTER_ELEMENT then + title = _("Edit filter element.") + description = _("Filter based on the given CSS selector. E.g.: name_of_css.element.class") + input_type = "string" + else + return false + end + + local input_dialog + input_dialog = InputDialog:new{ + title = title, + input = value, + input_type = input_type, + description = description, + buttons = { + { + { + text = _("Cancel"), + callback = function() + UIManager:close(input_dialog) + UIManager:show(kv) + end, + }, + { + text = _("Save"), + is_enter_default = true, + callback = function() + UIManager:close(input_dialog) + self:updateFeedConfig(id, key, input_dialog:getInputValue()) + end, + }, + } + }, + } + UIManager:show(input_dialog) + input_dialog:onShowKeyboard() + return true + else + local text + if key == FeedView.DOWNLOAD_FULL_ARTICLE then + text = _("Download full article?") + elseif key == FeedView.INCLUDE_IMAGES then + text = _("Include images?") + elseif key == FeedView.ENABLE_FILTER then + text = _("Enable CSS filter?") + end + + local multi_box + multi_box= MultiConfirmBox:new{ + text = text, + choice1_text = _("Yes"), + choice1_callback = function() + UIManager:close(multi_box) + self:updateFeedConfig(id, key, true) + end, + choice2_text = _("No"), + choice2_callback = function() + UIManager:close(multi_box) + self:updateFeedConfig(id, key, false) + end, + cancel_callback = function() + UIManager:close(multi_box) + UIManager:show(kv) + end, + } + UIManager:show(multi_box) + end +end + +function NewsDownloader:updateFeedConfig(id, key, value) + local kv = self.kv + -- Because this method is called at the menu, + -- we might not have an active view. So this conditional + -- statement avoids closing a null reference. + if #self.kv ~= 0 then + UIManager:close(self.kv) + end + -- It's possible that we will get a null value. + -- This block catches that possibility. + if id ~= nil and key ~= nil and value ~= nil then + -- This logger is a bit opaque because T() wasn't playing nice with booleans + logger.dbg("Newsdownloader: attempting to update config:") + else + logger.dbg("Newsdownloader: null value supplied to update. Not updating config") + return + end + + local ok, feed_config = pcall(dofile, self.feed_config_path) + if not ok or not feed_config then + UI:info(T(_("Invalid configuration file. Detailed error message:\n%1"), feed_config)) + return + end + -- If the file contains no table elements, then the user hasn't set any feeds. + if #feed_config <= 0 then + logger.dbg("NewsDownloader: empty feed list.", self.feed_config_path) + end + + -- Check to see if the id is larger than the number of feeds. If it is, + -- then we know this is a new add. Insert the base array. + if id > #feed_config then + table.insert( + feed_config, + self.empty_feed + ) + end + + local new_config = {} + -- In this loop, we cycle through the feed items. A series of + -- conditionals checks to see if we are at the right id + -- and key (i.e.: the key that triggered this function. + -- If we are at the right spot, we overrite (or create) the value + for idx, feed in ipairs(feed_config) do + -- Check to see if this is the correct feed to update. + if idx == id then + if key == FeedView.URL then + if feed[1] then + -- If the value exists, then it's been set. So all we do + -- is overwrite the value. + feed[1] = value + else + -- If the value doesn't exist, then we need to set it. + -- So we insert it into the table. + table.insert( + feed, + { + value + } + ) + end + elseif key == FeedView.LIMIT then + if feed.limit then + feed.limit = value + else + table.insert( + feed, + { + "limit", + value + } + ) + end + elseif key == FeedView.DOWNLOAD_FULL_ARTICLE then + if feed.download_full_article ~= nil then + feed.download_full_article = value + else + table.insert( + feed, + { + "download_full_article", + value + } + ) + end + elseif key == FeedView.INCLUDE_IMAGES then + if feed.include_images ~= nil then + feed.include_images = value + else + table.insert( + feed, + { + "include_images", + value + } + ) + end + elseif key == FeedView.ENABLE_FILTER then + if feed.enable_filter ~= nil then + feed.enable_filter = value + else + table.insert( + feed, + { + "enable_filter", + value + } + ) + end + elseif key == FeedView.FILTER_ELEMENT then + if feed.filter_element then + feed.filter_element = value + else + table.insert( + feed, + { + "filter_element", + value + } + ) + end + end + end + -- Now we insert the updated (or newly created) feed into the + -- new config feed that we're building in this loop. + table.insert( + new_config, + feed + ) + end + -- Save the config + logger.dbg("NewsDownloader: config to save", new_config) + self:saveConfig(new_config) + -- Refresh the view + local feed_item_vc = FeedView:getItem( + id, + new_config[id], + function(id, edit_key, value) + self:editFeedAttribute(id, edit_key, value) + end + ) + self:viewFeedItem( + feed_item_vc + ) + +end + +function NewsDownloader:deleteFeed(id) + logger.dbg("Newsdownloader: attempting to delete feed") + -- Check to see if we can get the config file. + local ok, feed_config = pcall(dofile, self.feed_config_path) + if not ok or not feed_config then + UI:info(T(_("Invalid configuration file. Detailed error message:\n%1"), feed_config)) + return + end + -- In this loop, we cycle through the feed items. A series of + -- conditionals checks to see if we are at the right id + -- and key (i.e.: the key that triggered this function. + -- If we are at the right spot, we overrite (or create) the value + local new_config = {} + for idx, feed in ipairs(feed_config) do + -- Check to see if this is the correct feed to update. + if idx ~= id then + table.insert( + new_config, + feed + ) + end + end + -- Save the config + local Trapper = require("ui/trapper") + Trapper:wrap(function() + logger.dbg("NewsDownloader: config to save", new_config) + self:saveConfig(new_config) + end) + -- Refresh the view + self:viewFeedList() +end + +function NewsDownloader:saveConfig(config) + local UI = require("ui/trapper") + UI:info(_("Saving news feed list...")) + local persist = Persist:new{ + path = self.feed_config_path + } + local ok = persist:save(config) + if not ok then + UI:info(_("Could not save news feed config.")) + else + UI:info(_("News feed config updated successfully.")) + end + UI:reset() end function NewsDownloader:changeFeedConfig() - local feed_config_file = io.open(feed_config_path, "rb") + local feed_config_file = io.open(self.feed_config_path, "rb") local config = feed_config_file:read("*all") feed_config_file:close() local config_editor + logger.info("NewsDownloader: opening configuration file", self.feed_config_path) config_editor = InputDialog:new{ - title = T(_("Config: %1"), BD.filepath(feed_config_path)), + title = T(_("Config: %1"), BD.filepath(self.feed_config_path)), input = config, input_type = "string", para_direction_rtl = false, -- force LTR @@ -429,7 +1061,7 @@ function NewsDownloader:changeFeedConfig() if not parse_error then local syntax_okay, syntax_error = pcall(loadstring(content)) if syntax_okay then - feed_config_file = io.open(feed_config_path, "w") + feed_config_file = io.open(self.feed_config_path, "w") feed_config_file:write(content) feed_config_file:close() return true, _("Configuration saved") @@ -437,8 +1069,8 @@ function NewsDownloader:changeFeedConfig() return false, T(_("Configuration invalid: %1"), syntax_error) end else - return false, T(_("Configuration invalid: %1"), parse_error) - end + return false, T(_("Configuration invalid: %1"), parse_error) + end end return false, _("Configuration empty") end, @@ -446,12 +1078,23 @@ function NewsDownloader:changeFeedConfig() UIManager:show(config_editor) config_editor:onShowKeyboard() end +function NewsDownloader:openDownloadsFolder() + local FileManager = require("apps/filemanager/filemanager") + if self.ui.document then + self.ui:onClose() + end + if FileManager.instance then + FileManager.instance:reinit(self.download_dir) + else + FileManager:showFiles(self.download_dir) + end +end function NewsDownloader:onCloseDocument() local document_full_path = self.ui.document.file - if document_full_path and news_download_dir_path and news_download_dir_path == string.sub(document_full_path, 1, string.len(news_download_dir_path)) then + if document_full_path and self.download_dir and self.download_dir == string.sub(document_full_path, 1, string.len(self.download_dir)) then logger.dbg("NewsDownloader: document_full_path:", document_full_path) - logger.dbg("NewsDownloader: news_download_dir_path:", news_download_dir_path) + logger.dbg("NewsDownloader: self.download_dir:", self.download_dir) logger.dbg("NewsDownloader: removing NewsDownloader file from history.") ReadHistory:removeItemByPath(document_full_path) local doc_dir = util.splitFilePathName(document_full_path) @@ -459,4 +1102,38 @@ function NewsDownloader:onCloseDocument() end end +-- +-- KeyValuePage doesn't like to get a table with sub tables. +-- This function flattens an array, moving all nested tables +-- up the food chain, so to speak +-- +function NewsDownloader:flattenArray(base_array, source_array) + for key, value in pairs(source_array) do + if value[2] == nil then + -- If the value is empty, then it's probably supposed to be a line + table.insert( + base_array, + "---" + ) + else + if value["callback"] then + table.insert( + base_array, + { + value[1], value[2], callback = value["callback"] + } + ) + else + table.insert( + base_array, + { + value[1], value[2] + } + ) + end + end + end + return base_array +end + return NewsDownloader