From 30499e33b0acd16f258cb5d984c7db02a26d82bb Mon Sep 17 00:00:00 2001 From: David <97603719+Commodore64user@users.noreply.github.com> Date: Fri, 20 Jun 2025 22:33:45 +0100 Subject: [PATCH 1/8] Add new presets.lua module and dictionary presets (#13774) --- .../apps/reader/modules/readerdictionary.lua | 216 +++++++++--- frontend/apps/reader/modules/readerfooter.lua | 120 +------ .../apps/reader/modules/readerwikipedia.lua | 52 +-- frontend/dispatcher.lua | 5 + frontend/ui/presets.lua | 325 ++++++++++++++++++ frontend/ui/widget/dictquicklookup.lua | 134 +++++--- 6 files changed, 633 insertions(+), 219 deletions(-) create mode 100644 frontend/ui/presets.lua diff --git a/frontend/apps/reader/modules/readerdictionary.lua b/frontend/apps/reader/modules/readerdictionary.lua index bc379ddbe..b3bbd1769 100644 --- a/frontend/apps/reader/modules/readerdictionary.lua +++ b/frontend/apps/reader/modules/readerdictionary.lua @@ -14,6 +14,7 @@ local KeyValuePage = require("ui/widget/keyvaluepage") local LuaData = require("luadata") local MultiConfirmBox = require("ui/widget/multiconfirmbox") local NetworkMgr = require("ui/network/manager") +local Presets = require("ui/presets") local SortWidget = require("ui/widget/sortwidget") local Trapper = require("ui/trapper") local UIManager = require("ui/uimanager") @@ -165,6 +166,17 @@ function ReaderDictionary:init() if not lookup_history then lookup_history = LuaData:open(DataStorage:getSettingsDir() .. "/lookup_history.lua", "LookupHistory") end + + self.preset_obj = { + presets = G_reader_settings:readSetting("dict_presets", {}), + cycle_index = G_reader_settings:readSetting("dict_presets_cycle_index"), + dispatcher_name = "load_dictionary_preset", + saveCycleIndex = function(this) + G_reader_settings:saveSetting("dict_presets_cycle_index", this.cycle_index) + end, + buildPreset = function() return self:buildPreset() end, + loadPreset = function(preset) self:loadPreset(preset) end, + } end function ReaderDictionary:registerKeyEvents() @@ -281,9 +293,18 @@ function ReaderDictionary:addToMainMenu(menu_items) end) end, }, + { + text = _("Dictionary presets"), + help_text = _("This feature allows you to organize dictionaries into presets (for example, by language). You can quickly switch between these presets to change which dictionaries are used for lookups.\n\nNote: presets only store dictionaries, no other settings."), + sub_item_table_func = function() + return Presets.genPresetMenuItemTable(self.preset_obj, _("Create new preset from enabled dictionaries"), + function() return self.enabled_dict_names and #self.enabled_dict_names > 0 end) + end, + }, { text = _("Download dictionaries"), sub_item_table_func = function() return self:_genDownloadDictionariesMenu() end, + separator = true, }, { text_func = function() @@ -313,32 +334,40 @@ function ReaderDictionary:addToMainMenu(menu_items) separator = true, }, { - text = _("Enable dictionary lookup history"), + text = _("Dictionary lookup history"), checked_func = function() return not self.disable_lookup_history end, - callback = function() - self.disable_lookup_history = not self.disable_lookup_history - G_reader_settings:saveSetting("disable_lookup_history", self.disable_lookup_history) - end, - }, - { - text = _("Clean dictionary lookup history"), - enabled_func = function() - return lookup_history:has("lookup_history") - end, - keep_menu_open = true, - callback = function(touchmenu_instance) - UIManager:show(ConfirmBox:new{ - text = _("Clean dictionary lookup history?"), - ok_text = _("Clean"), - ok_callback = function() - -- empty data table to replace current one - lookup_history:reset{} - touchmenu_instance:updateItems() + sub_item_table = { + { + text = _("Enable dictionary lookup history"), + checked_func = function() + return not self.disable_lookup_history end, - }) - end, + callback = function() + self.disable_lookup_history = not self.disable_lookup_history + G_reader_settings:saveSetting("disable_lookup_history", self.disable_lookup_history) + end, + }, + { + text = _("Clean dictionary lookup history"), + enabled_func = function() + return lookup_history:has("lookup_history") + end, + keep_menu_open = true, + callback = function(touchmenu_instance) + UIManager:show(ConfirmBox:new{ + text = _("Clean dictionary lookup history?"), + ok_text = _("Clean"), + ok_callback = function() + -- empty data table to replace current one + lookup_history:reset{} + touchmenu_instance:updateItems() + end, + }) + end, + }, + }, separator = true, }, { -- setting used by dictquicklookup @@ -385,7 +414,7 @@ function ReaderDictionary:addToMainMenu(menu_items) } } if not is_docless then - table.insert(menu_items.dictionary_settings.sub_item_table, 3, { + table.insert(menu_items.dictionary_settings.sub_item_table, 2, { keep_menu_open = true, text = _("Set dictionary priority for this book"), help_text = _("This feature enables you to specify dictionary priorities on a per-book basis. Results from higher-priority dictionaries will be displayed first when looking up words. Only dictionaries that are currently active can be selected and prioritized."), @@ -859,31 +888,70 @@ function ReaderDictionary:dismissLookupInfo() end function ReaderDictionary:onShowDictionaryLookup() + local buttons = {} + local preset_names = Presets.getPresets(self.preset_obj) + if preset_names and #preset_names > 0 then + table.insert(buttons, { + { + text = _("Search with preset"), + callback = function() + local text = self.dictionary_lookup_dialog:getInputText() + if text == "" or text:match("^%s*$") then return end + local current_dict_state = self:buildPreset() + local button_dialog, dialog_buttons = nil, {} -- CI won't like it if we call it buttons :( so dialog_buttons + for _, preset_name in ipairs(preset_names) do + table.insert(dialog_buttons, { + { + align = "left", + text = preset_name, + callback = function() + self:loadPreset(self.preset_obj.presets[preset_name], true) + UIManager:close(button_dialog) + UIManager:close(self.dictionary_lookup_dialog) + self:onLookupWord(text, true, nil, nil, nil, + function() + self:loadPreset(current_dict_state, true) + end + ) + end, + } + }) + end + button_dialog = ButtonDialog:new{ + buttons = dialog_buttons, + shrink_unneeded_width = true, + } + UIManager:show(button_dialog) + end, + } + }) + end + + table.insert(buttons, { + { + text = _("Cancel"), + id = "close", + callback = function() + UIManager:close(self.dictionary_lookup_dialog) + end, + }, + { + text = _("Search dictionary"), + is_enter_default = true, + callback = function() + if self.dictionary_lookup_dialog:getInputText() == "" then return end + UIManager:close(self.dictionary_lookup_dialog) + -- Trust that input text does not need any cleaning (allows querying for "-suffix") + self:onLookupWord(self.dictionary_lookup_dialog:getInputText(), true) + end, + }, + }) + self.dictionary_lookup_dialog = InputDialog:new{ title = _("Enter a word or phrase to look up"), input = "", input_type = "text", - buttons = { - { - { - text = _("Cancel"), - id = "close", - callback = function() - UIManager:close(self.dictionary_lookup_dialog) - end, - }, - { - text = _("Search dictionary"), - is_enter_default = true, - callback = function() - if self.dictionary_lookup_dialog:getInputText() == "" then return end - UIManager:close(self.dictionary_lookup_dialog) - -- Trust that input text does not need any cleaning (allows querying for "-suffix") - self:onLookupWord(self.dictionary_lookup_dialog:getInputText(), true) - end, - }, - } - }, + buttons = buttons, } UIManager:show(self.dictionary_lookup_dialog) self.dictionary_lookup_dialog:onShowKeyboard() @@ -1459,4 +1527,64 @@ The current default (★) is enabled.]]) }) end +function ReaderDictionary:buildPreset() + local preset = { enabled_dict_names = {} } -- Only store the names of enabled dictionaries. + for _, name in ipairs(self.enabled_dict_names) do + preset.enabled_dict_names[name] = true + end + return preset +end + +function ReaderDictionary:loadPreset(preset, skip_notification) + if not preset.enabled_dict_names then return end + -- build a list of currently available dictionary names for validation + local available_dict_names = {} + for _, ifo in ipairs(available_ifos) do + available_dict_names[ifo.name] = true + end + -- Only enable dictionaries from the preset that are still available, and re-build self.dicts_disabled + -- to make sure dicts added after the creation of the preset, are disabled as well. + local dicts_disabled, valid_enabled_names = {}, {} + for _, ifo in ipairs(available_ifos) do + if preset.enabled_dict_names[ifo.name] then + table.insert(valid_enabled_names, ifo.name) + else + dicts_disabled[ifo.file] = true + end + end + -- update both settings and save + self.dicts_disabled = dicts_disabled + self.enabled_dict_names = valid_enabled_names + G_reader_settings:saveSetting("dicts_disabled", self.dicts_disabled) + self:onSaveSettings() + self:updateSdcvDictNamesOptions() + -- Show a message if any dictionaries from the preset are missing. + if not skip_notification and util.tableSize(preset.enabled_dict_names) > #valid_enabled_names then + local missing_dicts = {} + for preset_name, _ in pairs(preset.enabled_dict_names) do + if not available_dict_names[preset_name] then + table.insert(missing_dicts, preset_name) + end + end + UIManager:show(InfoMessage:new{ + text = _("Some dictionaries from this preset have been deleted or are no longer available:") .. "\n\n• " .. table.concat(missing_dicts, "\n• "), + }) + end +end + +function ReaderDictionary:onCycleDictionaryPresets() + return Presets.cycleThroughPresets(self.preset_obj, true) +end + +function ReaderDictionary:onLoadDictionaryPreset(preset_name) + return Presets.onLoadPreset(self.preset_obj, preset_name, true) +end + +function ReaderDictionary.getPresets() -- for Dispatcher + local dict_config = { + presets = G_reader_settings:readSetting("dict_presets", {}) + } + return Presets.getPresets(dict_config) +end + return ReaderDictionary diff --git a/frontend/apps/reader/modules/readerfooter.lua b/frontend/apps/reader/modules/readerfooter.lua index b52f3ba0f..a653f5e5a 100644 --- a/frontend/apps/reader/modules/readerfooter.lua +++ b/frontend/apps/reader/modules/readerfooter.lua @@ -9,12 +9,10 @@ local FrameContainer = require("ui/widget/container/framecontainer") local Geom = require("ui/geometry") local HorizontalGroup = require("ui/widget/horizontalgroup") local HorizontalSpan = require("ui/widget/horizontalspan") -local InfoMessage = require("ui/widget/infomessage") -local InputDialog = require("ui/widget/inputdialog") local LeftContainer = require("ui/widget/container/leftcontainer") local LineWidget = require("ui/widget/linewidget") -local MultiConfirmBox = require("ui/widget/multiconfirmbox") local MultiInputDialog = require("ui/widget/multiinputdialog") +local Presets = require("ui/presets") local ProgressWidget = require("ui/widget/progresswidget") local RightContainer = require("ui/widget/container/rightcontainer") local Size = require("ui/size") @@ -25,7 +23,6 @@ local VerticalGroup = require("ui/widget/verticalgroup") local VerticalSpan = require("ui/widget/verticalspan") local WidgetContainer = require("ui/widget/container/widgetcontainer") local datetime = require("datetime") -local ffiUtil = require("ffi/util") local logger = require("logger") local util = require("util") local T = require("ffi/util").template @@ -665,6 +662,13 @@ function ReaderFooter:init() self.custom_text = G_reader_settings:readSetting("reader_footer_custom_text", "KOReader") self.custom_text_repetitions = tonumber(G_reader_settings:readSetting("reader_footer_custom_text_repetitions", "1")) + + self.preset_obj = { + presets = G_reader_settings:readSetting("footer_presets", {}), + dispatcher_name = "load_footer_preset", + buildPreset = function() return self:buildPreset() end, + loadPreset = function(preset) self:loadPreset(preset) end, + } end function ReaderFooter:set_custom_text(touchmenu_instance) @@ -1710,7 +1714,7 @@ With this feature enabled, the current page is factored in, resulting in the cou text = _("Status bar presets"), separator = true, sub_item_table_func = function() - return self:genPresetMenuItemTable() + return Presets.genPresetMenuItemTable(self.preset_obj, nil, nil) end, }) table.insert(sub_items, { @@ -1930,91 +1934,6 @@ function ReaderFooter:genAlignmentMenuItems(value) } end -function ReaderFooter:genPresetMenuItemTable() - local footer_presets = G_reader_settings:readSetting("footer_presets", {}) - local items = { - { - text = _("Create new preset from current settings"), - keep_menu_open = true, - callback = function(touchmenu_instance) - self:createPresetFromCurrentSettings(touchmenu_instance) - end, - separator = true, - }, - } - for preset_name in ffiUtil.orderedPairs(footer_presets) do - table.insert(items, { - text = preset_name, - keep_menu_open = true, - callback = function() - self:loadPreset(footer_presets[preset_name]) - end, - hold_callback = function(touchmenu_instance, item) - UIManager:show(MultiConfirmBox:new{ - text = T(_("What would you like to do with preset '%1'?"), preset_name), - choice1_text = _("Delete"), - choice1_callback = function() - footer_presets[preset_name] = nil - UIManager:broadcastEvent(Event:new("DispatcherActionValueChanged", - { name = "load_footer_preset", old_value = preset_name, new_value = nil })) - table.remove(touchmenu_instance.item_table, item.idx) - touchmenu_instance:updateItems() - end, - choice2_text = _("Update"), - choice2_callback = function() - footer_presets[preset_name] = self:buildPreset() - UIManager:show(InfoMessage:new{ - text = T(_("Preset '%1' was updated with current settings"), preset_name), - timeout = 2, - }) - end, - }) - end, - }) - end - return items -end - -function ReaderFooter:createPresetFromCurrentSettings(touchmenu_instance) - local input_dialog - input_dialog = InputDialog:new{ - title = _("Enter preset name"), - buttons = { - { - { - text = _("Cancel"), - id = "close", - callback = function() - UIManager:close(input_dialog) - end, - }, - { - text = _("Save"), - is_enter_default = true, - callback = function() - local preset_name = input_dialog:getInputText() - if preset_name == "" or preset_name:match("^%s*$") then return end - local footer_presets = G_reader_settings:readSetting("footer_presets") - if footer_presets[preset_name] then - UIManager:show(InfoMessage:new{ - text = T(_("A preset named '%1' already exists. Please choose a different name."), preset_name), - timeout = 2, - }) - else - footer_presets[preset_name] = self:buildPreset() - UIManager:close(input_dialog) - touchmenu_instance.item_table = self:genPresetMenuItemTable() - touchmenu_instance:updateItems() - end - end, - }, - }, - }, - } - UIManager:show(input_dialog) - input_dialog:onShowKeyboard() -end - function ReaderFooter:buildPreset() return { footer = util.tableDeepCopy(self.settings), @@ -2048,25 +1967,14 @@ function ReaderFooter:loadPreset(preset) end function ReaderFooter:onLoadFooterPreset(preset_name) - local footer_presets = G_reader_settings:readSetting("footer_presets") - if footer_presets and footer_presets[preset_name] then - self:loadPreset(footer_presets[preset_name]) - end - return true + return Presets.onLoadPreset(self.preset_obj, preset_name, true) end function ReaderFooter.getPresets() -- for Dispatcher - local footer_presets = G_reader_settings:readSetting("footer_presets") - local actions = {} - if footer_presets and next(footer_presets) then - for preset_name in pairs(footer_presets) do - table.insert(actions, preset_name) - end - if #actions > 1 then - table.sort(actions) - end - end - return actions, actions + local footer_config = { + presets = G_reader_settings:readSetting("footer_presets", {}) + } + return Presets.getPresets(footer_config) end function ReaderFooter:addAdditionalFooterContent(content_func) diff --git a/frontend/apps/reader/modules/readerwikipedia.lua b/frontend/apps/reader/modules/readerwikipedia.lua index 18d1c10f2..a57002355 100644 --- a/frontend/apps/reader/modules/readerwikipedia.lua +++ b/frontend/apps/reader/modules/readerwikipedia.lua @@ -260,32 +260,40 @@ You can choose an existing folder, or use a default folder named "Wikipedia" in separator = true, }, { - text = _("Enable Wikipedia history"), + text = _("Wikipedia lookup history"), checked_func = function() return not self.disable_history end, - callback = function() - self.disable_history = not self.disable_history - G_reader_settings:saveSetting("wikipedia_disable_history", self.disable_history) - end, - }, - { - text = _("Clean Wikipedia history"), - enabled_func = function() - return wikipedia_history:has("wikipedia_history") - end, - keep_menu_open = true, - callback = function(touchmenu_instance) - UIManager:show(ConfirmBox:new{ - text = _("Clean Wikipedia history?"), - ok_text = _("Clean"), - ok_callback = function() - -- empty data table to replace current one - wikipedia_history:reset{} - touchmenu_instance:updateItems() + sub_item_table = { + { + text = _("Enable Wikipedia history"), + checked_func = function() + return not self.disable_history end, - }) - end, + callback = function() + self.disable_history = not self.disable_history + G_reader_settings:saveSetting("wikipedia_disable_history", self.disable_history) + end, + }, + { + text = _("Clean Wikipedia history"), + enabled_func = function() + return wikipedia_history:has("wikipedia_history") + end, + keep_menu_open = true, + callback = function(touchmenu_instance) + UIManager:show(ConfirmBox:new{ + text = _("Clean Wikipedia history?"), + ok_text = _("Clean"), + ok_callback = function() + -- empty data table to replace current one + wikipedia_history:reset{} + touchmenu_instance:updateItems() + end, + }) + end, + }, + }, separator = true, }, { -- setting used in wikipedia.lua diff --git a/frontend/dispatcher.lua b/frontend/dispatcher.lua index 9113ea317..d5f266474 100644 --- a/frontend/dispatcher.lua +++ b/frontend/dispatcher.lua @@ -34,6 +34,7 @@ local Device = require("device") local Event = require("ui/event") local FileManager = require("apps/filemanager/filemanager") local Notification = require("ui/widget/notification") +local ReaderDictionary = require("apps/reader/modules/readerdictionary") local ReaderFooter = require("apps/reader/modules/readerfooter") local ReaderHighlight = require("apps/reader/modules/readerhighlight") local ReaderZooming = require("apps/reader/modules/readerzooming") @@ -60,6 +61,8 @@ local settingsList = { collections_search = {category="none", event="ShowCollectionsSearchDialog", title=_("Collections search"), general=true, separator=true}, ---- dictionary_lookup = {category="none", event="ShowDictionaryLookup", title=_("Dictionary lookup"), general=true}, + load_dictionary_preset = {category="string", event="LoadDictionaryPreset", title=_("Load dictionary preset"), args_func=ReaderDictionary.getPresets, general=true}, + cycle_dictionary_preset = {category="none", event="CycleDictionaryPresets", title=_("Cycle through dictionary presets"), general=true,}, wikipedia_lookup = {category="none", event="ShowWikipediaLookup", title=_("Wikipedia lookup"), general=true, separator=true}, ---- show_menu = {category="none", event="ShowMenu", title=_("Show menu"), general=true}, @@ -300,6 +303,8 @@ local dispatcher_menu_order = { "collections_search", ---- "dictionary_lookup", + "load_dictionary_preset", + "cycle_dictionary_preset", "wikipedia_lookup", ---- "show_menu", diff --git a/frontend/ui/presets.lua b/frontend/ui/presets.lua new file mode 100644 index 000000000..29f1aae32 --- /dev/null +++ b/frontend/ui/presets.lua @@ -0,0 +1,325 @@ +--[[-- +This module provides a unified interface for managing presets across different KOReader modules. +It handles creation, loading, updating, and deletion of presets, as well as menu generation. + +Usage: + local Presets = require("ui/presets") + + -- 1. In your module's init() method, set up a preset object: + self.preset_obj = { + presets = G_reader_settings:readSetting("my_module_presets", {}), -- or custom storage + cycle_index = G_reader_settings:readSetting("my_module_presets_cycle_index"), -- optional, only needed if cycling through presets + dispatcher_name = "load_my_module_preset", -- must match dispatcher.lua entry + saveCycleIndex = function(this) -- Save cycle index to persistent storage + G_reader_settings:saveSetting("my_module_presets_cycle_index", this.cycle_index) + end, + buildPreset = function() return self:buildPreset() end, -- Closure to build a preset from current state + loadPreset = function(preset) self:loadPreset(preset) end, -- Closure to apply a preset to the module + } + + -- 2. Implement required methods in your module: + function MyModule:buildPreset() + return { + -- Return a table with the settings you want to save in the preset + setting1 = self.setting1, + setting2 = self.setting2, + enabled_features = self.enabled_features, + } + end + + function MyModule:loadPreset(preset) + -- Apply the preset settings to your module + self.setting1 = preset.setting1 + self.setting2 = preset.setting2 + self.enabled_features = preset.enabled_features + -- Update UI or perform other necessary changes + self:refresh() + end + + -- 3. Create menu items for presets: (Alternatively, you could call Presets.genPresetMenuItemTable directly from touchmenu_instance) + function MyModule:genPresetMenuItemTable(touchmenu_instance) + return Presets.genPresetMenuItemTable( + self.preset_obj, -- preset object + _("Create new preset from current settings"), -- optional: custom text for UI menu + function() return self:hasValidSettings() end, -- optional: function to enable/disable creating presets + ) + end + + -- 4. Load a preset by name (for dispatcher/event handling): + function MyModule:onLoadMyModulePreset(preset_name) + return Presets.onLoadPreset( + self.preset_obj, + preset_name, + true -- show notification + ) + end + + -- 5. Cycle through presets (for dispatcher/event handling): + function MyModule:onCycleMyModulePresets() + return Presets.cycleThroughPresets( + self.preset_obj, + true -- show notification + ) + end + + -- 6. Get list of available presets (for dispatcher): + function MyModule.getPresets() -- Note: This is a static method on MyModule + local config = { + presets = G_reader_settings:readSetting("my_module_presets", {}) + } + return Presets.getPresets(config) + end + + -- 7. Add to dispatcher.lua: + load_my_module_preset = { + category = "string", + event = "LoadMyModulePreset", + title = _("Load my module preset"), + args_func = MyModule.getPresets, + reader = true + }, + cycle_my_module_preset = { + category = "none", + event = "CycleMyModulePresets", + title = _("Cycle through my module presets"), + reader = true + }, + +Required preset_obj fields: + - presets: table containing saved presets + - cycle_index: current index for cycling through presets (optional, defaults to 0) + - dispatcher_name: string matching the dispatcher action name (for dispatcher integration) + - saveCycleIndex(this): function to save cycle index (optional, only needed if cycling is used) + +Required module methods: + - buildPreset(): returns a table with the current settings to save as a preset + - loadPreset(preset): applies the settings from the preset table to the module + +The preset system handles: + - Creating, updating, deleting, and renaming presets through UI dialogs + - Generating menu items with hold actions for preset management + - Saving/loading presets to/from G_reader_settings (or custom storage) + - Cycling through presets with wrap-around + - User notifications when presets are loaded/updated/created + - Integration with Dispatcher for gesture/hotkey/profile support + - Broadcasting events to update dispatcher when presets change + - Input validation and duplicate name prevention +--]] + +local ConfirmBox = require("ui/widget/confirmbox") +local Event = require("ui/event") +local InfoMessage = require("ui/widget/infomessage") +local InputDialog = require("ui/widget/inputdialog") +local Notification = require("ui/widget/notification") +local UIManager = require("ui/uimanager") +local ffiUtil = require("ffi/util") +local T = require("ffi/util").template +local _ = require("gettext") + +local Presets = {} + +function Presets.editPresetName(options, preset_obj, on_success_callback) + local input_dialog + input_dialog = InputDialog:new{ + title = options.title or _("Enter preset name"), + input = options.initial_value or "", + buttons = { + { + { + text = _("Cancel"), + id = "close", + callback = function() + UIManager:close(input_dialog) + end, + }, + { + text = options.confirm_button_text or _("Create"), + is_enter_default = true, + callback = function() + local entered_preset_name = input_dialog:getInputText() + if entered_preset_name == "" or entered_preset_name:match("^%s*$") then + UIManager:show(InfoMessage:new{ + text = _("Invalid preset name. Please choose a different name."), + timeout = 2, + }) + return false + end + if options.initial_value and entered_preset_name == options.initial_value then + UIManager:close(input_dialog) + return false + end + if preset_obj.presets[entered_preset_name] then + UIManager:show(InfoMessage:new{ + text = T(_("A preset named '%1' already exists. Please choose a different name."), entered_preset_name), + timeout = 2, + }) + return false + end + + -- If all validation passes, call the success callback + on_success_callback(entered_preset_name) + UIManager:close(input_dialog) + end, + }, + }, + }, + } + UIManager:show(input_dialog) + input_dialog:onShowKeyboard() +end + +function Presets.genPresetMenuItemTable(preset_obj, text, enabled_func) + local presets = preset_obj.presets + local items = { + { + text = text or _("Create new preset from current settings"), + keep_menu_open = true, + enabled_func = enabled_func, + callback = function(touchmenu_instance) + Presets.editPresetName({}, preset_obj, + function(entered_preset_name) + local preset_data = preset_obj.buildPreset() + preset_obj.presets[entered_preset_name] = preset_data + touchmenu_instance.item_table = Presets.genPresetMenuItemTable(preset_obj) + touchmenu_instance:updateItems() + end + ) + end, + separator = true, + }, + } + for preset_name in ffiUtil.orderedPairs(presets) do + table.insert(items, { + text = preset_name, + keep_menu_open = true, + callback = function() + preset_obj.loadPreset(presets[preset_name]) + -- There's no guarantee that it'll be obvious to the user that the preset was loaded so, we show a notification. + UIManager:show(InfoMessage:new{ + text = T(_("Preset '%1' loaded successfully."), preset_name), + timeout = 2, + }) + end, + hold_callback = function(touchmenu_instance, item) + UIManager:show(ConfirmBox:new{ + text = T(_("What would you like to do with preset '%1'?"), preset_name), + icon = "notice-question", + ok_text = _("Update"), + ok_callback = function() + UIManager:show(ConfirmBox:new{ + text = T(_("Are you sure you want to overwrite preset '%1' with current settings?"), preset_name), + ok_callback = function() + presets[preset_name] = preset_obj.buildPreset() + UIManager:show(InfoMessage:new{ + text = T(_("Preset '%1' was updated with current settings"), preset_name), + timeout = 2, + }) + end, + }) + end, + other_buttons_first = true, + other_buttons = { + { + { + text = _("Delete"), + callback = function() + UIManager:show(ConfirmBox:new{ + text = T(_("Are you sure you want to delete preset '%1'?"), preset_name), + ok_text = _("Delete"), + ok_callback = function() + presets[preset_name] = nil + local action_key = preset_obj.dispatcher_name + if action_key then + UIManager:broadcastEvent(Event:new("DispatcherActionValueChanged", { + name = action_key, + old_value = preset_name, + new_value = nil -- delete the action + })) + end + table.remove(touchmenu_instance.item_table, item.idx) + touchmenu_instance:updateItems() + end, + }) + end, + }, + { + text = _("Rename"), + callback = function() + Presets.editPresetName({ + title = _("Enter new preset name"), + initial_value = preset_name, + confirm_button_text = _("Rename"), + }, preset_obj, + function(new_name) + presets[new_name] = presets[preset_name] + presets[preset_name] = nil + local action_key = preset_obj.dispatcher_name + if action_key then + UIManager:broadcastEvent(Event:new("DispatcherActionValueChanged", { + name = action_key, + old_value = preset_name, + new_value = new_name + })) + end + touchmenu_instance.item_table = Presets.genPresetMenuItemTable(preset_obj) + touchmenu_instance:updateItems() + end) -- editPresetName + end, -- rename callback + }, + }, + }, -- end of other_buttons + }) -- end of ConfirmBox + end, -- hold_callback + }) -- end of table.insert + end -- for each preset + return items +end + + +function Presets.onLoadPreset(preset_obj, preset_name, show_notification) + local presets = preset_obj.presets + if presets and presets[preset_name] then + preset_obj.loadPreset(presets[preset_name]) + if show_notification then + Notification:notify(T(_("Preset '%1' was loaded"), preset_name)) + end + end + return true +end + +function Presets.cycleThroughPresets(preset_obj, show_notification) + local preset_names = Presets.getPresets(preset_obj) + if #preset_names == 0 then + Notification:notify(_("No presets available"), Notification.SOURCE_ALWAYS_SHOW) + return true -- we *must* return true here to prevent further event propagation, i.e multiple notifications + end + -- Get and increment index, wrap around if needed + local index = (preset_obj.cycle_index or 0) + 1 + if index > #preset_names then + index = 1 + end + local next_preset_name = preset_names[index] + preset_obj.loadPreset(preset_obj.presets[next_preset_name]) + preset_obj.cycle_index = index + preset_obj:saveCycleIndex() + if show_notification then + Notification:notify(T(_("Loaded preset: %1"), next_preset_name)) + end + return true +end + +function Presets.getPresets(preset_obj) + local presets = preset_obj.presets + local actions = {} + if presets and next(presets) then + for preset_name in pairs(presets) do + table.insert(actions, preset_name) + end + if #actions > 1 then + table.sort(actions) + end + end + return actions, actions +end + +return Presets diff --git a/frontend/ui/widget/dictquicklookup.lua b/frontend/ui/widget/dictquicklookup.lua index 576eec263..66e55c31c 100644 --- a/frontend/ui/widget/dictquicklookup.lua +++ b/frontend/ui/widget/dictquicklookup.lua @@ -21,6 +21,7 @@ local Size = require("ui/size") local TextWidget = require("ui/widget/textwidget") local TitleBar = require("ui/widget/titlebar") local Translator = require("ui/translator") +local Presets = require("ui/presets") local UIManager = require("ui/uimanager") local VerticalGroup = require("ui/widget/verticalgroup") local VerticalSpan = require("ui/widget/verticalspan") @@ -1404,57 +1405,96 @@ function DictQuickLookup:onForwardingPanRelease(arg, ges) end function DictQuickLookup:onLookupInputWord(hint) + local buttons = { + { + { + text = _("Translate"), + callback = function() + local text = self.input_dialog:getInputText() + if text ~= "" then + UIManager:close(self.input_dialog) + Translator:showTranslation(text, true) + end + end, + }, + { + text = _("Search Wikipedia"), + is_enter_default = self.is_wiki, + callback = function() + local text = self.input_dialog:getInputText() + if text ~= "" then + UIManager:close(self.input_dialog) + self.is_wiki = true + self:lookupWikipedia(false, text, true) + end + end, + }, + }, + { + { + text = _("Cancel"), + id = "close", + callback = function() + UIManager:close(self.input_dialog) + end, + }, + { + text = _("Search dictionary"), + is_enter_default = not self.is_wiki, + callback = function() + local text = self.input_dialog:getInputText() + if text ~= "" then + UIManager:close(self.input_dialog) + self.is_wiki = false + self.ui:handleEvent(Event:new("LookupWord", text, true)) + end + end, + }, + }, + } + local preset_names = Presets.getPresets(self.ui.dictionary.preset_obj) + if preset_names and #preset_names > 0 then + table.insert(buttons, 2, { + { + text = _("Search with preset"), + callback = function() + local text = self.input_dialog:getInputText() + if text == "" or text:match("^%s*$") then return end + local current_dict_state = self.ui.dictionary:buildPreset() + local button_dialog, dialog_buttons = nil, {} -- CI won't like it if we call it buttons :( so dialog_buttons + for _, preset_name in ipairs(preset_names) do + table.insert(dialog_buttons, { + { + align = "left", + text = preset_name, + callback = function() + self.ui.dictionary:loadPreset(self.ui.dictionary.preset_obj.presets[preset_name], true) + UIManager:close(button_dialog) + UIManager:close(self.input_dialog) + self.ui:handleEvent(Event:new("LookupWord", text, true, nil, nil, nil, + function() + -- Restore original preset _after_ lookup is complete + self.ui.dictionary:loadPreset(current_dict_state, true) + end + )) + end + } + }) + end + button_dialog = ButtonDialog:new{ + buttons = dialog_buttons, + shrink_unneeded_width = true, + } + UIManager:show(button_dialog) + end, + } + }) + end self.input_dialog = InputDialog:new{ title = _("Enter a word or phrase to look up"), input = hint, input_hint = hint, - buttons = { - { - { - text = _("Translate"), - callback = function() - local text = self.input_dialog:getInputText() - if text ~= "" then - UIManager:close(self.input_dialog) - Translator:showTranslation(text, true) - end - end, - }, - { - text = _("Search Wikipedia"), - is_enter_default = self.is_wiki, - callback = function() - local text = self.input_dialog:getInputText() - if text ~= "" then - UIManager:close(self.input_dialog) - self.is_wiki = true - self:lookupWikipedia(false, text, true) - end - end, - }, - }, - { - { - text = _("Cancel"), - id = "close", - callback = function() - UIManager:close(self.input_dialog) - end, - }, - { - text = _("Search dictionary"), - is_enter_default = not self.is_wiki, - callback = function() - local text = self.input_dialog:getInputText() - if text ~= "" then - UIManager:close(self.input_dialog) - self.is_wiki = false - self.ui:handleEvent(Event:new("LookupWord", text, true)) - end - end, - }, - }, - }, + buttons = buttons, } UIManager:show(self.input_dialog) self.input_dialog:onShowKeyboard() From f266aa2bdc4671c515937e86fb3a4f03823b057f Mon Sep 17 00:00:00 2001 From: Benoit Pierre Date: Sat, 21 Jun 2025 07:56:23 +0200 Subject: [PATCH 2/8] bump base (#13971) - https://github.com/koreader/koreader-base/pull/2096 - https://github.com/koreader/koreader-base/pull/2097 - https://github.com/koreader/koreader-base/pull/2098 - https://github.com/koreader/koreader-base/pull/2099 - https://github.com/koreader/koreader-base/pull/2100 (close #13821) - https://github.com/koreader/koreader-base/pull/2101 - https://github.com/koreader/koreader-base/pull/2102 - https://github.com/koreader/koreader-base/pull/2103 - https://github.com/koreader/koreader-base/pull/2104 - https://github.com/koreader/koreader-base/pull/2105 - https://github.com/koreader/koreader-base/pull/2106 - https://github.com/koreader/koreader-base/pull/2107 - https://github.com/koreader/koreader-base/pull/2108 - https://github.com/koreader/koreader-base/pull/2109 - https://github.com/koreader/koreader-base/pull/2110 - https://github.com/koreader/koreader-base/pull/2111 --- base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base b/base index ca3f8fcda..63f95f0f2 160000 --- a/base +++ b/base @@ -1 +1 @@ -Subproject commit ca3f8fcda4da00b13e737d8e0c2f6baf003cc1aa +Subproject commit 63f95f0f2a8d87ce2b0c1a32e9f9774e2d80b556 From 306fd6c5142e3a2f13758ff366a7007a038dda16 Mon Sep 17 00:00:00 2001 From: David <97603719+Commodore64user@users.noreply.github.com> Date: Sat, 21 Jun 2025 07:12:03 +0100 Subject: [PATCH 3/8] [Hotkeys] remove alphabetic hotkeys from K4 (#13950) --- plugins/hotkeys.koplugin/defaults.lua | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/hotkeys.koplugin/defaults.lua b/plugins/hotkeys.koplugin/defaults.lua index 3dc71314e..9c56ce108 100644 --- a/plugins/hotkeys.koplugin/defaults.lua +++ b/plugins/hotkeys.koplugin/defaults.lua @@ -40,9 +40,9 @@ return { alt_plus_a = nil, alt_plus_b = nil, alt_plus_c = nil, - alt_plus_d = {dictionary_lookup = true,}, + alt_plus_d = Device:hasKeyboard() and {dictionary_lookup = true,} or {}, alt_plus_e = nil, - alt_plus_f = {file_search = true,}, + alt_plus_f = Device:hasKeyboard() and {file_search = true,} or {}, alt_plus_g = nil, alt_plus_h = nil, alt_plus_i = nil, @@ -59,7 +59,7 @@ return { alt_plus_t = nil, alt_plus_u = nil, alt_plus_v = nil, - alt_plus_w = {wikipedia_lookup = true,}, + alt_plus_w = Device:hasKeyboard() and {wikipedia_lookup = true,} or {}, alt_plus_x = nil, alt_plus_y = nil, alt_plus_z = nil, @@ -96,9 +96,9 @@ return { alt_plus_a = nil, alt_plus_b = nil, alt_plus_c = nil, - alt_plus_d = {dictionary_lookup = true,}, + alt_plus_d = Device:hasKeyboard() and {dictionary_lookup = true,} or {}, alt_plus_e = nil, - alt_plus_f = {file_search = true,}, + alt_plus_f = Device:hasKeyboard() and {file_search = true,} or {}, alt_plus_g = nil, alt_plus_h = nil, alt_plus_i = nil, @@ -111,11 +111,11 @@ return { alt_plus_p = nil, alt_plus_q = nil, alt_plus_r = nil, - alt_plus_s = {fulltext_search = true,}, + alt_plus_s = Device:hasKeyboard() and {fulltext_search = true,} or {}, alt_plus_t = nil, alt_plus_u = nil, alt_plus_v = nil, - alt_plus_w = {wikipedia_lookup = true,}, + alt_plus_w = Device:hasKeyboard() and {wikipedia_lookup = true,} or {}, alt_plus_x = nil, alt_plus_y = nil, alt_plus_z = nil, From 6bc5b1628257424b0a07df2cca9cff4700c5a842 Mon Sep 17 00:00:00 2001 From: David <97603719+Commodore64user@users.noreply.github.com> Date: Sat, 21 Jun 2025 10:36:10 +0100 Subject: [PATCH 4/8] [ReaderHighlight] NT: add key event to simulate a very long press (#13914) --- .../apps/reader/modules/readerhighlight.lua | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/frontend/apps/reader/modules/readerhighlight.lua b/frontend/apps/reader/modules/readerhighlight.lua index db6531115..af8d9cef8 100644 --- a/frontend/apps/reader/modules/readerhighlight.lua +++ b/frontend/apps/reader/modules/readerhighlight.lua @@ -245,20 +245,18 @@ function ReaderHighlight:registerKeyEvents() self.key_events.RightHighlightIndicator = { { "Right" }, event = "MoveHighlightIndicator", args = {1, 0} } self.key_events.HighlightPress = { { "Press" } } end - if Device:hasKeyboard() then + if Device:hasScreenKB() or Device:hasKeyboard() then + local modifier = Device:hasScreenKB() and "ScreenKB" or "Shift" -- Used for text selection with dpad/keys local QUICK_INDICATOR_MOVE = true - self.key_events.QuickUpHighlightIndicator = { { "Shift", "Up" }, event = "MoveHighlightIndicator", args = {0, -1, QUICK_INDICATOR_MOVE} } - self.key_events.QuickDownHighlightIndicator = { { "Shift", "Down" }, event = "MoveHighlightIndicator", args = {0, 1, QUICK_INDICATOR_MOVE} } - self.key_events.QuickLeftHighlightIndicator = { { "Shift", "Left" }, event = "MoveHighlightIndicator", args = {-1, 0, QUICK_INDICATOR_MOVE} } - self.key_events.QuickRightHighlightIndicator = { { "Shift", "Right" }, event = "MoveHighlightIndicator", args = {1, 0, QUICK_INDICATOR_MOVE} } - self.key_events.StartHighlightIndicator = { { "H" } } - elseif Device:hasScreenKB() then - local QUICK_INDICATOR_MOVE = true - self.key_events.QuickUpHighlightIndicator = { { "ScreenKB", "Up" }, event = "MoveHighlightIndicator", args = {0, -1, QUICK_INDICATOR_MOVE} } - self.key_events.QuickDownHighlightIndicator = { { "ScreenKB", "Down" }, event = "MoveHighlightIndicator", args = {0, 1, QUICK_INDICATOR_MOVE} } - self.key_events.QuickLeftHighlightIndicator = { { "ScreenKB", "Left" }, event = "MoveHighlightIndicator", args = {-1, 0, QUICK_INDICATOR_MOVE} } - self.key_events.QuickRightHighlightIndicator = { { "ScreenKB", "Right" }, event = "MoveHighlightIndicator", args = {1, 0, QUICK_INDICATOR_MOVE} } + self.key_events.QuickUpHighlightIndicator = { { modifier, "Up" }, event = "MoveHighlightIndicator", args = {0, -1, QUICK_INDICATOR_MOVE} } + self.key_events.QuickDownHighlightIndicator = { { modifier, "Down" }, event = "MoveHighlightIndicator", args = {0, 1, QUICK_INDICATOR_MOVE} } + self.key_events.QuickLeftHighlightIndicator = { { modifier, "Left" }, event = "MoveHighlightIndicator", args = {-1, 0, QUICK_INDICATOR_MOVE} } + self.key_events.QuickRightHighlightIndicator = { { modifier, "Right" }, event = "MoveHighlightIndicator", args = {1, 0, QUICK_INDICATOR_MOVE} } + self.key_events.HighlightModifierPress = { { modifier, "Press" } } + if Device:hasKeyboard() then + self.key_events.StartHighlightIndicator = { { "H" } } + end end end @@ -2673,7 +2671,7 @@ end -- dpad/keys support -function ReaderHighlight:onHighlightPress() +function ReaderHighlight:onHighlightPress(skip_tap_check) if not self._current_indicator_pos then return false end if self._start_indicator_highlight then self:onHoldRelease(nil, self:_createHighlightGesture("hold_release")) @@ -2681,7 +2679,7 @@ function ReaderHighlight:onHighlightPress() return true end -- Attempt to open an existing highlight - if self:onTap(nil, self:_createHighlightGesture("tap")) then + if not skip_tap_check and self:onTap(nil, self:_createHighlightGesture("tap")) then self:onStopHighlightIndicator(true) -- need_clear_selection=true return true end @@ -2764,6 +2762,19 @@ function ReaderHighlight:onHighlightPress() return true end +function ReaderHighlight:onHighlightModifierPress() + if not self._current_indicator_pos then return false end -- let event propagate to hotkeys + if not self._start_indicator_highlight then + self:onHighlightPress(true) + return true -- don't trigger hotkeys during text selection + end + -- Simulate very long-long press by setting the long hold flag. This will trigger the long-press dialog. + self.long_hold_reached = true + self:onHoldRelease(nil, self:_createHighlightGesture("hold_release")) + self:onStopHighlightIndicator() + return true +end + function ReaderHighlight:onStartHighlightIndicator() -- disable long-press icon (poke-ball), as it is triggered constantly due to NT devices needing a workaround for text selection to work. self.long_hold_reached_action = function() end From d9931464807108059f34cf0b1ed5160a0853323a Mon Sep 17 00:00:00 2001 From: David <97603719+Commodore64user@users.noreply.github.com> Date: Sat, 21 Jun 2025 10:37:01 +0100 Subject: [PATCH 5/8] [ReaderHighlight] NT: add better support for extending highlights (#13917) --- .../apps/reader/modules/readerhighlight.lua | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/frontend/apps/reader/modules/readerhighlight.lua b/frontend/apps/reader/modules/readerhighlight.lua index af8d9cef8..ea4059870 100644 --- a/frontend/apps/reader/modules/readerhighlight.lua +++ b/frontend/apps/reader/modules/readerhighlight.lua @@ -75,6 +75,9 @@ function ReaderHighlight:init() callback = function() this:startSelection(index) this:onClose() + if not Device:isTouchDevice() then + self:onStartHighlightIndicator() + end end, } end, @@ -2678,6 +2681,13 @@ function ReaderHighlight:onHighlightPress(skip_tap_check) self:onStopHighlightIndicator() return true end + -- Check if we're in select mode (or extending an existing highlight) + if self.select_mode and self.highlight_idx then + self:onHold(nil, self:_createHighlightGesture("hold")) + self:onHoldRelease(nil, self:_createHighlightGesture("hold_release")) + self:onStopHighlightIndicator() + return true + end -- Attempt to open an existing highlight if not skip_tap_check and self:onTap(nil, self:_createHighlightGesture("tap")) then self:onStopHighlightIndicator(true) -- need_clear_selection=true @@ -2797,6 +2807,16 @@ function ReaderHighlight:onStartHighlightIndicator() end function ReaderHighlight:onStopHighlightIndicator(need_clear_selection) + -- If we're in select mode and user presses back, end the selection + if self.select_mode and self.highlight_idx then + self.select_mode = false + if self.ui.annotation.annotations[self.highlight_idx].is_tmp then + self:deleteHighlight(self.highlight_idx) -- temporary highlight, delete it + else + UIManager:setDirty(self.dialog, "ui", self.view.flipping:getRefreshRegion()) + end + self.highlight_idx = nil + end if self._current_indicator_pos then local rect = self._current_indicator_pos self._previous_indicator_pos = rect From b2f9530788b23eee6604ada95f271937259e11f1 Mon Sep 17 00:00:00 2001 From: David <97603719+Commodore64user@users.noreply.github.com> Date: Sat, 21 Jun 2025 10:37:13 +0100 Subject: [PATCH 6/8] [DictQuickLookup] create addQueryWordToFirstResult method (#13911) So it can be disabled with a userpatch. --- frontend/ui/widget/dictquicklookup.lua | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/frontend/ui/widget/dictquicklookup.lua b/frontend/ui/widget/dictquicklookup.lua index 66e55c31c..e5de1a624 100644 --- a/frontend/ui/widget/dictquicklookup.lua +++ b/frontend/ui/widget/dictquicklookup.lua @@ -1160,12 +1160,7 @@ function DictQuickLookup:changeDictionary(index, skip_update) -- add queried word to 1st result's definition, so we can see -- what was the selected text and if we selected wrong if index == 1 then - if self.is_html then - self.definition = self.definition.."
_______
" - else - self.definition = self.definition.."\n_______\n" - end - self.definition = self.definition..T(_("(query : %1)"), self.word) + self:addQueryWordToResult() end end self.displaydictname = self.dictionary @@ -1188,6 +1183,16 @@ function DictQuickLookup:changeDictionary(index, skip_update) end end +function DictQuickLookup:addQueryWordToResult() + -- Extracted to a separate method so it can be removed by user patches. + if self.is_html then + self.definition = self.definition.."
_______
" + else + self.definition = self.definition.."\n_______\n" + end + self.definition = self.definition..T(_("(query : %1)"), self.word) +end + --[[ No longer used function DictQuickLookup:changeToDefaultDict() if self.dictionary then From 456ce2fa36831ddc0199e68eb71bb1dfd5ba7e60 Mon Sep 17 00:00:00 2001 From: Benoit Pierre Date: Thu, 19 Jun 2025 23:37:59 +0200 Subject: [PATCH 7/8] gettext: switch to binary catalogs Binary catalogs are more space efficient (total translations disk usage: 14MB instead of 35MB), and easier and faster (roughly 10 times) to load: no parsing, and less processing (no need to unescape strings). Not much difference on the Android APK size (a ~600KB reduction), but other TAR.GZ / ZIP distributions see a reduction of ~3.5MB. --- Makefile | 28 +--- frontend/gettext.lua | 327 +++++++++++++++++++++++-------------- make/gettext.mk | 33 ++++ spec/unit/gettext_spec.lua | 71 +++----- 4 files changed, 263 insertions(+), 196 deletions(-) create mode 100644 make/gettext.mk diff --git a/Makefile b/Makefile index c9c252089..b6f908e2e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -PHONY = all android-ndk android-sdk base clean distclean doc fetchthirdparty po pot re static-check +PHONY = all android-ndk android-sdk base clean distclean doc fetchthirdparty re static-check SOUND = $(INSTALL_DIR)/% # koreader-base directory @@ -81,6 +81,7 @@ ev_replay.py help history l10n/templates +l10n/*/*.po ota resources/fonts* resources/icons/src* @@ -134,7 +135,7 @@ $(foreach a,$1,'$(if $(filter --%,$a),$a,$(abspath $a))') $(or $2,koreader) $(call release_excludes,$(or $2,koreader)/) endef -all: base +all: base mo install -d $(INSTALL_DIR)/koreader rm -f $(INSTALL_DIR)/koreader/git-rev; echo "$(VERSION)" > $(INSTALL_DIR)/koreader/git-rev ifdef ANDROID @@ -190,7 +191,7 @@ else git submodule update --jobs 3 --init --recursive endif -clean: base-clean +clean: base-clean mo-clean rm -rf $(INSTALL_DIR) distclean: clean base-distclean @@ -209,31 +210,14 @@ ifneq (,$(wildcard make/$(TARGET).mk)) include make/$(TARGET).mk endif +include make/gettext.mk + android-ndk: $(MAKE) -C $(KOR_BASE)/toolchain $(ANDROID_NDK_HOME) android-sdk: $(MAKE) -C $(KOR_BASE)/toolchain $(ANDROID_HOME) -# for gettext -DOMAIN=koreader -TEMPLATE_DIR=l10n/templates -XGETTEXT_BIN=xgettext - -pot: po - mkdir -p $(TEMPLATE_DIR) - $(XGETTEXT_BIN) --from-code=utf-8 \ - --keyword=C_:1c,2 --keyword=N_:1,2 --keyword=NC_:1c,2,3 \ - --add-comments=@translators \ - reader.lua `find frontend -iname "*.lua" | sort` \ - `find plugins -iname "*.lua" | sort` \ - `find tools -iname "*.lua" | sort` \ - -o $(TEMPLATE_DIR)/$(DOMAIN).pot - -po: - git submodule update --remote l10n - - static-check: @if which luacheck > /dev/null; then \ luacheck -q {reader,setupkoenv,datastorage}.lua frontend plugins spec; \ diff --git a/frontend/gettext.lua b/frontend/gettext.lua index b74ea73b8..e4895a88f 100644 --- a/frontend/gettext.lua +++ b/frontend/gettext.lua @@ -21,6 +21,12 @@ See @{ffi.util.template}() for more information about the template function. local isAndroid, android = pcall(require, "android") local logger = require("logger") +local buffer = require("string.buffer") +local ffi = require("ffi") +local C = ffi.C + +require "table.new" +require "ffi/posix_h" local GetText = { context = {}, @@ -61,21 +67,6 @@ function GetText_mt.__call(gettext, msgid) return gettext.translation[msgid] and gettext.translation[msgid][0] or gettext.translation[msgid] or gettext.wrapUntranslated(msgid) end -local function c_escape(what_full, what) - if what == "\n" then return "" - elseif what == "a" then return "\a" - elseif what == "b" then return "\b" - elseif what == "f" then return "\f" - elseif what == "n" then return "\n" - elseif what == "r" then return "\r" - elseif what == "t" then return "\t" - elseif what == "v" then return "\v" - elseif what == "0" then return "\0" -- shouldn't happen, though - else - return what_full - end -end - --- Converts C logical operators to Lua. local function logicalCtoLua(logical_str) logical_str = logical_str:gsub("&&", "and") @@ -137,9 +128,13 @@ local function getPluralFunc(pl_tests, nplurals, plural_default) end local function addTranslation(msgctxt, msgid, msgstr, n) - -- translated string - local unescaped_string = string.gsub(msgstr, "(\\(.))", c_escape) - if msgctxt and msgctxt ~= "" then + assert(not msgctxt or msgctxt ~= "") + assert(msgid and msgid ~= "") + assert(msgstr) + if msgstr == "" then + return + end + if msgctxt then if not GetText.context[msgctxt] then GetText.context[msgctxt] = {} end @@ -147,26 +142,22 @@ local function addTranslation(msgctxt, msgid, msgstr, n) if not GetText.context[msgctxt][msgid] then GetText.context[msgctxt][msgid] = {} end - GetText.context[msgctxt][msgid][n] = unescaped_string ~= "" and unescaped_string or nil + GetText.context[msgctxt][msgid][n] = msgstr else - GetText.context[msgctxt][msgid] = unescaped_string ~= "" and unescaped_string or nil + GetText.context[msgctxt][msgid] = msgstr end else if n then if not GetText.translation[msgid] then GetText.translation[msgid] = {} end - GetText.translation[msgid][n] = unescaped_string ~= "" and unescaped_string or nil + GetText.translation[msgid][n] = msgstr else - GetText.translation[msgid] = unescaped_string ~= "" and unescaped_string or nil + GetText.translation[msgid] = msgstr end end end --- for PO file syntax, see --- https://www.gnu.org/software/gettext/manual/html_node/PO-Files.html --- we only implement a sane subset for now - function GetText_mt.__index.changeLang(new_lang) GetText.context = {} GetText.translation = {} @@ -180,119 +171,210 @@ function GetText_mt.__index.changeLang(new_lang) -- strip encoding suffix in locale like "zh_CN.utf8" new_lang = new_lang:sub(1, new_lang:find(".%.")) - local file = GetText.dirname .. "/" .. new_lang .. "/" .. GetText.textdomain .. ".po" - local po = io.open(file, "r") - - if not po then - logger.dbg("cannot open translation file:", file) + local mo = GetText.dirname .. "/" .. new_lang .. "/" .. GetText.textdomain .. ".mo" + if not GetText.loadMO(mo) then return false end - local data = {} - local in_comments = false - local fuzzy = false - local headers - local what = nil - while true do - local line = po:read("*l") - if line == nil or line == "" then - if data.msgid and data.msgid_plural and data["msgstr[0]"] then - for k, v in pairs(data) do - local n = tonumber(k:match("msgstr%[([0-9]+)%]")) - local msgstr = v + GetText.current_lang = new_lang + return true +end - if n and msgstr and msgstr ~= "" then - addTranslation(data.msgctxt, data.msgid, msgstr, n) - end - end - elseif data.msgid and data.msgstr and data.msgstr ~= "" then - -- header - if not headers and data.msgid == "" then - headers = data.msgstr - local plural_forms = data.msgstr:match("Plural%-Forms: (.*)") - local nplurals = plural_forms:match("nplurals=([0-9]+);") or 2 - local plurals = plural_forms:match("plural=%((.*)%);") +local function parse_headers(headers) + local plural_forms = headers:match("Plural%-Forms: (.*)") + local nplurals = plural_forms:match("nplurals=([0-9]+);") or 2 + local plurals = plural_forms:match("plural=%((.*)%);") - -- Hardcoded workaround for Hebrew which has 4 plural forms. - if plurals == "n == 1) ? 0 : ((n == 2) ? 1 : ((n > 10 && n % 10 == 0) ? 2 : 3)" then - plurals = "n == 1 ? 0 : (n == 2) ? 1 : (n > 10 && n % 10 == 0) ? 2 : 3" - end - -- Hardcoded workaround for Latvian. - if plurals == "n % 10 == 0 || n % 100 >= 11 && n % 100 <= 19) ? 0 : ((n % 10 == 1 && n % 100 != 11) ? 1 : 2" then - plurals = "n % 10 == 0 || n % 100 >= 11 && n % 100 <= 19 ? 0 : (n % 10 == 1 && n % 100 != 11) ? 1 : 2" - end - -- Hardcoded workaround for Romanian which has 3 plural forms. - if plurals == "n == 1) ? 0 : ((n == 0 || n != 1 && n % 100 >= 1 && n % 100 <= 19) ? 1 : 2" then - plurals = "n == 1 ? 0 : (n == 0 || n != 1 && n % 100 >= 1 && n % 100 <= 19) ? 1 : 2" - end + -- Hardcoded workaround for Hebrew which has 4 plural forms. + if plurals == "n == 1) ? 0 : ((n == 2) ? 1 : ((n > 10 && n % 10 == 0) ? 2 : 3)" then + plurals = "n == 1 ? 0 : (n == 2) ? 1 : (n > 10 && n % 10 == 0) ? 2 : 3" + end + -- Hardcoded workaround for Latvian. + if plurals == "n % 10 == 0 || n % 100 >= 11 && n % 100 <= 19) ? 0 : ((n % 10 == 1 && n % 100 != 11) ? 1 : 2" then + plurals = "n % 10 == 0 || n % 100 >= 11 && n % 100 <= 19 ? 0 : (n % 10 == 1 && n % 100 != 11) ? 1 : 2" + end + -- Hardcoded workaround for Romanian which has 3 plural forms. + if plurals == "n == 1) ? 0 : ((n == 0 || n != 1 && n % 100 >= 1 && n % 100 <= 19) ? 1 : 2" then + plurals = "n == 1 ? 0 : (n == 0 || n != 1 && n % 100 >= 1 && n % 100 <= 19) ? 1 : 2" + end - if not plurals then - -- Some languages (e.g., Arabic) may not use parentheses. - -- However, the following more inclusive match is more likely - -- to accidentally include junk and seldom relevant. - -- We might also be dealing with a language without plurals. - -- That would look like `plural=0`. - plurals = plural_forms:match("plural=(.*);") - end + if not plurals then + -- Some languages (e.g., Arabic) may not use parentheses. + -- However, the following more inclusive match is more likely + -- to accidentally include junk and seldom relevant. + -- We might also be dealing with a language without plurals. + -- That would look like `plural=0`. + plurals = plural_forms:match("plural=(.*);") + end - if plurals:find("[^n!=%%<>&:%(%)|?0-9 ]") then - -- we don't trust this input, go with default instead - plurals = GetText.plural_default - end + if plurals:find("[^n!=%%<>&:%(%)|?0-9 ]") then + -- we don't trust this input, go with default instead + plurals = GetText.plural_default + end - local pl_tests = {} - for pl_test in plurals:gmatch("[^:]+") do - table.insert(pl_tests, pl_test) - end + local pl_tests = {} + for pl_test in plurals:gmatch("[^:]+") do + table.insert(pl_tests, pl_test) + end - GetText.getPlural = getPluralFunc(pl_tests, nplurals, GetText.plural_default) - if not GetText.getPlural then - GetText.getPlural = getDefaultPlural - end - end + GetText.getPlural = getPluralFunc(pl_tests, nplurals, GetText.plural_default) + if not GetText.getPlural then + GetText.getPlural = getDefaultPlural + end +end - addTranslation(data.msgctxt, data.msgid, data.msgstr) +-- for MO file format, see +-- https://www.gnu.org/software/gettext/manual/html_node/MO-Files.html + +ffi.cdef[[ +struct __attribute__((packed)) mo_header { + uint32_t magic; + uint16_t revision_major; + uint16_t revision_minor; + uint32_t nb_strings; + uint32_t original_strings_table_offset; + uint32_t translated_strings_table_offset; + uint32_t hash_table_size; + uint32_t hash_table_offset; +}; + +struct __attribute__((packed)) mo_string_table { + uint32_t length; + uint32_t offset; +}; +]] +local MO_MAGIC = 0x950412de + +function GetText_mt.__index.loadMO(file) + local fd = C.open(file, C.O_RDONLY) + if fd < 0 then + logger.dbg(string.format("cannot open translation file: %s", file)) + return false + end + local strerror = function() + return ffi.string(C.strerror(ffi.errno())) + end + local seek_and_read = function(off, ptr, len) + local ret + ret = C.lseek(fd, off, C.SEEK_SET) + if ret ~= off then + logger.err(string.format("loading translation file failed: %s [%s]", file, ret < 0 and strerror() or "lseek")) + return false + end + ret = C.read(fd, ptr, len) + if ret ~= len then + logger.err(string.format("loading translation file failed: %s [%s]", file), ret < 0 and strerror() or "short read") + return false + end + return true + end + local mo_hdr = ffi.new("struct mo_header") + if not seek_and_read(0, mo_hdr, ffi.sizeof(mo_hdr)) then + C.close(fd) + return false + end + if mo_hdr.magic ~= MO_MAGIC then + logger.err(string.format("bad translation file: %s [magic]", file)) + C.close(fd) + return false + end + if mo_hdr.revision_major ~= 0 then + logger.err(string.format("bad translation file: %s [revision]", file)) + C.close(fd) + return false + end + local table_buf = buffer:new() + local table_size = mo_hdr.nb_strings * ffi.sizeof("struct mo_string_table") + local table_ptr = table_buf:reserve(table_size) + local read_strings_count + local read_strings = function(check_for_context) + local m_str_tbl = ffi.cast("struct mo_string_table *", table_ptr) + local str_buf = buffer:new() + read_strings_count = -1 + return function() + read_strings_count = read_strings_count + 1 + if read_strings_count >= mo_hdr.nb_strings then + return end - -- stop at EOF: - if line == nil then break end - data = {} - what = nil - else - -- comment - if line:match("^#") then - if not in_comments then - in_comments = true - fuzzy = false + local str_len = m_str_tbl[read_strings_count].length + local str_off = m_str_tbl[read_strings_count].offset + local str_ptr = str_buf:reserve(str_len) + if not seek_and_read(str_off, str_ptr, str_len) then + return + end + local ctx + local pos = 0 + if check_for_context then + -- 4: ␄ (End of Transmission). + local p = C.memchr(str_ptr, 4, str_len) + if p ~= nil then + local l = ffi.cast("ssize_t", p) - ffi.cast("ssize_t", str_ptr) + ctx = ffi.string(str_ptr, l) + pos = l + 1 end - if line:match(", fuzzy") then - fuzzy = true + end + local l = C.strnlen(str_ptr + pos, str_len - pos) + if l + pos < str_len then + -- Plurals! + local strings = {ffi.string(str_ptr + pos, l)} + pos = pos + l + 1 + while pos < str_len do + l = C.strnlen(str_ptr + pos, str_len - pos) + table.insert(strings, ffi.string(str_ptr + pos, l)) + pos = pos + l + 1 end - elseif fuzzy then - in_comments = false + return read_strings_count + 1, strings, ctx else - in_comments = false - -- new data item (msgid, msgstr, ... - local w, s = line:match("^%s*([%a_%[%]0-9]+)%s+\"(.*)\"%s*$") - if w then - what = w - else - -- string continuation - s = line:match("^%s*\"(.*)\"%s*$") - end - if what and s then - -- unescape \n or msgid won't match - s = s:gsub("\\n", "\n") - -- unescape " or msgid won't match - s = s:gsub('\\"', '"') - -- unescape \\ or msgid won't match - s = s:gsub("\\\\", "\\") - data[what] = (data[what] or "") .. s - end + return read_strings_count + 1, ffi.string(str_ptr + pos, str_len - pos), ctx end end end - po:close() - GetText.current_lang = new_lang + -- Read original strings. + if not seek_and_read(mo_hdr.original_strings_table_offset, table_ptr, table_size) then + C.close(fd) + return false + end + local original_context = {} + local original_strings = table.new(mo_hdr.nb_strings, 0) + for n, s, ctx in read_strings(true) do + if ctx then + original_context[n] = ctx + end + original_strings[n] = s + end + if read_strings_count ~= mo_hdr.nb_strings then + C.close(fd) + return false + end + -- Read translated strings. + if not seek_and_read(mo_hdr.translated_strings_table_offset, table_ptr, table_size) then + C.close(fd) + return false + end + for n, ts in read_strings() do + local ctx = original_context[n] + local os = original_strings[n] + if type(os) == "table" then + if type(ts) == "table" then + for pn, pts in ipairs(ts) do + addTranslation(ctx, os[1], pts, pn - 1) + end + else + addTranslation(ctx, os[1], ts, 0) + end + elseif type(ts) == "table" then + logger.warn(string.format("bad translation file: %s [singular / plurals mismatch]", file)) + else + if n == 1 and #os == 0 then + parse_headers(ts) + else + addTranslation(ctx, os, ts) + end + end + end + local ok = read_strings_count == mo_hdr.nb_strings + C.close(fd) + return ok end GetText_mt.__index.getPlural = getDefaultPlural @@ -412,7 +494,6 @@ elseif os.getenv("LANG") then end if isAndroid then - local ffi = require("ffi") local buf = ffi.new("char[?]", 16) android.lib.AConfiguration_getLanguage(android.app.config, buf) local lang = ffi.string(buf) diff --git a/make/gettext.mk b/make/gettext.mk new file mode 100644 index 000000000..d1b1949f8 --- /dev/null +++ b/make/gettext.mk @@ -0,0 +1,33 @@ +PHONY += mo mo-clean po pot + +SELF := $(lastword $(MAKEFILE_LIST)) + +DOMAIN = koreader +TEMPLATE_DIR = l10n/templates +MSGFMT_BIN = msgfmt +XGETTEXT_BIN = xgettext + +PO_FILES = $(wildcard l10n/*/*.po) +MO_FILES = $(PO_FILES:%.po=%.mo) + +%.mo: %.po + @$(MSGFMT_BIN) --no-hash -o $@ $< + +mo: + $(MAKE) $(if $(PARALLEL_JOBS),--jobs=$(PARALLEL_JOBS)) $(if $(PARALLEL_LOAD),--load-average=$(PARALLEL_LOAD)) --silent --file=$(SELF) $(MO_FILES) + +mo-clean: + rm -f $(MO_FILES) + +pot: po + mkdir -p $(TEMPLATE_DIR) + $(XGETTEXT_BIN) --from-code=utf-8 \ + --keyword=C_:1c,2 --keyword=N_:1,2 --keyword=NC_:1c,2,3 \ + --add-comments=@translators \ + reader.lua `find frontend -iname "*.lua" | sort` \ + `find plugins -iname "*.lua" | sort` \ + `find tools -iname "*.lua" | sort` \ + -o $(TEMPLATE_DIR)/$(DOMAIN).pot + +po: + git submodule update --remote l10n diff --git a/spec/unit/gettext_spec.lua b/spec/unit/gettext_spec.lua index eb3b0107d..29e802b41 100644 --- a/spec/unit/gettext_spec.lua +++ b/spec/unit/gettext_spec.lua @@ -106,75 +106,44 @@ msgstr "Fuzzy translated" describe("GetText module", function() local GetText - local test_po_ar - local test_po_nl, test_po_ru - local test_po_none, test_po_simple - local test_po_many setup(function() require("commonrequire") GetText = require("gettext") - GetText.dirname = "i18n-test" + GetText.dirname = (os.getenv("KO_HOME") or ".").."/i18n-test" local lfs = require("libs/libkoreader-lfs") + lfs.mkdir(GetText.dirname) - lfs.mkdir(GetText.dirname.."/nl_NL") - lfs.mkdir(GetText.dirname.."/none") - lfs.mkdir(GetText.dirname.."/ar") - lfs.mkdir(GetText.dirname.."/ru") - lfs.mkdir(GetText.dirname.."/simple") - lfs.mkdir(GetText.dirname.."/many") - test_po_nl = GetText.dirname.."/nl_NL/koreader.po" - local f = io.open(test_po_nl, "w") - f:write(test_po_part1, test_plurals_nl, test_po_part2) - f:close() + local pocreate = function(lang, ...) + local dir = GetText.dirname.."/"..lang + local po = dir.."/koreader.po" + local mo = dir.."/koreader.mo" + lfs.mkdir(dir) + local f = io.open(po, "w") + f:write(...) + f:close() + local ok = os.execute(string.format("msgfmt --no-hash -o %s %s", mo, po)) + assert(ok == 0) + end + + pocreate("nl_NL", test_po_part1, test_plurals_nl, test_po_part2) -- same file, just different plural for testing - test_po_none = GetText.dirname.."/none/koreader.po" - f = io.open(test_po_none, "w") - f:write(test_po_part1, test_plurals_none, test_po_part2) - f:close() + pocreate("none", test_po_part1, test_plurals_none, test_po_part2) -- same file, just different plural for testing - test_po_ar = GetText.dirname.."/ar/koreader.po" - f = io.open(test_po_ar, "w") - f:write(test_po_part1, test_plurals_ar, test_po_part2) - f:close() + pocreate("ar", test_po_part1, test_plurals_ar, test_po_part2) -- same file, just different plural for testing - test_po_ru = GetText.dirname.."/ru/koreader.po" - f = io.open(test_po_ru, "w") - f:write(test_po_part1, test_plurals_ru, test_po_part2) - f:close() + pocreate("ru", test_po_part1, test_plurals_ru, test_po_part2) -- same file, just different plural for testing - test_po_simple = GetText.dirname.."/simple/koreader.po" - f = io.open(test_po_simple, "w") - f:write(test_po_part1, test_plurals_simple, test_po_part2) - f:close() + pocreate("simple", test_po_part1, test_plurals_simple, test_po_part2) -- same file, just different plural for testing - test_po_many = GetText.dirname.."/many/koreader.po" - f = io.open(test_po_many, "w") - f:write(test_po_part1, test_plurals_many, test_po_part2) - f:close() - end) - - teardown(function() - os.remove(test_po_nl) - os.remove(test_po_none) - os.remove(test_po_ar) - os.remove(test_po_ru) - os.remove(test_po_simple) - os.remove(test_po_many) - os.remove(GetText.dirname.."/nl_NL") - os.remove(GetText.dirname.."/none") - os.remove(GetText.dirname.."/ar") - os.remove(GetText.dirname.."/ru") - os.remove(GetText.dirname.."/simple") - os.remove(GetText.dirname.."/many") - os.remove(GetText.dirname) + pocreate("many", test_po_part1, test_plurals_many, test_po_part2) end) describe("changeLang", function() From c456d18fa3a3abb284d42cb75ea86c9aa831e640 Mon Sep 17 00:00:00 2001 From: Benoit Pierre Date: Sat, 21 Jun 2025 17:08:07 +0200 Subject: [PATCH 8/8] make: tweak release excludes Make sure we don't include some gettext tests leftovers. --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index b6f908e2e..baef42281 100644 --- a/Makefile +++ b/Makefile @@ -105,6 +105,7 @@ endef define UPDATE_PATH_EXCLUDES += dummy-test-file* file.sdr* +i18n-test readerbookmark.* readerhighlight.* testdata