mirror of https://github.com/koreader/koreader
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).reviewable/pr6282/r1
parent
2e731dd4dd
commit
83cde64bcc
@ -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
|
@ -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.]]),
|
||||
}
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
Loading…
Reference in New Issue