mirror of
https://github.com/koreader/koreader.git
synced 2025-12-13 20:36:53 +01:00
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).
This commit is contained in:
@@ -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",
|
||||
|
||||
21
defaults.lua
21
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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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/<UUID>
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -90,7 +90,7 @@ local order = {
|
||||
"screen_disable_double_tab",
|
||||
},
|
||||
tools = {
|
||||
"calibre_wireless_connection",
|
||||
"calibre",
|
||||
"evernote",
|
||||
"statistics",
|
||||
"move_to_archive",
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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 <code>\/:*?"<>|</code> with an <code>_</code>.
|
||||
@@ -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
|
||||
|
||||
6
plugins/calibre.koplugin/_meta.lua
Normal file
6
plugins/calibre.koplugin/_meta.lua
Normal file
@@ -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.]]),
|
||||
}
|
||||
37
plugins/calibre.koplugin/extensions.lua
Normal file
37
plugins/calibre.koplugin/extensions.lua
Normal file
@@ -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
|
||||
333
plugins/calibre.koplugin/main.lua
Normal file
333
plugins/calibre.koplugin/main.lua
Normal file
@@ -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
|
||||
250
plugins/calibre.koplugin/metadata.lua
Normal file
250
plugins/calibre.koplugin/metadata.lua
Normal file
@@ -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
|
||||
608
plugins/calibre.koplugin/search.lua
Normal file
608
plugins/calibre.koplugin/search.lua
Normal file
@@ -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
|
||||
642
plugins/calibre.koplugin/wireless.lua
Normal file
642
plugins/calibre.koplugin/wireless.lua
Normal file
@@ -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
|
||||
@@ -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]]),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user