mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
80244ea4e2
Squash of feat/koreader-widget-board. KOReader plugin 2.0.0 gets the widget-board dashboard, redesigned book details, menus, lists and dialogs, Decor page and reading companions. Highlights, notes and bookmarks now stay in step across web, Kobo, KOReader and the phones (defects D-00 to D-20, migration 0121), and reading progress and narration hand off cleanly between devices.
164 lines
5.8 KiB
Lua
164 lines
5.8 KiB
Lua
--[[--
|
|
Each book's chapters, kept for the Chapter line widget.
|
|
|
|
A book's table of contents only exists while the book is open, and opening a
|
|
book just to draw a dashboard tile would be far too slow. So when a book
|
|
closes its contents are copied (title, page and depth of each entry, plus the
|
|
page count they were measured in) and written after the close has finished,
|
|
to cache/bookorbit/chapters/<md5>.lua. Only the most recent books are kept.
|
|
|
|
stations() turns a saved map into the stops the widget draws: the level of
|
|
the contents that reads like chapters, without entries too short to matter.
|
|
]]
|
|
|
|
-- KOReader modules are loaded when first used, so the board's data module
|
|
-- (which reads chapter maps) loads without them in specs.
|
|
local function lfs() return require("libs/libkoreader-lfs") end
|
|
local function logger() return require("logger") end
|
|
|
|
local ChapterMap = {}
|
|
|
|
ChapterMap.KEEP = 24
|
|
-- Entries shorter than this share of the book (title pages, a copyright
|
|
-- page, a dedication) fold into the stop that follows them.
|
|
ChapterMap.MIN_SPAN = 0.01
|
|
-- A level with at least this many entries reads as chapters.
|
|
ChapterMap.MIN_STOPS = 6
|
|
|
|
local dir = nil
|
|
local memo = {}
|
|
|
|
local function chaptersDir()
|
|
if not dir then
|
|
dir = require("datastorage"):getDataDir() .. "/cache/bookorbit/chapters"
|
|
require("util").makePath(dir)
|
|
end
|
|
return dir
|
|
end
|
|
|
|
local function validMd5(md5)
|
|
return type(md5) == "string" and md5:match("^%x+$") ~= nil
|
|
end
|
|
|
|
-- The contents of the open book, copied out of the reader. Nil when the book
|
|
-- has none.
|
|
function ChapterMap.capture(ui)
|
|
if not ui or not ui.document or not ui.toc then return nil end
|
|
pcall(ui.toc.fillToc, ui.toc)
|
|
local toc = ui.toc.toc
|
|
if type(toc) ~= "table" or #toc == 0 then return nil end
|
|
local ok, pages = pcall(ui.document.getPageCount, ui.document)
|
|
if not ok or not tonumber(pages) or pages <= 0 then return nil end
|
|
local entries = {}
|
|
for _i, item in ipairs(toc) do
|
|
if type(item) == "table" and tonumber(item.page) then
|
|
table.insert(entries, {
|
|
t = tostring(item.title or ""):gsub("^%s+", ""):gsub("%s+$", ""),
|
|
p = tonumber(item.page),
|
|
d = tonumber(item.depth) or 1,
|
|
})
|
|
end
|
|
end
|
|
if #entries == 0 then return nil end
|
|
return { pages = pages, entries = entries, at = os.time() }
|
|
end
|
|
|
|
local function prune()
|
|
local files = {}
|
|
for name in lfs().dir(chaptersDir()) do
|
|
if name:match("%.lua$") then
|
|
local path = chaptersDir() .. "/" .. name
|
|
table.insert(files, { path = path, at = lfs().attributes(path, "modification") or 0 })
|
|
end
|
|
end
|
|
if #files <= ChapterMap.KEEP then return end
|
|
table.sort(files, function(a, b) return a.at > b.at end)
|
|
for i = ChapterMap.KEEP + 1, #files do os.remove(files[i].path) end
|
|
end
|
|
|
|
function ChapterMap.save(md5, map)
|
|
if not validMd5(md5) or type(map) ~= "table" then return false end
|
|
local path = chaptersDir() .. "/" .. md5 .. ".lua"
|
|
local f = io.open(path .. ".tmp", "w")
|
|
if not f then return false end
|
|
f:write("return " .. require("dump")(map))
|
|
f:close()
|
|
os.rename(path .. ".tmp", path)
|
|
memo[md5] = map
|
|
pcall(prune)
|
|
return true
|
|
end
|
|
|
|
-- Copies the contents now, while the reader still exists, and writes them
|
|
-- once the close has finished.
|
|
function ChapterMap.remember(ui, md5)
|
|
if not validMd5(md5) then return end
|
|
local ok, map = pcall(ChapterMap.capture, ui)
|
|
if not ok then
|
|
logger().dbg("BookOrbit: chapters not captured:", map)
|
|
return
|
|
end
|
|
if not map then return end
|
|
require("ui/uimanager"):nextTick(function()
|
|
local saved, err = pcall(ChapterMap.save, md5, map)
|
|
if not saved then logger().warn("BookOrbit: chapters not saved:", err) end
|
|
end)
|
|
end
|
|
|
|
function ChapterMap.load(md5)
|
|
if not validMd5(md5) then return nil end
|
|
if memo[md5] ~= nil then return memo[md5] or nil end
|
|
local path = chaptersDir() .. "/" .. md5 .. ".lua"
|
|
local map = false
|
|
if lfs().attributes(path, "mode") == "file" then
|
|
local ok, value = pcall(dofile, path)
|
|
if ok and type(value) == "table" and type(value.entries) == "table" then map = value end
|
|
end
|
|
memo[md5] = map
|
|
return map or nil
|
|
end
|
|
|
|
function ChapterMap._reset()
|
|
memo = {}
|
|
dir = nil
|
|
end
|
|
|
|
-- The stops for a map: { title, pct } where pct is where the stop starts,
|
|
-- 0 to 100. Uses the shallowest level of the contents that has at least
|
|
-- MIN_STOPS entries (a book in parts gets its chapters, not four parts),
|
|
-- then folds entries shorter than MIN_SPAN of the book into the next one.
|
|
-- The last entry always stays: it is the end of the line.
|
|
function ChapterMap.stations(map)
|
|
if type(map) ~= "table" or type(map.entries) ~= "table" or not tonumber(map.pages) or map.pages <= 0 then return nil end
|
|
local max_depth = 1
|
|
for _i, e in ipairs(map.entries) do max_depth = math.max(max_depth, e.d or 1) end
|
|
local depth = max_depth
|
|
for level = 1, max_depth do
|
|
local count = 0
|
|
for _i, e in ipairs(map.entries) do
|
|
if (e.d or 1) <= level then count = count + 1 end
|
|
end
|
|
if count >= ChapterMap.MIN_STOPS then
|
|
depth = level
|
|
break
|
|
end
|
|
end
|
|
local picked = {}
|
|
for _i, e in ipairs(map.entries) do
|
|
if (e.d or 1) <= depth then
|
|
table.insert(picked, { title = e.t ~= "" and e.t or nil, pct = math.max(0, math.min(100, (e.p - 1) / map.pages * 100)) })
|
|
end
|
|
end
|
|
local stops = {}
|
|
for i, s in ipairs(picked) do
|
|
local next_pct = picked[i + 1] and picked[i + 1].pct or 100
|
|
if s.title and (next_pct - s.pct >= ChapterMap.MIN_SPAN * 100 or i == #picked) then
|
|
table.insert(stops, s)
|
|
end
|
|
end
|
|
if #stops < 2 then return nil end
|
|
return stops
|
|
end
|
|
|
|
return ChapterMap
|