CoverBrowser plugin: alt views for File Browser and History (#2940)

* CoverBrowser plugin: alt views for File Browser and History

* Added Prune cache and Compact cache menu actions

* Support for book descriptions, and settings stored in db
pull/3096/head
poire-z 7 years ago committed by Frans de Jonge
parent 7277059176
commit 3b5cd4c23b

@ -6,6 +6,7 @@ local order = {
"main",
},
setting = {
"filemanager_display_mode",
"show_hidden_files",
"----------------------------",
"sort_by",

@ -0,0 +1,587 @@
local Blitbuffer = require("ffi/blitbuffer")
local DataStorage = require("datastorage")
local DocumentRegistry = require("document/documentregistry")
local SQ3 = require("lua-ljsqlite3/init")
local UIManager = require("ui/uimanager")
local lfs = require("libs/libkoreader-lfs")
local logger = require("logger")
local util = require("ffi/util")
local splitFilePathName = require("util").splitFilePathName
local _ = require("gettext")
local T = require("ffi/util").template
-- Util functions needed by this plugin, but that may be added to existing base/ffi/ files
local xutil = require("xutil")
-- Database definition
local BOOKINFO_DB_VERSION = "2-20170701"
local BOOKINFO_DB_SCHEMA = [[
-- For caching book cover and metadata
CREATE TABLE IF NOT EXISTS bookinfo (
-- Internal book cache id
-- (not to be used to identify a book, it may change for a same book)
bcid INTEGER PRIMARY KEY AUTOINCREMENT,
-- File location and filename
directory TEXT NOT NULL, -- split by dir/name so we can get all files in a directory
filename TEXT NOT NULL, -- and can implement pruning of no more existing files
-- Extraction status and result
in_progress INTEGER, -- 0 (done), >0 : nb of tries (to avoid re-doing extractions that crashed us)
unsupported TEXT, -- NULL if supported / reason for being unsupported
cover_fetched TEXT, -- NULL / 'Y' = action of fetching cover was made (whether we got one or not)
has_meta TEXT, -- NULL / 'Y' = has metadata (title, authors...)
has_cover TEXT, -- NULL / 'Y' = has cover image (cover_*)
cover_sizetag TEXT, -- 'M' (Medium, MosaicMenuItem) / 's' (small, ListMenuItem)
-- Other properties that can be set and returned as is (not used here)
-- If user doesn't want to see these (wrong metadata, offending cover...)
ignore_meta TEXT, -- NULL / 'Y' = ignore these metadata
ignore_cover TEXT, -- NULL / 'Y' = ignore this cover
-- Book info
pages INTEGER,
-- Metadata (only these are returned by the engines)
title TEXT,
authors TEXT,
series TEXT,
language TEXT,
keywords TEXT,
description TEXT,
-- Cover image
cover_w INTEGER, -- blitbuffer width
cover_h INTEGER, -- blitbuffer height
cover_btype INTEGER, -- blitbuffer type (internal)
cover_bpitch INTEGER, -- blitbuffer pitch (internal)
cover_datalen INTEGER, -- blitbuffer uncompressed data length
cover_dataz BLOB -- blitbuffer data compressed with zlib
);
CREATE UNIQUE INDEX IF NOT EXISTS dir_filename ON bookinfo(directory, filename);
-- For keeping track of DB schema version
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT
);
-- this will not override previous version value, so we'll get the old one if old schema
INSERT OR IGNORE INTO config VALUES ('version', ']] .. BOOKINFO_DB_VERSION .. [[');
]]
local BOOKINFO_COLS_SET = {
"directory",
"filename",
"in_progress",
"unsupported",
"cover_fetched",
"has_meta",
"has_cover",
"cover_sizetag",
"ignore_meta",
"ignore_cover",
"pages",
"title",
"authors",
"series",
"language",
"keywords",
"description",
"cover_w",
"cover_h",
"cover_btype",
"cover_bpitch",
"cover_datalen",
"cover_dataz",
}
local bookinfo_values_sql = {} -- for "VALUES (?, ?, ?,...)" insert sql part
for i=1, #BOOKINFO_COLS_SET do
table.insert(bookinfo_values_sql, "?")
end
-- Build our most often used SQL queries according to columns
local BOOKINFO_INSERT_SQL = "INSERT OR REPLACE INTO bookinfo " ..
"(" .. table.concat(BOOKINFO_COLS_SET, ",") .. ") " ..
"VALUES (" .. table.concat(bookinfo_values_sql, ",") .. ")"
local BOOKINFO_SELECT_SQL = "SELECT " .. table.concat(BOOKINFO_COLS_SET, ",") .. " FROM bookinfo " ..
"WHERE directory=? and filename=? and in_progress=0"
local BOOKINFO_IN_PROGRESS_SQL = "SELECT in_progress, filename, unsupported FROM bookinfo WHERE directory=? and filename=?"
local BookInfoManager = {}
function BookInfoManager:init()
self.db_location = DataStorage:getSettingsDir() .. "/bookinfo_cache.sqlite3"
self.db_created = false
self.db_conn = nil
self.max_extract_tries = 3 -- don't try more than that to extract info from a same book
self.subprocesses_collector = nil
self.subprocesses_collect_interval = 10 -- do that every 10 seconds
self.subprocesses_pids = {}
self.subprocesses_last_added_ts = nil
self.subprocesses_killall_timeout_seconds = 300 -- cleanup timeout for stuck subprocesses
-- 300 seconds should be enough to open and get info from 9-10 books
end
-- DB management
function BookInfoManager:getDbSize()
local file_size = lfs.attributes(self.db_location, "size") or 0
local sstr
if file_size > 1024*1024 then
sstr = string.format("%4.1f MB", file_size/1024/1024)
elseif file_size > 1024 then
sstr = string.format("%4.1f KB", file_size/1024)
else
sstr = string.format("%d B", file_size)
end
return sstr
end
function BookInfoManager:createDB()
local db_conn = SQ3.open(self.db_location)
-- Less error cases to check if we do it that way
-- Create it (noop if already there)
db_conn:exec(BOOKINFO_DB_SCHEMA)
-- Check version (not updated by previous exec if already there)
local res = db_conn:exec("SELECT value FROM config where key='version';")
if res[1][1] ~= BOOKINFO_DB_VERSION then
logger.warn("BookInfo cache DB schema updated from version ", res[1][1], "to version", BOOKINFO_DB_VERSION)
logger.warn("Deleting existing", self.db_location, "to recreate it")
db_conn:close()
os.remove(self.db_location)
-- Re-create it
db_conn = SQ3.open(self.db_location)
db_conn:exec(BOOKINFO_DB_SCHEMA)
end
db_conn:close()
self.db_created = true
end
function BookInfoManager:openDbConnection()
if self.db_conn then
return
end
if not self.db_created then
self:createDB()
end
self.db_conn = SQ3.open(self.db_location)
xutil.sqlite_set_timeout(self.db_conn, 5000) -- 5 seconds
-- Prepare our most often used SQL statements
self.set_stmt = self.db_conn:prepare(BOOKINFO_INSERT_SQL)
self.get_stmt = self.db_conn:prepare(BOOKINFO_SELECT_SQL)
self.in_progress_stmt = self.db_conn:prepare(BOOKINFO_IN_PROGRESS_SQL)
end
function BookInfoManager:closeDbConnection()
if self.db_conn then
self.db_conn:close()
self.db_conn = nil
end
end
function BookInfoManager:deleteDb()
self:closeDbConnection()
os.remove(self.db_location)
self.db_created = false
end
function BookInfoManager:compactDb()
-- Reduce db size (note: "when VACUUMing a database, as much as twice the
-- size of the original database file is required in free disk space")
-- By default, sqlite will use a temporary file in /tmp/ . On Kobo, /tmp/
-- is 16 Mb, and this will crash if DB is > 16Mb. For now, it's safer to
-- use memory for temp files (which will also cause a crash when DB size
-- is bigger than available memory...)
local prev_size = self:getDbSize()
self:openDbConnection()
self.db_conn:exec("PRAGMA temp_store = 2") -- use memory for temp files
-- self.db_conn:exec("VACUUM")
-- Catch possible "memory or disk is full" error
local ok, errmsg = pcall(self.db_conn.exec, self.db_conn, "VACUUM") -- this may take some time
self:closeDbConnection()
if not ok then
return T(_("Failed compacting database: %1"), errmsg)
end
local cur_size = self:getDbSize()
return T(_("Cache database size reduced from %1 to %2."), prev_size, cur_size)
end
-- Settings management, stored in 'config' table
function BookInfoManager:loadSettings()
if lfs.attributes(self.db_location, "mode") ~= "file" then
-- no db, empty config
self.settings = {}
return
end
self.settings = {}
self:openDbConnection()
local res = self.db_conn:exec("SELECT key, value FROM config")
local keys = res[1]
local values = res[2]
for i, key in ipairs(keys) do
self.settings[key] = values[i]
end
end
function BookInfoManager:getSetting(key)
if not self.settings then
self:loadSettings()
end
return self.settings[key]
end
function BookInfoManager:saveSetting(key, value)
if not value or value == false or value == "" then
if lfs.attributes(self.db_location, "mode") ~= "file" then
-- If no db created, no need to save (and create db) an empty value
return
end
end
self:openDbConnection()
local query = "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)"
local stmt = self.db_conn:prepare(query)
if value == false then -- convert false to NULL
value = nil
elseif value == true then -- convert true to "Y"
value = "Y"
end
stmt:bind(key, value)
stmt:step() -- commited
stmt:clearbind():reset() -- cleanup
-- Reload settings, so we may get (or not if it failed) what we just saved
self:loadSettings()
end
-- Bookinfo management
function BookInfoManager:getBookInfo(filepath, get_cover)
local directory, filename = splitFilePathName(filepath)
self:openDbConnection()
local row = self.get_stmt:bind(directory, filename):step()
self.get_stmt:clearbind():reset() -- get ready for next query
if not row then -- filepath not in db
return nil
end
local bookinfo = {}
for num, col in ipairs(BOOKINFO_COLS_SET) do
if col == "pages" then
-- See http://scilua.org/ljsqlite3.html "SQLite Type Mappings"
bookinfo[col] = tonumber(row[num]) -- convert cdata<int64_t> to lua number
else
bookinfo[col] = row[num] -- as is
end
-- specific processing for cover columns
if col == "cover_w" then
if not get_cover then
-- don't bother making a blitbuffer
break
end
bookinfo["cover_bb"] = nil
if bookinfo["has_cover"] then
bookinfo["cover_w"] = tonumber(row[num])
bookinfo["cover_h"] = tonumber(row[num+1])
local cover_data = xutil.zlib_uncompress(row[num+5], row[num+4])
row[num+5] = nil -- release memory used by cover_dataz
-- Blitbuffer.fromstring() expects : w, h, bb_type, bb_data, pitch
bookinfo["cover_bb"] = Blitbuffer.fromstring(row[num], row[num+1], row[num+2], cover_data, row[num+3])
-- release memory used by uncompressed data:
cover_data = nil -- luacheck: no unused
end
break
end
end
return bookinfo
end
function BookInfoManager:extractBookInfo(filepath, cover_specs)
-- This will be run in a subprocess
-- Disable cre cache (that will not affect parent process), so we don't
-- fill it with books we're not actually reading
if not self.cre_cache_disabled then
require "libs/libkoreader-cre"
cre.initCache("", 1024*1024*32) -- empty path = no cache
self.cre_cache_disabled = true
end
local directory, filename = splitFilePathName(filepath)
-- Initialize the new row that we will INSERT
local dbrow = { }
-- Actually no need to initialize with nil values:
-- for dummy, col in ipairs(BOOKINFO_COLS_SET) do
-- dbrow[col] = nil
-- end
dbrow.directory = directory
dbrow.filename = filename
-- To be able to catch a BAD book we have already tried to process but
-- that made us crash, and that we would try to re-process again, we first
-- insert a nearly empty row with in_progress = 1 (incremented if previously set)
-- (This will also flag a book being processed when the user changed paged and
-- kill the previous page background process, but well...)
local tried_enough = false
local prev_tries = 0
-- Get nb of previous tries if record already there
self:openDbConnection()
self.in_progress_stmt:bind(directory, filename)
local cur_in_progress = self.in_progress_stmt:step()
self.in_progress_stmt:clearbind():reset() -- get ready for next query
if cur_in_progress then
prev_tries = tonumber(cur_in_progress[1])
end
-- Increment it and check if we have already tried enough
if prev_tries < self.max_extract_tries then
if prev_tries > 0 then
logger.dbg("Seen", prev_tries, "previous attempts at info extraction", filepath , ", trying again")
end
dbrow.in_progress = prev_tries + 1 -- extraction not yet successful
else
logger.info("Seen", prev_tries, "previous attempts at info extraction", filepath, ", too many, ignoring it.")
tried_enough = true
dbrow.in_progress = 0 -- row will exist, we'll never be called again
dbrow.unsupported = _("too many interruptions or crashes") -- but caller will now it failed
dbrow.cover_fetched = 'Y' -- so we don't try again if we're called later with cover_specs
end
-- Insert the temporary "in progress" record (or the definitive "unsupported" record)
for num, col in ipairs(BOOKINFO_COLS_SET) do
self.set_stmt:bind1(num, dbrow[col])
end
self.set_stmt:step() -- commited
self.set_stmt:clearbind():reset() -- get ready for next query
if tried_enough then
return -- Last insert done for this book, we're giving up
end
-- Proceed with extracting info
local document = DocumentRegistry:openDocument(filepath)
if document then
if document.loadDocument then -- needed for crengine
document:loadDocument()
-- Not needed for getting props:
-- document:render()
-- It would be needed to get nb of pages, but the nb obtained
-- by simply calling here document:getPageCount() is wrong,
-- often 2 to 3 times the nb of pages we see when opening
-- the document (may be some other cre settings should be applied
-- before calling render() ?)
else
-- for all others than crengine, we seem to get an accurate nb of pages
local pages = document:getPageCount()
dbrow.pages = pages
end
-- via pcall because picdocument:getProps() always fails (we could
-- check document.is_pic, but this way, we'll catch any other error)
local ok, props = pcall(document.getProps, document)
if ok then
dbrow.has_meta = 'Y'
if props.title and props.title ~= "" then dbrow.title = props.title end
if props.authors and props.authors ~= "" then dbrow.authors = props.authors end
if props.series and props.series ~= "" then dbrow.series = props.series end
if props.language and props.language ~= "" then dbrow.language = props.language end
if props.keywords and props.keywords ~= "" then dbrow.keywords = props.keywords end
if props.description and props.description ~= "" then dbrow.description = props.description end
end
if cover_specs then
local spec_sizetag = cover_specs.sizetag
local spec_max_cover_w = cover_specs.max_cover_w
local spec_max_cover_h = cover_specs.max_cover_h
dbrow.cover_fetched = 'Y' -- we had a try at getting a cover
-- XXX make picdocument return a blitbuffer of the image
local cover_bb = document:getCoverPageImage()
if cover_bb then
dbrow.has_cover = 'Y'
dbrow.cover_sizetag = spec_sizetag
-- we should scale down the cover to our max size
local cbb_w, cbb_h = cover_bb:getWidth(), cover_bb:getHeight()
local scale_factor = 1
if cbb_w > spec_max_cover_w or cbb_h > spec_max_cover_h then
-- scale down if bigger than what we will display
scale_factor = math.min(spec_max_cover_w / cbb_w, spec_max_cover_h / cbb_h)
cbb_w = math.min(math.floor(cbb_w * scale_factor)+1, spec_max_cover_w)
cbb_h = math.min(math.floor(cbb_h * scale_factor)+1, spec_max_cover_h)
local new_bb = cover_bb:scale(cbb_w, cbb_h)
cover_bb:free()
cover_bb = new_bb
end
dbrow.cover_w = cbb_w
dbrow.cover_h = cbb_h
dbrow.cover_btype = cover_bb:getType()
dbrow.cover_bpitch = cover_bb.pitch
local cover_data = Blitbuffer.tostring(cover_bb)
cover_bb:free() -- free bb before compressing to save memory
dbrow.cover_datalen = cover_data:len()
local cover_dataz = xutil.zlib_compress(cover_data)
-- release memory used by uncompressed data:
cover_data = nil -- luacheck: no unused
dbrow.cover_dataz = SQ3.blob(cover_dataz) -- cast to blob for sqlite
logger.dbg("cover for", filename, "scaled by", scale_factor, "=>", cbb_w, "x", cbb_h, "(compressed from ", dbrow.cover_datalen, " to ", cover_dataz:len())
end
end
DocumentRegistry:closeDocument(filepath)
else
dbrow.unsupported = _("not readable by engine")
dbrow.cover_fetched = 'Y' -- so we don't try again if we're called later if cover_specs
end
dbrow.in_progress = 0 -- extraction completed (successful or definitive failure)
for num, col in ipairs(BOOKINFO_COLS_SET) do
self.set_stmt:bind1(num, dbrow[col])
end
self.set_stmt:step()
self.set_stmt:clearbind():reset() -- get ready for next query
end
function BookInfoManager:setBookInfoProperties(filepath, props)
-- If we need to set column=NULL, use props[column] = false (as
-- props[column] = nil would make column disappear from props)
local directory, filename = splitFilePathName(filepath)
self:openDbConnection()
-- Let's do multiple one-column UPDATE (easier than building
-- a multiple columns UPDATE)
local base_query = "UPDATE bookinfo SET %s=? WHERE directory=? AND filename=?"
for k, v in pairs(props) do
local this_prop_query = string.format(base_query, k) -- add column name to query
local stmt = self.db_conn:prepare(this_prop_query)
if v == false then -- convert false to nil (NULL)
v = nil
end
stmt:bind(v, directory, filename)
stmt:step() -- commited
stmt:clearbind():reset() -- cleanup
end
end
function BookInfoManager:deleteBookInfo(filepath)
local directory, filename = splitFilePathName(filepath)
self:openDbConnection()
local query = "DELETE FROM bookinfo WHERE directory=? AND filename=?"
local stmt = self.db_conn:prepare(query)
stmt:bind(directory, filename)
stmt:step() -- commited
stmt:clearbind():reset() -- cleanup
end
function BookInfoManager:removeNonExistantEntries()
self:openDbConnection()
local res = self.db_conn:exec("SELECT bcid, directory || filename FROM bookinfo")
local bcids = res[1]
local filepaths = res[2]
local bcids_to_remove = {}
for i, filepath in ipairs(filepaths) do
if lfs.attributes(filepath, "mode") ~= "file" then
table.insert(bcids_to_remove, tonumber(bcids[i]))
end
end
local query = "DELETE FROM bookinfo WHERE bcid=?"
local stmt = self.db_conn:prepare(query)
for i=1, #bcids_to_remove do
stmt:bind(bcids_to_remove[i])
stmt:step() -- commited
stmt:clearbind():reset() -- cleanup
end
return T(_("Removed %1 / %2 entries from cache."), #bcids_to_remove, #bcids)
end
-- Background extraction management
function BookInfoManager:collectSubprocesses()
-- We need to regularly watch if a sub-process has terminated by
-- calling waitpid() so this process does not become a zombie hanging
-- around till we exit.
if #self.subprocesses_pids > 0 then
local i = 1
while i <= #self.subprocesses_pids do -- clean in-place
local pid = self.subprocesses_pids[i]
if xutil.isSubProcessDone(pid) then
table.remove(self.subprocesses_pids, i)
else
i = i + 1
end
end
if #self.subprocesses_pids > 0 then
-- still some pids around, we'll need to collect again
self.subprocesses_collector = UIManager:scheduleIn(
self.subprocesses_collect_interval, function()
self:collectSubprocesses()
end
)
-- If we're still waiting for some subprocess, and none have
-- been submitted for some time, it's that one is stuck (and that
-- the user has not left FileManager or changed page - that would
-- have caused a terminateBackgroundJobs() - if we're here, it's
-- that user has left reader in FileBrower and went away)
if util.gettime() > self.subprocesses_last_added_ts + self.subprocesses_killall_timeout_seconds then
logger.warn("Some subprocess were running for too long, killing them")
self:terminateBackgroundJobs()
-- we'll collect them next time we're run
end
else
self.subprocesses_collector = nil
end
end
end
function BookInfoManager:terminateBackgroundJobs()
logger.dbg("terminating", #self.subprocesses_pids, "subprocesses")
for i=1, #self.subprocesses_pids do
xutil.terminateSubProcess(self.subprocesses_pids[i])
end
end
function BookInfoManager:isExtractingInBackground()
return #self.subprocesses_pids > 0
end
function BookInfoManager:extractInBackground(files)
if #files == 0 then
return
end
-- Terminate any previous extraction background task that would be still running
self:terminateBackgroundJobs()
-- Close current handle on sqlite, so it's not shared by both processes
-- (both processes will re-open one when needed)
BookInfoManager:closeDbConnection()
-- Define task that will be run in subprocess
local task = function()
logger.dbg(" BG extraction started")
for idx = 1, #files do
local filepath = files[idx].filepath
local cover_specs = files[idx].cover_specs
logger.dbg(" BG extracting:", filepath)
self:extractBookInfo(filepath, cover_specs)
util.usleep(100000) -- give main process 100ms of free cpu to do its processing
end
logger.dbg(" BG extraction done")
end
-- Run task in sub-process, and remember its pid
local task_pid = xutil.runInSubProcess(task)
if not task_pid then
logger.warn("Failed lauching background extraction sub-process (fork failed)")
return false -- let caller know it failed
end
table.insert(self.subprocesses_pids, task_pid)
self.subprocesses_last_added_ts = util.gettime()
-- We need to collect terminated jobs pids (so they do not stay "zombies"
-- and fill linux processes table)
-- We set a single scheduled action for that
if not self.subprocesses_collector then -- there's not one already scheduled
self.subprocesses_collector = UIManager:scheduleIn(
self.subprocesses_collect_interval, function()
self:collectSubprocesses()
end
)
end
return true
end
BookInfoManager:init()
return BookInfoManager

@ -0,0 +1,465 @@
local Device = require("device")
local DocumentRegistry = require("document/documentregistry")
local FileManagerBookInfo = require("apps/filemanager/filemanagerbookinfo")
local ImageViewer = require("ui/widget/imageviewer")
local Menu = require("ui/widget/menu")
local TextViewer = require("ui/widget/textviewer")
local UIManager = require("ui/uimanager")
local logger = require("logger")
local util = require("ffi/util")
local _ = require("gettext")
local BookInfoManager = require("bookinfomanager")
-- This is a kind of "base class" for both MosaicMenu and ListMenu.
-- It implements the common code shared by these, mostly the non-UI
-- work : the updating of items and the management of backgrouns jobs.
--
-- Here are defined the common overriden methods of Menu:
-- :updateItems(select_number)
-- :onCloseWidget()
-- :onSwipe(arg, ges_ev)
--
-- MosaicMenu or ListMenu should implement specific UI methods:
-- :_recalculateDimen()
-- :_updateItemsBuildUI()
-- This last method is called in the middle of :updateItems() , and
-- should fill self.item_group with some specific UI layout. It may add
-- not found item to self.items_to_update for us to update() them
-- regularly.
-- Simple holder of methods that will replace those
-- in the real Menu class or instance
local CoverMenu = {}
function CoverMenu:updateItems(select_number)
-- As done in Menu:updateItems()
local old_dimen = self.dimen and self.dimen:copy()
-- self.layout must be updated for focusmanager
self.layout = {}
self.item_group:free() -- avoid memory leaks by calling free() on all our sub-widgets
self.item_group:clear()
-- strange, best here if resetLayout() are done after _recalculateDimen(),
-- unlike what is done in menu.lua
self:_recalculateDimen()
self.page_info:resetLayout()
self.return_button:resetLayout()
-- default to select the first item
if not select_number then
select_number = 1
end
-- Reset the list of items not found in db that will need to
-- be updated by a scheduled action
self.items_to_update = {}
-- Cancel any previous (now obsolete) scheduled update
if self.items_update_action then
UIManager:unschedule(self.items_update_action)
self.items_update_action = nil
end
-- Force garbage collecting before drawing a new page.
-- It's not really needed from a memory usage point of view, we did
-- all the free() where necessary, and koreader memory usage seems
-- stable when file browsing only (15-25 MB).
-- But I witnessed some freezes after browsing a lot when koreader's main
-- process was using 100% cpu (and some slow downs while drawing soon before
-- the freeze, like the full refresh happening before the final drawing of
-- new text covers), while still having a small memory usage (20/30 Mb)
-- that I suspect may be some garbage collecting happening at one point
-- and getting stuck...
-- With this, garbage collecting may be more deterministic, and it has
-- no negative impact on user experience.
collectgarbage()
collectgarbage()
-- Specific UI building implementation (defined in some other module)
self:_updateItemsBuildUI()
-- As done in Menu:updateItems()
if self.item_group[1] then
if not Device:isTouchDevice() then
-- only draw underline for nontouch device
-- reset focus manager accordingly
self.selected = { x = 1, y = select_number }
-- set focus to requested menu item
self.item_group[select_number]:onFocus()
-- This will not work with our MosaicMenu, as a MosaicMenuItem is
-- not a direct child of item_group (which contains VerticalSpans
-- and HorizontalGroup...)
end
-- update page information
self.page_info_text:setText(util.template(_("page %1 of %2"), self.page, self.page_num))
self.page_info_left_chev:showHide(self.page_num > 1)
self.page_info_right_chev:showHide(self.page_num > 1)
self.page_info_first_chev:showHide(self.page_num > 2)
self.page_info_last_chev:showHide(self.page_num > 2)
self.page_return_arrow:showHide(self.onReturn ~= nil)
self.page_info_left_chev:enableDisable(self.page > 1)
self.page_info_right_chev:enableDisable(self.page < self.page_num)
self.page_info_first_chev:enableDisable(self.page > 1)
self.page_info_last_chev:enableDisable(self.page < self.page_num)
self.page_return_arrow:enableDisable(#self.paths > 0)
else
self.page_info_text:setText(_("No choices available"))
end
UIManager:setDirty("all", function()
local refresh_dimen =
old_dimen and old_dimen:combine(self.dimen)
or self.dimen
return "ui", refresh_dimen
end)
-- As additionally done in FileChooser:updateItems()
if self.path_items then
self.path_items[self.path] = (self.page - 1) * self.perpage + (select_number or 1)
end
-- Deal with items not found in db
if #self.items_to_update > 0 then
-- Prepare for background info extraction job
local files_to_index = {} -- table of {filepath, cover_specs}
for i=1, #self.items_to_update do
table.insert(files_to_index, {
filepath = self.items_to_update[i].filepath,
cover_specs = self.items_to_update[i].cover_specs
})
end
-- Launch it at nextTick, so UIManager can render us smoothly
UIManager:nextTick(function()
local launched = BookInfoManager:extractInBackground(files_to_index)
if not launched then -- fork failed (never experienced that, but let's deal with it)
-- Cancel scheduled update, as it won't get any result
if self.items_update_action then
UIManager:unschedule(self.items_update_action)
self.items_update_action = nil
end
local InfoMessage = require("ui/widget/infomessage")
UIManager:show(InfoMessage:new{
text = _("Start-up of background extraction job failed.\nPlease restart KOReader or your device.")
})
end
end)
-- Scheduled update action
self.items_update_action = function()
logger.dbg("Scheduled items update:", #self.items_to_update, "waiting")
local is_still_extracting = BookInfoManager:isExtractingInBackground()
local i = 1
while i <= #self.items_to_update do -- process and clean in-place
local item = self.items_to_update[i]
item:update()
if item.bookinfo_found then
logger.dbg(" found", item.text)
local refreshfunc = function()
if item.refresh_dimen then
-- MosaicMenuItem may exceed its own dimen in its paintTo
-- with its "description" hint
return "ui", item.refresh_dimen
else
return "ui", item[1].dimen
end
end
UIManager:setDirty(self.show_parent, refreshfunc)
table.remove(self.items_to_update, i)
else
logger.dbg(" not yet found", item.text)
i = i + 1
end
end
if #self.items_to_update > 0 then -- re-schedule myself
if is_still_extracting then -- we have still chances to get new stuff
logger.dbg("re-scheduling items update:", #self.items_to_update, "still waiting")
UIManager:scheduleIn(1, self.items_update_action)
else
logger.dbg("Not all items found, but background extraction has stopped, not re-scheduling")
end
else
logger.dbg("items update completed")
end
end
UIManager:scheduleIn(1, self.items_update_action)
end
-- (We may not need to do the following if we extend onFileHold
-- code in filemanager.lua to check for existence and call a
-- method: self:getAdditionalButtons() to add our buttons
-- to its own set.)
-- We want to add some buttons to the onFileHold popup. This function
-- is dynamically created by FileManager:init(), and we don't want
-- to override this... So, here, when we see the onFileHold function,
-- we replace it by ours.
-- (FileManager may replace file_chooser.onFileHold after we've been called once, so we need
-- to replace it again if it is not ours)
if not self.onFileHold_ours -- never replaced
or self.onFileHold ~= self.onFileHold_ours then -- it is no more ours
-- Store original function, so we can call it
self.onFileHold_orig = self.onFileHold
-- Replace it with ours
-- This causes luacheck warning: "shadowing upvalue argument 'self' on line 34".
-- Ignoring it (as done in filemanager.lua for the same onFileHold)
self.onFileHold = function(self, file) -- luacheck: ignore
-- Call original function: it will create a ButtonDialogTitle
-- and store it as self.file_dialog, and UIManager:show() it.
self.onFileHold_orig(self, file)
local bookinfo = BookInfoManager:getBookInfo(file)
if not bookinfo then
-- If no bookinfo (yet) about this file, let the original dialog be
return true
end
-- Remember some of this original ButtonDialogTitle properties
local orig_title = self.file_dialog.title
local orig_title_align = self.file_dialog.title_align
local orig_buttons = self.file_dialog.buttons
-- Close original ButtonDialogTitle (it has not yet been painted
-- on screen, so we won't see it)
UIManager:close(self.file_dialog)
-- Replace Book information callback to use directly our bookinfo
orig_buttons[4][2].callback = function()
FileManagerBookInfo:show(file, bookinfo)
UIManager:close(self.file_dialog)
end
-- Add some new buttons to original buttons set
table.insert(orig_buttons, {
{ -- Allow user to view real size cover in ImageViewer
text = _("View full size cover"),
enabled = bookinfo.has_cover and true or false,
callback = function()
local document = DocumentRegistry:openDocument(file)
if document then
local cover_bb = document:getCoverPageImage()
local imgviewer = ImageViewer:new{
image = cover_bb,
with_title_bar = false,
fullscreen = true,
}
UIManager:show(imgviewer)
UIManager:close(self.file_dialog)
DocumentRegistry:closeDocument(file)
end
end,
},
{ -- Allow user to directly view description in TextViewer
text = bookinfo.description and _("View book description") or _("No book description"),
enabled = bookinfo.description and true or false,
callback = function()
local description = require("util").htmlToPlainTextIfHtml(bookinfo.description)
local textviewer = TextViewer:new{
title = bookinfo.title,
text = description,
}
UIManager:show(textviewer)
UIManager:close(self.file_dialog)
end,
},
})
table.insert(orig_buttons, {
{ -- Allow user to ignore some offending cover image
text = bookinfo.ignore_cover and _("Unignore cover") or _("Ignore cover"),
enabled = bookinfo.has_cover and true or false,
callback = function()
BookInfoManager:setBookInfoProperties(file, {
["ignore_cover"] = not bookinfo.ignore_cover and 'Y' or false,
})
UIManager:close(self.file_dialog)
self:updateItems()
end,
},
{ -- Allow user to ignore some bad metadata (filename will be used instead)
text = bookinfo.ignore_meta and _("Unignore metadata") or _("Ignore metadata"),
enabled = bookinfo.has_meta and true or false,
callback = function()
BookInfoManager:setBookInfoProperties(file, {
["ignore_meta"] = not bookinfo.ignore_meta and 'Y' or false,
})
UIManager:close(self.file_dialog)
self:updateItems()
end,
},
})
table.insert(orig_buttons, {
{ -- Allow a new extraction (multiple interruptions, book replaced)...
text = _("Refresh cached book information"),
enabled = bookinfo and true or false,
callback = function()
BookInfoManager:deleteBookInfo(file)
UIManager:close(self.file_dialog)
self:updateItems()
end,
},
})
-- Create the new ButtonDialogTitle, and let UIManager show it
local ButtonDialogTitle = require("ui/widget/buttondialogtitle")
self.file_dialog = ButtonDialogTitle:new{
title = orig_title,
title_align = orig_title_align,
buttons = orig_buttons,
}
UIManager:show(self.file_dialog)
return true
end
-- Remember our function
self.onFileHold_ours = self.onFileHold
end
end
-- Similar to onFileHold setup just above, but for History,
-- which is plugged in main.lua _FileManagerHistory_updateItemTable()
function CoverMenu:onHistoryMenuHold(item)
-- Call original function: it will create a ButtonDialog
-- and store it as self.histfile_dialog, and UIManager:show() it.
self.onMenuHold_orig(self, item)
local file = item.file
local bookinfo = BookInfoManager:getBookInfo(file)
if not bookinfo then
-- If no bookinfo (yet) about this file, let the original dialog be
return true
end
-- Remember some of this original ButtonDialog properties
local orig_buttons = self.histfile_dialog.buttons
-- Close original ButtonDialog (it has not yet been painted
-- on screen, so we won't see it)
UIManager:close(self.histfile_dialog)
-- Replace Book information callback to use directly our bookinfo
orig_buttons[2][1].callback = function()
FileManagerBookInfo:show(file, bookinfo)
UIManager:close(self.histfile_dialog)
end
-- Re-organise buttons to make them more coherent with those we're going to add
-- Move up "Clear history of deleted items" and down "Book information", so
-- it's now similar to File browser's onFileHold
-- (The original organisation is fine in classic mode)
orig_buttons[2], orig_buttons[4] = orig_buttons[4], orig_buttons[2]
-- Add some new buttons to original buttons set
table.insert(orig_buttons, {
{ -- Allow user to view real size cover in ImageViewer
text = _("View full size cover"),
enabled = bookinfo.has_cover and true or false,
callback = function()
local document = DocumentRegistry:openDocument(file)
if document then
local cover_bb = document:getCoverPageImage()
local imgviewer = ImageViewer:new{
image = cover_bb,
with_title_bar = false,
fullscreen = true,
}
UIManager:show(imgviewer)
UIManager:close(self.histfile_dialog)
DocumentRegistry:closeDocument(file)
end
end,
},
{ -- Allow user to directly view description in TextViewer
text = bookinfo.description and _("View book description") or _("No book description"),
enabled = bookinfo.description and true or false,
callback = function()
local description = require("util").htmlToPlainTextIfHtml(bookinfo.description)
local textviewer = TextViewer:new{
title = bookinfo.title,
text = description,
}
UIManager:show(textviewer)
UIManager:close(self.histfile_dialog)
end,
},
})
table.insert(orig_buttons, {
{ -- Allow user to ignore some offending cover image
text = bookinfo.ignore_cover and _("Unignore cover") or _("Ignore cover"),
enabled = bookinfo.has_cover and true or false,
callback = function()
BookInfoManager:setBookInfoProperties(file, {
["ignore_cover"] = not bookinfo.ignore_cover and 'Y' or false,
})
UIManager:close(self.histfile_dialog)
self:updateItems()
end,
},
{ -- Allow user to ignore some bad metadata (filename will be used instead)
text = bookinfo.ignore_meta and _("Unignore metadata") or _("Ignore metadata"),
enabled = bookinfo.has_meta and true or false,
callback = function()
BookInfoManager:setBookInfoProperties(file, {
["ignore_meta"] = not bookinfo.ignore_meta and 'Y' or false,
})
UIManager:close(self.histfile_dialog)
self:updateItems()
end,
},
})
table.insert(orig_buttons, {
{ -- Allow a new extraction (multiple interruptions, book replaced)...
text = _("Refresh cached book information"),
enabled = bookinfo and true or false,
callback = function()
BookInfoManager:deleteBookInfo(file)
UIManager:close(self.histfile_dialog)
self:updateItems()
end,
},
})
-- Create the new ButtonDialog, and let UIManager show it
local ButtonDialog = require("ui/widget/buttondialog")
self.histfile_dialog = ButtonDialog:new{
buttons = orig_buttons,
}
UIManager:show(self.histfile_dialog)
return true
end
function CoverMenu:onCloseWidget()
-- Due to close callback in FileManagerHistory:onShowHist, we may be called
-- multiple times (witnessed that with print(debug.traceback())
-- Stop background job if any (so that full cpu is available to reader)
logger.dbg("CoverMenu:onCloseWidget: terminating jobs if needed")
BookInfoManager:terminateBackgroundJobs()
BookInfoManager:closeDbConnection() -- sqlite connection no more needed
-- Cancel any still scheduled update
if self.items_update_action then
logger.dbg("CoverMenu:onCloseWidget: unscheduling items_update_action")
UIManager:unschedule(self.items_update_action)
self.items_update_action = nil
end
-- Propagate a call to free() to all our sub-widgets, to release memory used by their _bb
self.item_group:free()
-- Force garbage collecting when leaving too
collectgarbage()
collectgarbage()
-- Call original Menu:onCloseWidget (no subclass seems to override it)
Menu.onCloseWidget(self)
end
-- Overriden just to allow full refresh (useful with images)
function CoverMenu:onSwipe(arg, ges_ev)
if ges_ev.direction == "west" then
self:onNextPage()
elseif ges_ev.direction == "east" then
self:onPrevPage()
elseif ges_ev.direction ~= "north" and ges_ev.direction ~= "south" then
-- but not if north/south, and we're triggering menu
-- trigger full refresh
UIManager:setDirty(nil, "full")
end
end
return CoverMenu

@ -0,0 +1,743 @@
local Blitbuffer = require("ffi/blitbuffer")
local CenterContainer = require("ui/widget/container/centercontainer")
local Device = require("device")
local DocSettings = require("docsettings")
local Font = require("ui/font")
local FrameContainer = require("ui/widget/container/framecontainer")
local Geom = require("ui/geometry")
local GestureRange = require("ui/gesturerange")
local HorizontalGroup = require("ui/widget/horizontalgroup")
local HorizontalSpan = require("ui/widget/horizontalspan")
local ImageWidget = require("ui/widget/imagewidget")
local InfoMessage = require("ui/widget/infomessage")
local InputContainer = require("ui/widget/container/inputcontainer")
local LeftContainer = require("ui/widget/container/leftcontainer")
local LineWidget = require("ui/widget/linewidget")
local OverlapGroup = require("ui/widget/overlapgroup")
local RightContainer = require("ui/widget/container/rightcontainer")
local TextBoxWidget = require("ui/widget/textboxwidget")
local TextWidget = require("ui/widget/textwidget")
local UIManager = require("ui/uimanager")
local UnderlineContainer = require("ui/widget/container/underlinecontainer")
local VerticalGroup = require("ui/widget/verticalgroup")
local VerticalSpan = require("ui/widget/verticalspan")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local lfs = require("libs/libkoreader-lfs")
local util = require("util")
local _ = require("gettext")
local Screen = Device.screen
local T = require("ffi/util").template
local BookInfoManager = require("bookinfomanager")
-- Here is the specific UI implementation for "list" display modes
-- (see covermenu.lua for the generic code)
-- We will show a rotated dogear at bottom right corner of cover widget for
-- opened files (the dogear will make it look like a "used book")
local corner_mark = ImageWidget:new{
file = "resources/icons/dogear.png",
rotation_angle = 270
}
-- ItemShortCutIcon (for keyboard navigation) is private to menu.lua and can't be accessed,
-- so we need to redefine it
local ItemShortCutIcon = WidgetContainer:new{
dimen = Geom:new{ w = 22, h = 22 },
key = nil,
bordersize = 2,
radius = 0,
style = "square",
}
function ItemShortCutIcon:init()
if not self.key then
return
end
local radius = 0
local background = Blitbuffer.COLOR_WHITE
if self.style == "rounded_corner" then
radius = math.floor(self.width/2)
elseif self.style == "grey_square" then
background = Blitbuffer.gray(0.2)
end
local sc_face
if self.key:len() > 1 then
sc_face = Font:getFace("ffont", 14)
else
sc_face = Font:getFace("scfont", 22)
end
self[1] = FrameContainer:new{
padding = 0,
bordersize = self.bordersize,
radius = radius,
background = background,
dimen = self.dimen,
CenterContainer:new{
dimen = self.dimen,
TextWidget:new{
text = self.key,
face = sc_face,
},
},
}
end
-- Based on menu.lua's MenuItem
local ListMenuItem = InputContainer:new{
entry = {},
text = nil,
show_parent = nil,
detail = nil,
dimen = nil,
shortcut = nil,
shortcut_style = "square",
_underline_container = nil,
do_cover_image = false,
do_filename_only = false,
do_hint_opened = false,
been_opened = false,
init_done = false,
bookinfo_found = false,
cover_specs = nil,
has_description = false,
}
function ListMenuItem:init()
-- filepath may be provided as 'file' (history) or 'path' (filechooser)
-- store it as attribute so we can use it elsewhere
self.filepath = self.entry.file or self.entry.path
-- As done in MenuItem
-- Squared letter for keyboard navigation
if self.shortcut then
local shortcut_icon_dimen = Geom:new()
shortcut_icon_dimen.w = math.floor(self.dimen.h*2/5)
shortcut_icon_dimen.h = shortcut_icon_dimen.w
-- To keep a simpler widget structure, this shortcut icon will not
-- be part of it, but will be painted over the widget in our paintTo
self.shortcut_icon = ItemShortCutIcon:new{
dimen = shortcut_icon_dimen,
key = self.shortcut,
style = self.shortcut_style,
}
end
self.detail = self.text
-- we need this table per-instance, so we declare it here
if Device:isTouchDevice() then
self.ges_events = {
TapSelect = {
GestureRange:new{
ges = "tap",
range = self.dimen,
},
doc = "Select Menu Item",
},
HoldSelect = {
GestureRange:new{
ges = "hold",
range = self.dimen,
},
doc = "Hold Menu Item",
},
}
end
if Device:hasKeys() then
self.active_key_events = {
Select = { {"Press"}, doc = "chose selected item" },
}
end
-- We now build the minimal widget container that won't change after udpate()
-- As done in MenuItem
-- for compatibility with keyboard navigation
-- (which does not seem to work well when multiple pages,
-- even with classic menu)
self.underline_h = 1 -- smaller than default (3) to not shift our vertical aligment
self._underline_container = UnderlineContainer:new{
vertical_align = "center",
dimen = Geom:new{
w = self.width,
h = self.height
},
linesize = self.underline_h,
padding = 0,
-- widget : will be filled in self:update()
}
self[1] = self._underline_container
-- Remaining part of initialization is done in update(), because we may
-- have to do it more than once if item not found in db
self:update()
self.init_done = true
end
function ListMenuItem:update()
-- We will be a disctinctive widget whether we are a directory,
-- a known file with image / without image, or a not yet known file
local widget
-- we'll add a VerticalSpan of same size as underline container for balance
local dimen = Geom:new{
w = self.width,
h = self.height - 2 * self.underline_h
}
if lfs.attributes(self.filepath, "mode") == "directory" then
self.is_directory = true
-- nb items on the right, directory name on the left
local wright = TextWidget:new{
text = self.mandatory,
face = Font:getFace("infont", 15),
}
local wleft_width = dimen.w - wright:getSize().w
local wleft = TextBoxWidget:new{
text = self.text,
face = Font:getFace("cfont", 20),
width = wleft_width,
alignment = "left",
bold = true,
}
widget = OverlapGroup:new{
dimen = dimen,
LeftContainer:new{
dimen = dimen,
HorizontalGroup:new{
HorizontalSpan:new{ width = Screen:scaleBySize(10) },
wleft,
}
},
RightContainer:new{
dimen = dimen,
HorizontalGroup:new{
wright,
HorizontalSpan:new{ width = Screen:scaleBySize(10) },
},
},
}
else
-- File
local border_size = 1
local max_img_w = dimen.h - 2*border_size -- width = height, squared
local max_img_h = dimen.h - 2*border_size
local bookinfo = BookInfoManager:getBookInfo(self.filepath, self.do_cover_image)
if bookinfo and self.do_cover_image and not bookinfo.ignore_cover then
if not bookinfo.cover_fetched then
-- cover was not fetched previously, do as if not found
-- to force a new extraction
bookinfo = nil
end
-- If there's already a cover and it's a "M" size (MosaicMenuItem),
-- we'll use it and scale it down (it may slow a bit rendering,
-- but "M" size may be useful in another view (FileBrowser/History),
-- so we don't replace it).
end
if bookinfo then -- This book is known
self.bookinfo_found = true
local cover_bb_used = false
-- Build the left widget : image if wanted
local wleft = nil
local wleft_width = 0 -- if not do_cover_image
local wleft_height
if self.do_cover_image then
wleft_height = dimen.h
wleft_width = wleft_height -- make it squared
if bookinfo.has_cover and not bookinfo.ignore_cover then
cover_bb_used = true
-- Let ImageWidget do the scaling and give us the final size
local scale_factor = math.min(max_img_w / bookinfo.cover_w, max_img_h / bookinfo.cover_h)
local wimage = ImageWidget:new{
image = bookinfo.cover_bb,
scale_factor = scale_factor,
}
wimage:_render()
local image_size = wimage:getSize() -- get final widget size
wleft = CenterContainer:new{
dimen = Geom:new{ w = wleft_width, h = wleft_height },
FrameContainer:new{
width = image_size.w + 2*border_size,
height = image_size.h + 2*border_size,
margin = 0,
padding = 0,
bordersize = border_size,
wimage,
}
}
else
-- empty element the size of an image
wleft = CenterContainer:new{
dimen = Geom:new{ w = wleft_width, h = wleft_height },
HorizontalSpan:new{ width = wleft_width },
}
end
end
-- In case we got a blitbuffer and didnt use it (ignore_cover), free it
if bookinfo.cover_bb and not cover_bb_used then
bookinfo.cover_bb:free()
end
-- So we can draw an indicator if this book has a description
if bookinfo.description then
self.has_description = true
end
-- Gather some info, mostly for right widget:
-- file size (self.mandatory) (not available with History)
-- file type
-- pages read / nb of pages (not available for crengine doc not opened)
local directory, filename = util.splitFilePathName(self.filepath) -- luacheck: no unused
local filename_without_suffix, filetype = util.splitFileNameSuffix(filename)
local fileinfo_str = filetype
if self.mandatory then
fileinfo_str = self.mandatory .. " " .. fileinfo_str
end
-- Current page / pages are available or more accurate in .sdr/metadata.lua
local pages_str = ""
local percent_finished
local pages = bookinfo.pages -- default to those in bookinfo db
if DocSettings:hasSidecarFile(self.filepath) then
self.been_opened = true
local docinfo = DocSettings:open(self.filepath)
-- We can get nb of page in the new 'doc_pages' setting, or from the old 'stats.page'
if docinfo.data.doc_pages then
pages = docinfo.data.doc_pages
elseif docinfo.data.stats and docinfo.data.stats.pages then
if docinfo.data.stats.pages ~= 0 then -- crengine with statistics disabled stores 0
pages = docinfo.data.stats.pages
end
end
percent_finished = docinfo.data.percent_finished
end
if percent_finished then
if pages then
pages_str = T(_("%1 % of %2 pages"), math.floor(100*percent_finished), pages)
else
pages_str = string.format("%d %%", math.floor(100*percent_finished))
end
else
if pages then
pages_str = T(_("%1 pages"), pages)
end
end
-- Build the right widget
local wfileinfo = TextWidget:new{
text = fileinfo_str,
face = Font:getFace("cfont", 14),
}
local wpageinfo = TextWidget:new{
text = pages_str,
face = Font:getFace("cfont", 14),
}
local wright_width = math.max(wfileinfo:getSize().w, wpageinfo:getSize().w)
local wright_right_padding = Screen:scaleBySize(10)
-- We just built two string to be put one on top of the other, and we want
-- the combo centered. Given the nature of our strings (numbers,
-- uppercase MB/KB on top text, number and lowercase "page" on bottom text),
-- we get the annoying feeling it's not centered but shifted towards top.
-- Let's add a small VerticalSpan at top to give a better visual
-- feeling of centering.
local wright = CenterContainer:new{
dimen = Geom:new{ w = wright_width, h = dimen.h },
VerticalGroup:new{
align = "right",
VerticalSpan:new{ width = Screen:scaleBySize(2) },
wfileinfo,
wpageinfo,
}
}
-- Build the middle main widget, in the space available
local wmain_left_padding = Screen:scaleBySize(10)
if self.do_cover_image then
-- we need less padding, as cover image, most often in
-- portrait mode, will provide some padding
wmain_left_padding = Screen:scaleBySize(5)
end
local wmain_right_padding = Screen:scaleBySize(10) -- used only for next calculation
local wmain_width = dimen.w - wleft_width - wmain_left_padding - wmain_right_padding - wright_width - wright_right_padding
local fontname_title = "cfont"
local fontname_authors = "cfont"
local fontsize_title = 20
local fontsize_authors = 18
local wtitle, wauthors
local title, authors
-- whether to use or not title and authors
if self.do_filename_only or bookinfo.ignore_meta then
title = filename_without_suffix -- made out above
authors = nil
else
title = bookinfo.title and bookinfo.title or filename_without_suffix
authors = bookinfo.authors
end
-- add Series metadata if requested
if bookinfo.series then
if BookInfoManager:getSetting("append_series_to_title") then
if title then
title = title .. " - " .. bookinfo.series
else
title = bookinfo.series
end
end
if BookInfoManager:getSetting("append_series_to_authors") then
if authors then
authors = authors .. " - " .. bookinfo.series
else
authors = bookinfo.series
end
end
end
if bookinfo.unsupported then
-- Let's show this fact in place of the anyway empty authors slot
authors = T(_("(no book information: %1)"), bookinfo.unsupported)
end
-- Build title and authors texts with decreasing font size
-- till it fits in the space available
while true do
-- Free previously made widgets to avoid memory leaks
if wtitle then
wtitle:free()
end
if wauthors then
wauthors:free()
wauthors = nil
end
-- BookInfoManager:extractBookInfo() made sure
-- to save as nil (NULL) metadata that were an empty string
wtitle = TextBoxWidget:new{
text = title,
face = Font:getFace(fontname_title, fontsize_title),
width = wmain_width,
alignment = "left",
bold = true,
}
local height = wtitle:getSize().h
if authors then
wauthors = TextBoxWidget:new{
text = authors,
face = Font:getFace(fontname_authors, fontsize_authors),
width = wmain_width,
alignment = "left",
}
height = height + wauthors:getSize().h
end
if height < dimen.h then -- we fit !
break
end
-- If we don't fit, decrease both font sizes
fontsize_title = fontsize_title - 1
fontsize_authors = fontsize_authors - 1
-- Don't go too low, and get out of this loop
if fontsize_title < 3 or fontsize_authors < 3 then
break
end
end
local wmain = LeftContainer:new{
dimen = dimen,
VerticalGroup:new{
wtitle,
wauthors,
}
}
-- Build the final widget
widget = OverlapGroup:new{
dimen = dimen,
}
if self.do_cover_image then
-- add left widget
if wleft then
-- no need for left padding, as cover image, most often in
-- portrait mode, will have some padding - the rare landscape
-- mode cover image will be stuck to screen side thus
table.insert(widget, wleft)
end
-- pad main widget on the left with size of left widget
wmain = HorizontalGroup:new{
HorizontalSpan:new{ width = wleft_width },
HorizontalSpan:new{ width = wmain_left_padding },
wmain
}
else
-- pad main widget on the left
wmain = HorizontalGroup:new{
HorizontalSpan:new{ width = wmain_left_padding },
wmain
}
end
-- add padded main widget
table.insert(widget, LeftContainer:new{
dimen = dimen,
wmain
})
-- add right widget
table.insert(widget, RightContainer:new{
dimen = dimen,
HorizontalGroup:new{
wright,
HorizontalSpan:new{ width = wright_right_padding },
},
})
else -- bookinfo not found
if self.init_done then
-- Non-initial update(), but our widget is still not found:
-- it does not need to change, so avoid remaking the same widget
return
end
-- If we're in no image mode, don't save images in DB : people
-- who don't care about images will have a smaller DB, but
-- a new extraction will have to be made when one switch to image mode
if self.do_cover_image then
-- Not in db, we're going to fetch some cover
self.cover_specs = {
sizetag = "s",
max_cover_w = max_img_w,
max_cover_h = max_img_h,
}
end
--
if self.do_hint_opened and DocSettings:hasSidecarFile(self.filepath) then
self.been_opened = true
end
-- A real simple widget, nothing fancy
widget = LeftContainer:new{
dimen = dimen,
HorizontalGroup:new{
HorizontalSpan:new{ width = Screen:scaleBySize(10) },
TextBoxWidget:new{
text = self.text .. "", -- display hint it's being loaded
face = Font:getFace("cfont", 18),
width = dimen.w - 2 * Screen:scaleBySize(10),
alignment = "left",
}
},
}
end
end
-- Fill container with our widget
if self._underline_container[1] then
-- There is a previous one, that we need to free()
local previous_widget = self._underline_container[1]
previous_widget:free()
end
-- Add some pad at top to balance with hidden underline line at bottom
self._underline_container[1] = VerticalGroup:new{
VerticalSpan:new{ width = self.underline_h },
widget
}
end
function ListMenuItem:paintTo(bb, x, y)
-- We may get non-integer x or y (see mosaicmenu.lua)
-- Make them integer:
x = math.floor(x)
y = math.floor(y)
-- Original painting
InputContainer.paintTo(self, bb, x, y)
-- to which we paint over the shortcut icon
if self.shortcut_icon then
-- align it on bottom left corner of sub-widget
local target = self[1][1][2]
local ix = 0
local iy = target.dimen.h - self.shortcut_icon.dimen.h
self.shortcut_icon:paintTo(bb, x+ix, y+iy)
end
-- to which we paint over a dogear if needed
if self.do_hint_opened and self.been_opened then
-- align it on bottom right corner of widget
local ix = self.width - corner_mark:getSize().w
local iy = self.height - corner_mark:getSize().h
corner_mark:paintTo(bb, x+ix, y+iy)
end
-- to which we paint a small indicator if this book has a description
if self.has_description and not BookInfoManager:getSetting("no_hint_description") then
local target = self[1][1][2]
local d_w = Screen:scaleBySize(3)
local d_h = math.ceil(target.dimen.h / 4)
if self.do_cover_image and target[1][1][1] then
-- it has an image, align it on image's framecontainer's right border
target = target[1][1]
bb:paintBorder(target.dimen.x + target.dimen.w - 1, target.dimen.y, d_w, d_h, 1)
else
-- no image, align it to the left border
bb:paintBorder(x, y, d_w, d_h, 1)
end
end
end
-- As done in MenuItem
function ListMenuItem:onFocus()
self._underline_container.color = Blitbuffer.COLOR_BLACK
self.key_events = self.active_key_events
return true
end
function ListMenuItem:onUnfocus()
self._underline_container.color = Blitbuffer.COLOR_WHITE
self.key_events = {}
return true
end
function ListMenuItem:onShowItemDetail()
UIManager:show(InfoMessage:new{ text = self.detail, })
return true
end
-- The transient color inversions done in MenuItem:onTapSelect
-- and MenuItem:onHoldSelect are ugly when done on an image,
-- so let's not do it
-- Also, no need for 2nd arg 'pos' (only used in readertoc.lua)
function ListMenuItem:onTapSelect(arg)
self.menu:onMenuSelect(self.entry)
return true
end
function ListMenuItem:onHoldSelect(arg, ges)
self.menu:onMenuHold(self.entry)
return true
end
-- Simple holder of methods that will replace those
-- in the real Menu class or instance
local ListMenu = {}
function ListMenu:_recalculateDimen()
self.dimen.w = self.width
self.dimen.h = self.height or Screen:getHeight()
-- Find out available height from other UI elements made in Menu
self.others_height = 0
if self.title_bar then -- Menu:init() has been done
if not self.is_borderless then
self.others_height = self.others_height + 2
end
if not self.no_title then
self.others_height = self.others_height + self.header_padding
self.others_height = self.others_height + self.title_bar.dimen.h
end
if self.page_info then
self.others_height = self.others_height + self.page_info:getSize().h
end
else
-- Menu:init() not yet done: other elements used to calculate self.others_heights
-- are not yet defined, so next calculations will be wrong, and we may get
-- a self.perpage higher than it should be: Menu:init() will set a wrong self.page.
-- We'll have to update it, if we want FileManager to get back to the original page.
self.page_recalc_needed_next_time = true
-- Also remember original position, which will be changed by Menu/FileChooser
-- to a probably wrong value
self.itemnum_orig = self.path_items[self.path]
end
local available_height = self.dimen.h - self.others_height
-- 64 hardcoded for now, gives 10 items both in filemanager
-- and history on kobo glo hd
local item_height_min = Screen:scaleBySize(64)
self.perpage = math.floor(available_height / item_height_min)
self.page_num = math.ceil(#self.item_table / self.perpage)
local height_remaining = available_height - self.perpage * item_height_min
height_remaining = height_remaining - (self.perpage+1) -- N+1 LineWidget separators
self.item_height = item_height_min + math.floor(height_remaining / self.perpage)
self.item_width = self.dimen.w
self.item_dimen = Geom:new{
w = self.item_width,
h = self.item_height
}
if self.page_recalc_needed then
-- self.page has probably been set to a wrong value,
-- we recalculate it here as done in Menu:init()
if #self.item_table > 0 then
self.page = math.ceil((self.itemnum_orig or 1) / self.perpage)
end
self.page_recalc_needed = nil
self.itemnum_orig = nil
end
if self.page_recalc_needed_next_time then
self.page_recalc_needed = true
self.page_recalc_needed_next_time = nil
end
end
function ListMenu:_updateItemsBuildUI()
-- Build our list
-- We separate items with a 1px LineWidget (no need for
-- scaleBySize, thin is fine)
table.insert(self.item_group, LineWidget:new{
dimen = Geom:new{ w = self.width, h = 1 },
background = Blitbuffer.COLOR_GREY,
style = "solid",
})
local idx_offset = (self.page - 1) * self.perpage
for idx = 1, self.perpage do
local entry = self.item_table[idx_offset + idx]
if entry == nil then break end
-- Keyboard shortcuts, as done in Menu
local item_shortcut = nil
local shortcut_style = "square"
if self.is_enable_shortcut then
-- give different shortcut_style to keys in different
-- lines of keyboard
if idx >= 11 and idx <= 20 then
shortcut_style = "grey_square"
end
item_shortcut = self.item_shortcuts[idx]
if item_shortcut == "Enter" then
item_shortcut = "Ent"
end
end
local item_tmp = ListMenuItem:new{
height = self.item_height,
width = self.item_width,
entry = entry,
text = util.getMenuText(entry),
show_parent = self.show_parent,
mandatory = entry.mandatory,
dimen = self.item_dimen:new(),
shortcut = item_shortcut,
shortcut_style = shortcut_style,
menu = self,
do_cover_image = self._do_cover_images,
do_hint_opened = self._do_hint_opened,
do_filename_only = self._do_filename_only,
}
table.insert(self.item_group, item_tmp)
table.insert(self.item_group, LineWidget:new{
dimen = Geom:new{ w = self.width, h = 1 },
background = Blitbuffer.COLOR_GREY,
style = "solid",
})
-- this is for focus manager
table.insert(self.layout, {item_tmp})
if not item_tmp.bookinfo_found and not item_tmp.is_directory then
-- Register this item for update
table.insert(self.items_to_update, item_tmp)
end
end
end
return ListMenu

@ -0,0 +1,443 @@
local InputContainer = require("ui/widget/container/inputcontainer")
local UIManager = require("ui/uimanager")
local logger = require("logger")
local _ = require("gettext")
local BookInfoManager = require("bookinfomanager")
--[[
This plugin provides additional display modes to file browsers (File Manager
and History).
It does that by dynamically replacing some methods code to their classes
or instances.
--]]
-- We need to save the original methods early here as locals.
-- For some reason, saving them as attributes in init() does not allow
-- us to get back to classic mode
local FileChooser = require("ui/widget/filechooser")
local _FileChooser__recalculateDimen_orig = FileChooser._recalculateDimen
local _FileChooser_updateItems_orig = FileChooser.updateItems
local _FileChooser_onCloseWidget_orig = FileChooser.onCloseWidget
local _FileChooser_onSwipe_orig = FileChooser.onSwipe
local FileManagerHistory = require("apps/filemanager/filemanagerhistory")
local _FileManagerHistory_updateItemTable_orig = FileManagerHistory.updateItemTable
-- Available display modes
local DISPLAY_MODES = {
-- nil or "" -- classic : filename only
mosaic_image = true, -- 3x3 grid covers with images
mosaic_text = true, -- 3x3 grid covers text only
list_image_meta = true, -- image with metadata (title/authors)
list_only_meta = true, -- metadata with no image
list_image_filename = true, -- image with filename (no metadata)
}
local CoverBrowser = InputContainer:new{}
function CoverBrowser:init()
-- As we don't know how to run and kill subprocesses on Android (for
-- background info extraction), disable this plugin for now.
-- XXX What about the emulator on Windows ?
if require("ffi/util").isAndroid() then
return
end
self.filemanager_display_mode = BookInfoManager:getSetting("filemanager_display_mode")
self:setupFileManagerDisplayMode()
self.history_display_mode = BookInfoManager:getSetting("history_display_mode")
self:setupHistoryDisplayMode()
self.ui.menu:registerToMainMenu(self)
-- If KOReader has started directly to FileManager, the FileManager
-- instance is being init()'ed and there is no FileManager.instance yet,
-- but there'll be one at next tick.
UIManager:nextTick(function()
self:refreshFileManagerInstance()
end)
end
function CoverBrowser:addToMainMenu(menu_items)
if not self.ui.view then -- only for FileManager menu
menu_items.filemanager_display_mode = {
text = _("Display mode"),
sub_item_table = {
-- selecting these does not close menu, which may be nice
-- so one can see how they look below the menu
{
text = _("Classic (filename only)"),
checked_func = function() return not self.filemanager_display_mode end,
callback = function()
self:setupFileManagerDisplayMode("")
end,
},
{
text = _("Mosaic with cover images"),
checked_func = function() return self.filemanager_display_mode == "mosaic_image" end,
callback = function()
self:setupFileManagerDisplayMode("mosaic_image")
end,
},
{
text = _("Mosaic with text covers"),
checked_func = function() return self.filemanager_display_mode == "mosaic_text" end,
callback = function()
self:setupFileManagerDisplayMode("mosaic_text")
end,
},
{
text = _("List with image and metadata"),
checked_func = function() return self.filemanager_display_mode == "list_image_meta" end,
callback = function()
self:setupFileManagerDisplayMode("list_image_meta")
end,
},
{
text = _("List with metadata, no image"),
checked_func = function() return self.filemanager_display_mode == "list_only_meta" end,
callback = function()
self:setupFileManagerDisplayMode("list_only_meta")
end,
},
{
text = _("List with image and filename"),
checked_func = function() return self.filemanager_display_mode == "list_image_filename" end,
callback = function()
self:setupFileManagerDisplayMode("list_image_filename")
end,
separator = true,
},
-- Plug the same choices for History here as a submenu
-- (Any other suitable place for that ?)
{
separator = true,
text = _("History display mode"),
sub_item_table = {
{
text = _("Classic (filename only)"),
checked_func = function() return not self.history_display_mode end,
callback = function()
self:setupHistoryDisplayMode("")
end,
},
{
text = _("Mosaic with cover images"),
checked_func = function() return self.history_display_mode == "mosaic_image" end,
callback = function()
self:setupHistoryDisplayMode("mosaic_image")
end,
},
{
text = _("Mosaic with text covers"),
checked_func = function() return self.history_display_mode == "mosaic_text" end,
callback = function()
self:setupHistoryDisplayMode("mosaic_text")
end,
},
{
text = _("List with image and metadata"),
checked_func = function() return self.history_display_mode == "list_image_meta" end,
callback = function()
self:setupHistoryDisplayMode("list_image_meta")
end,
},
{
text = _("List with metadata, no image"),
checked_func = function() return self.history_display_mode == "list_only_meta" end,
callback = function()
self:setupHistoryDisplayMode("list_only_meta")
end,
},
{
text = _("List with image and filename"),
checked_func = function() return self.history_display_mode == "list_image_filename" end,
callback = function()
self:setupHistoryDisplayMode("list_image_filename")
end,
separator = true,
},
},
},
-- Misc settings
{
text = _("Other settings"),
sub_item_table = {
{
text = _("Show hint for books with description"),
checked_func = function() return not BookInfoManager:getSetting("no_hint_description") end,
callback = function()
if BookInfoManager:getSetting("no_hint_description") then
BookInfoManager:saveSetting("no_hint_description", false)
else
BookInfoManager:saveSetting("no_hint_description", true)
end
self:refreshFileManagerInstance()
end,
},
{
text = _("Show hint for opened books in history"),
checked_func = function() return BookInfoManager:getSetting("history_hint_opened") end,
callback = function()
if BookInfoManager:getSetting("history_hint_opened") then
BookInfoManager:saveSetting("history_hint_opened", false)
else
BookInfoManager:saveSetting("history_hint_opened", true)
end
self:refreshFileManagerInstance()
end,
},
{
text = _("Append series metadata to authors"),
checked_func = function() return BookInfoManager:getSetting("append_series_to_authors") end,
callback = function()
if BookInfoManager:getSetting("append_series_to_authors") then
BookInfoManager:saveSetting("append_series_to_authors", false)
else
BookInfoManager:saveSetting("append_series_to_authors", true)
end
self:refreshFileManagerInstance()
end,
},
{
text = _("Append series metadata to title"),
checked_func = function() return BookInfoManager:getSetting("append_series_to_title") end,
callback = function()
if BookInfoManager:getSetting("append_series_to_title") then
BookInfoManager:saveSetting("append_series_to_title", false)
else
BookInfoManager:saveSetting("append_series_to_title", true)
end
self:refreshFileManagerInstance()
end,
},
},
},
{
text = _("Book info cache management"),
sub_item_table = {
{
text_func = function() -- add current db size to menu text
local sstr = BookInfoManager:getDbSize()
return _("Current cache size: ") .. sstr
end,
-- no callback, only for information
},
{
text = _("Prune cache of removed books"),
callback = function()
local ConfirmBox = require("ui/widget/confirmbox")
UIManager:close(self.file_dialog)
UIManager:show(ConfirmBox:new{
-- Checking file existences is quite fast, but deleting entries is slow.
text = _("Are you sure that you want to prune cache of removed books?\n(This may take a while.)"),
ok_text = _("Prune cache"),
ok_callback = function()
local InfoMessage = require("ui/widget/infomessage")
local msg = InfoMessage:new{ text = _("Pruning cache of removed books…") }
UIManager:show(msg)
UIManager:nextTick(function()
local summary = BookInfoManager:removeNonExistantEntries()
UIManager:close(msg)
UIManager:show( InfoMessage:new{ text = summary } )
end)
end
})
end,
},
{
text = _("Compact cache database"),
callback = function()
local ConfirmBox = require("ui/widget/confirmbox")
UIManager:close(self.file_dialog)
UIManager:show(ConfirmBox:new{
text = _("Are you sure that you want to compact cache database?\n(This may take a while.)"),
ok_text = _("Compact database"),
ok_callback = function()
local InfoMessage = require("ui/widget/infomessage")
local msg = InfoMessage:new{ text = _("Compacting cache database…") }
UIManager:show(msg)
UIManager:nextTick(function()
local summary = BookInfoManager:compactDb()
UIManager:close(msg)
UIManager:show( InfoMessage:new{ text = summary } )
end)
end
})
end,
},
{
text = _("Delete cache database"),
callback = function()
local ConfirmBox = require("ui/widget/confirmbox")
UIManager:close(self.file_dialog)
UIManager:show(ConfirmBox:new{
text = _("Are you sure that you want to delete cover and metadata cache?\n(This will also reset your display mode settings.)"),
ok_text = _("Purge"),
ok_callback = function()
BookInfoManager:deleteDb()
end
})
end,
},
},
},
},
}
end
end
function CoverBrowser:refreshFileManagerInstance(cleanup)
local FileManager = require("apps/filemanager/filemanager")
local fm = FileManager.instance
if fm then
local fc = fm.file_chooser
if cleanup then -- clean instance properties we may have set
if fc.onFileHold_orig then
-- remove our onFileHold that extended file_dialog with new buttons
fc.onFileHold = fc.onFileHold_orig
fc.onFileHold_orig = nil
fc.onFileHold_ours = nil
end
end
fc:updateItems()
end
end
function CoverBrowser:setupFileManagerDisplayMode(display_mode)
if not display_mode then -- if none provided, use current one
display_mode = self.filemanager_display_mode
end
if not DISPLAY_MODES[display_mode] then
display_mode = nil
end
self.filemanager_display_mode = display_mode
BookInfoManager:saveSetting("filemanager_display_mode", self.filemanager_display_mode)
logger.dbg("CoverBrowser: setting FileManager display mode to:", display_mode or "classic")
if not display_mode then -- classic mode
-- Put back original methods
FileChooser.updateItems = _FileChooser_updateItems_orig
FileChooser.onCloseWidget = _FileChooser_onCloseWidget_orig
FileChooser.onSwipe = _FileChooser_onSwipe_orig
FileChooser._recalculateDimen = _FileChooser__recalculateDimen_orig
-- Also clean-up what we added, even if it does not bother original code
FileChooser._updateItemsBuildUI = nil
FileChooser._do_cover_images = nil
FileChooser._do_filename_only = nil
FileChooser._do_hint_opened = nil
self:refreshFileManagerInstance(true)
return
end
-- In both mosaic and list modes, replace original methods with those from
-- our generic CoverMenu
local CoverMenu = require("covermenu")
FileChooser.updateItems = CoverMenu.updateItems
FileChooser.onCloseWidget = CoverMenu.onCloseWidget
FileChooser.onSwipe = CoverMenu.onSwipe
if display_mode == "mosaic_image" or display_mode == "mosaic_text" then -- mosaic mode
-- Replace some other original methods with those from our MosaicMenu
local MosaicMenu = require("mosaicmenu")
FileChooser._recalculateDimen = MosaicMenu._recalculateDimen
FileChooser._updateItemsBuildUI = MosaicMenu._updateItemsBuildUI
-- Set MosaicMenu behaviour:
FileChooser._do_cover_images = display_mode ~= "mosaic_text"
FileChooser._do_hint_opened = true -- dogear at bottom
-- One could override default 3x3 grid here (put that as settings ?)
-- FileChooser.nb_cols_portrait = 4
-- FileChooser.nb_rows_portrait = 4
-- FileChooser.nb_cols_landscape = 6
-- FileChooser.nb_rows_landscape = 3
elseif display_mode == "list_image_meta" or display_mode == "list_only_meta" or
display_mode == "list_image_filename" then -- list modes
-- Replace some other original methods with those from our ListMenu
local ListMenu = require("listmenu")
FileChooser._recalculateDimen = ListMenu._recalculateDimen
FileChooser._updateItemsBuildUI = ListMenu._updateItemsBuildUI
-- Set ListMenu behaviour:
FileChooser._do_cover_images = display_mode ~= "list_only_meta"
FileChooser._do_filename_only = display_mode == "list_image_filename"
FileChooser._do_hint_opened = true -- dogear at bottom
end
self:refreshFileManagerInstance()
end
local function _FileManagerHistory_updateItemTable(self)
-- 'self' here is the single FileManagerHistory instance
-- FileManagerHistory has just created a new instance of Menu as 'hist_menu'
-- at each display of History. Soon after instantiation, this method
-- is called. The first time it is called, we replace some methods.
local display_mode = self.display_mode
local hist_menu = self.hist_menu
if not hist_menu._coverbrowser_overridden then
hist_menu._coverbrowser_overridden = true
-- In both mosaic and list modes, replace original methods with those from
-- our generic CoverMenu
local CoverMenu = require("covermenu")
hist_menu.updateItems = CoverMenu.updateItems
hist_menu.onCloseWidget = CoverMenu.onCloseWidget
hist_menu.onSwipe = CoverMenu.onSwipe
-- Also replace original onMenuHold (it will use original method, so remember it)
hist_menu.onMenuHold_orig = hist_menu.onMenuHold
hist_menu.onMenuHold = CoverMenu.onHistoryMenuHold
if display_mode == "mosaic_image" or display_mode == "mosaic_text" then -- mosaic mode
-- Replace some other original methods with those from our MosaicMenu
local MosaicMenu = require("mosaicmenu")
hist_menu._recalculateDimen = MosaicMenu._recalculateDimen
hist_menu._updateItemsBuildUI = MosaicMenu._updateItemsBuildUI
-- Set MosaicMenu behaviour:
hist_menu._do_cover_images = display_mode ~= "mosaic_text"
-- no need for do_hint_opened with History
elseif display_mode == "list_image_meta" or display_mode == "list_only_meta" or
display_mode == "list_image_filename" then -- list modes
-- Replace some other original methods with those from our ListMenu
local ListMenu = require("listmenu")
hist_menu._recalculateDimen = ListMenu._recalculateDimen
hist_menu._updateItemsBuildUI = ListMenu._updateItemsBuildUI
-- Set ListMenu behaviour:
hist_menu._do_cover_images = display_mode ~= "list_only_meta"
hist_menu._do_filename_only = display_mode == "list_image_filename"
-- no need for do_hint_opened with History
end
hist_menu._do_hint_opened = BookInfoManager:getSetting("history_hint_opened")
end
-- We do now the single thing done in FileManagerHistory:updateItemTable():
hist_menu:switchItemTable(self.hist_menu_title, require("readhistory").hist)
end
function CoverBrowser:setupHistoryDisplayMode(display_mode)
if not display_mode then -- if none provided, use current one
display_mode = self.history_display_mode
end
if not DISPLAY_MODES[display_mode] then
display_mode = nil
end
self.history_display_mode = display_mode
BookInfoManager:saveSetting("history_display_mode", self.history_display_mode)
logger.dbg("CoverBrowser: setting History display mode to:", display_mode or "classic")
-- We only need to replace one FileManagerHistory method
if not display_mode then -- classic mode
-- Put back original methods
FileManagerHistory.updateItemTable = _FileManagerHistory_updateItemTable_orig
FileManagerHistory.display_mode = nil
else
-- Replace original method with the one defined above
FileManagerHistory.updateItemTable = _FileManagerHistory_updateItemTable
-- And let it know which display_mode we should use
FileManagerHistory.display_mode = display_mode
end
end
return CoverBrowser

@ -0,0 +1,741 @@
local Blitbuffer = require("ffi/blitbuffer")
local BottomContainer = require("ui/widget/container/bottomcontainer")
local CenterContainer = require("ui/widget/container/centercontainer")
local Device = require("device")
local DocSettings = require("docsettings")
local Font = require("ui/font")
local FrameContainer = require("ui/widget/container/framecontainer")
local Geom = require("ui/geometry")
local GestureRange = require("ui/gesturerange")
local HorizontalGroup = require("ui/widget/horizontalgroup")
local HorizontalSpan = require("ui/widget/horizontalspan")
local ImageWidget = require("ui/widget/imagewidget")
local InfoMessage = require("ui/widget/infomessage")
local InputContainer = require("ui/widget/container/inputcontainer")
local OverlapGroup = require("ui/widget/overlapgroup")
local TextBoxWidget = require("ui/widget/textboxwidget")
local TextWidget = require("ui/widget/textwidget")
local UIManager = require("ui/uimanager")
local UnderlineContainer = require("ui/widget/container/underlinecontainer")
local VerticalGroup = require("ui/widget/verticalgroup")
local VerticalSpan = require("ui/widget/verticalspan")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local lfs = require("libs/libkoreader-lfs")
local _ = require("gettext")
local Screen = Device.screen
local getMenuText = require("util").getMenuText
local BookInfoManager = require("bookinfomanager")
-- Here is the specific UI implementation for "mosaic" display modes
-- (see covermenu.lua for the generic code)
-- We will show a rotated dogear at bottom right corner of cover widget for
-- opened files (the dogear will make it look like a "used book")
local corner_mark = ImageWidget:new{
file = "resources/icons/dogear.png",
rotation_angle = 270
}
-- ItemShortCutIcon (for keyboard navigation) is private to menu.lua and can't be accessed,
-- so we need to redefine it
local ItemShortCutIcon = WidgetContainer:new{
dimen = Geom:new{ w = 22, h = 22 },
key = nil,
bordersize = 2,
radius = 0,
style = "square",
}
function ItemShortCutIcon:init()
if not self.key then
return
end
local radius = 0
local background = Blitbuffer.COLOR_WHITE
if self.style == "rounded_corner" then
radius = math.floor(self.width/2)
elseif self.style == "grey_square" then
background = Blitbuffer.gray(0.2)
end
local sc_face
if self.key:len() > 1 then
sc_face = Font:getFace("ffont", 14)
else
sc_face = Font:getFace("scfont", 22)
end
self[1] = FrameContainer:new{
padding = 0,
bordersize = self.bordersize,
radius = radius,
background = background,
dimen = self.dimen,
CenterContainer:new{
dimen = self.dimen,
TextWidget:new{
text = self.key,
face = sc_face,
},
},
}
end
-- We may find a better algorithm, or just a set of
-- nice looking combinations of 3 sizes to iterate thru
-- The rendering of the TextBoxWidget we're doing below
-- with decreasing font sizes till it fits is quite expensive.
local FakeCover = FrameContainer:new{
width = nil,
height = nil,
margin = 0,
padding = 0,
bordersize = 1,
filename = nil,
title = nil,
authors = nil,
-- these font sizes will be scaleBySize'd by Font:getFace()
authors_font_max = 20,
authors_font_min = 6,
title_font_max = 24,
title_font_min = 10,
filename_font_max = 10,
filename_font_min = 8,
top_pad = Screen:scaleBySize(5),
bottom_pad = Screen:scaleBySize(5),
sizedec_step = Screen:scaleBySize(2), -- speeds up a bit if we don't do all font sizes
initial_sizedec = 0,
}
function FakeCover:init()
-- BookInfoManager:extractBookInfo() made sure
-- to save as nil (NULL) metadata that were an empty string
local authors = self.authors
local title = self.title
local filename = self.filename
-- (some engines may have already given filename (without extension) as title)
if not title then -- use filename as title (big and centered)
title = filename
filename = nil
end
-- If no authors, and title is filename without extension, it was
-- probably made by an engine, and we can consider it a filename, and
-- act according to common usage in naming files.
if not authors and title and self.filename:sub(1,title:len()) == title then
-- Replace a hyphen surrounded by spaces (which most probably was
-- used to separate Authors/Title/Serie/Year/Categorie in the
-- filename with a \n
title = title:gsub(" %- ", "\n")
-- Same with |
title = title:gsub("|", "\n")
-- Also replace underscores with spaces
title = title:gsub("_", " ")
end
-- We build the VerticalGroup widget with decreasing font sizes till
-- the widget fits into available height
local width = self.width - 2*(self.bordersize + self.margin + self.padding)
local height = self.height - 2*(self.bordersize + self.margin + self.padding)
local text_width = 7/8 * width -- make width of text smaller to have some padding
local inter_pad
local sizedec = self.initial_sizedec
local authors_wg, title_wg, filename_wg
local loop2 = false -- we may do a second pass with modifier title and authors strings
while true do
-- Free previously made widgets to avoid memory leaks
if authors_wg then
authors_wg:free()
authors_wg = nil
end
if title_wg then
title_wg:free()
title_wg = nil
end
if filename_wg then
filename_wg:free()
filename_wg = nil
end
-- Build new widgets
local texts_height = 0
if authors then
authors_wg = TextBoxWidget:new{
text = authors,
face = Font:getFace("cfont", math.max(self.authors_font_max - sizedec, self.authors_font_min)),
width = text_width,
alignment = "center",
}
texts_height = texts_height + authors_wg:getSize().h
end
if title then
title_wg = TextBoxWidget:new{
text = title,
face = Font:getFace("cfont", math.max(self.title_font_max - sizedec, self.title_font_min)),
width = text_width,
alignment = "center",
}
texts_height = texts_height + title_wg:getSize().h
end
if filename then
filename_wg = TextBoxWidget:new{
text = filename,
face = Font:getFace("cfont", math.max(self.filename_font_max - sizedec, self.filename_font_min)),
width = text_width,
alignment = "center",
}
texts_height = texts_height + filename_wg:getSize().h
end
local free_height = height - texts_height
if authors then
free_height = free_height - self.top_pad
end
if filename then
free_height = free_height - self.bottom_pad
end
inter_pad = math.floor(free_height / 2)
-- XXX We can benefit from adding to ui/widget/textboxwidget.lua at line 141
-- ("either a very long english word"):
-- if adjusted_idx == offset then self.has_split_inside_word = true end
-- The following as no effect till then
local textboxes_ok = true
if (authors_wg and authors_wg.has_split_inside_word) or (title_wg and title_wg.has_split_inside_word) then
-- We may get a nicer cover at next lower font size
textboxes_ok = false
end
if textboxes_ok and free_height > 0.2 * height then -- enough free space to not look constrained
break
end
-- (We may store the first widgets matching free space requirements but
-- not textboxes_ok, so that if we never ever get textboxes_ok candidate,
-- we can use them instead of the super-small strings-modified we'll have
-- at the end that are worse than the firsts)
sizedec = sizedec + self.sizedec_step
if sizedec > 20 then -- break out of loop when too small
-- but try a 2nd loop with some cleanup to strings (for filenames
-- with no space but hyphen or underscore instead)
if not loop2 then
loop2 = true
sizedec = self.initial_sizedec -- restart from initial big size
-- Replace underscores and hyphens with spaces, to allow text wrap there.
if title then
title = title:gsub("-", " "):gsub("_", " ")
end
if authors then
authors = authors:gsub("-", " "):gsub("_", " ")
end
else -- 2nd loop done, no luck, give up
break
end
end
end
local vgroup = VerticalGroup:new{}
if authors then
table.insert(vgroup, VerticalSpan:new{ width = self.top_pad })
table.insert(vgroup, authors_wg)
end
table.insert(vgroup, VerticalSpan:new{ width = inter_pad })
if title then
table.insert(vgroup, title_wg)
end
table.insert(vgroup, VerticalSpan:new{ width = inter_pad })
if filename then
table.insert(vgroup, filename_wg)
table.insert(vgroup, VerticalSpan:new{ width = self.bottom_pad })
end
-- As we are a FrameContainer, a border will be painted around self[1]
self[1] = CenterContainer:new{
dimen = Geom:new{
w = width,
h = height,
},
vgroup,
}
end
-- Based on menu.lua's MenuItem
local MosaicMenuItem = InputContainer:new{
entry = {},
text = nil,
show_parent = nil,
detail = nil,
dimen = nil,
shortcut = nil,
shortcut_style = "square",
_underline_container = nil,
do_cover_image = false,
do_hint_opened = false,
been_opened = false,
init_done = false,
bookinfo_found = false,
cover_specs = nil,
has_description = false,
}
function MosaicMenuItem:init()
-- filepath may be provided as 'file' (history) or 'path' (filechooser)
-- store it as attribute so we can use it elsewhere
self.filepath = self.entry.file or self.entry.path
-- As done in MenuItem
-- Squared letter for keyboard navigation
if self.shortcut then
local shortcut_icon_dimen = Geom:new()
shortcut_icon_dimen.w = math.floor(self.dimen.h*1/5)
shortcut_icon_dimen.h = shortcut_icon_dimen.w
-- To keep a simpler widget structure, this shortcut icon will not
-- be part of it, but will be painted over the widget in our paintTo
self.shortcut_icon = ItemShortCutIcon:new{
dimen = shortcut_icon_dimen,
key = self.shortcut,
style = self.shortcut_style,
}
end
self.detail = self.text
-- we need this table per-instance, so we declare it here
if Device:isTouchDevice() then
self.ges_events = {
TapSelect = {
GestureRange:new{
ges = "tap",
range = self.dimen,
},
doc = "Select Menu Item",
},
HoldSelect = {
GestureRange:new{
ges = "hold",
range = self.dimen,
},
doc = "Hold Menu Item",
},
}
end
if Device:hasKeys() then
self.active_key_events = {
Select = { {"Press"}, doc = "chose selected item" },
}
end
-- We now build the minimal widget container that won't change after udpate()
-- As done in MenuItem
-- for compatibility with keyboard navigation
-- (which does not seem to work well when multiple pages,
-- even with classic menu)
self.underline_h = 1 -- smaller than default (3), don't waste space
self._underline_container = UnderlineContainer:new{
vertical_align = "center",
dimen = Geom:new{
w = self.width,
h = self.height
},
linesize = self.underline_h,
-- widget : will be filled in self:update()
}
self[1] = self._underline_container
-- Remaining part of initialization is done in update(), because we may
-- have to do it more than once if item not found in db
self:update()
self.init_done = true
end
function MosaicMenuItem:update()
-- We will be a disctinctive widget whether we are a directory,
-- a known file with image / without image, or a not yet known file
local widget
local dimen = Geom:new{
w = self.width,
h = self.height - self.underline_h
}
if lfs.attributes(self.filepath, "mode") == "directory" then
self.is_directory = true
-- Directory : rounded corners
local margin = Screen:scaleBySize(5) -- make directories less wide
local padding = Screen:scaleBySize(5)
local border_size = Screen:scaleBySize(2) -- make directories bolder
local dimen_in = Geom:new{
w = dimen.w - (margin + padding + border_size)*2,
h = dimen.h - (margin + padding + border_size)*2
}
local text = self.text
if text:match('/$') then -- remove /, more readable
text = text:sub(1, -2)
end
local directory = TextBoxWidget:new{
text = text,
face = Font:getFace("cfont", 20),
width = dimen_in.w,
alignment = "center",
bold = true,
}
local nbitems = TextBoxWidget:new{
text = self.mandatory,
face = Font:getFace("infont", 15),
width = dimen_in.w,
alignment = "center",
}
widget = FrameContainer:new{
width = dimen.w,
height = dimen.h,
margin = margin,
padding = padding,
bordersize = border_size,
radius = Screen:scaleBySize(10),
OverlapGroup:new{
dimen = dimen_in,
CenterContainer:new{ dimen=dimen_in, directory},
BottomContainer:new{ dimen=dimen_in, nbitems},
},
}
else
-- File : various appearances
-- We'll draw a border around cover images, it may not be
-- needed with some covers, but it's nicer when cover is
-- a pure white background (like rendered text page)
local border_size = 1
local max_img_w = dimen.w - 2*border_size
local max_img_h = dimen.h - 2*border_size
if self.do_hint_opened and DocSettings:hasSidecarFile(self.filepath) then
self.been_opened = true
end
local bookinfo = BookInfoManager:getBookInfo(self.filepath, self.do_cover_image)
if bookinfo and self.do_cover_image and not bookinfo.ignore_cover then
if bookinfo.cover_fetched then
if bookinfo.has_cover and bookinfo.cover_sizetag ~= "M" then
-- there is a cover, but it's a small one (made by ListMenuItem),
-- and it would be ugly if scaled up to MosaicMenuItem size:
-- do as if not found to force a new extraction with our size
if bookinfo.cover_bb then
bookinfo.cover_bb:free()
end
bookinfo = nil
-- Note: with the current size differences between FileManager
-- and the History windows, we'll get lower max_img_* in History.
-- So, when one get Items first generated by the other, it will
-- have to do some scaling. Hopefully, people most probably
-- browse a lot more files than have them in history, so
-- it's most probably History that will have to do some scaling.
end
-- if not has_cover, book has no cover, no need to try again
else
-- cover was not fetched previously, do as if not found
-- to force a new extraction
bookinfo = nil
end
end
if bookinfo then -- This book is known
local cover_bb_used = false
self.bookinfo_found = true
-- For wikipedia saved as epub, we made a cover from the 1st pic of the page,
-- which may not say much about the book. So, here, pretend we don't have
-- a cover
if bookinfo.authors and bookinfo.authors:match("^Wikipedia ") then
bookinfo.has_cover = nil
end
if self.do_cover_image and bookinfo.has_cover and not bookinfo.ignore_cover then
cover_bb_used = true
-- Let ImageWidget do the scaling and give us a bb that fit
local scale_factor = math.min(max_img_w / bookinfo.cover_w, max_img_h / bookinfo.cover_h)
local image= ImageWidget:new{
image = bookinfo.cover_bb,
scale_factor = scale_factor,
}
image:_render()
local image_size = image:getSize()
widget = CenterContainer:new{
dimen = dimen,
FrameContainer:new{
width = image_size.w + 2*border_size,
height = image_size.h + 2*border_size,
margin = 0,
padding = 0,
bordersize = border_size,
image,
}
}
else
-- add Series metadata if requested
if bookinfo.series then
if BookInfoManager:getSetting("append_series_to_title") then
if bookinfo.title then
bookinfo.title = bookinfo.title .. " - " .. bookinfo.series
else
bookinfo.title = bookinfo.series
end
end
if BookInfoManager:getSetting("append_series_to_authors") then
if bookinfo.authors then
bookinfo.authors = bookinfo.authors .. " - " .. bookinfo.series
else
bookinfo.authors = bookinfo.series
end
end
end
widget = CenterContainer:new{
dimen = dimen,
FakeCover:new{
-- reduced width to make it look less squared, more like a book
width = math.floor(dimen.w * 7/8),
height = dimen.h,
bordersize = border_size,
filename = self.text,
title = not bookinfo.ignore_meta and bookinfo.title,
authors = not bookinfo.ignore_meta and bookinfo.authors,
}
}
end
-- In case we got a blitbuffer and didnt use it (ignore_cover, wikipedia), free it
if bookinfo.cover_bb and not cover_bb_used then
bookinfo.cover_bb:free()
end
-- So we can draw an indicator if this book has a description
if bookinfo.description then
self.has_description = true
end
else -- bookinfo not found
if self.init_done then
-- Non-initial update(), but our widget is still not found:
-- it does not need to change, so avoid making the same FakeCover
return
end
-- If we're in no image mode, don't save images in DB : people
-- who don't care about images will have a smaller DB, but
-- a new extraction will have to be made when one switch to image mode
if self.do_cover_image then
-- Not in db, we're going to fetch some cover
self.cover_specs = {
sizetag = "M",
max_cover_w = max_img_w,
max_cover_h = max_img_h,
}
end
-- Same as real FakeCover, but let it be squared (like a file)
widget = CenterContainer:new{
dimen = dimen,
FakeCover:new{
width = dimen.w,
height = dimen.h,
bordersize = border_size,
filename = self.text .. "\n", -- display hint it's being loaded
initial_sizedec = 4, -- start with a smaller font when filenames only
}
}
end
end
-- Fill container with our widget
if self._underline_container[1] then
-- There is a previous one, that we need to free()
local previous_widget = self._underline_container[1]
previous_widget:free()
end
self._underline_container[1] = widget
end
function MosaicMenuItem:paintTo(bb, x, y)
-- We may get non-integer x or y that would cause some mess with image
-- inside FrameContainer were image would be drawn on top of the top border...
-- XXX We can stop having non-integer x/y by patching textwidget.lua
-- TextWidget:updateSize():
-- self._length = math.ceil(tsize.x)
-- self._height = math.ceil(self.face.size * 1.5)
-- In the meantime, make them integer:
x = math.floor(x)
y = math.floor(y)
-- Original painting
InputContainer.paintTo(self, bb, x, y)
-- to which we paint over the shortcut icon
if self.shortcut_icon then
-- align it on bottom left corner of widget
local target = self
local ix = 0
local iy = target.dimen.h - self.shortcut_icon.dimen.h
self.shortcut_icon:paintTo(bb, x+ix, y+iy)
end
-- to which we paint over a dogear if needed
if self.do_hint_opened and self.been_opened then
-- align it on bottom right corner of sub-widget
local target = self[1][1][1]
local ix = self.width - math.ceil((self.width - target.dimen.w)/2) - corner_mark:getSize().w
local iy = self.height - math.ceil((self.height - target.dimen.h)/2) - corner_mark:getSize().h
-- math.ceil() makes it looks better than math.floor()
corner_mark:paintTo(bb, x+ix, y+iy)
end
-- to which we paint a small indicator if this book has a description
if self.has_description and not BookInfoManager:getSetting("no_hint_description") then
-- On book's right (for similarity to ListMenuItem)
local target = self[1][1][1]
local d_w = Screen:scaleBySize(3)
local d_h = math.ceil(target.dimen.h / 8)
-- Paint it directly relative to target.dimen.x/y which has been computed at this point
local ix = target.dimen.w - 1
local iy = 0
bb:paintBorder(target.dimen.x+ix, target.dimen.y+iy, d_w, d_h, 1)
local x_overflow = target.dimen.x+ix+d_w - x - self.dimen.w
if x_overflow > 0 then
-- Set alternate dimen to be marked as dirty to include this description in refresh
self.refresh_dimen = self[1].dimen:copy()
self.refresh_dimen.w = self.refresh_dimen.w + x_overflow
end
end
end
-- As done in MenuItem
function MosaicMenuItem:onFocus()
self._underline_container.color = Blitbuffer.COLOR_BLACK
self.key_events = self.active_key_events
return true
end
function MosaicMenuItem:onUnfocus()
self._underline_container.color = Blitbuffer.COLOR_WHITE
self.key_events = {}
return true
end
function MosaicMenuItem:onShowItemDetail()
UIManager:show(InfoMessage:new{ text = self.detail, })
return true
end
-- The transient color inversions done in MenuItem:onTapSelect
-- and MenuItem:onHoldSelect are ugly when done on an image,
-- so let's not do it
-- Also, no need for 2nd arg 'pos' (only used in readertoc.lua)
function MosaicMenuItem:onTapSelect(arg)
self.menu:onMenuSelect(self.entry)
return true
end
function MosaicMenuItem:onHoldSelect(arg, ges)
self.menu:onMenuHold(self.entry)
return true
end
-- Simple holder of methods that will replace those
-- in the real Menu class or instance
local MosaicMenu = {}
function MosaicMenu:_recalculateDimen()
self.dimen.w = self.width
self.dimen.h = self.height or Screen:getHeight()
local portrait_mode = true
if Screen:getWidth() > Screen:getHeight() then
portrait_mode = false
end
-- 3 x 3 grid by default if not initially provided (4 x 2 in landscape mode)
if portrait_mode then
self.nb_cols = self.nb_cols_portrait or 3
self.nb_rows = self.nb_rows_portrait or 3
else
self.nb_cols = self.nb_cols_landscape or 4
self.nb_rows = self.nb_rows_landscape or 2
end
self.perpage = self.nb_rows * self.nb_cols
self.page_num = math.ceil(#self.item_table / self.perpage)
-- Find out available height from other UI elements made in Menu
self.others_height = 0
if self.title_bar then -- init() has been done
if not self.is_borderless then
self.others_height = self.others_height + 2
end
if not self.no_title then
self.others_height = self.others_height + self.header_padding
self.others_height = self.others_height + self.title_bar.dimen.h
end
if self.page_info then
self.others_height = self.others_height + self.page_info:getSize().h
end
end
-- Set our items target size
self.item_margin = Screen:scaleBySize(10)
self.item_height = math.floor((self.dimen.h - self.others_height - (1+self.nb_rows)*self.item_margin) / self.nb_rows)
self.item_width = math.floor((self.dimen.w - (1+self.nb_cols)*self.item_margin) / self.nb_cols)
self.item_dimen = Geom:new{
w = self.item_width,
h = self.item_height
}
end
function MosaicMenu:_updateItemsBuildUI()
-- Build our grid
local idx_offset = (self.page - 1) * self.perpage
local cur_row = nil
for idx = 1, self.perpage do
local entry = self.item_table[idx_offset + idx]
if entry == nil then break end
if idx % self.nb_cols == 1 then -- new row
table.insert(self.item_group, VerticalSpan:new{ width = self.item_margin })
cur_row = HorizontalGroup:new{}
table.insert(self.item_group, cur_row)
table.insert(cur_row, HorizontalSpan:new({ width = self.item_margin }))
end
-- Keyboard shortcuts, as done in Menu
local item_shortcut = nil
local shortcut_style = "square"
if self.is_enable_shortcut then
-- give different shortcut_style to keys in different
-- lines of keyboard
if idx >= 11 and idx <= 20 then
shortcut_style = "grey_square"
end
item_shortcut = self.item_shortcuts[idx]
if item_shortcut == "Enter" then
item_shortcut = "Ent"
end
end
local item_tmp = MosaicMenuItem:new{
height = self.item_height,
width = self.item_width,
entry = entry,
text = getMenuText(entry),
show_parent = self.show_parent,
mandatory = entry.mandatory,
dimen = self.item_dimen:new(),
shortcut = item_shortcut,
shortcut_style = shortcut_style,
menu = self,
do_cover_image = self._do_cover_images,
do_hint_opened = self._do_hint_opened,
}
table.insert(cur_row, item_tmp)
table.insert(cur_row, HorizontalSpan:new({ width = self.item_margin }))
-- this is for focus manager
table.insert(self.layout, {item_tmp})
if not item_tmp.bookinfo_found and not item_tmp.is_directory then
-- Register this item for update
table.insert(self.items_to_update, item_tmp)
end
end
table.insert(self.item_group, VerticalSpan:new{ width = self.item_margin }) -- bottom padding
end
return MosaicMenu

@ -0,0 +1,102 @@
local ffi = require("ffi")
local util = require("ffi/util")
local C = ffi.C
-- Utilities functions needed by this plugin, but that may be added to
-- existing base/ffi/ files
local xutil = {}
-- Sub-process management (may be put into base/ffi/util.lua)
function xutil.runInSubProcess(func)
if util.isAndroid() then
-- not sure how to do that on android
return nil
else
local pid = C.fork()
if pid == 0 then -- child process
-- Just run the provided lua code object in this new process,
-- and exit immediatly (so we do not release drivers and
-- resources still used by parent process)
func()
os.exit(0)
end
-- parent/main process, return pid of child
if pid == -1 then -- On failure, -1 is returned in the parent
return false
end
return pid
end
end
function xutil.isSubProcessDone(pid)
local status = ffi.new('int[1]')
local ret = C.waitpid(pid, status, 1) -- 1 = WNOHANG : don't wait, just tell
-- status = tonumber(status[0])
-- local logger = require("logger")
-- logger.dbg("waitpid for", pid, ":", ret, "/", status)
-- still running: ret = 0 , status = 0
-- exited: ret = pid , status = 0 or 9 if killed
-- no more running: ret = -1 , status = 0
if ret == pid or ret == -1 then
return true
end
end
function xutil.terminateSubProcess(pid)
local done = xutil.isSubProcessDone(pid)
if not done then
-- local logger = require("logger")
-- logger.dbg("killing subprocess", pid)
-- we kill with signal 9/SIGKILL, which may be violent, but ensures
-- that it is terminated (a process may catch or ignore SIGTERM)
C.kill(pid, 9)
-- process will still have to be collected with calls to util.isSubProcessDone(),
-- which may still return false for some small amount of time after our kill()
end
end
-- Data compression/decompression of strings thru zlib (may be put in a new base/ffi/zlib.lua)
-- from http://luajit.org/ext_ffi_tutorial.html
ffi.cdef[[
unsigned long compressBound(unsigned long sourceLen);
int compress2(uint8_t *dest, unsigned long *destLen,
const uint8_t *source, unsigned long sourceLen, int level);
int uncompress(uint8_t *dest, unsigned long *destLen,
const uint8_t *source, unsigned long sourceLen);
]]
local zlib = ffi.load(ffi.os == "Windows" and "zlib1" or "z")
function xutil.zlib_compress(data)
local n = zlib.compressBound(#data)
local buf = ffi.new("uint8_t[?]", n)
local buflen = ffi.new("unsigned long[1]", n)
local res = zlib.compress2(buf, buflen, data, #data, 9)
assert(res == 0)
return ffi.string(buf, buflen[0])
end
function xutil.zlib_uncompress(zdata, datalen)
local buf = ffi.new("uint8_t[?]", datalen)
local buflen = ffi.new("unsigned long[1]", datalen)
local res = zlib.uncompress(buf, buflen, zdata, #zdata)
assert(res == 0)
return ffi.string(buf, buflen[0])
end
-- Not provided by base/thirdparty/lua-ljsqlite3/init.lua
-- Add a timeout to a lua-ljsqlite3 connection
-- We need that if we have multiple processes accessing the same
-- SQLite db for reading or writting (read lock and write lock can't be
-- obtained at the same time, so waiting & retry is needed)
-- SQLite will retry getting a lock every 1ms to 100ms for
-- the timeout_ms given here
local sql = ffi.load("sqlite3")
function xutil.sqlite_set_timeout(conn, timeout_ms)
sql.sqlite3_busy_timeout(conn._ptr, timeout_ms)
end
-- For reference, SQ3 doc at: http://scilua.org/ljsqlite3.html
return xutil
Loading…
Cancel
Save