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
Martín Fernández 4 years ago committed by GitHub
parent 2e731dd4dd
commit 83cde64bcc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -9,7 +9,6 @@ globals = {
read_globals = {
"_ENV",
"ANDROID_FONT_DIR",
"KOBO_TOUCH_MIRRORED",
"KOBO_SYNC_BRIGHTNESS_WITH_NICKEL",
"DHINTCOUNT",
@ -113,14 +112,6 @@ read_globals = {
"DGESDETECT_DISABLE_DOUBLE_TAP",
"FRONTLIGHT_SENSITIVITY_DECREASE",
"DALPHA_SORT_CASE_INSENSITIVE",
"SEARCH_CASESENSITIVE",
"SEARCH_AUTHORS",
"SEARCH_TITLE",
"SEARCH_TAGS",
"SEARCH_SERIES",
"SEARCH_PATH",
"SEARCH_LIBRARY_PATH",
"SEARCH_LIBRARY_PATH2",
"KOBO_LIGHT_ON_START",
"NETWORK_PROXY",
"DUSE_TURBO_LIB",

@ -224,22 +224,23 @@ FRONTLIGHT_SENSITIVITY_DECREASE = 2
-- insensitive sort
DALPHA_SORT_CASE_INSENSITIVE = true
-- no longer needed
-- Set a path to a folder that is filled by Calibre (must contain the file metadata.calibre)
-- e.g.
-- "/mnt/sd/.hidden" for Kobo with files in ".hidden" on the SD card
-- "/mnt/onboard/MyPath" for Kobo with files in "MyPath" on the device itself
-- "/mnt/us/documents/" for Kindle files in folder "documents"
SEARCH_LIBRARY_PATH = ""
SEARCH_LIBRARY_PATH2 = ""
--SEARCH_LIBRARY_PATH = ""
--SEARCH_LIBRARY_PATH2 = ""
--
-- Search parameters
SEARCH_CASESENSITIVE = false
SEARCH_AUTHORS = true
SEARCH_TITLE = true
SEARCH_TAGS = true
SEARCH_SERIES = true
SEARCH_PATH = true
--SEARCH_CASESENSITIVE = false
--
--SEARCH_AUTHORS = true
--SEARCH_TITLE = true
--SEARCH_TAGS = true
--SEARCH_SERIES = true
--SEARCH_PATH = true
-- Light parameter for Kobo
KOBO_LIGHT_ON_START = -2 -- -1, -2 or 0-100.

@ -641,6 +641,12 @@ function FileManager:reinit(path, focused_file)
-- self:onRefresh()
end
function FileManager:getCurrentDir()
if self.instance then
return self.instance.file_chooser.path
end
end
function FileManager:toggleHiddenFiles()
self.file_chooser:toggleHiddenFiles()
G_reader_settings:saveSetting("show_hidden", self.file_chooser.show_hidden)

@ -6,7 +6,6 @@ local Device = require("device")
local Event = require("ui/event")
local InputContainer = require("ui/widget/container/inputcontainer")
local PluginLoader = require("pluginloader")
local Search = require("apps/filemanager/filemanagersearch")
local SetDefaults = require("apps/filemanager/filemanagersetdefaults")
local UIManager = require("ui/uimanager")
local Screen = Device.screen
@ -475,14 +474,6 @@ function FileManagerMenu:setUpdateItemTable()
end,
}
-- search tab
self.menu_items.find_book_in_calibre_catalog = {
text = _("Find a book via calibre metadata"),
callback = function()
Search:getCalibre()
Search:ShowSearch()
end
}
self.menu_items.find_file = {
-- @translators Search for files by name.
text = _("Find a file"),

@ -1,689 +0,0 @@
local CenterContainer = require("ui/widget/container/centercontainer")
local DocumentRegistry = require("document/documentregistry")
local Font = require("ui/font")
local InputDialog = require("ui/widget/inputdialog")
local InfoMessage = require("ui/widget/infomessage")
local InputContainer = require("ui/widget/container/inputcontainer")
local Menu = require("ui/widget/menu")
local Screen = require("device").screen
local UIManager = require("ui/uimanager")
local lfs = require("libs/libkoreader-lfs")
local logger = require("logger")
local FFIUtil = require("ffi/util")
local util = require("util")
local _ = require("gettext")
local T = require("ffi/util").template
local calibre = "metadata.calibre"
local koreaderfile = "temp/metadata.koreader"
local Search = InputContainer:new{
search_dialog = nil,
title = 1,
authors = 2,
authors2 = 3,
path = 4,
series = 5,
series_index = 6,
tags = 7,
tags2 = 8,
tags3 = 9,
count = 0,
data = {},
results = {},
browse_tags = {},
browse_series = {},
error = nil,
use_previous_search_results = false,
lastsearch = nil,
use_own_metadata_file = false,
metafile_1 = nil,
metafile_2 = nil,
}
local function findcalibre(root)
local t = nil
-- protect lfs.dir which will raise error on no-permission directory
local ok, iter, dir_obj = pcall(lfs.dir, root)
if ok then
for entity in iter, dir_obj do
if t then
break
else
if entity ~= "." and entity ~= ".." then
local fullPath=root .. "/" .. entity
local mode = lfs.attributes(fullPath, "mode")
if mode == "file" then
if entity == calibre or entity == "." .. calibre then
t = root .. "/" .. entity
-- If we got so far, SEARCH_LIBRARY_PATH is either empty or bogus, so, re-set it,
-- so that we actually can convert a book's relative path to its absolute path.
-- NOTE: No-one should actually rely on that, as the value is *NEVER* saved to the defaults.
-- (SetDefaults can only do that with values modified from within its own advanced menu).
_G['SEARCH_LIBRARY_PATH'] = root .. "/"
logger.info("FMSearch: Found a SEARCH_LIBRARY_PATH @", SEARCH_LIBRARY_PATH)
end
elseif mode == "directory" then
t = findcalibre(fullPath)
end
end
end
end
end
return t
end
function Search:getCalibre()
-- check if we find the calibre file
-- check 1st file
if SEARCH_LIBRARY_PATH == nil then
logger.dbg("search Calibre database")
self.metafile_1 = findcalibre("/mnt")
if not self.metafile_1 then
self.error = _("The SEARCH_LIBRARY_PATH variable must be defined in 'persistent.defaults.lua' in order to use the calibre file search functionality.")
end
else
if string.sub(SEARCH_LIBRARY_PATH, string.len(SEARCH_LIBRARY_PATH)) ~= "/" then
_G['SEARCH_LIBRARY_PATH'] = SEARCH_LIBRARY_PATH .. "/"
end
if io.open(SEARCH_LIBRARY_PATH .. calibre, "r") == nil then
if io.open(SEARCH_LIBRARY_PATH .. "." .. calibre, "r") == nil then
self.error = SEARCH_LIBRARY_PATH .. calibre .. " " .. _("not found.")
logger.err(self.error)
else
self.metafile_1 = SEARCH_LIBRARY_PATH .. "." .. calibre
end
else
self.metafile_1 = SEARCH_LIBRARY_PATH .. calibre
end
if not (SEARCH_AUTHORS or SEARCH_TITLE or SEARCH_PATH or SEARCH_SERIES or SEARCH_TAGS) then
self.metafile_1 = nil
UIManager:show(InfoMessage:new{text = _("You must specify at least one field to search at! (SEARCH_XXX = true in defaults.lua)")})
elseif self.metafile_1 == nil then
self.metafile_1 = findcalibre("/mnt")
end
end
-- check 2nd file
local dummy
if string.sub(SEARCH_LIBRARY_PATH2, string.len(SEARCH_LIBRARY_PATH2)) ~= "/" then
_G['SEARCH_LIBRARY_PATH2'] = SEARCH_LIBRARY_PATH2 .. "/"
end
if io.open(SEARCH_LIBRARY_PATH2 .. calibre, "r") == nil then
if io.open(SEARCH_LIBRARY_PATH2 .. "." .. calibre, "r") ~= nil then
dummy = SEARCH_LIBRARY_PATH2 .. "." .. calibre
end
else
dummy = SEARCH_LIBRARY_PATH2 .. calibre
end
if dummy and dummy ~= self.metafile_1 then
self.metafile_2 = dummy
else
self.metafile_2 = nil
end
-- check if they are newer than our own file
self.use_own_metadata_file = false
if self.metafile_1 then
pcall(lfs.mkdir("temp"))
if io.open(koreaderfile, "r") then
if lfs.attributes(koreaderfile, "modification") > lfs.attributes(self.metafile_1, "modification") then
if self.metafile_2 then
if lfs.attributes(koreaderfile, "modification") > lfs.attributes(self.metafile_2, "modification") then
self.use_own_metadata_file = true
logger.info("FMSearch: Using our own simplified metadata file as it's newer than", self.metafile_2)
end
else
self.use_own_metadata_file = true
logger.info("FMSearch: Using our own simplified metadata file as it's newer than", self.metafile_1)
end
end
end
end
end
function Search:ShowSearch()
if self.metafile_1 ~= nil then
local dummy = self.search_value
self.search_dialog = InputDialog:new{
title = _("Search books"),
input = self.search_value,
buttons = {
{
{
text = _("Browse series"),
enabled = true,
callback = function()
self.search_value = self.search_dialog:getInputText()
if self.search_value == dummy and self.lastsearch == "series" then
self.use_previous_search_results = true
else
self.use_previous_search_results = false
end
self.lastsearch = "series"
self:close()
end,
},
{
text = _("Browse tags"),
enabled = true,
callback = function()
self.search_value = self.search_dialog:getInputText()
if self.search_value == dummy and self.lastsearch == "tags" then
self.use_previous_search_results = true
else
self.use_previous_search_results = false
end
self.lastsearch = "tags"
self:close()
end,
},
},
{
{
text = _("Cancel"),
enabled = true,
callback = function()
self.search_dialog:onClose()
UIManager:close(self.search_dialog)
end,
},
{
-- @translators Search for books in calibre Library, via on-device metadata (as setup by Calibre's 'Send To Device').
text = _("Find books"),
enabled = true,
callback = function()
self.search_value = self.search_dialog:getInputText()
if self.search_value == dummy and self.lastsearch == "find" then
self.use_previous_search_results = true
else
self.use_previous_search_results = false
end
self.lastsearch = "find"
self:close()
end,
},
},
},
width = math.floor(Screen:getWidth() * 0.8),
height = math.floor(Screen:getHeight() * 0.2),
}
UIManager:show(self.search_dialog)
self.search_dialog:onShowKeyboard()
else
if self.error then
UIManager:show(InfoMessage:new{
text = ("%s\n%s"):format(
self.error,
_("Unable to find a calibre metadata file.")),
})
end
end
end
function Search:init()
self.error = nil
self.data = {}
self.results = {}
end
function Search:close()
if self.search_value then
self.search_dialog:onClose()
UIManager:close(self.search_dialog)
if string.len(self.search_value) > 0 or self.lastsearch ~= "find" then
self:find(self.lastsearch)
end
end
end
function Search:find(option)
local f
local line
local i = 1
local upsearch
local firstrun
-- removes leading and closing characters and converts hex-unicodes
local ReplaceHexChars = function(s, n, j)
local l=string.len(s)
if string.sub(s, l, l) == "\"" then
s=string.sub(s, n, string.len(s)-1)
else
s=string.sub(s, n, string.len(s)-j)
end
s=string.gsub(s, "\\u([a-f0-9][a-f0-9][a-f0-9][a-f0-9])", function(w) return util.unicodeCodepointToUtf8(tonumber(w, 16)) end)
return s
end
-- ready entries with multiple lines from calibre
local ReadMultipleLines = function(s)
self.data[i][s] = ""
if s == self.authors then
self.data[i][self.authors2] = ""
elseif s == self.tags then
self.data[i][self.tags2] = ""
self.data[i][self.tags3] = ""
end
while line ~= " ], " and line ~= " ]" do
line = f:read()
if line ~= " ], " and line ~= " ]" then
self.data[i][s] = self.data[i][s] .. "," .. ReplaceHexChars(line, 8, 3)
if s == self.authors then
self.data[i][self.authors2] = self.data[i][self.authors2] .. " & " .. ReplaceHexChars(line, 8, 3)
elseif s == self.tags then
local tags_line = ReplaceHexChars(line, 8, 3)
self.data[i][self.tags2] = self.data[i][self.tags2] .. " & " .. tags_line
self.data[i][self.tags3] = self.data[i][self.tags3] .. "\t" .. tags_line
self.browse_tags[tags_line] = (self.browse_tags[tags_line] or 0) + 1
end
end
end
self.data[i][s] = string.sub(self.data[i][s], 2)
if s == self.authors then
self.data[i][self.authors2] = string.sub(self.data[i][self.authors2], 4)
elseif s == self.tags then
self.data[i][self.tags2] = string.sub(self.data[i][self.tags2], 4)
self.data[i][self.tags3] = self.data[i][self.tags3] .. "\t"
end
end
if not self.use_previous_search_results then
self.results = {}
self.data = {}
self.browse_series = {}
self.browse_tags = {}
if SEARCH_CASESENSITIVE then
upsearch = self.search_value or ""
else
upsearch = string.upper(self.search_value or "")
end
firstrun = true
self.data[i] = {"-","-","-","-","-","-","-","-","-"}
if self.use_own_metadata_file then
local g = io.open(koreaderfile, "r")
line = g:read()
if line ~= "#metadata.Koreader Version 1.1" and line ~= "#metadata.koreader Version 1.1" then
self.use_own_metadata_file = false
g:close()
else
line = g:read()
end
if self.use_own_metadata_file then
while line do
for j = 1,9 do
self.data[i][j] = line or ""
line = g:read()
end
local search_content = ""
if option == "find" and SEARCH_AUTHORS then
search_content = search_content .. self.data[i][self.authors] .. "\n"
end
if option == "find" and SEARCH_TITLE then
search_content = search_content .. self.data[i][self.title] .. "\n"
end
if option == "find" and SEARCH_PATH then
search_content = search_content .. self.data[i][self.path] .. "\n"
end
if (option == "series" or SEARCH_SERIES) and self.data[i][self.series] ~= "-" then
search_content = search_content .. self.data[i][self.series] .. "\n"
self.browse_series[self.data[i][self.series]] = (self.browse_series[self.data[i][self.series]] or 0) + 1
end
if option == "tags" or SEARCH_TAGS then
search_content = search_content .. self.data[i][self.tags] .. "\n"
end
if not SEARCH_CASESENSITIVE then search_content = string.upper(search_content) end
for j in string.gmatch(self.data[i][self.tags3],"\t[^\t]+") do
if j~="\t" then
self.browse_tags[string.sub(j, 2)] = (self.browse_tags[string.sub(j, 2)] or 0) + 1
end
end
-- NOTE: This skips kePubs downloaded by nickel, because they don't have a file extension,
-- they're stored as .kobo/kepub/<UUID>
if DocumentRegistry:hasProvider(self.data[i][self.path]) then
if upsearch ~= "" then
if string.find(search_content, upsearch, nil, true) then
i = i + 1
end
else
if option == "series" then
if self.browse_series[self.data[i][self.series]] then
i = i + 1
end
elseif option == "tags" then
local found = false
for j in string.gmatch(self.data[i][self.tags3],"\t[^\t]+") do
if j~="\t" and self.browse_tags[string.sub(j, 2)] then
found = true
end
end
if found then
i = i + 1
end
end
end
end
self.data[i] = {"-","-","-","-","-","-","-","-","-"}
end
g.close()
end
end
if not self.use_own_metadata_file then
logger.info("FMSearch: Writing our own simplified metadata file . . .")
local g = io.open(koreaderfile, "w")
g:write("#metadata.koreader Version 1.1\n")
f = io.open(self.metafile_1, "r")
line = f:read()
while line do
if line == " }, " or line == " }" then
-- new calibre data set
local search_content = ""
if option == "find" and SEARCH_AUTHORS then search_content = search_content .. self.data[i][self.authors] .. "\n" end
if option == "find" and SEARCH_TITLE then search_content = search_content .. self.data[i][self.title] .. "\n" end
if option == "find" and SEARCH_PATH then search_content = search_content .. self.data[i][self.path] .. "\n" end
if (option == "series" or SEARCH_SERIES) and self.data[i][self.series] ~= "-" then
search_content = search_content .. self.data[i][self.series] .. "\n"
self.browse_series[self.data[i][self.series]] = (self.browse_series[self.data[i][self.series]] or 0) + 1
end
if option == "tags" or SEARCH_TAGS then search_content = search_content .. self.data[i][self.tags] .. "\n" end
if not SEARCH_CASESENSITIVE then search_content = string.upper(search_content) end
for j = 1,9 do
g:write(self.data[i][j] .. "\n")
end
if upsearch ~= "" then
if string.find(search_content, upsearch, nil, true) then
i = i + 1
end
else
if option == "series" then
if self.browse_series[self.data[i][self.series]] then
i = i + 1
end
elseif option == "tags" then
local found = false
for j in string.gmatch(self.data[i][self.tags3], "\t[^\t]+") do
if j~="\t" and self.browse_tags[string.sub(j, 2)] then
found = true
end
end
if found then
i = i + 1
end
end
end
self.data[i] = {"-","-","-","-","-","-","-","-","-"}
elseif line == " \"authors\": [" then -- AUTHORS
ReadMultipleLines(self.authors)
elseif line == " \"tags\": [" then -- TAGS
ReadMultipleLines(self.tags)
elseif string.sub(line, 1, 11) == " \"title\"" then -- TITLE
self.data[i][self.title] = ReplaceHexChars(line, 15, 3)
elseif string.sub(line, 1, 11) == " \"lpath\"" then -- LPATH
self.data[i][self.path] = ReplaceHexChars(line, 15, 3)
if firstrun then
self.data[i][self.path] = SEARCH_LIBRARY_PATH .. self.data[i][self.path]
else
self.data[i][self.path] = SEARCH_LIBRARY_PATH2 .. self.data[i][self.path]
end
elseif string.sub(line, 1, 12) == " \"series\"" and line ~= " \"series\": null, " then -- SERIES
self.data[i][self.series] = ReplaceHexChars(line, 16, 3)
elseif string.sub(line, 1, 18) == " \"series_index\"" and line ~= " \"series_index\": null, " then -- SERIES_INDEX
self.data[i][self.series_index] = ReplaceHexChars(line, 21, 2)
end
line = f:read()
if not line and firstrun then
if f ~= nil then f:close() end
firstrun = false
if self.metafile_2 then
f = io.open(self.metafile_2, "r")
line = f:read()
end
end
end
g.close()
if lfs.attributes(koreaderfile, "modification") < lfs.attributes(self.metafile_1, "modification") then
lfs.touch(koreaderfile,
lfs.attributes(self.metafile_1, "modification") + 1,
lfs.attributes(self.metafile_1, "modification") + 1)
end
if self.metafile_2 then
if lfs.attributes(koreaderfile, "modification") < lfs.attributes(self.metafile_2, "modification") then
lfs.touch(koreaderfile, lfs.attributes(self.metafile_2, "modification") + 1, lfs.attributes(self.metafile_2, "modification") + 1)
end
end
end
i = i - 1
self.count = i
end
if self.count > 0 then
self.data[self.count + 1] = nil
if option == "find" then
self:showresults()
else
self:browse(option,1)
end
else
UIManager:show(InfoMessage:new{
text = T(_("No match for %1."), self.search_value)
})
end
end
function Search:onMenuHold(item)
if not item.info or item.info:len() <= 0 then return end
if item.notchecked then
item.info = item.info .. item.path
local f = io.open(item.path, "r")
if f == nil then
item.info = item.info .. "\n" .. _("File not found.")
else
item.info = item.info .. "\n" .. _("Size:") .. " " .. string.format("%4.1fM", lfs.attributes(item.path, "size")/1024/1024)
f:close()
end
item.notchecked = false
end
local thumbnail
local doc = DocumentRegistry:openDocument(item.path)
if doc then
if doc.loadDocument then -- CreDocument
doc:loadDocument(false) -- load only metadata
end
thumbnail = doc:getCoverPageImage()
doc:close()
end
local thumbwidth = math.min(240, Screen:getWidth()/3)
UIManager:show(InfoMessage:new{
text = item.info,
image = thumbnail,
image_width = thumbwidth,
image_height = thumbwidth/2*3
})
end
function Search:showresults()
local ReaderUI = require("apps/reader/readerui")
local menu_container = CenterContainer:new{
dimen = Screen:getSize(),
}
self.search_menu = Menu:new{
width = Screen:getWidth()-15,
height = Screen:getHeight()-15,
show_parent = menu_container,
onMenuHold = self.onMenuHold,
cface = Font:getFace("smallinfofont"),
_manager = self,
}
table.insert(menu_container, self.search_menu)
self.search_menu.close_callback = function()
UIManager:close(menu_container)
end
if not self.use_previous_search_results then
self.results = {}
local i = 1
while i <= self.count do
local dummy = T(_("Title: %1"), (self.data[i][self.title] or "-")) .. "\n \n" ..
T(_("Author(s): %1"), (self.data[i][self.authors2] or "-")) .. "\n \n" ..
T(_("Tags: %1"), (self.data[i][self.tags2] or "-")) .. "\n \n" ..
T(_("Series: %1"), (self.data[i][self.series] or "-"))
if self.data[i][self.series] ~= "-" then
dummy = dummy .. " (" .. tostring(self.data[i][self.series_index]):gsub(".0$","") .. ")"
end
dummy = dummy .. "\n \n" .. _("Path: ")
local book = self.data[i][self.path]
table.insert(self.results, {
info = dummy,
notchecked = true,
path = self.data[i][self.path],
text = self.data[i][self.authors] .. ": " .. self.data[i][self.title],
callback = function()
ReaderUI:showReader(book)
self.search_menu:onClose()
end
})
i = i + 1
end
end
table.sort(self.results, function(v1,v2) return v1.text < v2.text end)
self.search_menu:switchItemTable(_("Search Results"), self.results)
UIManager:show(menu_container)
end
function Search:browse(option, run, chosen)
local ReaderUI = require("apps/reader/readerui")
local restart_me = false
local menu_container = CenterContainer:new{
dimen = Screen:getSize(),
}
self.search_menu = Menu:new{
width = Screen:getWidth()-15,
height = Screen:getHeight()-15,
show_parent = menu_container,
onMenuHold = self.onMenuHold,
cface = Font:getFace("smallinfofont"),
_manager = self,
}
table.insert(menu_container, self.search_menu)
self.search_menu.close_callback = function()
UIManager:close(menu_container)
if restart_me then
if string.len(self.search_value) > 0 or self.lastsearch ~= "find" then
self.use_previous_search_results = true
self:getCalibre(1)
self:find(self.lastsearch)
end
end
end
local upsearch
local dummy
if SEARCH_CASESENSITIVE then
upsearch = self.search_value or ""
else
upsearch = string.upper(self.search_value or "")
end
if run == 1 then
self.results = {}
if option == "series" then
for v,n in FFIUtil.orderedPairs(self.browse_series) do
dummy = v
if not SEARCH_CASESENSITIVE then dummy = string.upper(dummy) end
if string.find(dummy, upsearch, nil, true) then
table.insert(self.results, {
text = v .. " (" .. tostring(self.browse_series[v]) .. ")",
callback = function()
self:browse(option,2,v)
end
})
end
end
else
for v,n in FFIUtil.orderedPairs(self.browse_tags) do
dummy = v
if not SEARCH_CASESENSITIVE then dummy = string.upper(dummy) end
if string.find(dummy, upsearch, nil, true) then
table.insert(self.results, {
text = v .. " (" .. tostring(self.browse_tags[v]) .. ")",
callback = function()
self:browse(option,2,v)
end
})
end
end
end
else
restart_me = true
self.results = {}
local i = 1
while i <= self.count do
if (option == "tags" and self.data[i][self.tags3]:find("\t" .. chosen .. "\t",nil,true)) or (option == "series" and chosen == self.data[i][self.series]) then
local entry = T(_("Title: %1"), (self.data[i][self.title] or "-")) .. "\n \n" ..
T(_("Author(s): %1"), (self.data[i][self.authors2] or "-")) .. "\n \n" ..
T(_("Tags: %1"), (self.data[i][self.tags2] or "-")) .. "\n \n" ..
T(_("Series: %1"), (self.data[i][self.series] or "-"))
if self.data[i][self.series] ~= "-" then
entry = entry .. " (" .. tostring(self.data[i][self.series_index]):gsub(".0$","") .. ")"
end
entry = entry .. "\n \n" .. _("Path: ")
local book = self.data[i][self.path]
local text
if option == "series" then
if self.data[i][self.series_index] == "0.0" then
text = self.data[i][self.title] .. " (" .. self.data[i][self.authors] .. ")"
else
text = string.format("%6.1f", self.data[i][self.series_index]:gsub(".0$","")) .. ": " .. self.data[i][self.title] .. " (" .. self.data[i][self.authors] .. ")"
end
else
text = self.data[i][self.authors] .. ": " .. self.data[i][self.title]
end
table.insert(self.results, {
text = text,
info = entry,
notchecked = true,
path = self.data[i][self.path],
callback = function()
ReaderUI:showReader(book)
self.search_menu:onClose()
end
})
end
i = i + 1
end
end
local menu_title
if run == 1 then
menu_title = _("Browse") .. " " .. option
else
menu_title = chosen
end
table.sort(self.results, function(v1,v2) return v1.text < v2.text end)
self.search_menu:switchItemTable(menu_title, self.results)
UIManager:show(menu_container)
end
return Search

@ -108,6 +108,9 @@ local action_strings = {
wallabag_download = _("Wallabag retrieval"),
kosync_push_progress = _("Push progress from this device"),
kosync_pull_progress = _("Pull progress from other devices"),
calibre_search = _("Search in calibre metadata"),
calibre_browse_tags = _("Browse all calibre tags"),
calibre_browse_series = _("Browse all calibre series"),
}
local custom_multiswipes_path = DataStorage:getSettingsDir().."/multiswipes.lua"
@ -792,6 +795,10 @@ function ReaderGesture:buildMenu(ges, default)
{"kosync_push_progress", not self.is_docless},
{"kosync_pull_progress", not self.is_docless},
{"calibre_search", true},
{"calibre_browse_tags", true},
{"calibre_browse_series", true},
}
local return_menu = {}
-- add default action to the top of the submenu
@ -1580,6 +1587,12 @@ function ReaderGesture:gestureAction(action, ges)
self.ui:handleEvent(Event:new("KOSyncPushProgress"))
elseif action == "kosync_pull_progress" then
self.ui:handleEvent(Event:new("KOSyncPullProgress"))
elseif action == "calibre_search" then
self.ui:handleEvent(Event:new("CalibreSearch"))
elseif action == "calibre_browse_tags" then
self.ui:handleEvent(Event:new("CalibreBrowseTags"))
elseif action == "calibre_browse_series" then
self.ui:handleEvent(Event:new("CalibreBrowseSeries"))
end
return true
end

@ -96,7 +96,7 @@ local Kindle = Generic:new{
canHWInvert = yes,
-- NOTE: Newer devices will turn the frontlight off at 0
canTurnFrontlightOff = yes,
home_dir = "/mnt/us/documents",
home_dir = "/mnt/us",
}
function Kindle:initNetworkManager(NetworkMgr)

@ -90,7 +90,7 @@ local order = {
"screen_disable_double_tab",
},
tools = {
"calibre_wireless_connection",
"calibre",
"evernote",
"statistics",
"move_to_archive",

@ -114,7 +114,7 @@ local order = {
},
tools = {
"read_timer",
"calibre_wireless_connection",
"calibre",
"evernote",
"statistics",
"progress_sync",
@ -149,6 +149,7 @@ local order = {
"----------------------------",
"goodreads",
"----------------------------",
"find_book_in_calibre_catalog",
"fulltext_search",
},
filemanager = {},

@ -537,6 +537,17 @@ function util.isEmptyDir(path)
return true
end
--- check if the given path is a file
---- @string path
---- @treturn bool
function util.fileExists(path)
local file = io.open(path, "r")
if file ~= nil then
file:close()
return true
end
end
--- Checks if the given path exists. Doesn't care if it's a file or directory.
---- @string path
---- @treturn bool
@ -563,6 +574,53 @@ function util.makePath(path)
return lfs.mkdir(path)
end
--- As `rm`
-- @string path of the file to remove
-- @treturn bool true on success; nil, err_message on error
function util.removeFile(file)
local lfs = require("libs/libkoreader-lfs")
if file and lfs.attributes(file, "mode") == "file" then
return os.remove(file)
elseif file then
return nil, file .. " is not a file"
else
return nil, "file is nil"
end
end
-- Gets total, used and available bytes for the mountpoint that holds a given directory.
-- @string path of the directory
-- @treturn table with total, used and available bytes
function util.diskUsage(dir)
-- safe way of testing df & awk
local function doCommand(d)
local handle = io.popen("df -k " .. d .. " 2>&1 | awk '$3 ~ /[0-9]+/ { print $2,$3,$4 }' 2>&1 || echo ::ERROR::")
if not handle then return end
local output = handle:read("*all")
handle:close()
if not output:find "::ERROR::" then
return output
end
end
local err = { total = nil, used = nil, available = nil }
local lfs = require("libs/libkoreader-lfs")
if not dir or lfs.attributes(dir, "mode") ~= "directory" then return err end
local usage = doCommand(dir)
if not usage then return err end
local stage, result = {}, {}
for size in usage:gmatch("%w+") do
table.insert(stage, size)
end
for k, v in pairs({"total", "used", "available"}) do
if stage[k] ~= nil then
-- sizes are in kb, return bytes here
result[v] = stage[k] * 1024
end
end
return result
end
--- Replaces characters that are invalid filenames.
--
-- Replaces the characters <code>\/:*?"<>|</code> with an <code>_</code>.
@ -968,6 +1026,23 @@ function util.clearTable(t)
for i = 0, c do t[i] = nil end
end
--- Dumps a table into a file.
--- @table t the table to be dumped
--- @string file the file to store the table
--- @treturn bool true on success, false otherwise
function util.dumpTable(t, file)
if not t or not file or file == "" then return end
local dump = require("dump")
local f = io.open(file, "w")
if f then
f:write("return "..dump(t))
f:close()
return true
end
return false
end
--- Encode URL also known as percent-encoding see https://en.wikipedia.org/wiki/Percent-encoding
--- @string text the string to encode
--- @treturn encode string

@ -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

@ -8,7 +8,7 @@ describe("defaults module", function()
it("should load all defaults from defaults.lua", function()
Defaults:init()
assert.is_same(106, #Defaults.defaults_name)
assert.is_same(98, #Defaults.defaults_name)
end)
it("should save changes to defaults.persistent.lua", function()
@ -16,17 +16,15 @@ describe("defaults module", function()
os.remove(persistent_filename)
-- To see indices and help updating this when new settings are added:
-- for i=1, 106 do print(i.." ".. Defaults.defaults_name[i]) end
-- for i=1, 98 do print(i.." ".. Defaults.defaults_name[i]) end
-- not in persistent but checked in defaults
Defaults.changed[20] = true
Defaults.changed[50] = true
Defaults.changed[56] = true
Defaults.changed[85] = true
Defaults.changed[101] = true --SEARCH_LIBRARY_PATH = ""
Defaults:saveSettings()
assert.is_same(106, #Defaults.defaults_name)
assert.is_same("SEARCH_LIBRARY_PATH", Defaults.defaults_name[101])
assert.is_same(98, #Defaults.defaults_name)
assert.is_same("DTAP_ZONE_BACKWARD", Defaults.defaults_name[85])
assert.is_same("DCREREADER_CONFIG_WORD_SPACING_LARGE", Defaults.defaults_name[50])
assert.is_same("DCREREADER_CONFIG_H_MARGIN_SIZES_XXX_LARGE", Defaults.defaults_name[20])
@ -37,7 +35,6 @@ DCREREADER_CONFIG_WORD_SPACING_LARGE = {
[1] = 100,
[2] = 90
}
SEARCH_LIBRARY_PATH = ""
DTAP_ZONE_BACKWARD = {
["y"] = 0,
["x"] = 0,
@ -82,23 +79,22 @@ DCREREADER_CONFIG_WORD_SPACING_LARGE = {
[2] = 90,
[1] = 100
}
SEARCH_LIBRARY_PATH = ""
DTAP_ZONE_BACKWARD = {
["y"] = 10,
["x"] = 10.125,
["h"] = 20.25,
["w"] = 20.75
}
DCREREADER_CONFIG_H_MARGIN_SIZES_XXX_LARGE = {
[2] = 50,
[1] = 50
}
DDOUBLE_TAP_ZONE_PREV_CHAPTER = {
["y"] = 0,
["x"] = 0,
["h"] = 0.25,
["w"] = 0.75
}
DCREREADER_CONFIG_H_MARGIN_SIZES_XXX_LARGE = {
[2] = 50,
[1] = 50
}
DTAP_ZONE_BACKWARD = {
["y"] = 10,
["x"] = 10.125,
["h"] = 20.25,
["w"] = 20.75
}
]],
fd:read("*a"))
fd:close()
@ -110,7 +106,6 @@ DDOUBLE_TAP_ZONE_PREV_CHAPTER = {
local fd = io.open(persistent_filename, "w")
fd:write(
[[-- For configuration changes that persists between updates
SEARCH_TITLE = true
DCREREADER_CONFIG_H_MARGIN_SIZES_LARGE = {
[1] = 15,
[2] = 15
@ -128,14 +123,13 @@ DHINTCOUNT = 2
fd = io.open(persistent_filename)
assert.Equals(
[[-- For configuration changes that persists between updates
SEARCH_TITLE = true
DCREREADER_VIEW_MODE = "page"
DCREREADER_CONFIG_H_MARGIN_SIZES_LARGE = {
[2] = 15,
[1] = 15
}
DHINTCOUNT = 2
DGLOBAL_CACHE_FREE_PROPORTION = 1
DCREREADER_VIEW_MODE = "page"
DHINTCOUNT = 2
]],
fd:read("*a"))
fd:close()

Loading…
Cancel
Save