From 83cde64bccad8a461a5ebe457733ca3959d26b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Fern=C3=A1ndez?= Date: Fri, 19 Jun 2020 12:22:38 +0200 Subject: [PATCH] unified calibre plugin (#6177) joins calibre metadata search and calibre wireless connections into a single plugin search metadata changes: - search directly into calibre metadata files. - search can be performed on more than one library (configurable from a menu) - device scans now find all calibre libraries under a given root - search options can be configured from a menu. (case sensitive, find by title, author and path) - removed legacy global variables. - *option* to search from the reader - *option* to generate a cache of books for faster searches. calibre wireless connection changes: - keep track of books in a library (includes prunning books from calibre metadata if the file was deleted locally) - remove files on device from calibre - support password protected connections - FM integration: if we're in the inbox dir it will be updated each time a book is added or deleted. - disconnect when requested by calibre, available on newer calibre versions (+4.17) - remove unused opcodes. - better report of client name, version and device id - free disk space checks for all calibre versions - bump supported extensions to match what KOReader can handle. Users can override this with their own list of extensions (or from calibre, by configuring the wireless device). --- .luacheckrc | 9 - defaults.lua | 21 +- frontend/apps/filemanager/filemanager.lua | 6 + frontend/apps/filemanager/filemanagermenu.lua | 9 - .../apps/filemanager/filemanagersearch.lua | 689 ------------------ .../apps/reader/modules/readergesture.lua | 13 + frontend/device/kindle/device.lua | 2 +- .../ui/elements/filemanager_menu_order.lua | 2 +- frontend/ui/elements/reader_menu_order.lua | 3 +- frontend/util.lua | 75 ++ plugins/calibre.koplugin/_meta.lua | 6 + plugins/calibre.koplugin/extensions.lua | 37 + plugins/calibre.koplugin/main.lua | 333 +++++++++ plugins/calibre.koplugin/metadata.lua | 250 +++++++ plugins/calibre.koplugin/search.lua | 608 ++++++++++++++++ plugins/calibre.koplugin/wireless.lua | 642 ++++++++++++++++ plugins/calibrecompanion.koplugin/_meta.lua | 6 - plugins/calibrecompanion.koplugin/main.lua | 493 ------------- spec/unit/defaults_spec.lua | 36 +- 19 files changed, 2000 insertions(+), 1240 deletions(-) delete mode 100644 frontend/apps/filemanager/filemanagersearch.lua create mode 100644 plugins/calibre.koplugin/_meta.lua create mode 100644 plugins/calibre.koplugin/extensions.lua create mode 100644 plugins/calibre.koplugin/main.lua create mode 100644 plugins/calibre.koplugin/metadata.lua create mode 100644 plugins/calibre.koplugin/search.lua create mode 100644 plugins/calibre.koplugin/wireless.lua delete mode 100644 plugins/calibrecompanion.koplugin/_meta.lua delete mode 100644 plugins/calibrecompanion.koplugin/main.lua diff --git a/.luacheckrc b/.luacheckrc index 9298cedb7..b56daf1f5 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -9,7 +9,6 @@ globals = { read_globals = { "_ENV", - "ANDROID_FONT_DIR", "KOBO_TOUCH_MIRRORED", "KOBO_SYNC_BRIGHTNESS_WITH_NICKEL", "DHINTCOUNT", @@ -113,14 +112,6 @@ read_globals = { "DGESDETECT_DISABLE_DOUBLE_TAP", "FRONTLIGHT_SENSITIVITY_DECREASE", "DALPHA_SORT_CASE_INSENSITIVE", - "SEARCH_CASESENSITIVE", - "SEARCH_AUTHORS", - "SEARCH_TITLE", - "SEARCH_TAGS", - "SEARCH_SERIES", - "SEARCH_PATH", - "SEARCH_LIBRARY_PATH", - "SEARCH_LIBRARY_PATH2", "KOBO_LIGHT_ON_START", "NETWORK_PROXY", "DUSE_TURBO_LIB", diff --git a/defaults.lua b/defaults.lua index 4168eb94f..71e37f8e9 100644 --- a/defaults.lua +++ b/defaults.lua @@ -224,22 +224,23 @@ FRONTLIGHT_SENSITIVITY_DECREASE = 2 -- insensitive sort DALPHA_SORT_CASE_INSENSITIVE = true +-- no longer needed -- Set a path to a folder that is filled by Calibre (must contain the file metadata.calibre) -- e.g. -- "/mnt/sd/.hidden" for Kobo with files in ".hidden" on the SD card -- "/mnt/onboard/MyPath" for Kobo with files in "MyPath" on the device itself -- "/mnt/us/documents/" for Kindle files in folder "documents" -SEARCH_LIBRARY_PATH = "" -SEARCH_LIBRARY_PATH2 = "" - +--SEARCH_LIBRARY_PATH = "" +--SEARCH_LIBRARY_PATH2 = "" +-- -- Search parameters -SEARCH_CASESENSITIVE = false - -SEARCH_AUTHORS = true -SEARCH_TITLE = true -SEARCH_TAGS = true -SEARCH_SERIES = true -SEARCH_PATH = true +--SEARCH_CASESENSITIVE = false +-- +--SEARCH_AUTHORS = true +--SEARCH_TITLE = true +--SEARCH_TAGS = true +--SEARCH_SERIES = true +--SEARCH_PATH = true -- Light parameter for Kobo KOBO_LIGHT_ON_START = -2 -- -1, -2 or 0-100. diff --git a/frontend/apps/filemanager/filemanager.lua b/frontend/apps/filemanager/filemanager.lua index 7560ef70b..5794d1cf9 100644 --- a/frontend/apps/filemanager/filemanager.lua +++ b/frontend/apps/filemanager/filemanager.lua @@ -641,6 +641,12 @@ function FileManager:reinit(path, focused_file) -- self:onRefresh() end +function FileManager:getCurrentDir() + if self.instance then + return self.instance.file_chooser.path + end +end + function FileManager:toggleHiddenFiles() self.file_chooser:toggleHiddenFiles() G_reader_settings:saveSetting("show_hidden", self.file_chooser.show_hidden) diff --git a/frontend/apps/filemanager/filemanagermenu.lua b/frontend/apps/filemanager/filemanagermenu.lua index b4a4b22c2..38260c44f 100644 --- a/frontend/apps/filemanager/filemanagermenu.lua +++ b/frontend/apps/filemanager/filemanagermenu.lua @@ -6,7 +6,6 @@ local Device = require("device") local Event = require("ui/event") local InputContainer = require("ui/widget/container/inputcontainer") local PluginLoader = require("pluginloader") -local Search = require("apps/filemanager/filemanagersearch") local SetDefaults = require("apps/filemanager/filemanagersetdefaults") local UIManager = require("ui/uimanager") local Screen = Device.screen @@ -475,14 +474,6 @@ function FileManagerMenu:setUpdateItemTable() end, } - -- search tab - self.menu_items.find_book_in_calibre_catalog = { - text = _("Find a book via calibre metadata"), - callback = function() - Search:getCalibre() - Search:ShowSearch() - end - } self.menu_items.find_file = { -- @translators Search for files by name. text = _("Find a file"), diff --git a/frontend/apps/filemanager/filemanagersearch.lua b/frontend/apps/filemanager/filemanagersearch.lua deleted file mode 100644 index de420722c..000000000 --- a/frontend/apps/filemanager/filemanagersearch.lua +++ /dev/null @@ -1,689 +0,0 @@ -local CenterContainer = require("ui/widget/container/centercontainer") -local DocumentRegistry = require("document/documentregistry") -local Font = require("ui/font") -local InputDialog = require("ui/widget/inputdialog") -local InfoMessage = require("ui/widget/infomessage") -local InputContainer = require("ui/widget/container/inputcontainer") -local Menu = require("ui/widget/menu") -local Screen = require("device").screen -local UIManager = require("ui/uimanager") -local lfs = require("libs/libkoreader-lfs") -local logger = require("logger") -local FFIUtil = require("ffi/util") -local util = require("util") -local _ = require("gettext") -local T = require("ffi/util").template - -local calibre = "metadata.calibre" -local koreaderfile = "temp/metadata.koreader" - -local Search = InputContainer:new{ - search_dialog = nil, - title = 1, - authors = 2, - authors2 = 3, - path = 4, - series = 5, - series_index = 6, - tags = 7, - tags2 = 8, - tags3 = 9, - count = 0, - data = {}, - results = {}, - browse_tags = {}, - browse_series = {}, - error = nil, - use_previous_search_results = false, - lastsearch = nil, - use_own_metadata_file = false, - metafile_1 = nil, - metafile_2 = nil, -} - -local function findcalibre(root) - local t = nil - -- protect lfs.dir which will raise error on no-permission directory - local ok, iter, dir_obj = pcall(lfs.dir, root) - if ok then - for entity in iter, dir_obj do - if t then - break - else - if entity ~= "." and entity ~= ".." then - local fullPath=root .. "/" .. entity - local mode = lfs.attributes(fullPath, "mode") - if mode == "file" then - if entity == calibre or entity == "." .. calibre then - t = root .. "/" .. entity - -- If we got so far, SEARCH_LIBRARY_PATH is either empty or bogus, so, re-set it, - -- so that we actually can convert a book's relative path to its absolute path. - -- NOTE: No-one should actually rely on that, as the value is *NEVER* saved to the defaults. - -- (SetDefaults can only do that with values modified from within its own advanced menu). - _G['SEARCH_LIBRARY_PATH'] = root .. "/" - logger.info("FMSearch: Found a SEARCH_LIBRARY_PATH @", SEARCH_LIBRARY_PATH) - end - elseif mode == "directory" then - t = findcalibre(fullPath) - end - end - end - end - end - return t -end - -function Search:getCalibre() - -- check if we find the calibre file - -- check 1st file - if SEARCH_LIBRARY_PATH == nil then - logger.dbg("search Calibre database") - self.metafile_1 = findcalibre("/mnt") - if not self.metafile_1 then - self.error = _("The SEARCH_LIBRARY_PATH variable must be defined in 'persistent.defaults.lua' in order to use the calibre file search functionality.") - end - else - if string.sub(SEARCH_LIBRARY_PATH, string.len(SEARCH_LIBRARY_PATH)) ~= "/" then - _G['SEARCH_LIBRARY_PATH'] = SEARCH_LIBRARY_PATH .. "/" - end - if io.open(SEARCH_LIBRARY_PATH .. calibre, "r") == nil then - if io.open(SEARCH_LIBRARY_PATH .. "." .. calibre, "r") == nil then - self.error = SEARCH_LIBRARY_PATH .. calibre .. " " .. _("not found.") - logger.err(self.error) - else - self.metafile_1 = SEARCH_LIBRARY_PATH .. "." .. calibre - end - else - self.metafile_1 = SEARCH_LIBRARY_PATH .. calibre - end - - if not (SEARCH_AUTHORS or SEARCH_TITLE or SEARCH_PATH or SEARCH_SERIES or SEARCH_TAGS) then - self.metafile_1 = nil - UIManager:show(InfoMessage:new{text = _("You must specify at least one field to search at! (SEARCH_XXX = true in defaults.lua)")}) - elseif self.metafile_1 == nil then - self.metafile_1 = findcalibre("/mnt") - end - end - -- check 2nd file - local dummy - - if string.sub(SEARCH_LIBRARY_PATH2, string.len(SEARCH_LIBRARY_PATH2)) ~= "/" then - _G['SEARCH_LIBRARY_PATH2'] = SEARCH_LIBRARY_PATH2 .. "/" - end - if io.open(SEARCH_LIBRARY_PATH2 .. calibre, "r") == nil then - if io.open(SEARCH_LIBRARY_PATH2 .. "." .. calibre, "r") ~= nil then - dummy = SEARCH_LIBRARY_PATH2 .. "." .. calibre - end - else - dummy = SEARCH_LIBRARY_PATH2 .. calibre - end - if dummy and dummy ~= self.metafile_1 then - self.metafile_2 = dummy - else - self.metafile_2 = nil - end - - -- check if they are newer than our own file - self.use_own_metadata_file = false - if self.metafile_1 then - pcall(lfs.mkdir("temp")) - if io.open(koreaderfile, "r") then - if lfs.attributes(koreaderfile, "modification") > lfs.attributes(self.metafile_1, "modification") then - if self.metafile_2 then - if lfs.attributes(koreaderfile, "modification") > lfs.attributes(self.metafile_2, "modification") then - self.use_own_metadata_file = true - logger.info("FMSearch: Using our own simplified metadata file as it's newer than", self.metafile_2) - end - else - self.use_own_metadata_file = true - logger.info("FMSearch: Using our own simplified metadata file as it's newer than", self.metafile_1) - end - end - end - end -end - -function Search:ShowSearch() - if self.metafile_1 ~= nil then - local dummy = self.search_value - self.search_dialog = InputDialog:new{ - title = _("Search books"), - input = self.search_value, - buttons = { - { - { - text = _("Browse series"), - enabled = true, - callback = function() - self.search_value = self.search_dialog:getInputText() - if self.search_value == dummy and self.lastsearch == "series" then - self.use_previous_search_results = true - else - self.use_previous_search_results = false - end - self.lastsearch = "series" - self:close() - end, - }, - { - text = _("Browse tags"), - enabled = true, - callback = function() - self.search_value = self.search_dialog:getInputText() - if self.search_value == dummy and self.lastsearch == "tags" then - self.use_previous_search_results = true - else - self.use_previous_search_results = false - end - self.lastsearch = "tags" - self:close() - end, - }, - }, - { - { - text = _("Cancel"), - enabled = true, - callback = function() - self.search_dialog:onClose() - UIManager:close(self.search_dialog) - end, - }, - { - -- @translators Search for books in calibre Library, via on-device metadata (as setup by Calibre's 'Send To Device'). - text = _("Find books"), - enabled = true, - callback = function() - self.search_value = self.search_dialog:getInputText() - if self.search_value == dummy and self.lastsearch == "find" then - self.use_previous_search_results = true - else - self.use_previous_search_results = false - end - self.lastsearch = "find" - self:close() - end, - }, - }, - }, - width = math.floor(Screen:getWidth() * 0.8), - height = math.floor(Screen:getHeight() * 0.2), - } - UIManager:show(self.search_dialog) - self.search_dialog:onShowKeyboard() - else - if self.error then - UIManager:show(InfoMessage:new{ - text = ("%s\n%s"):format( - self.error, - _("Unable to find a calibre metadata file.")), - }) - end - end - -end - -function Search:init() - self.error = nil - self.data = {} - self.results = {} -end - -function Search:close() - if self.search_value then - self.search_dialog:onClose() - UIManager:close(self.search_dialog) - if string.len(self.search_value) > 0 or self.lastsearch ~= "find" then - self:find(self.lastsearch) - end - end -end - -function Search:find(option) - local f - local line - local i = 1 - local upsearch - local firstrun - - -- removes leading and closing characters and converts hex-unicodes - local ReplaceHexChars = function(s, n, j) - local l=string.len(s) - - if string.sub(s, l, l) == "\"" then - s=string.sub(s, n, string.len(s)-1) - else - s=string.sub(s, n, string.len(s)-j) - end - - s=string.gsub(s, "\\u([a-f0-9][a-f0-9][a-f0-9][a-f0-9])", function(w) return util.unicodeCodepointToUtf8(tonumber(w, 16)) end) - - return s - end - - -- ready entries with multiple lines from calibre - local ReadMultipleLines = function(s) - self.data[i][s] = "" - if s == self.authors then - self.data[i][self.authors2] = "" - elseif s == self.tags then - self.data[i][self.tags2] = "" - self.data[i][self.tags3] = "" - end - while line ~= " ], " and line ~= " ]" do - line = f:read() - if line ~= " ], " and line ~= " ]" then - self.data[i][s] = self.data[i][s] .. "," .. ReplaceHexChars(line, 8, 3) - if s == self.authors then - self.data[i][self.authors2] = self.data[i][self.authors2] .. " & " .. ReplaceHexChars(line, 8, 3) - elseif s == self.tags then - local tags_line = ReplaceHexChars(line, 8, 3) - self.data[i][self.tags2] = self.data[i][self.tags2] .. " & " .. tags_line - self.data[i][self.tags3] = self.data[i][self.tags3] .. "\t" .. tags_line - self.browse_tags[tags_line] = (self.browse_tags[tags_line] or 0) + 1 - end - end - end - self.data[i][s] = string.sub(self.data[i][s], 2) - if s == self.authors then - self.data[i][self.authors2] = string.sub(self.data[i][self.authors2], 4) - elseif s == self.tags then - self.data[i][self.tags2] = string.sub(self.data[i][self.tags2], 4) - self.data[i][self.tags3] = self.data[i][self.tags3] .. "\t" - end - end - - if not self.use_previous_search_results then - self.results = {} - self.data = {} - self.browse_series = {} - self.browse_tags = {} - - if SEARCH_CASESENSITIVE then - upsearch = self.search_value or "" - else - upsearch = string.upper(self.search_value or "") - end - - firstrun = true - - self.data[i] = {"-","-","-","-","-","-","-","-","-"} - - if self.use_own_metadata_file then - local g = io.open(koreaderfile, "r") - line = g:read() - if line ~= "#metadata.Koreader Version 1.1" and line ~= "#metadata.koreader Version 1.1" then - self.use_own_metadata_file = false - g:close() - else - line = g:read() - end - if self.use_own_metadata_file then - while line do - - for j = 1,9 do - self.data[i][j] = line or "" - line = g:read() - end - - local search_content = "" - if option == "find" and SEARCH_AUTHORS then - search_content = search_content .. self.data[i][self.authors] .. "\n" - end - if option == "find" and SEARCH_TITLE then - search_content = search_content .. self.data[i][self.title] .. "\n" - end - if option == "find" and SEARCH_PATH then - search_content = search_content .. self.data[i][self.path] .. "\n" - end - if (option == "series" or SEARCH_SERIES) and self.data[i][self.series] ~= "-" then - search_content = search_content .. self.data[i][self.series] .. "\n" - self.browse_series[self.data[i][self.series]] = (self.browse_series[self.data[i][self.series]] or 0) + 1 - end - if option == "tags" or SEARCH_TAGS then - search_content = search_content .. self.data[i][self.tags] .. "\n" - end - if not SEARCH_CASESENSITIVE then search_content = string.upper(search_content) end - - for j in string.gmatch(self.data[i][self.tags3],"\t[^\t]+") do - if j~="\t" then - self.browse_tags[string.sub(j, 2)] = (self.browse_tags[string.sub(j, 2)] or 0) + 1 - end - end - -- NOTE: This skips kePubs downloaded by nickel, because they don't have a file extension, - -- they're stored as .kobo/kepub/ - if DocumentRegistry:hasProvider(self.data[i][self.path]) then - if upsearch ~= "" then - if string.find(search_content, upsearch, nil, true) then - i = i + 1 - end - else - if option == "series" then - if self.browse_series[self.data[i][self.series]] then - i = i + 1 - end - elseif option == "tags" then - local found = false - for j in string.gmatch(self.data[i][self.tags3],"\t[^\t]+") do - if j~="\t" and self.browse_tags[string.sub(j, 2)] then - found = true - end - end - if found then - i = i + 1 - end - end - end - end - self.data[i] = {"-","-","-","-","-","-","-","-","-"} - end - g.close() - end - end - if not self.use_own_metadata_file then - logger.info("FMSearch: Writing our own simplified metadata file . . .") - local g = io.open(koreaderfile, "w") - g:write("#metadata.koreader Version 1.1\n") - - f = io.open(self.metafile_1, "r") - line = f:read() - while line do - if line == " }, " or line == " }" then - -- new calibre data set - - local search_content = "" - if option == "find" and SEARCH_AUTHORS then search_content = search_content .. self.data[i][self.authors] .. "\n" end - if option == "find" and SEARCH_TITLE then search_content = search_content .. self.data[i][self.title] .. "\n" end - if option == "find" and SEARCH_PATH then search_content = search_content .. self.data[i][self.path] .. "\n" end - if (option == "series" or SEARCH_SERIES) and self.data[i][self.series] ~= "-" then - search_content = search_content .. self.data[i][self.series] .. "\n" - self.browse_series[self.data[i][self.series]] = (self.browse_series[self.data[i][self.series]] or 0) + 1 - end - if option == "tags" or SEARCH_TAGS then search_content = search_content .. self.data[i][self.tags] .. "\n" end - if not SEARCH_CASESENSITIVE then search_content = string.upper(search_content) end - - for j = 1,9 do - g:write(self.data[i][j] .. "\n") - end - - if upsearch ~= "" then - if string.find(search_content, upsearch, nil, true) then - i = i + 1 - end - else - if option == "series" then - if self.browse_series[self.data[i][self.series]] then - i = i + 1 - end - elseif option == "tags" then - local found = false - for j in string.gmatch(self.data[i][self.tags3], "\t[^\t]+") do - if j~="\t" and self.browse_tags[string.sub(j, 2)] then - found = true - end - end - if found then - i = i + 1 - end - end - end - - self.data[i] = {"-","-","-","-","-","-","-","-","-"} - - elseif line == " \"authors\": [" then -- AUTHORS - ReadMultipleLines(self.authors) - elseif line == " \"tags\": [" then -- TAGS - ReadMultipleLines(self.tags) - elseif string.sub(line, 1, 11) == " \"title\"" then -- TITLE - self.data[i][self.title] = ReplaceHexChars(line, 15, 3) - elseif string.sub(line, 1, 11) == " \"lpath\"" then -- LPATH - self.data[i][self.path] = ReplaceHexChars(line, 15, 3) - if firstrun then - self.data[i][self.path] = SEARCH_LIBRARY_PATH .. self.data[i][self.path] - else - self.data[i][self.path] = SEARCH_LIBRARY_PATH2 .. self.data[i][self.path] - end - elseif string.sub(line, 1, 12) == " \"series\"" and line ~= " \"series\": null, " then -- SERIES - self.data[i][self.series] = ReplaceHexChars(line, 16, 3) - elseif string.sub(line, 1, 18) == " \"series_index\"" and line ~= " \"series_index\": null, " then -- SERIES_INDEX - self.data[i][self.series_index] = ReplaceHexChars(line, 21, 2) - end - line = f:read() - - if not line and firstrun then - if f ~= nil then f:close() end - firstrun = false - - if self.metafile_2 then - f = io.open(self.metafile_2, "r") - line = f:read() - end - end - end - g.close() - if lfs.attributes(koreaderfile, "modification") < lfs.attributes(self.metafile_1, "modification") then - lfs.touch(koreaderfile, - lfs.attributes(self.metafile_1, "modification") + 1, - lfs.attributes(self.metafile_1, "modification") + 1) - end - if self.metafile_2 then - if lfs.attributes(koreaderfile, "modification") < lfs.attributes(self.metafile_2, "modification") then - lfs.touch(koreaderfile, lfs.attributes(self.metafile_2, "modification") + 1, lfs.attributes(self.metafile_2, "modification") + 1) - end - end - end - i = i - 1 - self.count = i - end - if self.count > 0 then - self.data[self.count + 1] = nil - if option == "find" then - self:showresults() - else - self:browse(option,1) - end - else - UIManager:show(InfoMessage:new{ - text = T(_("No match for %1."), self.search_value) - }) - end -end - -function Search:onMenuHold(item) - if not item.info or item.info:len() <= 0 then return end - - if item.notchecked then - item.info = item.info .. item.path - local f = io.open(item.path, "r") - if f == nil then - item.info = item.info .. "\n" .. _("File not found.") - else - item.info = item.info .. "\n" .. _("Size:") .. " " .. string.format("%4.1fM", lfs.attributes(item.path, "size")/1024/1024) - f:close() - end - item.notchecked = false - end - local thumbnail - local doc = DocumentRegistry:openDocument(item.path) - if doc then - if doc.loadDocument then -- CreDocument - doc:loadDocument(false) -- load only metadata - end - thumbnail = doc:getCoverPageImage() - doc:close() - end - local thumbwidth = math.min(240, Screen:getWidth()/3) - UIManager:show(InfoMessage:new{ - text = item.info, - image = thumbnail, - image_width = thumbwidth, - image_height = thumbwidth/2*3 - }) -end - -function Search:showresults() - local ReaderUI = require("apps/reader/readerui") - local menu_container = CenterContainer:new{ - dimen = Screen:getSize(), - } - self.search_menu = Menu:new{ - width = Screen:getWidth()-15, - height = Screen:getHeight()-15, - show_parent = menu_container, - onMenuHold = self.onMenuHold, - cface = Font:getFace("smallinfofont"), - _manager = self, - } - table.insert(menu_container, self.search_menu) - self.search_menu.close_callback = function() - UIManager:close(menu_container) - end - if not self.use_previous_search_results then - self.results = {} - local i = 1 - while i <= self.count do - local dummy = T(_("Title: %1"), (self.data[i][self.title] or "-")) .. "\n \n" .. - T(_("Author(s): %1"), (self.data[i][self.authors2] or "-")) .. "\n \n" .. - T(_("Tags: %1"), (self.data[i][self.tags2] or "-")) .. "\n \n" .. - T(_("Series: %1"), (self.data[i][self.series] or "-")) - if self.data[i][self.series] ~= "-" then - dummy = dummy .. " (" .. tostring(self.data[i][self.series_index]):gsub(".0$","") .. ")" - end - dummy = dummy .. "\n \n" .. _("Path: ") - local book = self.data[i][self.path] - table.insert(self.results, { - info = dummy, - notchecked = true, - path = self.data[i][self.path], - text = self.data[i][self.authors] .. ": " .. self.data[i][self.title], - callback = function() - ReaderUI:showReader(book) - self.search_menu:onClose() - end - }) - i = i + 1 - end - end - table.sort(self.results, function(v1,v2) return v1.text < v2.text end) - self.search_menu:switchItemTable(_("Search Results"), self.results) - UIManager:show(menu_container) -end - -function Search:browse(option, run, chosen) - local ReaderUI = require("apps/reader/readerui") - local restart_me = false - local menu_container = CenterContainer:new{ - dimen = Screen:getSize(), - } - self.search_menu = Menu:new{ - width = Screen:getWidth()-15, - height = Screen:getHeight()-15, - show_parent = menu_container, - onMenuHold = self.onMenuHold, - cface = Font:getFace("smallinfofont"), - _manager = self, - } - table.insert(menu_container, self.search_menu) - - self.search_menu.close_callback = function() - UIManager:close(menu_container) - if restart_me then - if string.len(self.search_value) > 0 or self.lastsearch ~= "find" then - self.use_previous_search_results = true - self:getCalibre(1) - self:find(self.lastsearch) - end - end - - end - local upsearch - local dummy - if SEARCH_CASESENSITIVE then - upsearch = self.search_value or "" - else - upsearch = string.upper(self.search_value or "") - end - - if run == 1 then - self.results = {} - if option == "series" then - for v,n in FFIUtil.orderedPairs(self.browse_series) do - dummy = v - if not SEARCH_CASESENSITIVE then dummy = string.upper(dummy) end - if string.find(dummy, upsearch, nil, true) then - table.insert(self.results, { - text = v .. " (" .. tostring(self.browse_series[v]) .. ")", - callback = function() - self:browse(option,2,v) - end - }) - end - end - else - for v,n in FFIUtil.orderedPairs(self.browse_tags) do - dummy = v - if not SEARCH_CASESENSITIVE then dummy = string.upper(dummy) end - if string.find(dummy, upsearch, nil, true) then - table.insert(self.results, { - text = v .. " (" .. tostring(self.browse_tags[v]) .. ")", - callback = function() - self:browse(option,2,v) - end - }) - end - end - end - else - restart_me = true - self.results = {} - local i = 1 - while i <= self.count do - if (option == "tags" and self.data[i][self.tags3]:find("\t" .. chosen .. "\t",nil,true)) or (option == "series" and chosen == self.data[i][self.series]) then - local entry = T(_("Title: %1"), (self.data[i][self.title] or "-")) .. "\n \n" .. - T(_("Author(s): %1"), (self.data[i][self.authors2] or "-")) .. "\n \n" .. - T(_("Tags: %1"), (self.data[i][self.tags2] or "-")) .. "\n \n" .. - T(_("Series: %1"), (self.data[i][self.series] or "-")) - if self.data[i][self.series] ~= "-" then - entry = entry .. " (" .. tostring(self.data[i][self.series_index]):gsub(".0$","") .. ")" - end - entry = entry .. "\n \n" .. _("Path: ") - local book = self.data[i][self.path] - local text - if option == "series" then - if self.data[i][self.series_index] == "0.0" then - text = self.data[i][self.title] .. " (" .. self.data[i][self.authors] .. ")" - else - text = string.format("%6.1f", self.data[i][self.series_index]:gsub(".0$","")) .. ": " .. self.data[i][self.title] .. " (" .. self.data[i][self.authors] .. ")" - end - else - text = self.data[i][self.authors] .. ": " .. self.data[i][self.title] - end - table.insert(self.results, { - text = text, - info = entry, - notchecked = true, - path = self.data[i][self.path], - callback = function() - ReaderUI:showReader(book) - self.search_menu:onClose() - end - }) - end - i = i + 1 - end - end - - local menu_title - if run == 1 then - menu_title = _("Browse") .. " " .. option - else - menu_title = chosen - end - - table.sort(self.results, function(v1,v2) return v1.text < v2.text end) - - self.search_menu:switchItemTable(menu_title, self.results) - UIManager:show(menu_container) -end - -return Search diff --git a/frontend/apps/reader/modules/readergesture.lua b/frontend/apps/reader/modules/readergesture.lua index a691b760e..86a1cd880 100644 --- a/frontend/apps/reader/modules/readergesture.lua +++ b/frontend/apps/reader/modules/readergesture.lua @@ -108,6 +108,9 @@ local action_strings = { wallabag_download = _("Wallabag retrieval"), kosync_push_progress = _("Push progress from this device"), kosync_pull_progress = _("Pull progress from other devices"), + calibre_search = _("Search in calibre metadata"), + calibre_browse_tags = _("Browse all calibre tags"), + calibre_browse_series = _("Browse all calibre series"), } local custom_multiswipes_path = DataStorage:getSettingsDir().."/multiswipes.lua" @@ -792,6 +795,10 @@ function ReaderGesture:buildMenu(ges, default) {"kosync_push_progress", not self.is_docless}, {"kosync_pull_progress", not self.is_docless}, + + {"calibre_search", true}, + {"calibre_browse_tags", true}, + {"calibre_browse_series", true}, } local return_menu = {} -- add default action to the top of the submenu @@ -1580,6 +1587,12 @@ function ReaderGesture:gestureAction(action, ges) self.ui:handleEvent(Event:new("KOSyncPushProgress")) elseif action == "kosync_pull_progress" then self.ui:handleEvent(Event:new("KOSyncPullProgress")) + elseif action == "calibre_search" then + self.ui:handleEvent(Event:new("CalibreSearch")) + elseif action == "calibre_browse_tags" then + self.ui:handleEvent(Event:new("CalibreBrowseTags")) + elseif action == "calibre_browse_series" then + self.ui:handleEvent(Event:new("CalibreBrowseSeries")) end return true end diff --git a/frontend/device/kindle/device.lua b/frontend/device/kindle/device.lua index 5c4a84c62..ee3b783d0 100644 --- a/frontend/device/kindle/device.lua +++ b/frontend/device/kindle/device.lua @@ -96,7 +96,7 @@ local Kindle = Generic:new{ canHWInvert = yes, -- NOTE: Newer devices will turn the frontlight off at 0 canTurnFrontlightOff = yes, - home_dir = "/mnt/us/documents", + home_dir = "/mnt/us", } function Kindle:initNetworkManager(NetworkMgr) diff --git a/frontend/ui/elements/filemanager_menu_order.lua b/frontend/ui/elements/filemanager_menu_order.lua index 06e427447..a89fefa42 100644 --- a/frontend/ui/elements/filemanager_menu_order.lua +++ b/frontend/ui/elements/filemanager_menu_order.lua @@ -90,7 +90,7 @@ local order = { "screen_disable_double_tab", }, tools = { - "calibre_wireless_connection", + "calibre", "evernote", "statistics", "move_to_archive", diff --git a/frontend/ui/elements/reader_menu_order.lua b/frontend/ui/elements/reader_menu_order.lua index 248a458d8..2b903c843 100644 --- a/frontend/ui/elements/reader_menu_order.lua +++ b/frontend/ui/elements/reader_menu_order.lua @@ -114,7 +114,7 @@ local order = { }, tools = { "read_timer", - "calibre_wireless_connection", + "calibre", "evernote", "statistics", "progress_sync", @@ -149,6 +149,7 @@ local order = { "----------------------------", "goodreads", "----------------------------", + "find_book_in_calibre_catalog", "fulltext_search", }, filemanager = {}, diff --git a/frontend/util.lua b/frontend/util.lua index ff544ac35..be6a3c937 100644 --- a/frontend/util.lua +++ b/frontend/util.lua @@ -537,6 +537,17 @@ function util.isEmptyDir(path) return true end +--- check if the given path is a file +---- @string path +---- @treturn bool +function util.fileExists(path) + local file = io.open(path, "r") + if file ~= nil then + file:close() + return true + end +end + --- Checks if the given path exists. Doesn't care if it's a file or directory. ---- @string path ---- @treturn bool @@ -563,6 +574,53 @@ function util.makePath(path) return lfs.mkdir(path) end +--- As `rm` +-- @string path of the file to remove +-- @treturn bool true on success; nil, err_message on error +function util.removeFile(file) + local lfs = require("libs/libkoreader-lfs") + if file and lfs.attributes(file, "mode") == "file" then + return os.remove(file) + elseif file then + return nil, file .. " is not a file" + else + return nil, "file is nil" + end +end + +-- Gets total, used and available bytes for the mountpoint that holds a given directory. +-- @string path of the directory +-- @treturn table with total, used and available bytes +function util.diskUsage(dir) + -- safe way of testing df & awk + local function doCommand(d) + local handle = io.popen("df -k " .. d .. " 2>&1 | awk '$3 ~ /[0-9]+/ { print $2,$3,$4 }' 2>&1 || echo ::ERROR::") + if not handle then return end + local output = handle:read("*all") + handle:close() + if not output:find "::ERROR::" then + return output + end + end + local err = { total = nil, used = nil, available = nil } + local lfs = require("libs/libkoreader-lfs") + if not dir or lfs.attributes(dir, "mode") ~= "directory" then return err end + local usage = doCommand(dir) + if not usage then return err end + local stage, result = {}, {} + for size in usage:gmatch("%w+") do + table.insert(stage, size) + end + for k, v in pairs({"total", "used", "available"}) do + if stage[k] ~= nil then + -- sizes are in kb, return bytes here + result[v] = stage[k] * 1024 + end + end + return result +end + + --- Replaces characters that are invalid filenames. -- -- Replaces the characters \/:*?"<>| with an _. @@ -968,6 +1026,23 @@ function util.clearTable(t) for i = 0, c do t[i] = nil end end +--- Dumps a table into a file. +--- @table t the table to be dumped +--- @string file the file to store the table +--- @treturn bool true on success, false otherwise +function util.dumpTable(t, file) + if not t or not file or file == "" then return end + local dump = require("dump") + local f = io.open(file, "w") + if f then + f:write("return "..dump(t)) + f:close() + return true + end + return false +end + + --- Encode URL also known as percent-encoding see https://en.wikipedia.org/wiki/Percent-encoding --- @string text the string to encode --- @treturn encode string diff --git a/plugins/calibre.koplugin/_meta.lua b/plugins/calibre.koplugin/_meta.lua new file mode 100644 index 000000000..1045583bf --- /dev/null +++ b/plugins/calibre.koplugin/_meta.lua @@ -0,0 +1,6 @@ +local _ = require("gettext") +return { + name = "calibre", + fullname = _("Calibre"), + description = _([[Integration with calibre. Send documents from calibre library via Wi-Fi and search calibre metadata.]]), +} diff --git a/plugins/calibre.koplugin/extensions.lua b/plugins/calibre.koplugin/extensions.lua new file mode 100644 index 000000000..79e301338 --- /dev/null +++ b/plugins/calibre.koplugin/extensions.lua @@ -0,0 +1,37 @@ +--[[ + File formats supported by KOReader. These are reported when the device talks with calibre wireless server. + + Note that the server can allow or restrict file formats based on calibre configuration for each device. + Optionally KOReader users can set their own supported formats to report to the server. +--]] + +local user_path = require("datastorage"):getDataDir() .. "/calibre-extensions.lua" +local ok, extensions = pcall(dofile, user_path) + +if ok then + return extensions +else + return { + "azw", + "cbz", + "chm", + "djv", + "djvu", + "doc", + "docx", + "epub", + "fb2", + "htm", + "html", + "md", + "mobi", + "pdb", + "pdf", + "prc", + "rtf", + "txt", + "xhtml", + "xps", + "zip", + } +end diff --git a/plugins/calibre.koplugin/main.lua b/plugins/calibre.koplugin/main.lua new file mode 100644 index 000000000..35e41451b --- /dev/null +++ b/plugins/calibre.koplugin/main.lua @@ -0,0 +1,333 @@ +--[[ + This plugin implements KOReader integration with *some* calibre features: + + - metadata search + - wireless transfers + + This module handles the UI part of the plugin. +--]] + +local BD = require("ui/bidi") +local CalibreSearch = require("search") +local CalibreWireless = require("wireless") +local InfoMessage = require("ui/widget/infomessage") +local LuaSettings = require("luasettings") +local UIManager = require("ui/uimanager") +local WidgetContainer = require("ui/widget/container/widgetcontainer") +local _ = require("gettext") +local T = require("ffi/util").template + +local Calibre = WidgetContainer:new{ + name = "calibre", + is_doc_only = false, +} + +function Calibre:onCalibreSearch() + CalibreSearch:ShowSearch() + return true +end + +function Calibre:onCalibreBrowseTags() + CalibreSearch.search_value = "" + CalibreSearch:find("tags", 1) + return true +end + +function Calibre:onCalibreBrowseSeries() + CalibreSearch.search_value = "" + CalibreSearch:find("series", 1) + return true +end + +function Calibre:onNetworkDisconnected() + self:closeWirelessConnection() +end + +function Calibre:onSuspend() + self:closeWirelessConnection() +end + +function Calibre:onClose() + self:closeWirelessConnection() +end + +function Calibre:closeWirelessConnection() + if CalibreWireless.calibre_socket then + CalibreWireless:disconnect() + end +end + +function Calibre:init() + CalibreWireless:init() + self.ui.menu:registerToMainMenu(self) +end + +function Calibre:addToMainMenu(menu_items) + menu_items.calibre = { + -- its name is "calibre", but all our top menu items are uppercase. + text = _("Calibre"), + sub_item_table = { + { + text_func = function() + if CalibreWireless.calibre_socket then + return _("Disconnect") + else + return _("Connect") + end + end, + separator = true, + enabled_func = function() + return G_reader_settings:nilOrTrue("calibre_wireless") + end, + callback = function() + if not CalibreWireless.calibre_socket then + CalibreWireless:connect() + else + CalibreWireless:disconnect() + end + end, + }, + { text = _("Search settings"), + keep_menu_open = true, + sub_item_table = self:getSearchMenuTable(), + }, + { + text = _("Wireless settings"), + keep_menu_open = true, + sub_item_table = self:getWirelessMenuTable(), + }, + } + } + -- insert the metadata search + if G_reader_settings:isTrue("calibre_search_from_reader") or not self.ui.view then + menu_items.find_book_in_calibre_catalog = { + text = _("Find a book via calibre metadata"), + callback = function() + CalibreSearch:ShowSearch() + end + } + end +end + +-- search options available from UI +function Calibre:getSearchMenuTable() + return { + { + text = _("Manage libraries"), + separator = true, + keep_menu_open = true, + sub_item_table_func = function() + local result = {} + -- append previous scanned dirs to the list. + local cache = LuaSettings:open(CalibreSearch.user_libraries) + for path, _ in pairs(cache.data) do + table.insert(result, { + text = path, + keep_menu_open = true, + checked_func = function() + return cache:readSetting(path) + end, + callback = function() + cache:saveSetting(path, not cache:readSetting(path)) + cache:flush() + CalibreSearch:invalidateCache() + end, + }) + end + -- if there's no result then no libraries are stored + if #result == 0 then + table.insert(result, { + text = _("No calibre libraries"), + enabled = false + }) + end + table.insert(result, 1, { + text = _("Rescan disk for calibre libraries"), + separator = true, + callback = function() + CalibreSearch:prompt() + end, + }) + return result + end, + }, + { + text = _("Enable searches in the reader"), + checked_func = function() + return G_reader_settings:isTrue("calibre_search_from_reader") + end, + callback = function() + local current = G_reader_settings:isTrue("calibre_search_from_reader") + G_reader_settings:saveSetting("calibre_search_from_reader", not current) + UIManager:show(InfoMessage:new{ + text = _("This will take effect on next restart."), + }) + end, + }, + { + text = _("Store metadata in cache"), + checked_func = function() + return G_reader_settings:nilOrTrue("calibre_search_cache_metadata") + end, + callback = function() + G_reader_settings:flipNilOrTrue("calibre_search_cache_metadata") + end, + }, + { + text = _("Case sensitive search"), + checked_func = function() + return not G_reader_settings:nilOrTrue("calibre_search_case_insensitive") + end, + callback = function() + G_reader_settings:flipNilOrTrue("calibre_search_case_insensitive") + end, + }, + { + text = _("Search by title"), + checked_func = function() + return G_reader_settings:nilOrTrue("calibre_search_find_by_title") + end, + callback = function() + G_reader_settings:flipNilOrTrue("calibre_search_find_by_title") + end, + }, + { + text = _("Search by authors"), + checked_func = function() + return G_reader_settings:nilOrTrue("calibre_search_find_by_authors") + end, + callback = function() + G_reader_settings:flipNilOrTrue("calibre_search_find_by_authors") + end, + }, + { + text = _("Search by path"), + checked_func = function() + return G_reader_settings:nilOrTrue("calibre_search_find_by_path") + end, + callback = function() + G_reader_settings:flipNilOrTrue("calibre_search_find_by_path") + end, + }, + } +end + +-- wireless options available from UI +function Calibre:getWirelessMenuTable() + local function isEnabled() + local enabled = G_reader_settings:nilOrTrue("calibre_wireless") + return enabled and not CalibreWireless.calibre_socket + end + return { + { + text = _("Enable wireless client"), + separator = true, + enabled_func = function() + return not CalibreWireless.calibre_socket + end, + checked_func = function() + return G_reader_settings:nilOrTrue("calibre_wireless") + end, + callback = function() + G_reader_settings:flipNilOrTrue("calibre_wireless") + end, + }, + { + text = _("Set password"), + enabled_func = isEnabled, + callback = function() + CalibreWireless:setPassword() + end, + }, + { + text = _("Set inbox directory"), + enabled_func = isEnabled, + callback = function() + CalibreWireless:setInboxDir() + end, + }, + { + text_func = function() + local address = _("automatic") + if G_reader_settings:has("calibre_wireless_url") then + address = G_reader_settings:readSetting("calibre_wireless_url") + address = string.format("%s:%s", address["address"], address["port"]) + end + return T(_("Server address (%1)"), BD.ltr(address)) + end, + enabled_func = isEnabled, + sub_item_table = { + { + text = _("Automatic"), + checked_func = function() + return G_reader_settings:hasNot("calibre_wireless_url") + end, + callback = function() + G_reader_settings:delSetting("calibre_wireless_url") + end, + }, + { + text = _("Manual"), + checked_func = function() + return G_reader_settings:has("calibre_wireless_url") + end, + callback = function(touchmenu_instance) + local MultiInputDialog = require("ui/widget/multiinputdialog") + local url_dialog + local calibre_url = G_reader_settings:readSetting("calibre_wireless_url") + local calibre_url_address, calibre_url_port + if calibre_url then + calibre_url_address = calibre_url["address"] + calibre_url_port = calibre_url["port"] + end + url_dialog = MultiInputDialog:new{ + title = _("Set custom calibre address"), + fields = { + { + text = calibre_url_address, + input_type = "string", + hint = _("IP Address"), + }, + { + text = calibre_url_port, + input_type = "number", + hint = _("Port"), + }, + }, + buttons = { + { + { + text = _("Cancel"), + callback = function() + UIManager:close(url_dialog) + end, + }, + { + text = _("OK"), + callback = function() + local fields = url_dialog:getFields() + if fields[1] ~= "" then + local port = tonumber(fields[2]) + if not port or port < 1 or port > 65355 then + --default port + port = 9090 + end + G_reader_settings:saveSetting("calibre_wireless_url", {address = fields[1], port = port }) + end + UIManager:close(url_dialog) + if touchmenu_instance then touchmenu_instance:updateItems() end + end, + }, + }, + }, + } + UIManager:show(url_dialog) + url_dialog:onShowKeyboard() + end, + }, + }, + }, + } +end + +return Calibre diff --git a/plugins/calibre.koplugin/metadata.lua b/plugins/calibre.koplugin/metadata.lua new file mode 100644 index 000000000..7ebfe871f --- /dev/null +++ b/plugins/calibre.koplugin/metadata.lua @@ -0,0 +1,250 @@ +--[[ + This module implements functions for loading, saving and editing calibre metadata files. + + Calibre uses JSON to store metadata on device after each wired transfer. + In wireless transfers calibre sends the same metadata to the client, which is in charge + of storing it. +--]] + +local rapidjson = require("rapidjson") +local logger = require("logger") +local util = require("util") + +local unused_metadata = { + "application_id", + "author_link_map", + "author_sort", + "author_sort_map", + "book_producer", + "comments", + "cover", + "db_id", + "identifiers", + "languages", + "pubdate", + "publication_type", + "publisher", + "rating", + "rights", + "thumbnail", + "timestamp", + "title_sort", + "user_categories", + "user_metadata", + "_series_sort_", +} + +--- find calibre files for a given dir +local function findCalibreFiles(dir) + local function existOrLast(file) + local fullname + local options = { file, "." .. file } + for _, option in pairs(options) do + fullname = dir .. "/" .. option + if util.fileExists(fullname) then + return true, fullname + end + end + return false, fullname + end + local ok_meta, file_meta = existOrLast("metadata.calibre") + local ok_drive, file_drive = existOrLast("driveinfo.calibre") + return ok_meta, ok_drive, file_meta, file_drive +end + +local CalibreMetadata = { + -- info about the library itself. It should + -- hold a table with the contents of "driveinfo.calibre" + drive = {}, + -- info about the books in this library. It should + -- hold a table with the contents of "metadata.calibre" + books = {}, +} + +--- loads driveinfo from JSON file +function CalibreMetadata:loadDeviceInfo(file) + if not file then file = self.driveinfo end + local json, err = rapidjson.load(file) + if not json then + logger.warn("Unable to load device info from JSON file:", err) + return {} + end + return json +end + +-- saves driveinfo to JSON file +function CalibreMetadata:saveDeviceInfo(arg) + -- keep previous device name. This allow us to identify the calibre driver used. + -- "Folder" is used by connect to folder + -- "KOReader" is used by smart device app + -- "Amazon", "Kobo", "Bq" ... are used by platform device drivers + local previous_name = self.drive.device_name + self.drive = arg + if previous_name then + self.drive.device_name = previous_name + end + rapidjson.dump(self.drive, self.driveinfo) +end + +-- loads books' metadata from JSON file +function CalibreMetadata:loadBookList() + local json, err = rapidjson.load(self.metadata) + if not json then + logger.warn("Unable to load book list from JSON file:", self.metadata, err) + return {} + end + return json +end + +-- saves books' metadata to JSON file +function CalibreMetadata:saveBookList() + -- replace bad table values with null + local file = self.metadata + local books = self.books + for index, book in ipairs(books) do + for key, item in pairs(book) do + if type(item) == "function" then + books[index][key] = rapidjson.null + end + end + end + rapidjson.dump(rapidjson.array(books), file, { pretty = true }) +end + +-- add a book to our books table +function CalibreMetadata:addBook(metadata) + for _, key in pairs(unused_metadata) do + metadata[key] = nil + end + table.insert(self.books, #self.books + 1, metadata) +end + +-- remove a book from our books table +function CalibreMetadata:removeBook(lpath) + for index, book in ipairs(self.books) do + if book.lpath == lpath then + table.remove(self.books, index) + end + end +end + +-- gets the uuid and index of a book from its path +function CalibreMetadata:getBookUuid(lpath) + for index, book in ipairs(self.books) do + if book.lpath == lpath then + return book.uuid, index + end + end + return "none" +end + +-- gets the book id at the given index +function CalibreMetadata:getBookId(index) + local book = {} + book.priKey = index + for _, key in pairs({ "uuid", "lpath", "last_modified"}) do + book[key] = self.books[index][key] + end + return book +end + +-- gets the book metadata at the given index +function CalibreMetadata:getBookMetadata(index) + local book = self.books[index] + for key, value in pairs(book) do + if type(value) == "function" then + book[key] = rapidjson.null + end + end + return book +end + +-- removes deleted books from table +function CalibreMetadata:prune() + local count = 0 + for index, book in ipairs(self.books) do + local path = self.path .. "/" .. book.lpath + if not util.fileExists(path) then + logger.dbg("prunning book from DB at index", index, "path", path) + self:removeBook(book.lpath) + count = count + 1 + end + end + if count > 0 then + self:saveBookList() + end + return count +end + +-- removes unused metadata from books +function CalibreMetadata:cleanUnused() + local slim_books = self.books + for index, _ in ipairs(slim_books) do + for _, key in pairs(unused_metadata) do + slim_books[index][key] = nil + end + end + self.books = slim_books + self:saveBookList() +end + +-- cleans all temp data stored for current library. +function CalibreMetadata:clean() + self.books = {} + self.drive = {} + self.path = nil + self.driveinfo = nil + self.metadata = nil +end + +-- get keys from driveinfo.calibre +function CalibreMetadata:getDeviceInfo(dir, kind) + if not dir or not kind then return end + local _, ok_drive, __, driveinfo = findCalibreFiles(dir) + if not ok_drive then return end + local drive = self:loadDeviceInfo(driveinfo) + if drive then + return drive[kind] + end +end + +-- initialize a directory as a calibre library. + +-- This is the main function. Call it to initialize a calibre library +-- in a given path. It will find calibre files if they're on disk and +-- try to load info from them. + +-- NOTE: you should care about the books table, because it could be huge. +-- If you're not working with the metadata directly (ie: in wireless connections) +-- you should copy relevant data to another table and free this one to keep things tidy. + +function CalibreMetadata:init(dir, is_search) + if not dir then return end + local socket = require("socket") + local start = socket.gettime() + self.path = dir + local ok_meta, ok_drive, file_meta, file_drive = findCalibreFiles(dir) + self.driveinfo = file_drive + if ok_drive then + self.drive = self:loadDeviceInfo() + end + self.metadata = file_meta + if ok_meta then + self.books = self:loadBookList() + elseif is_search then + -- no metadata to search + return false + end + + local deleted_count = self:prune() + local elapsed = socket.gettime() - start + logger.info(string.format( + "calibre info loaded from disk in %f milliseconds: %d books. %d pruned", + elapsed * 1000, #self.books, deleted_count)) + if not is_search then + self:cleanUnused() + end + return true +end + +return CalibreMetadata diff --git a/plugins/calibre.koplugin/search.lua b/plugins/calibre.koplugin/search.lua new file mode 100644 index 000000000..db94a21cc --- /dev/null +++ b/plugins/calibre.koplugin/search.lua @@ -0,0 +1,608 @@ +--[[ + This module implements calibre metadata searching. +--]] + +local CalibreMetadata = require("metadata") +local CenterContainer = require("ui/widget/container/centercontainer") +local ConfirmBox = require("ui/widget/confirmbox") +local DataStorage = require("datastorage") +local Device = require("device") +local DocumentRegistry = require("document/documentregistry") +local Font = require("ui/font") +local InputDialog = require("ui/widget/inputdialog") +local InfoMessage = require("ui/widget/infomessage") +local InputContainer = require("ui/widget/container/inputcontainer") +local Menu = require("ui/widget/menu") +local Screen = require("device").screen +local UIManager = require("ui/uimanager") +local logger = require("logger") +local socket = require("socket") +local util = require("util") +local _ = require("gettext") +local T = require("ffi/util").template + +-- cache files +local libraries_file = "calibre-libraries.lua" +local metadata_file = "calibre-books.lua" + +-- loads a table from disk +local function loadTable(path) + local ok, data = pcall(dofile, path) + if ok then + return data + else + return nil, data + end +end + +-- get root dir for disk scans +local function getDefaultRootDir() + if Device:isCervantes() or Device:isKobo() then + return "/mnt" + else + return Device.home_dir or lfs.currentdir() + end +end + +-- get metadata from calibre libraries +local function getAllMetadata(t) + local books = {} + for path, enabled in pairs(t) do + if enabled and CalibreMetadata:init(path, true) then + -- calibre BQ driver reports invalid lpath + if Device:isCervantes() then + local device_name = CalibreMetadata.drive.device_name + if device_name and string.match(string.upper(device_name), "BQ") then + path = path .. "/Books" + end + end + for _, book in ipairs(CalibreMetadata.books) do + local slim_book = {} + slim_book.title = book.title + slim_book.lpath = book.lpath + slim_book.authors = book.authors + slim_book.series = book.series + slim_book.series_index = book.series_index + slim_book.tags = book.tags + slim_book.size = book.size + slim_book.rootpath = path + table.insert(books, #books + 1, slim_book) + end + CalibreMetadata:clean() + end + end + return books +end + +-- check if a string matches a query +local function match(str, query, case_insensitive) + if query and case_insensitive then + return string.find(string.upper(str), string.upper(query)) + elseif query then + return string.find(str, query) + else + return true + end +end + +-- get books that exactly match the search tag +local function getBooksByTag(t, tag) + local result = {} + for _, book in ipairs(t) do + for __, _tag in ipairs(book.tags) do + if tag == _tag then + table.insert(result, book) + end + end + end + return result +end + +-- get books that exactly match the search series +local function getBooksBySeries(t, series) + local result = {} + for _, book in ipairs(t) do + if book.series and type(book.series) ~= "function" then + if book.series == series then + table.insert(result, book) + end + end + end + return result +end + +-- get tags that match the search criteria and their frequency +local function searchByTag(t, query, case_insensitive) + local freq = {} + for _, book in ipairs(t) do + for __, tag in ipairs(book.tags) do + if match(tag, query, case_insensitive) then + freq[tag] = (freq[tag] or 0) + 1 + end + end + end + return freq +end + +-- get series that match the search criteria and their frequency +local function searchBySeries(t, query, case_insensitive) + local freq = {} + for _, book in ipairs(t) do + if book.series and type(book.series) ~= "function" then + if match(book.series, query, case_insensitive) then + freq[book.series] = (freq[book.series] or 0) + 1 + end + end + end + return freq +end + +-- get book info as one big string with relevant metadata +local function getBookInfo(book) + -- comma separated elements from a table + local function getEntries(t) + if not t then return end + local id + for i, v in ipairs(t) do + if v ~= nil then + if i == 1 then + id = v + else + id = id .. ", " .. v + end + end + end + return id + end + -- all entries can be empty, except size, which is always filled by calibre. + local title = _("Title:") .. " " .. book.title or "-" + local authors = _("Author(s):") .. " " .. getEntries(book.authors) or "-" + local size = _("Size:") .. " " .. string.format("%4.1fM", book.size/1024/1024) + local tags = getEntries(book.tags) + if tags then + tags = _("Tags:") .. " " .. tags + end + local series + if book.series and type(book.series) ~= "function" then + series = _("Series:") .. " " .. book.series + end + return string.format("%s\n%s\n%s%s%s", title, authors, + tags and tags .. "\n" or "", + series and series .. "\n" or "", + size) +end + +local CalibreSearch = InputContainer:new{ + books = {}, + libraries = {}, + last_scan = {}, + search_options = { + "cache_metadata", + "case_insensitive", + "find_by_title", + "find_by_authors", + "find_by_path", + }, + user_libraries = DataStorage:getDataDir() .. "/cache/" .. libraries_file, + user_book_cache = DataStorage:getDataDir() .. "/cache/" .. metadata_file, +} + +function CalibreSearch:ShowSearch() + self.search_dialog = InputDialog:new{ + title = _("Search books"), + input = self.search_value, + buttons = { + { + { + text = _("Browse series"), + enabled = true, + callback = function() + self.search_value = self.search_dialog:getInputText() + self.lastsearch = "series" + self:close() + end, + }, + { + text = _("Browse tags"), + enabled = true, + callback = function() + self.search_value = self.search_dialog:getInputText() + self.lastsearch = "tags" + self:close() + end, + }, + }, + { + { + text = _("Cancel"), + enabled = true, + callback = function() + self.search_dialog:onClose() + UIManager:close(self.search_dialog) + end, + }, + { + -- @translators Search for books in calibre Library, via on-device metadata (as setup by Calibre's 'Send To Device'). + text = _("Find books"), + enabled = true, + callback = function() + self.search_value = self.search_dialog:getInputText() + self.lastsearch = "find" + self:close() + end, + }, + }, + }, + width = math.floor(Screen:getWidth() * 0.8), + height = math.floor(Screen:getHeight() * 0.2), + } + UIManager:show(self.search_dialog) + self.search_dialog:onShowKeyboard() +end + +function CalibreSearch:close() + if self.search_value then + self.search_dialog:onClose() + UIManager:close(self.search_dialog) + if string.len(self.search_value) > 0 or self.lastsearch ~= "find" then + self:find(self.lastsearch) + end + end +end + +function CalibreSearch:onMenuHold(item) + if not item.info or item.info:len() <= 0 then return end + local thumbnail + local doc = DocumentRegistry:openDocument(item.path) + if doc then + if doc.loadDocument then -- CreDocument + doc:loadDocument(false) -- load only metadata + end + thumbnail = doc:getCoverPageImage() + doc:close() + end + local thumbwidth = math.min(240, Screen:getWidth()/3) + UIManager:show(InfoMessage:new{ + text = item.info, + image = thumbnail, + image_width = thumbwidth, + image_height = thumbwidth/2*3 + }) +end + +function CalibreSearch:bookCatalog(t, option) + local catalog = {} + local series, subseries + if option and option == "series" then + series = true + end + for _, book in ipairs(t) do + local entry = {} + entry.info = getBookInfo(book) + entry.path = book.rootpath .. "/" .. book.lpath + if series then + local major, minor = string.format("%05.2f", book.series_index):match("([^.]+).([^.]+)") + if minor ~= "00" then + subseries = true + end + entry.text = string.format("%s.%s | %s - %s", major, minor, book.title, book.authors[1]) + else + entry.text = string.format("%s - %s", book.title, book.authors[1]) + end + entry.callback = function() + local ReaderUI = require("apps/reader/readerui") + ReaderUI:showReader(book.rootpath .. "/" .. book.lpath) + self.search_menu:onClose() + end + table.insert(catalog, entry) + end + if series and not subseries then + for index, entry in ipairs(catalog) do + catalog[index].text = entry.text:gsub(".00", "", 1) + end + end + return catalog +end + +-- find books, series or tags +function CalibreSearch:find(option) + for _, opt in pairs(self.search_options) do + self[opt] = G_reader_settings:nilOrTrue("calibre_search_"..opt) + end + + if #self.libraries == 0 then + local libs, err = loadTable(self.user_libraries) + if not libs then + logger.warn("no calibre libraries", err) + self:prompt(_("No calibre libraries")) + return + else + self.libraries = libs + end + end + + if #self.books == 0 then + self.books = self:getMetadata() + end + -- this shouldn't happen unless the user disabled all libraries or they are empty. + if #self.books == 0 then + logger.warn("no metadata to search, aborting") + self:prompt(_("No metadata found")) + return + end + + -- measure time elapsed searching + local start = socket.gettime() + if option == "find" then + local books = self:findBooks(self.books, self.search_value) + local result = self:bookCatalog(books) + self:showresults(result) + else + self:browse(option,1) + end + local elapsed = socket.gettime() - start + logger.info(string.format("search done in %f milliseconds (%s, %s, %s, %s, %s)", + elapsed * 1000, + option == "find" and "books" or option, + "case sensitive: " .. tostring(not self.case_insensitive), + "title: " .. tostring(self.find_by_title), + "authors: " .. tostring(self.find_by_authors), + "path: " .. tostring(self.find_by_path))) +end + +-- find books with current search options +function CalibreSearch:findBooks(t, query) + -- handle case sensitivity + local function bookMatch(s, p) + if not s or not p then return false end + if self.case_insensitive then + return string.match(string.upper(s), string.upper(p)) + else + return string.match(s, p) + end + end + -- handle other search preferences + local function bookSearch(book, pattern) + if self.find_by_title and bookMatch(book.title, pattern) then + return true + end + if self.find_by_authors then + for _, author in ipairs(book.authors) do + if bookMatch(author, pattern) then + return true + end + end + end + if self.find_by_path and bookMatch(book.lpath, pattern) then + return true + end + return false + end + -- performs a book search + local results = {} + for i, book in ipairs(t) do + if bookSearch(book, query) then + table.insert(results, #results + 1, book) + end + end + return results +end + +-- browse tags or series +function CalibreSearch:browse(option, run, chosen) + local menu_container = CenterContainer:new{ + dimen = Screen:getSize(), + } + self.search_menu = Menu:new{ + width = Screen:getWidth()-15, + height = Screen:getHeight()-15, + show_parent = menu_container, + onMenuHold = self.onMenuHold, + cface = Font:getFace("smallinfofont"), + _manager = self, + } + table.insert(menu_container, self.search_menu) + self.search_menu.close_callback = function() + UIManager:close(menu_container) + end + if run == 1 then + local menu_entries = {} + local search_value + if self.search_value ~= "" then + search_value = self.search_value + end + local name, source + if option == "tags" then + name = _("Browse by tags") + source = searchByTag(self.books, search_value, self.case_insensitive) + elseif option == "series" then + name = _("Browse by series") + source = searchBySeries(self.books, search_value, self.case_insensitive) + end + for k, v in pairs(source) do + local entry = {} + entry.text = string.format("%s (%d)", k, v) + entry.callback = function() + self:browse(option, 2, k) + end + table.insert(menu_entries, entry) + end + table.sort(menu_entries, function(v1,v2) return v1.text < v2.text end) + self.search_menu:switchItemTable(name, menu_entries) + UIManager:show(menu_container) + else + local results + if option == "tags" then + results = getBooksByTag(self.books, chosen) + elseif option == "series" then + results = getBooksBySeries(self.books, chosen) + end + if results then + local catalog = self:bookCatalog(results, option) + self:showresults(catalog, chosen) + end + end + +end + +-- show search results +function CalibreSearch:showresults(t, title) + if not title then + title = _("Search Results") + end + local menu_container = CenterContainer:new{ + dimen = Screen:getSize(), + } + self.search_menu = Menu:new{ + width = Screen:getWidth()-15, + height = Screen:getHeight()-15, + show_parent = menu_container, + onMenuHold = self.onMenuHold, + cface = Font:getFace("smallinfofont"), + _manager = self, + } + table.insert(menu_container, self.search_menu) + self.search_menu.close_callback = function() + UIManager:close(menu_container) + end + + table.sort(t, function(v1,v2) return v1.text < v2.text end) + self.search_menu:switchItemTable(title, t) + UIManager:show(menu_container) +end + +-- prompt the user for a library scan +function CalibreSearch:prompt(message) + local rootdir = getDefaultRootDir() + local warning = T(_("Scanning libraries can take time. All storage media under %1 will be analyzed"), rootdir) + if message then + message = message .. "\n\n" .. warning + end + UIManager:show(ConfirmBox:new{ + text = message or warning, + ok_text = _("Scan") .. " " .. rootdir, + ok_callback = function() + self.libraries = {} + self.last_scan = {} + self:findCalibre(rootdir) + local paths = "" + for i, dir in ipairs(self.last_scan) do + self.libraries[dir.path] = true + paths = paths .. "\n" .. i .. ": " .. dir.path + end + local count = #self.last_scan + -- append current wireless dir if it wasn't found on the scan + -- this will happen if it is in a nested dir. + local inbox_dir = G_reader_settings:readSetting("inbox_dir") + if inbox_dir and not self.libraries[inbox_dir] then + if CalibreMetadata:getDeviceInfo(inbox_dir, "date_last_connected") then + self.libraries[inbox_dir] = true + count = count + 1 + paths = paths .. "\n" .. count .. ": " .. inbox_dir + end + end + util.dumpTable(self.libraries, self.user_libraries) + self:invalidateCache() + self.books = self:getMetadata() + local info_text + if count == 0 then + info_text = _("No calibre libraries were found") + else + info_text = T(_("Found %1 calibre libraries with %2 books:%3"), count, #self.books, paths) + end + UIManager:show(InfoMessage:new{ text = info_text }) + end, + }) +end + +-- find all calibre libraries under a given root dir +function CalibreSearch:findCalibre(root) + -- protect lfs.dir which will raise error on no-permission directory + local ok, iter, dir_obj = pcall(lfs.dir, root) + local contains_metadata = false + if ok then + for entity in iter, dir_obj do + -- nested libraries aren't allowed + if not contains_metadata then + if entity ~= "." and entity ~= ".." then + local path = root .. "/" .. entity + local mode = lfs.attributes(path, "mode") + if mode == "file" then + if entity == "metadata.calibre" or entity == ".metadata.calibre" then + local library = {} + library.path = root + contains_metadata = true + table.insert(self.last_scan, #self.last_scan + 1, library) + end + elseif mode == "directory" then + self:findCalibre(path) + end + end + end + end + end +end + +-- invalidate current cache +function CalibreSearch:invalidateCache() + util.removeFile(self.user_book_cache) + self.books = {} +end + +-- get metadata from cache or calibre files +function CalibreSearch:getMetadata() + local start = socket.gettime() + local template = "metadata: %d books imported from %s in %f milliseconds" + + -- try to load metadata from cache + if self.cache_metadata then + local function cacheIsNewer(timestamp) + if not timestamp then return false end + local Y, M, D, h, m, s = timestamp:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)") + local date = os.time({year = Y, month = M, day = D, hour = h, min = m, sec = s}) + return lfs.attributes(self.user_book_cache, "modification") > date + end + local cache, err = loadTable(self.user_book_cache) + if not cache then + logger.warn("invalid cache:", err) + else + local is_newer = true + for path, enabled in pairs(self.libraries) do + if enabled and not cacheIsNewer(CalibreMetadata:getDeviceInfo(path, "date_last_connected")) then + is_newer = false + break + end + end + if is_newer then + local elapsed = socket.gettime() - start + logger.info(string.format(template, #cache, "cache", elapsed * 1000)) + return cache + else + logger.warn("cache is older than metadata, ignoring it") + end + end + end + + -- try to load metadata from calibre files and dump it to cache file, if enabled. + local books = getAllMetadata(self.libraries) + if self.cache_metadata then + local dump = {} + local function removeNull(t) + for _, key in ipairs({"series", "series_index"}) do + if type(t[key]) == "function" then + t[key] = nil + end + end + return t + end + for index, book in ipairs(books) do + table.insert(dump, index, removeNull(book)) + end + util.dumpTable(dump, self.user_book_cache) + end + local elapsed = socket.gettime() - start + logger.info(string.format(template, #books, "calibre", elapsed * 1000)) + return books +end + +return CalibreSearch diff --git a/plugins/calibre.koplugin/wireless.lua b/plugins/calibre.koplugin/wireless.lua new file mode 100644 index 000000000..281b6299c --- /dev/null +++ b/plugins/calibre.koplugin/wireless.lua @@ -0,0 +1,642 @@ +--[[ + This module implements the 'smart device app' protocol that communicates with calibre wireless server. + More details can be found at calibre/devices/smart_device_app/driver.py. +--]] + +local BD = require("ui/bidi") +local CalibreMetadata = require("metadata") +local ConfirmBox = require("ui/widget/confirmbox") +local InputContainer = require("ui/widget/container/inputcontainer") +local InputDialog = require("ui/widget/inputdialog") +local InfoMessage = require("ui/widget/infomessage") +local NetworkMgr = require("ui/network/manager") +local UIManager = require("ui/uimanager") +local logger = require("logger") +local rapidjson = require("rapidjson") +local sleep = require("ffi/util").sleep +local sha = require("ffi/sha2") +local util = require("util") +local _ = require("gettext") +local T = require("ffi/util").template + +require("ffi/zeromq_h") + +-- supported formats +local extensions = require("extensions") +local function getExtensionPathLengths() + local t = {} + for _, v in pairs(extensions) do + -- magic number from calibre, see + -- https://github.com/koreader/koreader/pull/6177#discussion_r430753964 + t[v] = 37 + end + return t +end + +-- get real free space on disk or fallback to 1GB +local function getFreeSpace(dir) + return util.diskUsage(dir).available or 1024 * 1024 * 1024 +end + +-- update the view of the dir if we are currently browsing it. +local function updateDir(dir) + local FileManager = require("apps/filemanager/filemanager") + if FileManager:getCurrentDir() == dir then + FileManager.instance:reinit(dir) + end +end + +local CalibreWireless = InputContainer:new{ + id = "KOReader", + model = require("device").model, + version = require("version"):getCurrentRevision(), + -- calibre companion local port + port = 8134, + -- calibre broadcast ports used to find calibre server + broadcast_ports = {54982, 48123, 39001, 44044, 59678}, + opcodes = { + NOOP = 12, + OK = 0, + ERROR = 20, + BOOK_DONE = 11, + CALIBRE_BUSY = 18, + SET_LIBRARY_INFO = 19, + DELETE_BOOK = 13, + DISPLAY_MESSAGE = 17, + FREE_SPACE = 5, + GET_BOOK_FILE_SEGMENT = 14, + GET_BOOK_METADATA = 15, + GET_BOOK_COUNT = 6, + GET_DEVICE_INFORMATION = 3, + GET_INITIALIZATION_INFO = 9, + SEND_BOOKLISTS = 7, + SEND_BOOK = 8, + SEND_BOOK_METADATA = 16, + SET_CALIBRE_DEVICE_INFO = 1, + SET_CALIBRE_DEVICE_NAME = 2, + TOTAL_SPACE = 4, + }, + calibre = {}, +} + +function CalibreWireless:init() + -- reversed operator codes and names dictionary + self.opnames = {} + for name, code in pairs(self.opcodes) do + self.opnames[code] = name + end +end + +function CalibreWireless:find_calibre_server() + local socket = require("socket") + local udp = socket.udp4() + udp:setoption("broadcast", true) + udp:setsockname("*", 8134) + udp:settimeout(3) + for _, port in ipairs(self.broadcast_ports) do + -- broadcast anything to calibre ports and listen to the reply + local _, err = udp:sendto("hello", "255.255.255.255", port) + if not err then + local dgram, host = udp:receivefrom() + if dgram and host then + -- replied diagram has greet message from calibre and calibre hostname + -- calibre opds port and calibre socket port we will later connect to + local _, _, _, replied_port = dgram:match("(.-)%(on (.-)%);(.-),(.-)$") + return host, replied_port + end + end + end +end + +function CalibreWireless:checkCalibreServer(host, port) + local socket = require("socket") + local tcp = socket.tcp() + tcp:settimeout(5) + local client = tcp:connect(host, port) + -- In case of error, the method returns nil followed by a string describing the error. In case of success, the method returns 1. + if client then + tcp:close() + return true + end + return false +end + +function CalibreWireless:initCalibreMQ(host, port) + local StreamMessageQueue = require("ui/message/streammessagequeue") + if self.calibre_socket == nil then + self.calibre_socket = StreamMessageQueue:new{ + host = host, + port = port, + receiveCallback = function(data) + self:onReceiveJSON(data) + if not self.connect_message then + self.password_check_callback = function() + local msg + if self.invalid_password then + msg = _("Invalid password") + self.invalid_password = nil + self:disconnect() + elseif self.disconnected_by_server then + msg = _("Disconnected by calibre") + self.disconnected_by_server = nil + else + msg = T(_("Connected to calibre server at %1"), + BD.ltr(T("%1:%2", host, port))) + end + UIManager:show(InfoMessage:new{ + text = msg, + timeout = 2, + }) + end + self.connect_message = true + UIManager:scheduleIn(1, self.password_check_callback) + if self.failed_connect_callback then + --don't disconnect if we connect in 10 seconds + UIManager:unschedule(self.failed_connect_callback) + end + end + end, + } + self.calibre_socket:start() + self.calibre_messagequeue = UIManager:insertZMQ(self.calibre_socket) + end + logger.info("connected to calibre", host, port) +end + +-- will callback initCalibreMQ if inbox is confirmed to be set +function CalibreWireless:setInboxDir(host, port) + local calibre_device = self + require("ui/downloadmgr"):new{ + onConfirm = function(inbox) + local driver = CalibreMetadata:getDeviceInfo(inbox, "device_name") + local warning = function() + if not driver then return end + return not driver:lower():match("koreader") and not driver:lower():match("folder") + end + local save_and_resume = function() + logger.info("set inbox directory", inbox) + G_reader_settings:saveSetting("inbox_dir", inbox) + if host and port then + calibre_device:initCalibreMQ(host, port) + end + end + -- probably not a good idea to mix calibre drivers because + -- their default settings usually don't match (lpath et al) + if warning() then + UIManager:show(ConfirmBox:new{ + text = T(_([[This folder is already initialized as a %1. + +Mixing calibre libraries is not recommended unless you know what you're doing. + +Do you want to continue? ]]), driver), + + ok_text = _("Continue"), + ok_callback = function() + save_and_resume() + end, + }) + else + save_and_resume() + end + end, + }:chooseDir() +end + +function CalibreWireless:connect() + self.connect_message = false + local host, port + if G_reader_settings:hasNot("calibre_wireless_url") then + host, port = self:find_calibre_server() + else + local calibre_url = G_reader_settings:readSetting("calibre_wireless_url") + host, port = calibre_url["address"], calibre_url["port"] + if not self:checkCalibreServer(host, port) then + host = nil + else + self.failed_connect_callback = function() + UIManager:show(InfoMessage:new{ + text = _("Cannot connect to calibre server."), + }) + self:disconnect() + end + -- wait 10 seconds to connect to calibre + UIManager:scheduleIn(10, self.failed_connect_callback) + end + end + if host and port then + local inbox_dir = G_reader_settings:readSetting("inbox_dir") + if inbox_dir then + CalibreMetadata:init(inbox_dir) + self:initCalibreMQ(host, port) + else + self:setInboxDir(host, port) + end + elseif not NetworkMgr:isConnected() then + NetworkMgr:promptWifiOn() + else + logger.info("cannot connect to calibre server") + UIManager:show(InfoMessage:new{ + text = _("Cannot connect to calibre server."), + }) + return + end +end + +function CalibreWireless:disconnect() + logger.info("disconnect from calibre") + self.connect_message = false + self.calibre_socket:stop() + UIManager:removeZMQ(self.calibre_messagequeue) + self.calibre_socket = nil + self.calibre_messagequeue = nil + CalibreMetadata:clean() +end + +function CalibreWireless:reconnect() + -- to use when something went wrong and we aren't in sync with calibre + sleep(1) + self:disconnect() + sleep(1) + self:connect() +end + +function CalibreWireless:onReceiveJSON(data) + self.buffer = (self.buffer or "") .. (data or "") + --logger.info("data buffer", self.buffer) + -- messages from calibre stream socket are encoded in JSON strings like this + -- 34[0, {"key0":value, "key1": value}] + -- the JSON string has a leading length string field followed by the actual + -- JSON data in which the first element is always the operator code which can + -- be looked up in the opnames dictionary + while self.buffer ~= nil do + --logger.info("buffer", self.buffer) + local index = self.buffer:find('%[') or 1 + local size = tonumber(self.buffer:sub(1, index - 1)) + local json_data + if size and #self.buffer >= index - 1 + size then + json_data = self.buffer:sub(index, index - 1 + size) + --logger.info("json_data", json_data) + -- reset buffer to nil if all buffer is copied out to json data + self.buffer = self.buffer:sub(index + size) + --logger.info("new buffer", self.buffer) + -- data is not complete which means there are still missing data not received + else + return + end + local json, err = rapidjson.decode(json_data) + if json then + --logger.dbg("received json table", json) + local opcode = json[1] + local arg = json[2] + if self.opnames[opcode] == 'GET_INITIALIZATION_INFO' then + self:getInitInfo(arg) + elseif self.opnames[opcode] == 'GET_DEVICE_INFORMATION' then + self:getDeviceInfo(arg) + elseif self.opnames[opcode] == 'SET_CALIBRE_DEVICE_INFO' then + self:setCalibreInfo(arg) + elseif self.opnames[opcode] == 'FREE_SPACE' then + self:getFreeSpace(arg) + elseif self.opnames[opcode] == 'SET_LIBRARY_INFO' then + self:setLibraryInfo(arg) + elseif self.opnames[opcode] == 'GET_BOOK_COUNT' then + self:getBookCount(arg) + elseif self.opnames[opcode] == 'SEND_BOOK' then + self:sendBook(arg) + elseif self.opnames[opcode] == 'DELETE_BOOK' then + self:deleteBook(arg) + elseif self.opnames[opcode] == 'GET_BOOK_FILE_SEGMENT' then + self:sendToCalibre(arg) + elseif self.opnames[opcode] == 'DISPLAY_MESSAGE' then + self:serverFeedback(arg) + elseif self.opnames[opcode] == 'NOOP' then + self:noop(arg) + end + else + logger.warn("failed to decode json data", err) + end + end +end + +function CalibreWireless:sendJsonData(opname, data) + local json, err = rapidjson.encode(rapidjson.array({self.opcodes[opname], data})) + if json then + -- length of json data should be before the real json data + self.calibre_socket:send(tostring(#json)..json) + else + logger.warn("failed to encode json data", err) + end +end + +function CalibreWireless:getInitInfo(arg) + logger.dbg("GET_INITIALIZATION_INFO", arg) + local s = "" + for i, v in ipairs(arg.calibre_version) do + if i == #arg.calibre_version then + s = s .. v + else + s = s .. v .. "." + end + end + self.calibre.version = arg.calibre_version + self.calibre.version_string = s + local getPasswordHash = function() + local password = G_reader_settings:readSetting("calibre_wireless_password") + local challenge = arg.passwordChallenge + if password and challenge then + return sha.sha1(password..challenge) + else + return "" + end + end + + local init_info = { + appName = self.id, + acceptedExtensions = extensions, + cacheUsesLpaths = true, + canAcceptLibraryInfo = true, + canDeleteMultipleBooks = true, + canReceiveBookBinary = true, + canSendOkToSendbook = true, + canStreamBooks = true, + canStreamMetadata = true, + canUseCachedMetadata = true, + ccVersionNumber = self.version, + coverHeight = 240, + deviceKind = self.model, + deviceName = T("%1 (%2)", self.id, self.model), + extensionPathLengths = getExtensionPathLengths(), + passwordHash = getPasswordHash(), + maxBookContentPacketLen = 4096, + useUuidFileNames = false, + versionOK = true, + } + self:sendJsonData('OK', init_info) +end + +function CalibreWireless:setPassword() + local function passwordCheck(p) + local t = type(p) + if t == "number" or (t == "string" and p:match("%S")) then + return true + end + return false + end + local password_dialog + password_dialog = InputDialog:new{ + title = _("Set a password for calibre wireless server"), + input = G_reader_settings:readSetting("calibre_wireless_password") or "", + buttons = {{ + { + text = _("Cancel"), + callback = function() + UIManager:close(password_dialog) + end, + }, + { + text = _("Set password"), + callback = function() + local pass = password_dialog:getInputText() + if passwordCheck(pass) then + G_reader_settings:saveSetting("calibre_wireless_password", pass) + else + G_reader_settings:delSetting("calibre_wireless_password") + end + UIManager:close(password_dialog) + end, + }, + }}, + } + UIManager:show(password_dialog) + password_dialog:onShowKeyboard() +end + +function CalibreWireless:getDeviceInfo(arg) + logger.dbg("GET_DEVICE_INFORMATION", arg) + local device_info = { + device_info = { + device_store_uuid = CalibreMetadata.drive.device_store_uuid, + device_name = T("%1 (%2)", self.id, self.model), + }, + version = self.version, + device_version = self.version, + } + self:sendJsonData('OK', device_info) +end + +function CalibreWireless:setCalibreInfo(arg) + logger.dbg("SET_CALIBRE_DEVICE_INFO", arg) + CalibreMetadata:saveDeviceInfo(arg) + self:sendJsonData('OK', {}) +end + +function CalibreWireless:getFreeSpace(arg) + logger.dbg("FREE_SPACE", arg) + local free_space = { + free_space_on_device = getFreeSpace(G_reader_settings:readSetting("inbox_dir")), + } + self:sendJsonData('OK', free_space) +end + +function CalibreWireless:setLibraryInfo(arg) + logger.dbg("SET_LIBRARY_INFO", arg) + self:sendJsonData('OK', {}) +end + +function CalibreWireless:getBookCount(arg) + logger.dbg("GET_BOOK_COUNT", arg) + local books = { + willStream = true, + willScan = true, + count = #CalibreMetadata.books, + } + self:sendJsonData('OK', books) + for index, _ in ipairs(CalibreMetadata.books) do + local book = CalibreMetadata:getBookId(index) + logger.dbg(string.format("sending book id %d/%d", index, #CalibreMetadata.books)) + self:sendJsonData('OK', book) + end +end + +function CalibreWireless:noop(arg) + logger.dbg("NOOP", arg) + -- calibre wants to close the socket, time to disconnect + if arg.ejecting then + self:sendJsonData('OK', {}) + self.disconnected_by_server = true + self:disconnect() + return + end + -- calibre announces the count of books that need more metadata + if arg.count then + self.pending = arg.count + self.current = 1 + return + end + -- calibre requests more metadata for a book by its index + if arg.priKey then + local book = CalibreMetadata:getBookMetadata(arg.priKey) + logger.dbg(string.format("sending book metadata %d/%d", self.current, self.pending)) + self:sendJsonData('OK', book) + if self.current == self.pending then + self.current = nil + self.pending = nil + return + end + self.current = self.current + 1 + return + end + -- keep-alive NOOP + self:sendJsonData('OK', {}) +end + +function CalibreWireless:sendBook(arg) + logger.dbg("SEND_BOOK", arg) + local inbox_dir = G_reader_settings:readSetting("inbox_dir") + local filename = inbox_dir .. "/" .. arg.lpath + local fits = getFreeSpace(inbox_dir) >= (arg.length + 128 * 1024) + local to_write_bytes = arg.length + local calibre_device = self + local calibre_socket = self.calibre_socket + local outfile + if fits then + logger.dbg("write to file", filename) + util.makePath((util.splitFilePathName(filename))) + outfile = io.open(filename, "wb") + else + local msg = T(_("Can't receive file %1/%2: %3\nNo space left on device"), + arg.thisBook + 1, arg.totalBooks, BD.filepath(filename)) + if self:isCalibreAtLeast(4,18,0) then + -- report the error back to calibre + self:sendJsonData('ERROR', {message = msg}) + return + else + -- report the error in the client + UIManager:show(InfoMessage:new{ + text = msg, + timeout = 2, + }) + self.error_on_copy = true + end + end + -- switching to raw data receiving mode + self.calibre_socket.receiveCallback = function(data) + --logger.info("receive file data", #data) + --logger.info("Memory usage KB:", collectgarbage("count")) + local to_write_data = data:sub(1, to_write_bytes) + if fits then + outfile:write(to_write_data) + end + to_write_bytes = to_write_bytes - #to_write_data + if to_write_bytes == 0 then + if fits then + -- close file as all file data is received and written to local storage + outfile:close() + logger.dbg("complete writing file", filename) + -- add book to local database/table + CalibreMetadata:addBook(arg.metadata) + UIManager:show(InfoMessage:new{ + text = T(_("Received file %1/%2: %3"), + arg.thisBook + 1, arg.totalBooks, BD.filepath(filename)), + timeout = 2, + }) + CalibreMetadata:saveBookList() + updateDir(inbox_dir) + end + -- switch to JSON data receiving mode + calibre_socket.receiveCallback = function(json_data) + calibre_device:onReceiveJSON(json_data) + end + -- if calibre sends multiple files there may be left JSON data + calibre_device.buffer = data:sub(#to_write_data + 1) or "" + --logger.info("device buffer", calibre_device.buffer) + if calibre_device.buffer ~= "" then + UIManager:scheduleIn(0.1, function() + -- since data is already copied to buffer + -- onReceiveJSON parameter should be nil + calibre_device:onReceiveJSON() + end) + end + end + end + self:sendJsonData('OK', {}) + -- end of the batch + if (arg.thisBook + 1) == arg.totalBooks then + if not self.error_on_copy then return end + self.error_on_copy = nil + UIManager:show(ConfirmBox:new{ + text = T(_("Insufficient disk space.\n\ncalibre %1 will report all books as in device. This might lead to errors. Please reconnect to get updated info"), + self.calibre.version_string), + ok_text = _("Reconnect"), + ok_callback = function() + -- send some info to avoid harmless but annoying exceptions in calibre + self:getFreeSpace() + self:getBookCount() + -- scheduled because it blocks! + UIManager:scheduleIn(1, function() + self:reconnect() + end) + end, + }) + end +end + +function CalibreWireless:deleteBook(arg) + logger.dbg("DELETE_BOOK", arg) + self:sendJsonData('OK', {}) + local inbox_dir = G_reader_settings:readSetting("inbox_dir") + if not inbox_dir then return end + -- remove all books requested by calibre + local titles = "" + for i, v in ipairs(arg.lpaths) do + local book_uuid, index = CalibreMetadata:getBookUuid(v) + if not index then + logger.warn("requested to delete a book no longer on device", arg.lpaths[i]) + else + titles = titles .. "\n" .. CalibreMetadata.books[index].title + util.removeFile(inbox_dir.."/"..v) + CalibreMetadata:removeBook(v) + end + self:sendJsonData('OK', { uuid = book_uuid }) + -- do things once at the end of the batch + if i == #arg.lpaths then + local msg + if i == 1 then + msg = T(_("Deleted file: %1"), BD.filepath(arg.lpaths[1])) + else + msg = T(_("Deleted %1 files in %2:\n %3"), + #arg.lpaths, BD.filepath(inbox_dir), titles) + end + UIManager:show(InfoMessage:new{ + text = msg, + timeout = 2, + }) + CalibreMetadata:saveBookList() + updateDir(inbox_dir) + end + end +end + +function CalibreWireless:serverFeedback(arg) + logger.dbg("DISPLAY_MESSAGE", arg) + -- here we only care about password errors + if arg.messageKind == 1 then + self.invalid_password = true + end +end + +function CalibreWireless:sendToCalibre(arg) + logger.dbg("GET_BOOK_FILE_SEGMENT", arg) + -- not implemented yet, we just send an invalid opcode to raise a control error in calibre. + -- If we don't do this calibre will wait *a lot* for the file(s) + self:sendJsonData('NOOP', {}) +end + +function CalibreWireless:isCalibreAtLeast(x,y,z) + local v = self.calibre.version + local function semanticVersion(a,b,c) + return ((a * 100000) + (b * 1000)) + c + end + return semanticVersion(v[1],v[2],v[3]) >= semanticVersion(x,y,z) +end + +return CalibreWireless diff --git a/plugins/calibrecompanion.koplugin/_meta.lua b/plugins/calibrecompanion.koplugin/_meta.lua deleted file mode 100644 index 7a3735578..000000000 --- a/plugins/calibrecompanion.koplugin/_meta.lua +++ /dev/null @@ -1,6 +0,0 @@ -local _ = require("gettext") -return { - name = "calibrecompanion", - fullname = _("Calibre wireless connection"), - description = _([[Send documents from calibre library directly to device via Wi-Fi connection]]), -} diff --git a/plugins/calibrecompanion.koplugin/main.lua b/plugins/calibrecompanion.koplugin/main.lua deleted file mode 100644 index 44c828a01..000000000 --- a/plugins/calibrecompanion.koplugin/main.lua +++ /dev/null @@ -1,493 +0,0 @@ -local BD = require("ui/bidi") -local InputContainer = require("ui/widget/container/inputcontainer") -local InfoMessage = require("ui/widget/infomessage") -local UIManager = require("ui/uimanager") -local JSON = require("json") -local _ = require("gettext") -local NetworkMgr = require("ui/network/manager") -local logger = require("logger") -local util = require("frontend/util") -local T = require("ffi/util").template - -require("ffi/zeromq_h") - ---[[ - This plugin implements a simple Calibre Companion protocol that communicates - with Calibre Wireless Server from which users can send documents to KOReader - devices directly with WIFI connection. - - Note that Calibre Companion(CC) is a trade mark held by MultiPie Ltd. The - Android app Calibre Companion provided by MultiPie is closed-source. This - plugin only implements a subset function of CC according to the open-source - smart device driver from Calibre source tree. - - More details can be found at calibre/devices/smart_device_app/driver.py. ---]] -local CalibreCompanion = InputContainer:new{ - name = "calibrecompanion", - -- calibre companion local port - port = 8134, - -- calibre broadcast ports used to find calibre server - broadcast_ports = {54982, 48123, 39001, 44044, 59678}, - opcodes = { - NOOP = 12, - OK = 0, - BOOK_DONE = 11, - CALIBRE_BUSY = 18, - SET_LIBRARY_INFO = 19, - DELETE_BOOK = 13, - DISPLAY_MESSAGE = 17, - FREE_SPACE = 5, - GET_BOOK_FILE_SEGMENT = 14, - GET_BOOK_METADATA = 15, - GET_BOOK_COUNT = 6, - GET_DEVICE_INFORMATION = 3, - GET_INITIALIZATION_INFO = 9, - SEND_BOOKLISTS = 7, - SEND_BOOK = 8, - SEND_BOOK_METADATA = 16, - SET_CALIBRE_DEVICE_INFO = 1, - SET_CALIBRE_DEVICE_NAME = 2, - TOTAL_SPACE = 4, - }, -} - -function CalibreCompanion:init() - -- reversed operator codes and names dictionary - self.opnames = {} - for name, code in pairs(self.opcodes) do - self.opnames[code] = name - end - self.ui.menu:registerToMainMenu(self) -end - -function CalibreCompanion:find_calibre_server() - local socket = require("socket") - local udp = socket.udp4() - udp:setoption("broadcast", true) - udp:setsockname("*", 8134) - udp:settimeout(3) - for _, port in ipairs(self.broadcast_ports) do - -- broadcast anything to calibre ports and listen to the reply - local _, err = udp:sendto("hello", "255.255.255.255", port) - if not err then - local dgram, host = udp:receivefrom() - if dgram and host then - -- replied diagram has greet message from calibre and calibre hostname - -- calibre opds port and calibre socket port we will later connect to - local _, _, _, replied_port = dgram:match("(.-)%(on (.-)%);(.-),(.-)$") - return host, replied_port - end - end - end -end - -function CalibreCompanion:checkCalibreServer(host, port) - local socket = require("socket") - local tcp = socket.tcp() - tcp:settimeout(5) - local client = tcp:connect(host, port) - -- In case of error, the method returns nil followed by a string describing the error. In case of success, the method returns 1. - if client then - tcp:close() - return true - end - return false -end - -function CalibreCompanion:addToMainMenu(menu_items) - menu_items.calibre_wireless_connection = { - text = _("calibre wireless connection"), - sub_item_table = { - { - text_func = function() - if self.calibre_socket then - return _("Disconnect") - else - return _("Connect") - end - end, - callback = function() - if not self.calibre_socket then - self:connect() - else - self:disconnect() - end - end - }, - { - text = _("Set inbox directory"), - callback = function() - CalibreCompanion:setInboxDir() - end - }, - { - text_func = function() - local address = _("automatic") - if G_reader_settings:has("calibre_wireless_url") then - address = G_reader_settings:readSetting("calibre_wireless_url") - address = string.format("%s:%s", address["address"], address["port"]) - end - return T(_("Server address (%1)"), BD.ltr(address)) - end, - sub_item_table = { - { - text = _("Automatic"), - checked_func = function() - return G_reader_settings:hasNot("calibre_wireless_url") - end, - callback = function() - G_reader_settings:delSetting("calibre_wireless_url") - end, - }, - { - text = _("Manual"), - checked_func = function() - return G_reader_settings:has("calibre_wireless_url") - end, - callback = function(touchmenu_instance) - local MultiInputDialog = require("ui/widget/multiinputdialog") - local url_dialog - local calibre_url = G_reader_settings:readSetting("calibre_wireless_url") - local calibre_url_address, calibre_url_port - if calibre_url then - calibre_url_address = calibre_url["address"] - calibre_url_port = calibre_url["port"] - end - url_dialog = MultiInputDialog:new{ - title = _("Set custom calibre address"), - fields = { - { - text = calibre_url_address, - input_type = "string", - hint = _("IP Address"), - }, - { - text = calibre_url_port, - input_type = "number", - hint = _("Port"), - }, - }, - buttons = { - { - { - text = _("Cancel"), - callback = function() - UIManager:close(url_dialog) - end, - }, - { - text = _("OK"), - callback = function() - local fields = url_dialog:getFields() - if fields[1] ~= "" then - local port = tonumber(fields[2]) - if not port or port < 1 or port > 65355 then - --default port - port = 9090 - end - G_reader_settings:saveSetting("calibre_wireless_url", {address = fields[1], port = port }) - end - UIManager:close(url_dialog) - if touchmenu_instance then touchmenu_instance:updateItems() end - end, - }, - }, - }, - } - UIManager:show(url_dialog) - url_dialog:onShowKeyboard() - end, - }, - } - } - } - } -end - -function CalibreCompanion:initCalibreMQ(host, port) - local StreamMessageQueue = require("ui/message/streammessagequeue") - if self.calibre_socket == nil then - self.calibre_socket = StreamMessageQueue:new{ - host = host, - port = port, - receiveCallback = function(data) - self:onReceiveJSON(data) - if not self.connect_message then - UIManager:show(InfoMessage:new{ - text = T(_("Connected to calibre server at %1"), BD.ltr(T("%1:%2", host, port))), - }) - self.connect_message = true - if self.failed_connect_callback then - --don't disconnect if we connect in 10 seconds - UIManager:unschedule(self.failed_connect_callback) - end - end - end, - } - self.calibre_socket:start() - self.calibre_messagequeue = UIManager:insertZMQ(self.calibre_socket) - end - logger.info("connected to calibre", host, port) -end - --- will callback initCalibreMQ if inbox is confirmed to be set -function CalibreCompanion:setInboxDir(host, port) - local calibre_device = self - require("ui/downloadmgr"):new{ - onConfirm = function(inbox) - logger.info("set inbox directory", inbox) - G_reader_settings:saveSetting("inbox_dir", inbox) - if host and port then - calibre_device:initCalibreMQ(host, port) - end - end, - }:chooseDir() -end - -function CalibreCompanion:connect() - self.connect_message = false - local host, port - if G_reader_settings:hasNot("calibre_wireless_url") then - host, port = self:find_calibre_server() - else - local calibre_url = G_reader_settings:readSetting("calibre_wireless_url") - host, port = calibre_url["address"], calibre_url["port"] - if not self:checkCalibreServer(host, port) then - host = nil - else - self.failed_connect_callback = function() - UIManager:show(InfoMessage:new{ - text = _("Cannot connect to calibre server."), - }) - self:disconnect() - end - -- wait 10 seconds to connect to calibre - UIManager:scheduleIn(10, self.failed_connect_callback) - end - end - if host and port then - local inbox_dir = G_reader_settings:readSetting("inbox_dir") - if inbox_dir then - self:initCalibreMQ(host, port) - else - self:setInboxDir(host, port) - end - elseif not NetworkMgr:isConnected() then - NetworkMgr:promptWifiOn() - else - logger.info("cannot connect to calibre server") - UIManager:show(InfoMessage:new{ - text = _("Cannot connect to calibre server."), - }) - return - end -end - -function CalibreCompanion:disconnect() - logger.info("disconnect from calibre") - self.connect_message = false - self.calibre_socket:stop() - UIManager:removeZMQ(self.calibre_messagequeue) - self.calibre_socket = nil - self.calibre_messagequeue = nil -end - -function CalibreCompanion:onReceiveJSON(data) - self.buffer = (self.buffer or "") .. (data or "") - --logger.info("data buffer", self.buffer) - -- messages from calibre stream socket are encoded in JSON strings like this - -- 34[0, {"key0":value, "key1": value}] - -- the JSON string has a leading length string field followed by the actual - -- JSON data in which the first element is always the operator code which can - -- be looked up in the opnames dictionary - while self.buffer ~= nil do - --logger.info("buffer", self.buffer) - local index = self.buffer:find('%[') or 1 - local size = tonumber(self.buffer:sub(1, index - 1)) - local json_data - if size and #self.buffer >= index - 1 + size then - json_data = self.buffer:sub(index, index - 1 + size) - --logger.info("json_data", json_data) - -- reset buffer to nil if all buffer is copied out to json data - self.buffer = self.buffer:sub(index + size) - --logger.info("new buffer", self.buffer) - -- data is not complete which means there are still missing data not received - else - return - end - local ok, json = pcall(JSON.decode, json_data) - if ok and json then - logger.dbg("received json table", json) - local opcode = json[1] - local arg = json[2] - if self.opnames[opcode] == 'GET_INITIALIZATION_INFO' then - self:getInitInfo(arg) - elseif self.opnames[opcode] == 'GET_DEVICE_INFORMATION' then - self:getDeviceInfo(arg) - elseif self.opnames[opcode] == 'SET_CALIBRE_DEVICE_INFO' then - self:setCalibreInfo(arg) - elseif self.opnames[opcode] == 'FREE_SPACE' then - self:getFreeSpace(arg) - elseif self.opnames[opcode] == 'SET_LIBRARY_INFO' then - self:setLibraryInfo(arg) - elseif self.opnames[opcode] == 'GET_BOOK_COUNT' then - self:getBookCount(arg) - elseif self.opnames[opcode] == 'SEND_BOOKLISTS' then - self:sendBooklists(arg) - elseif self.opnames[opcode] == 'SEND_BOOK' then - self:sendBook(arg) - elseif self.opnames[opcode] == 'NOOP' then - self:noop(arg) - end - else - logger.dbg("failed to decode json data", json_data) - end - end -end - -function CalibreCompanion:sendJsonData(opname, data) - local ok, json = pcall(JSON.encode, {self.opcodes[opname], data}) - if ok and json then - -- length of json data should be before the real json data - self.calibre_socket:send(tostring(#json)..json) - end -end - -function CalibreCompanion:getInitInfo(arg) - logger.dbg("GET_INITIALIZATION_INFO", arg) - self.calibre_info = arg - local init_info = { - canUseCachedMetadata = true, - acceptedExtensions = {"epub", "mobi", "pdf", "djvu", "fb2", "pdb", "cbz"}, - canStreamMetadata = true, - canAcceptLibraryInfo = true, - extensionPathLengths = { - epub = 42, - mobi = 42, - pdf = 42, - djvu = 42, - fb2 = 42, - pdb = 42, - cbz = 42, - }, - useUuidFileNames = false, - passwordHash = "", - canReceiveBookBinary = true, - maxBookContentPacketLen = 4096, - appName = "KOReader Calibre plugin", - ccVersionNumber = 106, - deviceName = "KOReader", - canStreamBooks = true, - versionOK = true, - canDeleteMultipleBooks = true, - canSendOkToSendbook = true, - coverHeight = 240, - cacheUsesLpaths = true, - deviceKind = "KOReader", - } - self:sendJsonData('OK', init_info) -end - -function CalibreCompanion:getDeviceInfo(arg) - logger.dbg("GET_DEVICE_INFORMATION", arg) - local device_info = { - device_info = { - device_store_uuid = G_reader_settings:readSetting("device_store_uuid"), - device_name = "KOReader Calibre Companion", - }, - version = 106, - device_version = "KOReader", - } - self:sendJsonData('OK', device_info) -end - -function CalibreCompanion:setCalibreInfo(arg) - logger.dbg("SET_CALIBRE_DEVICE_INFO", arg) - self.calibre_info = arg - G_reader_settings:saveSetting("device_store_uuid", arg.device_store_uuid) - self:sendJsonData('OK', {}) -end - -function CalibreCompanion:getFreeSpace(arg) - logger.dbg("FREE_SPACE", arg) - --- @todo Portable free space calculation? - -- Assume we have 1GB of free space on device. - local free_space = { - free_space_on_device = 1024*1024*1024, - } - self:sendJsonData('OK', free_space) -end - -function CalibreCompanion:setLibraryInfo(arg) - logger.dbg("SET_LIBRARY_INFO", arg) - self.library_info = arg - self:sendJsonData('OK', {}) -end - -function CalibreCompanion:getBookCount(arg) - logger.dbg("GET_BOOK_COUNT", arg) - local books = { - willStream = true, - willScan = true, - count = 0, - } - self:sendJsonData('OK', books) -end - -function CalibreCompanion:noop(arg) - logger.dbg("NOOP", arg) - if not arg.count then - self:sendJsonData('OK', {}) - end -end - -function CalibreCompanion:sendBooklists(arg) - logger.dbg("SEND_BOOKLISTS", arg) -end - -function CalibreCompanion:sendBook(arg) - logger.dbg("SEND_BOOK", arg) - local inbox_dir = G_reader_settings:readSetting("inbox_dir") - local filename = inbox_dir .. "/" .. arg.lpath - logger.dbg("write to file", filename) - util.makePath((util.splitFilePathName(filename))) - local outfile = io.open(filename, "wb") - local to_write_bytes = arg.length - local calibre_device = self - local calibre_socket = self.calibre_socket - -- switching to raw data receiving mode - self.calibre_socket.receiveCallback = function(data) - --logger.info("receive file data", #data) - --logger.info("Memory usage KB:", collectgarbage("count")) - local to_write_data = data:sub(1, to_write_bytes) - outfile:write(to_write_data) - to_write_bytes = to_write_bytes - #to_write_data - if to_write_bytes == 0 then - -- close file as all file data is received and written to local storage - outfile:close() - logger.info("complete writing file", filename) - UIManager:show(InfoMessage:new{ - text = _("Received file:") .. BD.filepath(filename), - timeout = 1, - }) - -- switch to JSON data receiving mode - calibre_socket.receiveCallback = function(json_data) - calibre_device:onReceiveJSON(json_data) - end - -- if calibre sends multiple files there may be left JSON data - calibre_device.buffer = data:sub(#to_write_data + 1) or "" - logger.info("device buffer", calibre_device.buffer) - if calibre_device.buffer ~= "" then - UIManager:scheduleIn(0.1, function() - -- since data is already copied to buffer - -- onReceiveJSON parameter should be nil - calibre_device:onReceiveJSON() - end) - end - end - end - self:sendJsonData('OK', {}) -end - -return CalibreCompanion diff --git a/spec/unit/defaults_spec.lua b/spec/unit/defaults_spec.lua index a9e5686d0..258f4464b 100644 --- a/spec/unit/defaults_spec.lua +++ b/spec/unit/defaults_spec.lua @@ -8,7 +8,7 @@ describe("defaults module", function() it("should load all defaults from defaults.lua", function() Defaults:init() - assert.is_same(106, #Defaults.defaults_name) + assert.is_same(98, #Defaults.defaults_name) end) it("should save changes to defaults.persistent.lua", function() @@ -16,17 +16,15 @@ describe("defaults module", function() os.remove(persistent_filename) -- To see indices and help updating this when new settings are added: - -- for i=1, 106 do print(i.." ".. Defaults.defaults_name[i]) end + -- for i=1, 98 do print(i.." ".. Defaults.defaults_name[i]) end -- not in persistent but checked in defaults Defaults.changed[20] = true Defaults.changed[50] = true Defaults.changed[56] = true Defaults.changed[85] = true - Defaults.changed[101] = true --SEARCH_LIBRARY_PATH = "" Defaults:saveSettings() - assert.is_same(106, #Defaults.defaults_name) - assert.is_same("SEARCH_LIBRARY_PATH", Defaults.defaults_name[101]) + assert.is_same(98, #Defaults.defaults_name) assert.is_same("DTAP_ZONE_BACKWARD", Defaults.defaults_name[85]) assert.is_same("DCREREADER_CONFIG_WORD_SPACING_LARGE", Defaults.defaults_name[50]) assert.is_same("DCREREADER_CONFIG_H_MARGIN_SIZES_XXX_LARGE", Defaults.defaults_name[20]) @@ -37,7 +35,6 @@ DCREREADER_CONFIG_WORD_SPACING_LARGE = { [1] = 100, [2] = 90 } -SEARCH_LIBRARY_PATH = "" DTAP_ZONE_BACKWARD = { ["y"] = 0, ["x"] = 0, @@ -82,23 +79,22 @@ DCREREADER_CONFIG_WORD_SPACING_LARGE = { [2] = 90, [1] = 100 } -SEARCH_LIBRARY_PATH = "" -DTAP_ZONE_BACKWARD = { - ["y"] = 10, - ["x"] = 10.125, - ["h"] = 20.25, - ["w"] = 20.75 -} -DCREREADER_CONFIG_H_MARGIN_SIZES_XXX_LARGE = { - [2] = 50, - [1] = 50 -} DDOUBLE_TAP_ZONE_PREV_CHAPTER = { ["y"] = 0, ["x"] = 0, ["h"] = 0.25, ["w"] = 0.75 } +DCREREADER_CONFIG_H_MARGIN_SIZES_XXX_LARGE = { + [2] = 50, + [1] = 50 +} +DTAP_ZONE_BACKWARD = { + ["y"] = 10, + ["x"] = 10.125, + ["h"] = 20.25, + ["w"] = 20.75 +} ]], fd:read("*a")) fd:close() @@ -110,7 +106,6 @@ DDOUBLE_TAP_ZONE_PREV_CHAPTER = { local fd = io.open(persistent_filename, "w") fd:write( [[-- For configuration changes that persists between updates -SEARCH_TITLE = true DCREREADER_CONFIG_H_MARGIN_SIZES_LARGE = { [1] = 15, [2] = 15 @@ -128,14 +123,13 @@ DHINTCOUNT = 2 fd = io.open(persistent_filename) assert.Equals( [[-- For configuration changes that persists between updates -SEARCH_TITLE = true +DCREREADER_VIEW_MODE = "page" DCREREADER_CONFIG_H_MARGIN_SIZES_LARGE = { [2] = 15, [1] = 15 } -DHINTCOUNT = 2 DGLOBAL_CACHE_FREE_PROPORTION = 1 -DCREREADER_VIEW_MODE = "page" +DHINTCOUNT = 2 ]], fd:read("*a")) fd:close()