Loading bookloresync.koplugin/booklore_menu_actions.lua 0 → 100644 +96 −0 Original line number Diff line number Diff line local M = {} function M.new(deps) local _ = deps._ local FileManager = deps.FileManager local UIManager = deps.UIManager local module = {} local function closeFileDialog() local fc = FileManager.instance and FileManager.instance.file_chooser if fc and fc.file_dialog then UIManager:close(fc.file_dialog) end end function module.register(plugin) -- Row 1: primary actions FileManager.addFileDialogButtons(FileManager, "booklore_sync_actions_row1", function(file, is_file, _book_props) if not is_file then return nil end return { { text = _("Booklore Sync"), callback = function() closeFileDialog() plugin:_fileDialogBookloreSync(file) end, }, { text = _("Match Book"), callback = function() closeFileDialog() plugin:fileDialogMatchBook(file) end, }, } end) -- Row 2: data actions FileManager.addFileDialogButtons(FileManager, "booklore_sync_actions_row2", function(file, is_file, _book_props) if not is_file then return nil end return { { text = _("Show Stored Data"), callback = function() closeFileDialog() plugin:fileDialogShowStoredData(file) end, }, { text = _("Extract from Sidecar"), callback = function() closeFileDialog() plugin:fileDialogExtractFromSidecar(file) end, }, } end) -- Row 3: tracking + status actions FileManager.addFileDialogButtons(FileManager, "booklore_sync_actions_row3", function(file, is_file, _book_props) if not is_file then return nil end return { { text_func = function() if plugin.db and not plugin.db:isBookTrackingEnabled(file) then return _("Enable tracking") end return _("Disable tracking") end, callback = function() closeFileDialog() plugin:fileDialogToggleTracking(file) end, }, { text = _("Set Reading Status"), callback = function() closeFileDialog() plugin:fileDialogSetReadingStatus(file) end, }, } end) end function module.unregister() FileManager.removeFileDialogButtons(FileManager, "booklore_sync_actions_row1") FileManager.removeFileDialogButtons(FileManager, "booklore_sync_actions_row2") FileManager.removeFileDialogButtons(FileManager, "booklore_sync_actions_row3") end return module end return M bookloresync.koplugin/main.lua +79 −84 Original line number Diff line number Diff line Loading @@ -35,6 +35,7 @@ local UpdatesModuleFactory = require("booklore_updates_module") local PendingSyncModuleFactory = require("booklore_pending_sync") local MatchingModuleFactory = require("booklore_matching_module") local DeletionModuleFactory = require("booklore_deletion_module") local MenuActionsModuleFactory = require("booklore_menu_actions") local ok_branding, Branding = pcall(require, "booklore_branding") if not ok_branding or type(Branding) ~= "table" then Branding = { Loading Loading @@ -217,6 +218,12 @@ local DeletionModule = DeletionModuleFactory.new({ UIManager = UIManager, }) local MenuActionsModule = MenuActionsModuleFactory.new({ _ = _, FileManager = FileManager, UIManager = UIManager, }) --[[-- DbSettings - a LuaSettings-compatible wrapper backed by the plugin_settings Loading Loading @@ -939,85 +946,7 @@ function BookloreSync:init() end self.ui.menu:registerToMainMenu(self) -- Register file manager long-press (hold) dialog buttons. -- Register on the FileManager CLASS table (not a live instance), matching the -- coverbrowser pattern: FileManager.addFileDialogButtons(FileManager, id, func). -- showFileDialog reads file_manager.file_dialog_added_buttons where file_manager is -- the live instance; Lua's __index chain finds the entry on the class table. -- Registration must be unconditional - init() is always called from the reader -- context where self.ui.file_chooser is nil. -- Row 1: primary actions FileManager.addFileDialogButtons(FileManager, "booklore_sync_actions_row1", function(file, is_file, _book_props) if not is_file then return nil end return { { text = _("Booklore Sync"), callback = function() local fc = FileManager.instance and FileManager.instance.file_chooser if fc and fc.file_dialog then UIManager:close(fc.file_dialog) end self:_fileDialogBookloreSync(file) end, }, { text = _("Match Book"), callback = function() local fc = FileManager.instance and FileManager.instance.file_chooser if fc and fc.file_dialog then UIManager:close(fc.file_dialog) end self:fileDialogMatchBook(file) end, }, } end) -- Row 2: data actions FileManager.addFileDialogButtons(FileManager, "booklore_sync_actions_row2", function(file, is_file, _book_props) if not is_file then return nil end return { { text = _("Show Stored Data"), callback = function() local fc = FileManager.instance and FileManager.instance.file_chooser if fc and fc.file_dialog then UIManager:close(fc.file_dialog) end self:fileDialogShowStoredData(file) end, }, { text = _("Extract from Sidecar"), callback = function() local fc = FileManager.instance and FileManager.instance.file_chooser if fc and fc.file_dialog then UIManager:close(fc.file_dialog) end self:fileDialogExtractFromSidecar(file) end, }, } end) -- Row 3: tracking + status actions FileManager.addFileDialogButtons(FileManager, "booklore_sync_actions_row3", function(file, is_file, _book_props) if not is_file then return nil end return { { text_func = function() if self.db and not self.db:isBookTrackingEnabled(file) then return _("Enable tracking") end return _("Disable tracking") end, callback = function() local fc = FileManager.instance and FileManager.instance.file_chooser if fc and fc.file_dialog then UIManager:close(fc.file_dialog) end self:fileDialogToggleTracking(file) end, }, { text = _("Set Reading Status"), callback = function() local fc = FileManager.instance and FileManager.instance.file_chooser if fc and fc.file_dialog then UIManager:close(fc.file_dialog) end self:fileDialogSetReadingStatus(file) end, }, } end) MenuActionsModule.register(self) self:registerDispatcherActions() Loading Loading @@ -1073,9 +1002,7 @@ end ---BookloreSync:onExit. function BookloreSync:onExit() FileManager.removeFileDialogButtons(FileManager, "booklore_sync_actions_row1") FileManager.removeFileDialogButtons(FileManager, "booklore_sync_actions_row2") FileManager.removeFileDialogButtons(FileManager, "booklore_sync_actions_row3") MenuActionsModule.unregister() if self.db then self.db:close() Loading Loading @@ -3414,6 +3341,74 @@ function BookloreSync:_refreshCredentials() return username, password end --[[-- Ensure a file has a local book_cache row without requiring open/close first. Populates the core fields normally created at open time: - file hash (for matching and later server lookup) - title/author (best-effort from sidecar stats) - sdr_path metadata (for sidecar-based features) @param file_path string @return table|nil book_cache row @return string|nil error message on failure --]] ---BookloreSync:_ensureBookCachedForFile. function BookloreSync:_ensureBookCachedForFile(file_path) if not self.db or not file_path or file_path == "" then return nil, "invalid_arguments" end local book = self.db:getBookByFilePath(file_path) local existing_book_id = book and book.book_id or nil local file_hash = book and book.file_hash or nil local title = book and book.title or nil local author = book and book.author or nil if not file_hash or file_hash == "" then file_hash = self:calculateBookHash(file_path) or "" end if (not title or title == "") or (not author or author == "") then local stats = self.metadata_extractor and self.metadata_extractor:getStats(file_path) or nil if type(stats) == "table" then if (not title or title == "") and stats.title and stats.title ~= "" then title = stats.title end if (not author or author == "") and stats.authors then if type(stats.authors) == "table" then author = table.concat(stats.authors, ", ") elseif type(stats.authors) == "string" then author = stats.authors end end end end local ok = self.db:saveBookCache(file_path, file_hash, existing_book_id, title, author, nil, nil) if not ok then return nil, "save_failed" end local hydrated = self.db:getBookByFilePath(file_path) if not hydrated then return nil, "hydrate_failed" end local book_cache_id = hydrated.id or self.db:getBookCacheIdByFilePath(file_path) if book_cache_id and self.db.upsertBookMetadata then local ok_ds, DocSettings = pcall(require, "docsettings") if ok_ds and DocSettings and DocSettings.getSidecarDir then local ok_sdr, sdr_path = pcall(DocSettings.getSidecarDir, DocSettings, file_path) if ok_sdr and sdr_path and sdr_path ~= "" then self.db:upsertBookMetadata(book_cache_id, { sdr_path = sdr_path }) end end end return hydrated, nil end ---BookloreSync:fileDialogSyncAnnotations. function BookloreSync:fileDialogSyncAnnotations(file_path) if not self.db then Loading Loading @@ -3489,10 +3484,10 @@ function BookloreSync:fileDialogMatchBook(file_path) return end local book = self.db:getBookByFilePath(file_path) local book, cache_err = self:_ensureBookCachedForFile(file_path) if not book then UIManager:show(InfoMessage:new{ text = _("Booklore: book not found in local database.\nOpen the book first to register it."), text = T(_("Booklore: failed to prepare local data for matching (%1)."), tostring(cache_err or _("Unknown error"))), }) return end Loading test/main_helpers_spec.lua +92 −0 Original line number Diff line number Diff line Loading @@ -143,4 +143,96 @@ describe("BookloreSync helper methods", function() assert.are.equal(1, #init_calls) end) it("hydrates local cache for unopened files before matching", function() local saved_args local hydrated_row = { id = 77, file_path = "/books/new.epub", file_hash = "hash-1", book_id = nil, title = "Sidecar Title", author = "A, B", } plugin.db = { getBookByFilePath = function(_, fp) if fp == "/books/new.epub" and saved_args then return hydrated_row end return nil end, saveBookCache = function(_, file_path, file_hash, book_id, title, author) saved_args = { file_path = file_path, file_hash = file_hash, book_id = book_id, title = title, author = author, } return true end, getBookCacheIdByFilePath = function() return 77 end, upsertBookMetadata = function(_, book_cache_id, fields) assert.are.equal(77, book_cache_id) assert.is_truthy(fields.sdr_path) end, } plugin.metadata_extractor = { getStats = function() return { title = "Sidecar Title", authors = { "A", "B" } } end, } plugin.calculateBookHash = function() return "hash-1" end package.loaded["docsettings"] = nil package.preload["docsettings"] = function() return { getSidecarDir = function(_, file_path) return file_path .. ".sdr" end, } end local row, err = plugin:_ensureBookCachedForFile("/books/new.epub") assert.is_nil(err) assert.are.equal(hydrated_row, row) assert.are.equal("/books/new.epub", saved_args.file_path) assert.are.equal("hash-1", saved_args.file_hash) assert.are.equal("Sidecar Title", saved_args.title) assert.are.equal("A, B", saved_args.author) end) it("fileDialogMatchBook proceeds without prior open when cache hydrate succeeds", function() local interactive_called = false plugin.db = { getBookByFilePath = function() return { id = 5, file_path = "/books/new.epub", title = "New", book_id = nil } end, saveBookCache = function() return true end, getBookCacheIdByFilePath = function() return 5 end, upsertBookMetadata = function() end, } plugin.settings = { readSetting = function(_, key) if key == "booklore_username" then return "u" end if key == "booklore_password" then return "p" end return "" end, } plugin.calculateBookHash = function() return "h" end plugin.metadata_extractor = { getStats = function() return nil end } plugin._matchSingleBookInteractive = function(_, book) interactive_called = true assert.are.equal("/books/new.epub", book.file_path) end package.loaded["docsettings"] = nil package.preload["docsettings"] = function() return { getSidecarDir = function() return "/books/new.epub.sdr" end } end plugin:fileDialogMatchBook("/books/new.epub") assert.is_true(interactive_called) end) end) Loading
bookloresync.koplugin/booklore_menu_actions.lua 0 → 100644 +96 −0 Original line number Diff line number Diff line local M = {} function M.new(deps) local _ = deps._ local FileManager = deps.FileManager local UIManager = deps.UIManager local module = {} local function closeFileDialog() local fc = FileManager.instance and FileManager.instance.file_chooser if fc and fc.file_dialog then UIManager:close(fc.file_dialog) end end function module.register(plugin) -- Row 1: primary actions FileManager.addFileDialogButtons(FileManager, "booklore_sync_actions_row1", function(file, is_file, _book_props) if not is_file then return nil end return { { text = _("Booklore Sync"), callback = function() closeFileDialog() plugin:_fileDialogBookloreSync(file) end, }, { text = _("Match Book"), callback = function() closeFileDialog() plugin:fileDialogMatchBook(file) end, }, } end) -- Row 2: data actions FileManager.addFileDialogButtons(FileManager, "booklore_sync_actions_row2", function(file, is_file, _book_props) if not is_file then return nil end return { { text = _("Show Stored Data"), callback = function() closeFileDialog() plugin:fileDialogShowStoredData(file) end, }, { text = _("Extract from Sidecar"), callback = function() closeFileDialog() plugin:fileDialogExtractFromSidecar(file) end, }, } end) -- Row 3: tracking + status actions FileManager.addFileDialogButtons(FileManager, "booklore_sync_actions_row3", function(file, is_file, _book_props) if not is_file then return nil end return { { text_func = function() if plugin.db and not plugin.db:isBookTrackingEnabled(file) then return _("Enable tracking") end return _("Disable tracking") end, callback = function() closeFileDialog() plugin:fileDialogToggleTracking(file) end, }, { text = _("Set Reading Status"), callback = function() closeFileDialog() plugin:fileDialogSetReadingStatus(file) end, }, } end) end function module.unregister() FileManager.removeFileDialogButtons(FileManager, "booklore_sync_actions_row1") FileManager.removeFileDialogButtons(FileManager, "booklore_sync_actions_row2") FileManager.removeFileDialogButtons(FileManager, "booklore_sync_actions_row3") end return module end return M
bookloresync.koplugin/main.lua +79 −84 Original line number Diff line number Diff line Loading @@ -35,6 +35,7 @@ local UpdatesModuleFactory = require("booklore_updates_module") local PendingSyncModuleFactory = require("booklore_pending_sync") local MatchingModuleFactory = require("booklore_matching_module") local DeletionModuleFactory = require("booklore_deletion_module") local MenuActionsModuleFactory = require("booklore_menu_actions") local ok_branding, Branding = pcall(require, "booklore_branding") if not ok_branding or type(Branding) ~= "table" then Branding = { Loading Loading @@ -217,6 +218,12 @@ local DeletionModule = DeletionModuleFactory.new({ UIManager = UIManager, }) local MenuActionsModule = MenuActionsModuleFactory.new({ _ = _, FileManager = FileManager, UIManager = UIManager, }) --[[-- DbSettings - a LuaSettings-compatible wrapper backed by the plugin_settings Loading Loading @@ -939,85 +946,7 @@ function BookloreSync:init() end self.ui.menu:registerToMainMenu(self) -- Register file manager long-press (hold) dialog buttons. -- Register on the FileManager CLASS table (not a live instance), matching the -- coverbrowser pattern: FileManager.addFileDialogButtons(FileManager, id, func). -- showFileDialog reads file_manager.file_dialog_added_buttons where file_manager is -- the live instance; Lua's __index chain finds the entry on the class table. -- Registration must be unconditional - init() is always called from the reader -- context where self.ui.file_chooser is nil. -- Row 1: primary actions FileManager.addFileDialogButtons(FileManager, "booklore_sync_actions_row1", function(file, is_file, _book_props) if not is_file then return nil end return { { text = _("Booklore Sync"), callback = function() local fc = FileManager.instance and FileManager.instance.file_chooser if fc and fc.file_dialog then UIManager:close(fc.file_dialog) end self:_fileDialogBookloreSync(file) end, }, { text = _("Match Book"), callback = function() local fc = FileManager.instance and FileManager.instance.file_chooser if fc and fc.file_dialog then UIManager:close(fc.file_dialog) end self:fileDialogMatchBook(file) end, }, } end) -- Row 2: data actions FileManager.addFileDialogButtons(FileManager, "booklore_sync_actions_row2", function(file, is_file, _book_props) if not is_file then return nil end return { { text = _("Show Stored Data"), callback = function() local fc = FileManager.instance and FileManager.instance.file_chooser if fc and fc.file_dialog then UIManager:close(fc.file_dialog) end self:fileDialogShowStoredData(file) end, }, { text = _("Extract from Sidecar"), callback = function() local fc = FileManager.instance and FileManager.instance.file_chooser if fc and fc.file_dialog then UIManager:close(fc.file_dialog) end self:fileDialogExtractFromSidecar(file) end, }, } end) -- Row 3: tracking + status actions FileManager.addFileDialogButtons(FileManager, "booklore_sync_actions_row3", function(file, is_file, _book_props) if not is_file then return nil end return { { text_func = function() if self.db and not self.db:isBookTrackingEnabled(file) then return _("Enable tracking") end return _("Disable tracking") end, callback = function() local fc = FileManager.instance and FileManager.instance.file_chooser if fc and fc.file_dialog then UIManager:close(fc.file_dialog) end self:fileDialogToggleTracking(file) end, }, { text = _("Set Reading Status"), callback = function() local fc = FileManager.instance and FileManager.instance.file_chooser if fc and fc.file_dialog then UIManager:close(fc.file_dialog) end self:fileDialogSetReadingStatus(file) end, }, } end) MenuActionsModule.register(self) self:registerDispatcherActions() Loading Loading @@ -1073,9 +1002,7 @@ end ---BookloreSync:onExit. function BookloreSync:onExit() FileManager.removeFileDialogButtons(FileManager, "booklore_sync_actions_row1") FileManager.removeFileDialogButtons(FileManager, "booklore_sync_actions_row2") FileManager.removeFileDialogButtons(FileManager, "booklore_sync_actions_row3") MenuActionsModule.unregister() if self.db then self.db:close() Loading Loading @@ -3414,6 +3341,74 @@ function BookloreSync:_refreshCredentials() return username, password end --[[-- Ensure a file has a local book_cache row without requiring open/close first. Populates the core fields normally created at open time: - file hash (for matching and later server lookup) - title/author (best-effort from sidecar stats) - sdr_path metadata (for sidecar-based features) @param file_path string @return table|nil book_cache row @return string|nil error message on failure --]] ---BookloreSync:_ensureBookCachedForFile. function BookloreSync:_ensureBookCachedForFile(file_path) if not self.db or not file_path or file_path == "" then return nil, "invalid_arguments" end local book = self.db:getBookByFilePath(file_path) local existing_book_id = book and book.book_id or nil local file_hash = book and book.file_hash or nil local title = book and book.title or nil local author = book and book.author or nil if not file_hash or file_hash == "" then file_hash = self:calculateBookHash(file_path) or "" end if (not title or title == "") or (not author or author == "") then local stats = self.metadata_extractor and self.metadata_extractor:getStats(file_path) or nil if type(stats) == "table" then if (not title or title == "") and stats.title and stats.title ~= "" then title = stats.title end if (not author or author == "") and stats.authors then if type(stats.authors) == "table" then author = table.concat(stats.authors, ", ") elseif type(stats.authors) == "string" then author = stats.authors end end end end local ok = self.db:saveBookCache(file_path, file_hash, existing_book_id, title, author, nil, nil) if not ok then return nil, "save_failed" end local hydrated = self.db:getBookByFilePath(file_path) if not hydrated then return nil, "hydrate_failed" end local book_cache_id = hydrated.id or self.db:getBookCacheIdByFilePath(file_path) if book_cache_id and self.db.upsertBookMetadata then local ok_ds, DocSettings = pcall(require, "docsettings") if ok_ds and DocSettings and DocSettings.getSidecarDir then local ok_sdr, sdr_path = pcall(DocSettings.getSidecarDir, DocSettings, file_path) if ok_sdr and sdr_path and sdr_path ~= "" then self.db:upsertBookMetadata(book_cache_id, { sdr_path = sdr_path }) end end end return hydrated, nil end ---BookloreSync:fileDialogSyncAnnotations. function BookloreSync:fileDialogSyncAnnotations(file_path) if not self.db then Loading Loading @@ -3489,10 +3484,10 @@ function BookloreSync:fileDialogMatchBook(file_path) return end local book = self.db:getBookByFilePath(file_path) local book, cache_err = self:_ensureBookCachedForFile(file_path) if not book then UIManager:show(InfoMessage:new{ text = _("Booklore: book not found in local database.\nOpen the book first to register it."), text = T(_("Booklore: failed to prepare local data for matching (%1)."), tostring(cache_err or _("Unknown error"))), }) return end Loading
test/main_helpers_spec.lua +92 −0 Original line number Diff line number Diff line Loading @@ -143,4 +143,96 @@ describe("BookloreSync helper methods", function() assert.are.equal(1, #init_calls) end) it("hydrates local cache for unopened files before matching", function() local saved_args local hydrated_row = { id = 77, file_path = "/books/new.epub", file_hash = "hash-1", book_id = nil, title = "Sidecar Title", author = "A, B", } plugin.db = { getBookByFilePath = function(_, fp) if fp == "/books/new.epub" and saved_args then return hydrated_row end return nil end, saveBookCache = function(_, file_path, file_hash, book_id, title, author) saved_args = { file_path = file_path, file_hash = file_hash, book_id = book_id, title = title, author = author, } return true end, getBookCacheIdByFilePath = function() return 77 end, upsertBookMetadata = function(_, book_cache_id, fields) assert.are.equal(77, book_cache_id) assert.is_truthy(fields.sdr_path) end, } plugin.metadata_extractor = { getStats = function() return { title = "Sidecar Title", authors = { "A", "B" } } end, } plugin.calculateBookHash = function() return "hash-1" end package.loaded["docsettings"] = nil package.preload["docsettings"] = function() return { getSidecarDir = function(_, file_path) return file_path .. ".sdr" end, } end local row, err = plugin:_ensureBookCachedForFile("/books/new.epub") assert.is_nil(err) assert.are.equal(hydrated_row, row) assert.are.equal("/books/new.epub", saved_args.file_path) assert.are.equal("hash-1", saved_args.file_hash) assert.are.equal("Sidecar Title", saved_args.title) assert.are.equal("A, B", saved_args.author) end) it("fileDialogMatchBook proceeds without prior open when cache hydrate succeeds", function() local interactive_called = false plugin.db = { getBookByFilePath = function() return { id = 5, file_path = "/books/new.epub", title = "New", book_id = nil } end, saveBookCache = function() return true end, getBookCacheIdByFilePath = function() return 5 end, upsertBookMetadata = function() end, } plugin.settings = { readSetting = function(_, key) if key == "booklore_username" then return "u" end if key == "booklore_password" then return "p" end return "" end, } plugin.calculateBookHash = function() return "h" end plugin.metadata_extractor = { getStats = function() return nil end } plugin._matchSingleBookInteractive = function(_, book) interactive_called = true assert.are.equal("/books/new.epub", book.file_path) end package.loaded["docsettings"] = nil package.preload["docsettings"] = function() return { getSidecarDir = function() return "/books/new.epub.sdr" end } end plugin:fileDialogMatchBook("/books/new.epub") assert.is_true(interactive_called) end) end)