--[[-- This module provides a way to display book information (filename and book metadata) ]] local BD = require("ui/bidi") local ButtonDialog = require("ui/widget/buttondialog") local ConfirmBox = require("ui/widget/confirmbox") local Device = require("device") local DocSettings = require("docsettings") local Document = require("document/document") local DocumentRegistry = require("document/documentregistry") local Event = require("ui/event") local InfoMessage = require("ui/widget/infomessage") local InputDialog = require("ui/widget/inputdialog") local TextViewer = require("ui/widget/textviewer") local UIManager = require("ui/uimanager") local WidgetContainer = require("ui/widget/container/widgetcontainer") local filemanagerutil = require("apps/filemanager/filemanagerutil") local lfs = require("libs/libkoreader-lfs") local util = require("util") local _ = require("gettext") local N_ = _.ngettext local T = require("ffi/util").template local BookInfo = WidgetContainer:extend{ props = { "title", "authors", "series", "series_index", "language", "keywords", "description", }, prop_text = { cover = _("Cover image:"), title = _("Title:"), authors = _("Authors:"), series = _("Series:"), series_index = _("Series index:"), language = _("Language:"), keywords = _("Keywords:"), description = _("Description:"), pages = _("Pages:"), }, } function BookInfo:init() if self.document then -- only for Reader menu self.ui.menu:registerToMainMenu(self) end end function BookInfo:addToMainMenu(menu_items) menu_items.book_info = { text = _("Book information"), callback = function() self:onShowBookInfo() end, } end -- Shows book information. function BookInfo:show(file, book_props) self.prop_updated = nil local kv_pairs = {} -- File section local folder, filename = util.splitFilePathName(file) local __, filetype = filemanagerutil.splitFileNameType(filename) local attr = lfs.attributes(file) local file_size = attr.size or 0 local size_f = util.getFriendlySize(file_size) local size_b = util.getFormattedSize(file_size) table.insert(kv_pairs, { _("Filename:"), BD.filename(filename) }) table.insert(kv_pairs, { _("Format:"), filetype:upper() }) table.insert(kv_pairs, { _("Size:"), string.format("%s (%s bytes)", size_f, size_b) }) table.insert(kv_pairs, { _("File date:"), os.date("%Y-%m-%d %H:%M:%S", attr.modification) }) table.insert(kv_pairs, { _("Folder:"), BD.dirpath(filemanagerutil.abbreviate(folder)), separator = true }) -- Book section -- book_props may be provided if caller already has them available -- but it may lack "pages", that we may get from sidecar file if not book_props or not book_props.pages then book_props = BookInfo.getDocProps(file, book_props) end -- cover image self.custom_book_cover = DocSettings:findCustomCoverFile(file) local key_text = self.prop_text["cover"] if self.custom_book_cover then key_text = "\u{F040} " .. key_text end table.insert(kv_pairs, { key_text, _("Tap to display"), callback = function() self:onShowBookCover(file) end, hold_callback = function() self:showCustomDialog(file, book_props) end, separator = true, }) -- metadata local custom_props local custom_metadata_file = DocSettings:findCustomMetadataFile(file) if custom_metadata_file then self.custom_doc_settings = DocSettings.openSettingsFile(custom_metadata_file) custom_props = self.custom_doc_settings:readSetting("custom_props") end local values_lang for _i, prop_key in ipairs(self.props) do local prop = book_props[prop_key] if prop == nil or prop == "" then prop = _("N/A") elseif prop_key == "title" then prop = BD.auto(prop) elseif prop_key == "authors" or prop_key == "keywords" then if prop:find("\n") then -- BD auto isolate each entry prop = util.splitToArray(prop, "\n") for i = 1, #prop do prop[i] = BD.auto(prop[i]) end prop = table.concat(prop, "\n") else prop = BD.auto(prop) end elseif prop_key == "language" then -- Get a chance to have title, authors... rendered with alternate -- glyphs for the book language (e.g. japanese book in chinese UI) values_lang = prop elseif prop_key == "description" then -- Description may (often in EPUB, but not always) or may not (rarely in PDF) be HTML prop = util.htmlToPlainTextIfHtml(prop) end key_text = self.prop_text[prop_key] if custom_props and custom_props[prop_key] then -- customized key_text = "\u{F040} " .. key_text end table.insert(kv_pairs, { key_text, prop, hold_callback = function() self:showCustomDialog(file, book_props, prop_key) end, }) end -- pages local is_doc = self.document and true or false table.insert(kv_pairs, { self.prop_text["pages"], book_props["pages"] or _("N/A"), separator = is_doc }) -- Page section if is_doc then local lines_nb, words_nb = self.ui.view:getCurrentPageLineWordCounts() if lines_nb == 0 then lines_nb = _("N/A") words_nb = _("N/A") end table.insert(kv_pairs, { _("Current page lines:"), lines_nb }) table.insert(kv_pairs, { _("Current page words:"), words_nb }) end local KeyValuePage = require("ui/widget/keyvaluepage") self.kvp_widget = KeyValuePage:new{ title = _("Book information"), value_overflow_align = "right", kv_pairs = kv_pairs, values_lang = values_lang, close_callback = function() self.custom_doc_settings = nil self.custom_book_cover = nil if self.prop_updated then UIManager:broadcastEvent(Event:new("InvalidateMetadataCache", file)) UIManager:broadcastEvent(Event:new("BookMetadataChanged", self.prop_updated)) end end, } UIManager:show(self.kvp_widget) end function BookInfo.getCustomProp(prop_key, filepath) local custom_metadata_file = DocSettings:findCustomMetadataFile(filepath) return custom_metadata_file and DocSettings.openSettingsFile(custom_metadata_file):readSetting("custom_props")[prop_key] end -- Returns extended and customized metadata. function BookInfo.extendProps(original_props, filepath) -- do not customize if filepath is not passed (eg from covermenu) local custom_metadata_file = filepath and DocSettings:findCustomMetadataFile(filepath) local custom_props = custom_metadata_file and DocSettings.openSettingsFile(custom_metadata_file):readSetting("custom_props") or {} original_props = original_props or {} local props = {} for _, prop_key in ipairs(BookInfo.props) do props[prop_key] = custom_props[prop_key] or original_props[prop_key] end props.pages = original_props.pages -- if original title is empty, generate it as filename without extension props.display_title = props.title or filemanagerutil.splitFileNameType(filepath) return props end -- Returns customized document metadata, including number of pages. function BookInfo.getDocProps(file, book_props, no_open_document) if DocSettings:hasSidecarFile(file) then local doc_settings = DocSettings:open(file) if not book_props then -- Files opened after 20170701 have a "doc_props" setting with -- complete metadata and "doc_pages" with accurate nb of pages book_props = doc_settings:readSetting("doc_props") end if not book_props then -- File last opened before 20170701 may have a "stats" setting. -- with partial metadata, or empty metadata if statistics plugin -- was not enabled when book was read (we can guess that from -- the fact that stats.page = 0) local stats = doc_settings:readSetting("stats") if stats and stats.pages ~= 0 then -- title, authors, series, series_index, language book_props = Document:getProps(stats) end end -- Files opened after 20170701 have an accurate "doc_pages" setting. local doc_pages = doc_settings:readSetting("doc_pages") if doc_pages and book_props then book_props.pages = doc_pages end end -- If still no book_props (book never opened or empty "stats"), -- but custom metadata exists, it has a copy of original doc_props if not book_props then local custom_metadata_file = DocSettings:findCustomMetadataFile(file) if custom_metadata_file then book_props = DocSettings.openSettingsFile(custom_metadata_file):readSetting("doc_props") end end -- If still no book_props, open the document to get them if not book_props and not no_open_document then local document = DocumentRegistry:openDocument(file) if document then local loaded = true local pages if document.loadDocument then -- CreDocument if not document:loadDocument(false) then -- load only metadata -- failed loading, calling other methods would segfault loaded = false end -- For CreDocument, we would need to call document:render() -- to get nb of pages, but the nb obtained by simply calling -- here document:getPageCount() is wrong, often 2 to 3 times -- the nb of pages we see when opening the document (may be -- some other cre settings should be applied before calling -- render() ?) else -- for all others than crengine, we seem to get an accurate nb of pages pages = document:getPageCount() end if loaded then book_props = document:getProps() book_props.pages = pages end document:close() end end return BookInfo.extendProps(book_props, file) end -- Shows book information for currently opened document. function BookInfo:onShowBookInfo() if self.document then self.ui.doc_props.pages = self.ui.doc_settings:readSetting("doc_pages") self:show(self.document.file, self.ui.doc_props) end end function BookInfo:showBookProp(prop_key, prop_text) if prop_key == "description" then prop_text = util.htmlToPlainTextIfHtml(prop_text) end UIManager:show(TextViewer:new{ title = self.prop_text[prop_key], text = prop_text, }) end function BookInfo:onShowBookDescription(description, file) if not description then if file then description = BookInfo.getDocProps(file).description elseif self.document then -- currently opened document description = self.ui.doc_props.description end end if description then self:showBookProp("description", description) else UIManager:show(InfoMessage:new{ text = _("No book description available."), }) end end function BookInfo:onShowBookCover(file, force_orig) local cover_bb = self:getCoverImage(self.document, file, force_orig) if cover_bb then local ImageViewer = require("ui/widget/imageviewer") local imgviewer = ImageViewer:new{ image = cover_bb, with_title_bar = false, fullscreen = true, } UIManager:show(imgviewer) else UIManager:show(InfoMessage:new{ text = _("No cover image available."), }) end end function BookInfo:getCoverImage(doc, file, force_orig) local cover_bb -- check for a custom cover (orig cover is forcibly requested in "Book information" only) if not force_orig then local custom_cover = DocSettings:findCustomCoverFile(file or (doc and doc.file)) if custom_cover then local cover_doc = DocumentRegistry:openDocument(custom_cover) if cover_doc then cover_bb = cover_doc:getCoverPageImage() cover_doc:close() return cover_bb, custom_cover end end end -- orig cover local is_doc = doc and true or false if not is_doc then doc = DocumentRegistry:openDocument(file) if doc and doc.loadDocument then -- CreDocument doc:loadDocument(false) -- load only metadata end end if doc then cover_bb = doc:getCoverPageImage() if not is_doc then doc:close() end end return cover_bb end function BookInfo:updateBookInfo(file, book_props, prop_updated, prop_value_old) if self.document and prop_updated == "cover" then self.ui.doc_settings:getCustomCoverFile(true) -- reset cover file cache end self.prop_updated = { filepath = file, doc_props = book_props, metadata_key_updated = prop_updated, metadata_value_old = prop_value_old, } self.kvp_widget:onClose() self:show(file, book_props) end function BookInfo:setCustomCover(file, book_props) if self.custom_book_cover then -- reset custom cover if os.remove(self.custom_book_cover) then DocSettings.removeSidecarDir(util.splitFilePathName(self.custom_book_cover)) self:updateBookInfo(file, book_props, "cover") end else -- choose an image and set custom cover local PathChooser = require("ui/widget/pathchooser") local path_chooser = PathChooser:new{ select_directory = false, file_filter = function(filename) return DocumentRegistry:isImageFile(filename) end, onConfirm = function(image_file) if DocSettings:flushCustomCover(file, image_file) then self:updateBookInfo(file, book_props, "cover") end end, } UIManager:show(path_chooser) end end function BookInfo:setCustomMetadata(file, book_props, prop_key, prop_value) -- in file local custom_doc_settings, custom_props, display_title, no_custom_metadata if self.custom_doc_settings then custom_doc_settings = self.custom_doc_settings else -- no custom metadata file, create new custom_doc_settings = DocSettings.openSettingsFile() display_title = book_props.display_title -- backup book_props.display_title = nil custom_doc_settings:saveSetting("doc_props", book_props) -- save a copy of original props end custom_props = custom_doc_settings:readSetting("custom_props", {}) local prop_value_old = custom_props[prop_key] or book_props[prop_key] custom_props[prop_key] = prop_value -- nil when resetting a custom prop if next(custom_props) == nil then -- no more custom metadata os.remove(custom_doc_settings.sidecar_file) DocSettings.removeSidecarDir(util.splitFilePathName(custom_doc_settings.sidecar_file)) no_custom_metadata = true else if book_props.pages then -- keep a copy of original 'pages' up to date local original_props = custom_doc_settings:readSetting("doc_props") original_props.pages = book_props.pages end custom_doc_settings:flushCustomMetadata(file) end book_props.display_title = book_props.display_title or display_title -- restore -- in memory prop_value = prop_value or custom_doc_settings:readSetting("doc_props")[prop_key] -- set custom or restore original book_props[prop_key] = prop_value if prop_key == "title" then -- generate when resetting the customized title and original is empty book_props.display_title = book_props.title or filemanagerutil.splitFileNameType(file) end if self.document and self.document.file == file then -- currently opened document self.ui.doc_props[prop_key] = prop_value if prop_key == "title" then self.ui.doc_props.display_title = book_props.display_title end if no_custom_metadata then self.ui.doc_settings:getCustomMetadataFile(true) -- reset metadata file cache end end self:updateBookInfo(file, book_props, prop_key, prop_value_old) end function BookInfo:showCustomEditDialog(file, book_props, prop_key) local prop = book_props[prop_key] if prop and prop_key == "description" then prop = util.htmlToPlainTextIfHtml(prop) end local input_dialog input_dialog = InputDialog:new{ title = _("Edit book metadata:") .. " " .. self.prop_text[prop_key]:gsub(":", ""), input = prop, input_type = prop_key == "series_index" and "number", allow_newline = prop_key == "authors" or prop_key == "keywords" or prop_key == "description", buttons = { { { text = _("Cancel"), id = "close", callback = function() UIManager:close(input_dialog) end, }, { text = _("Save"), callback = function() local prop_value = input_dialog:getInputValue() if prop_value and prop_value ~= "" then UIManager:close(input_dialog) self:setCustomMetadata(file, book_props, prop_key, prop_value) end end, }, }, }, } UIManager:show(input_dialog) input_dialog:onShowKeyboard() end function BookInfo:showCustomDialog(file, book_props, prop_key) local original_prop, custom_prop, prop_is_cover if prop_key then -- metadata if self.custom_doc_settings then original_prop = self.custom_doc_settings:readSetting("doc_props")[prop_key] custom_prop = self.custom_doc_settings:readSetting("custom_props")[prop_key] else original_prop = book_props[prop_key] end if original_prop and prop_key == "description" then original_prop = util.htmlToPlainTextIfHtml(original_prop) end prop_is_cover = false else -- cover prop_key = "cover" prop_is_cover = true end local button_dialog local buttons = { { { text = _("Copy original"), enabled = original_prop ~= nil and Device:hasClipboard(), callback = function() UIManager:close(button_dialog) Device.input.setClipboardText(original_prop) end, }, { text = _("View original"), enabled = original_prop ~= nil or prop_is_cover, callback = function() if prop_is_cover then self:onShowBookCover(file, true) else self:showBookProp(prop_key, original_prop) end end, }, }, { { text = _("Reset custom"), enabled = custom_prop ~= nil or (prop_is_cover and self.custom_book_cover ~= nil), callback = function() local confirm_box = ConfirmBox:new{ text = prop_is_cover and _("Reset custom cover?\nImage file will be deleted.") or _("Reset custom book metadata field?"), ok_text = _("Reset"), ok_callback = function() UIManager:close(button_dialog) if prop_is_cover then self:setCustomCover(file, book_props) else self:setCustomMetadata(file, book_props, prop_key) end end, } UIManager:show(confirm_box) end, }, { text = _("Set custom"), enabled = not prop_is_cover or (prop_is_cover and self.custom_book_cover == nil), callback = function() UIManager:close(button_dialog) if prop_is_cover then self:setCustomCover(file, book_props) else self:showCustomEditDialog(file, book_props, prop_key) end end, }, }, } button_dialog = ButtonDialog:new{ title = _("Book metadata:") .. " " .. self.prop_text[prop_key]:gsub(":", ""), title_align = "center", buttons = buttons, } UIManager:show(button_dialog) end function BookInfo:moveBookMetadata() -- called by filemanagermenu only local file_chooser = self.ui.file_chooser local function scanPath() local sys_folders = { -- do not scan sys_folders ["/dev"] = true, ["/proc"] = true, ["/sys"] = true, } local books_to_move = {} local dirs = { file_chooser.path } while #dirs ~= 0 do local new_dirs = {} for _, d in ipairs(dirs) do local ok, iter, dir_obj = pcall(lfs.dir, d) if ok then for f in iter, dir_obj do local fullpath = "/" .. f if d ~= "/" then fullpath = d .. fullpath end local attributes = lfs.attributes(fullpath) or {} if attributes.mode == "directory" and f ~= "." and f ~= ".." and file_chooser:show_dir(f) and not sys_folders[fullpath] then table.insert(new_dirs, fullpath) elseif attributes.mode == "file" and not util.stringStartsWith(f, "._") and file_chooser:show_file(f) and DocSettings.isSidecarFileNotInPreferredLocation(fullpath) then table.insert(books_to_move, fullpath) end end end end dirs = new_dirs end return books_to_move end UIManager:show(ConfirmBox:new{ text = _("Scan books in current folder and subfolders for their metadata location?"), ok_text = _("Scan"), ok_callback = function() local books_to_move = scanPath() local books_to_move_nb = #books_to_move if books_to_move_nb == 0 then UIManager:show(InfoMessage:new{ text = _("No books with metadata not in your preferred location found."), }) else UIManager:show(ConfirmBox:new{ text = T(N_("1 book with metadata not in your preferred location found.", "%1 books with metadata not in your preferred location found.", books_to_move_nb), books_to_move_nb) .. "\n" .. _("Move book metadata to your preferred location?"), ok_text = _("Move"), ok_callback = function() UIManager:close(self.menu_container) for _, book in ipairs(books_to_move) do DocSettings.updateLocation(book, book) end file_chooser:refreshPath() end, }) end end, }) end function BookInfo.showBooksWithHashBasedMetadata() local header = T(_("Hash-based metadata has been saved in %1 for the following documents. Hash-based storage may slow down file browser navigation in large directories. Thus, if not using hash-based metadata storage, it is recommended to open the associated documents in KOReader to automatically migrate their metadata to the preferred storage location, or to delete %1, which will speed up file browser navigation."), DocSettings.getSidecarStorage("hash")) local file_info = { header .. "\n" } local sdrs = DocSettings.findSidecarFilesInHashLocation() for i, sdr in ipairs(sdrs) do local sidecar_file, custom_metadata_file = unpack(sdr) local doc_settings = DocSettings.openSettingsFile(sidecar_file) local doc_props = doc_settings:readSetting("doc_props") local custom_props = custom_metadata_file and DocSettings.openSettingsFile(custom_metadata_file):readSetting("custom_props") or {} local doc_path = doc_settings:readSetting("doc_path") local title = custom_props.title or doc_props.title or filemanagerutil.splitFileNameType(doc_path) local author = custom_props.authors or doc_props.authors or _("N/A") doc_path = lfs.attributes(doc_path, "mode") == "file" and doc_path or _("N/A") local text = T(_("%1. Title: %2; Author: %3\nDocument: %4"), i, title, author, doc_path) table.insert(file_info, text) end local doc_nb = #file_info - 1 UIManager:show(TextViewer:new{ title = T(N_("1 document with hash-based metadata", "%1 documents with hash-based metadata", doc_nb), doc_nb), title_multilines = true, justified = false, text = table.concat(file_info, "\n"), }) end return BookInfo