From e1b137339cdc4fc08b9175abc5b954b3a89807ac Mon Sep 17 00:00:00 2001 From: weijiuqiao <59040746+weijiuqiao@users.noreply.github.com> Date: Wed, 1 Jun 2022 04:11:35 +0800 Subject: [PATCH] [feat, plugin] Vocabulary builder (#9132) Made the old dictionary lookup history into a flashcard-ish vocabulary builder. --- .../apps/reader/modules/readerdictionary.lua | 17 +- .../ui/elements/filemanager_menu_order.lua | 1 + frontend/ui/elements/reader_menu_order.lua | 1 + frontend/ui/widget/dictquicklookup.lua | 4 +- plugins/vocabbuilder.koplugin/_meta.lua | 6 + plugins/vocabbuilder.koplugin/db.lua | 248 ++++ plugins/vocabbuilder.koplugin/main.lua | 1181 +++++++++++++++++ 7 files changed, 1451 insertions(+), 7 deletions(-) create mode 100644 plugins/vocabbuilder.koplugin/_meta.lua create mode 100644 plugins/vocabbuilder.koplugin/db.lua create mode 100644 plugins/vocabbuilder.koplugin/main.lua diff --git a/frontend/apps/reader/modules/readerdictionary.lua b/frontend/apps/reader/modules/readerdictionary.lua index 802c4b2c5..349612417 100644 --- a/frontend/apps/reader/modules/readerdictionary.lua +++ b/frontend/apps/reader/modules/readerdictionary.lua @@ -3,6 +3,7 @@ local ConfirmBox = require("ui/widget/confirmbox") local DataStorage = require("datastorage") local Device = require("device") local DictQuickLookup = require("ui/widget/dictquicklookup") +local Event = require("ui/event") local Geom = require("ui/geometry") local InfoMessage = require("ui/widget/infomessage") local InputContainer = require("ui/widget/container/inputcontainer") @@ -401,7 +402,7 @@ function ReaderDictionary:addToMainMenu(menu_items) end end -function ReaderDictionary:onLookupWord(word, is_sane, boxes, highlight, link) +function ReaderDictionary:onLookupWord(word, is_sane, boxes, highlight, link, tweak_buttons_func) logger.dbg("dict lookup word:", word, boxes) -- escape quotes and other funny characters in word word = self:cleanSelection(word, is_sane) @@ -411,7 +412,7 @@ function ReaderDictionary:onLookupWord(word, is_sane, boxes, highlight, link) -- Wrapped through Trapper, as we may be using Trapper:dismissablePopen() in it Trapper:wrap(function() - self:stardictLookup(word, self.enabled_dict_names, not self.disable_fuzzy_search, boxes, link) + self:stardictLookup(word, self.enabled_dict_names, not self.disable_fuzzy_search, boxes, link, tweak_buttons_func) end) return true end @@ -882,12 +883,11 @@ function ReaderDictionary:startSdcv(word, dict_names, fuzzy_search) return results end -function ReaderDictionary:stardictLookup(word, dict_names, fuzzy_search, boxes, link) +function ReaderDictionary:stardictLookup(word, dict_names, fuzzy_search, boxes, link, tweak_buttons_func) if word == "" then return end - if not self.disable_lookup_history then local book_title = self.ui.doc_settings and self.ui.doc_settings:readSetting("doc_props").title or _("Dictionary lookup") if book_title == "" then -- no or empty metadata title if self.ui.document and self.ui.document.file then @@ -895,6 +895,10 @@ function ReaderDictionary:stardictLookup(word, dict_names, fuzzy_search, boxes, book_title = util.splitFileNameSuffix(filename) end end + + -- Event for plugin to catch lookup with book title + self.ui:handleEvent(Event:new("WordLookedUp", word, book_title)) + if not self.disable_lookup_history then lookup_history:addTableItem("lookup_history", { book_title = book_title, time = os.time(), @@ -945,16 +949,17 @@ function ReaderDictionary:stardictLookup(word, dict_names, fuzzy_search, boxes, return end - self:showDict(word, tidyMarkup(results), boxes, link) + self:showDict(word, tidyMarkup(results), boxes, link, tweak_buttons_func) end -function ReaderDictionary:showDict(word, results, boxes, link) +function ReaderDictionary:showDict(word, results, boxes, link, tweak_buttons_func) if results and results[1] then logger.dbg("showing quick lookup window", #self.dict_window_list+1, ":", word, results) self.dict_window = DictQuickLookup:new{ window_list = self.dict_window_list, ui = self.ui, highlight = self.highlight, + tweak_buttons_func = tweak_buttons_func, dialog = self.dialog, -- original lookup word word = word, diff --git a/frontend/ui/elements/filemanager_menu_order.lua b/frontend/ui/elements/filemanager_menu_order.lua index 0b18444cb..805478c5a 100644 --- a/frontend/ui/elements/filemanager_menu_order.lua +++ b/frontend/ui/elements/filemanager_menu_order.lua @@ -139,6 +139,7 @@ local order = { search = { "dictionary_lookup", "dictionary_lookup_history", + "vocabulary_builder", "dictionary_settings", "----------------------------", "wikipedia_lookup", diff --git a/frontend/ui/elements/reader_menu_order.lua b/frontend/ui/elements/reader_menu_order.lua index 06e00eb77..3f7323a1a 100644 --- a/frontend/ui/elements/reader_menu_order.lua +++ b/frontend/ui/elements/reader_menu_order.lua @@ -187,6 +187,7 @@ local order = { search = { "dictionary_lookup", "dictionary_lookup_history", + "vocabulary_builder", "dictionary_settings", "----------------------------", "wikipedia_lookup", diff --git a/frontend/ui/widget/dictquicklookup.lua b/frontend/ui/widget/dictquicklookup.lua index de9288a03..8135dd15b 100644 --- a/frontend/ui/widget/dictquicklookup.lua +++ b/frontend/ui/widget/dictquicklookup.lua @@ -505,7 +505,9 @@ function DictQuickLookup:init() }) end end - + if self.tweak_buttons_func then + self.tweak_buttons_func(buttons) + end -- Bottom buttons get a bit less padding so their line separators -- reach out from the content to the borders a bit more local buttons_padding = Size.padding.default diff --git a/plugins/vocabbuilder.koplugin/_meta.lua b/plugins/vocabbuilder.koplugin/_meta.lua new file mode 100644 index 000000000..d75754252 --- /dev/null +++ b/plugins/vocabbuilder.koplugin/_meta.lua @@ -0,0 +1,6 @@ +local _ = require("gettext") +return { + name = "vocabulary_builder", + fullname = _("Vocabulary builder"), + description = _([[This plugin processes dictionary word lookups and uses spaced repetition to help you remember new words.]]), +} diff --git a/plugins/vocabbuilder.koplugin/db.lua b/plugins/vocabbuilder.koplugin/db.lua new file mode 100644 index 000000000..8e3f6b48f --- /dev/null +++ b/plugins/vocabbuilder.koplugin/db.lua @@ -0,0 +1,248 @@ +local DataStorage = require("datastorage") +local Device = require("device") +local SQ3 = require("lua-ljsqlite3/init") +local LuaData = require("luadata") + +local db_location = DataStorage:getSettingsDir() .. "/vocabulary_builder.sqlite3" + +local DB_SCHEMA_VERSION = 20220522 +local VOCABULARY_DB_SCHEMA = [[ + -- To store looked up words + CREATE TABLE IF NOT EXISTS "vocabulary" ( + "word" TEXT NOT NULL UNIQUE, + "book_title" TEXT DEFAULT '', + "create_time" INTEGER NOT NULL, + "review_time" INTEGER, + "due_time" INTEGER NOT NULL, + "review_count" INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY("word") + ); + CREATE INDEX IF NOT EXISTS due_time_index ON vocabulary(due_time); +]] + +local VocabularyBuilder = { + count = 0, +} + +function VocabularyBuilder:init() + VocabularyBuilder:createDB() +end + +function VocabularyBuilder:hasItems() + if self.count > 0 then + return true + end + self.count = self:selectCount() + return self.count > 0 +end + +function VocabularyBuilder:selectCount(conn) + if conn then + return tonumber(conn:rowexec("SELECT count(0) FROM vocabulary;")) + else + local db_conn = SQ3.open(db_location) + local count = tonumber(db_conn:rowexec("SELECT count(0) FROM vocabulary;")) + db_conn:close() + return count + end +end + +function VocabularyBuilder:createDB() + local db_conn = SQ3.open(db_location) + -- Make it WAL, if possible + if Device:canUseWAL() then + db_conn:exec("PRAGMA journal_mode=WAL;") + else + db_conn:exec("PRAGMA journal_mode=TRUNCATE;") + end + -- Create db + db_conn:exec(VOCABULARY_DB_SCHEMA) + -- Check version + local db_version = tonumber(db_conn:rowexec("PRAGMA user_version;")) + if db_version < DB_SCHEMA_VERSION then + if db_version == 0 then + self:insertLookupData(db_conn) + end + -- Update version + db_conn:exec(string.format("PRAGMA user_version=%d;", DB_SCHEMA_VERSION)) + + end + db_conn:close() +end + +function VocabularyBuilder:insertLookupData(db_conn) + local file_path = DataStorage:getSettingsDir() .. "/lookup_history.lua" + + local lookup_history = LuaData:open(file_path, { name = "LookupHistory" }) + if lookup_history:has("lookup_history") then + local lookup_history_table = lookup_history:readSetting("lookup_history") + local words = {} + + for i = #lookup_history_table, 1, -1 do + local value = lookup_history_table[i] + if not words[value.word] then + local insert_sql = [[INSERT OR REPLACE INTO vocabulary + (word, book_title, create_time, due_time) values + (?, ?, ?, ?); + ]] + local stmt = db_conn:prepare(insert_sql) + + stmt:bind(value.word, value.book_title or "", value.time, value.time + 5*60) + stmt:step() + stmt:clearbind():reset() + + words[value.word] = true + end + end + + end +end + +function VocabularyBuilder:_select_items(items, start_idx) + local conn = SQ3.open(db_location) + local sql = string.format("SELECT * FROM vocabulary ORDER BY due_time limit %d OFFSET %d;", 32, start_idx-1) + + local results = conn:exec(sql) + conn:close() + if not results then return end + + local current_time = os.time() + + for i = 1, #results.word do + local item = items[start_idx+i-1] + if item then + item.word = results.word[i] + item.review_count = math.max(0, math.min(8, tonumber(results.review_count[i]))) + item.book_title = results.book_title[i] or "" + item.create_time = tonumber( results.create_time[i]) + item.review_time = nil --use this field to flag change + item.due_time = tonumber(results.due_time[i]) + item.is_dim = tonumber(results.due_time[i]) > current_time + item.got_it_callback = function(item_input) + VocabularyBuilder:gotOrForgot(item_input, true) + end + item.forgot_callback = function(item_input) + VocabularyBuilder:gotOrForgot(item_input, false) + end + item.remove_callback = function(item_input) + VocabularyBuilder:remove(item_input) + end + end + end + + +end + +function VocabularyBuilder:select_items(items, start_idx, end_idx) + local start_cursor + if #items == 0 then + start_cursor = 0 + else + for i = start_idx+1, end_idx do + if not items[i].word then + start_cursor = i + break + end + end + end + + if not start_cursor then return end + self:_select_items(items, start_cursor) +end + + +function VocabularyBuilder:gotOrForgot(item, isGot) + local current_time = os.time() + + local due_time + local target_count = math.min(math.max(item.review_count + (isGot and 1 or -1), 0), 8) + if target_count == 0 then + due_time = current_time + 5 * 60 + elseif target_count == 1 then + due_time = current_time + 30 * 60 + elseif target_count == 2 then + due_time = current_time + 12 * 3600 + elseif target_count == 3 then + due_time = current_time + 24 * 3600 + elseif target_count == 4 then + due_time = current_time + 48 * 3600 + elseif target_count == 5 then + due_time = current_time + 96 * 3600 + elseif target_count == 6 then + due_time = current_time + 24 * 7 * 3600 + elseif target_count == 7 then + due_time = current_time + 24 * 15 * 3600 + else + due_time = current_time + 24 * 30 * 3600 + end + + item.review_count = target_count + item.review_time = current_time + item.due_time = due_time +end + +function VocabularyBuilder:batchUpdateItems(items) + local sql = [[UPDATE vocabulary + SET review_count = ?, + review_time = ?, + due_time = ? + WHERE word = ?;]] + + local conn = SQ3.open(db_location) + local stmt = conn:prepare(sql) + + for _, item in ipairs(items) do + if item.review_time then + stmt:bind(item.review_count, item.review_time, item.due_time, item.word) + stmt:step() + stmt:clearbind():reset() + end + end + conn:close() +end + +function VocabularyBuilder:insertOrUpdate(entry) + local conn = SQ3.open(db_location) + + conn:exec(string.format([[INSERT INTO vocabulary (word, book_title, create_time, due_time) + VALUES ('%s', '%s', %d, %d) + ON CONFLICT(word) DO UPDATE SET book_title = excluded.book_title, + create_time = excluded.create_time, + review_count = MAX(review_count-1, 0), + due_time = %d; + ]], entry.word, entry.book_title, entry.time, entry.time+300, entry.time+300)) + self.count = tonumber(conn:rowexec("SELECT count(0) from vocabulary;")) + conn:close() +end + +function VocabularyBuilder:remove(item) + local conn = SQ3.open(db_location) + conn:exec(string.format("DELETE FROM vocabulary WHERE word = '%s' ;", item.word)) + self.count = self.count - 1 + conn:close() +end + +function VocabularyBuilder:resetProgress() + local conn = SQ3.open(db_location) + local due_time = os.time() + conn:exec(string.format("UPDATE vocabulary SET review_count = 0, due_time = %d;", due_time)) + conn:close() +end + +function VocabularyBuilder:resetWordProgress(word) + local conn = SQ3.open(db_location) + local due_time = os.time() + conn:exec(string.format("UPDATE vocabulary SET review_count = 0, due_time = %d WHERE word = '%s';", due_time, word)) + conn:close() +end + +function VocabularyBuilder:purge() + local conn = SQ3.open(db_location) + conn:exec("DELETE FROM vocabulary;") + self.count = 0 + conn:close() +end + +VocabularyBuilder:init() + +return VocabularyBuilder diff --git a/plugins/vocabbuilder.koplugin/main.lua b/plugins/vocabbuilder.koplugin/main.lua new file mode 100644 index 000000000..efd281742 --- /dev/null +++ b/plugins/vocabbuilder.koplugin/main.lua @@ -0,0 +1,1181 @@ +--[[-- +This plugin processes dictionary word lookups and uses spaced repetition to help you remember new words. + +@module koplugin.vocabbuilder +--]]-- + +local BD = require("ui/bidi") +local Blitbuffer = require("ffi/blitbuffer") +local BottomContainer = require("ui/widget/container/bottomcontainer") +local DB = require("db") +local Button = require("ui/widget/button") +local ButtonTable = require("ui/widget/buttontable") +local CenterContainer = require("ui/widget/container/centercontainer") +local ConfirmBox = require("ui/widget/confirmbox") +local Device = require("device") +local Event = require("ui/event") +local Font = require("ui/font") +local FocusManager = require("ui/widget/focusmanager") +local FrameContainer = require("ui/widget/container/framecontainer") +local Geom = require("ui/geometry") +local GestureRange = require("ui/gesturerange") +local HorizontalGroup = require("ui/widget/horizontalgroup") +local HorizontalSpan = require("ui/widget/horizontalspan") +local InputContainer = require("ui/widget/container/inputcontainer") +local LeftContainer = require("ui/widget/container/leftcontainer") +local LineWidget = require("ui/widget/linewidget") +local MovableContainer = require("ui/widget/container/movablecontainer") +local RightContainer = require("ui/widget/container/rightcontainer") +local OverlapGroup = require("ui/widget/overlapgroup") +local Screen = Device.screen +local Size = require("ui/size") +local TextWidget = require("ui/widget/textwidget") +local TextBoxWidget = require("ui/widget/textboxwidget") +local TitleBar = require("ui/widget/titlebar") +local ToggleSwitch = require("ui/widget/toggleswitch") +local UIManager = require("ui/uimanager") +local VerticalGroup = require("ui/widget/verticalgroup") +local VerticalSpan = require("ui/widget/verticalspan") +local WidgetContainer = require("ui/widget/container/widgetcontainer") +local T = require("ffi/util").template +local _ = require("gettext") +local C_ = _.pgettext + +-------- shared values +local word_face = Font:getFace("x_smallinfofont") +local subtitle_face = Font:getFace("cfont", 12) +local subtitle_italic_face = Font:getFace("NotoSans-Italic.ttf", 12) +local subtitle_color = Blitbuffer.COLOR_DARK_GRAY +local dim_color = Blitbuffer.Color8(0x22) +local settings = G_reader_settings:readSetting("vocabulary_builder", {enabled = true}) + +--[[-- +Menu dialogue widget +--]]-- +local MenuDialog = FocusManager:new{ + padding = Size.padding.fullscreen, + tap_close_callback = nil, + clean_callback = nil, + reset_callback = nil, +} + +function MenuDialog:init() + self.layout = {} + if Device:hasKeys() then + self.key_events.Close = { { Device.input.group.Back }, doc = "close dialog" } + end + if Device:isTouchDevice() then + self.ges_events.TapClose = { + GestureRange:new { + ges = "tap", + range = Geom:new { + x = 0, + y = 0, + w = Screen:getWidth(), + h = Screen:getHeight(), + } + } + } + end + + local switch_ratio = 0.61 + local size = Screen:getSize() + local width = math.floor(size.w * 0.8) + local switch = ToggleSwitch:new{ + width = math.floor(width * switch_ratio), + default_value = 2, + name = "vocabulary_builder", + name_text = nil, --_("Accept new words"), + event = "ChangeEnableStatus", + args = {"off", "on"}, + default_arg = "on", + toggle = { _("off"), _("on") }, + values = {1, 2}, + alternate = false, + enabled = true, + config = self, + readonly = self.readonly, + } + switch:setPosition(settings.enabled and 2 or 1) + self:mergeLayoutInVertical(switch) + + local reset_button = { + text = _("Reset all progress"), + callback = function() + UIManager:show(ConfirmBox:new{ + text = _("Reset progress of all words?"), + ok_text = _("Reset"), + ok_callback = function() + DB:resetProgress() + self:onClose() + self.reset_callback() + end + }) + end + } + + local clean_button = { + text = _("Clean all words"), + callback = function() + UIManager:show(ConfirmBox:new{ + text = _("Clean all words including progress?"), + ok_text = _("Clean"), + ok_callback = function() + DB:purge() + self:onClose() + self.clean_callback() + end + }) + end, + } + + local buttons = ButtonTable:new{ + width = width, + buttons = { + {reset_button}, + {clean_button} + }, + show_parent = self + } + self:mergeLayoutInVertical(buttons) + + self.covers_fullscreen = true + local switch_guide_width = math.ceil(math.max(5, width * (1-switch_ratio) - Size.padding.fullscreen)) + self[1] = CenterContainer:new{ + dimen = size, + FrameContainer:new{ + padding = self.padding, + background = Blitbuffer.COLOR_WHITE, + bordersize = Size.border.window, + radius = Size.radius.window, + padding_bottom = Size.padding.button, + VerticalGroup:new{ + HorizontalGroup:new{ + RightContainer:new{ + dimen = Geom:new{w = switch_guide_width, h = switch:getSize().h }, + TextWidget:new{ + text = _("Accept new words"), + face = Font:getFace("xx_smallinfofont"), + max_width = switch_guide_width + } + }, + HorizontalSpan:new{width = Size.padding.fullscreen}, + switch, + }, + VerticalSpan:new{ width = Size.padding.large}, + LineWidget:new{ + background = Blitbuffer.COLOR_GRAY, + dimen = Geom:new{ + w = width, + h = Screen:scaleBySize(1), + } + }, + + buttons + } + } + } + +end + +function MenuDialog:onShow() + UIManager:setDirty(self, function() + return "flashui", self[1][1].dimen + end) +end + +function MenuDialog:onCloseWidget() + UIManager:setDirty(nil, function() + return "ui", self[1][1].dimen + end) +end + +function MenuDialog:onTapClose() + UIManager:close(self) + if self.tap_close_callback then + self.tap_close_callback() + end + return true +end + +function MenuDialog:onClose() + self:onTapClose() + return true +end + +function MenuDialog:onChangeEnableStatus(args, position) + settings.enabled = position == 2 + G_reader_settings:saveSetting("vocabulary_builder", settings) +end + +function MenuDialog:onConfigChoose(values, name, event, args, position) + UIManager:tickAfterNext(function() + if values then + self:onChangeEnableStatus(args, position) + end + UIManager:setDirty(nil, "ui", nil, true) + end) +end + + +--[[-- +Individual word info dialogue widget +--]]-- +local WordInfoDialog = InputContainer:new{ + title = nil, + book_title = nil, + dates = nil, + padding = Size.padding.large, + margin = Size.margin.title, + tap_close_callback = nil, + remove_callback = nil, + reset_callback = nil, + dismissable = true, -- set to false if any button callback is required +} + +function WordInfoDialog:init() + if self.dismissable then + if Device:hasKeys() then + self.key_events.Close = { { Device.input.group.Back }, doc = "close dialog" } + end + if Device:isTouchDevice() then + self.ges_events.TapClose = { + GestureRange:new { + ges = "tap", + range = Geom:new { + x = 0, + y = 0, + w = Screen:getWidth(), + h = Screen:getHeight(), + } + } + } + end + end + + local width = math.floor(math.min(Screen:getWidth(), Screen:getHeight()) * 0.61) + local reset_button = { + text = _("Reset progress"), + callback = function() + self.reset_callback() + UIManager:close(self) + end + } + local remove_button = { + text = _("Remove word"), + callback = function() + self.remove_callback() + UIManager:close(self) + end + } + + local focus_button = ButtonTable:new{ + width = width, + buttons = {{reset_button, remove_button}}, + show_parent = self + } + self[1] = CenterContainer:new{ + dimen = Screen:getSize(), + MovableContainer:new{ + FrameContainer:new{ + VerticalGroup:new{ + align = "center", + FrameContainer:new{ + padding =self.padding, + margin = self.margin, + bordersize = 0, + VerticalGroup:new { + align = "left", + TextWidget:new{ + text = self.title, + width = width, + face = word_face, + bold = true, + alignment = self.title_align or "left", + }, + TextBoxWidget:new{ + text = self.book_title, + width = width, + face = subtitle_italic_face, + fgcolor = subtitle_color, + alignment = self.title_align or "left", + }, + VerticalSpan:new{width= Size.padding.default}, + TextBoxWidget:new{ + text = self.dates, + width = width, + face = subtitle_face, + alignment = self.title_align or "left", + }, + } + + }, + LineWidget:new{ + background = Blitbuffer.COLOR_GRAY, + dimen = Geom:new{ + w = width + self.padding + self.margin, + h = Screen:scaleBySize(2), + } + }, + focus_button + }, + background = Blitbuffer.COLOR_WHITE, + bordersize = Size.border.window, + radius = Size.radius.window, + padding = Size.padding.button, + padding_bottom = 0, + } + } + } + +end + +function WordInfoDialog:setTitle(title) + self.title = title + self:free() + self:init() + UIManager:setDirty("all", "ui") +end + +function WordInfoDialog:onShow() + UIManager:setDirty(self, function() + return "flashui", self[1][1].dimen + end) +end + +function WordInfoDialog:onCloseWidget() + UIManager:setDirty(nil, function() + return "ui", self[1][1].dimen + end) +end + +function WordInfoDialog:onTapClose() + UIManager:close(self) + if self.tap_close_callback then + self.tap_close_callback() + end + return true +end + +function WordInfoDialog:onClose() + self:onTapClose() + return true +end + +function WordInfoDialog:paintTo(...) + InputContainer.paintTo(self, ...) + self.dimen = self[1][1].dimen -- FrameContainer +end + + + +-- values useful for item cells +local review_button_width = math.ceil(math.min(Screen:scaleBySize(95), Screen:getWidth()/6)) +local ellipsis_button_width = Screen:scaleBySize(34) +local star_width = Screen:scaleBySize(25) + +local point_widget = TextWidget:new{ + text = " • ", + bold = true, + face = Font:getFace("cfont", 24), + fgcolor = dim_color +} + +--[[-- +Individual word item widget +--]]-- +local VocabItemWidget = InputContainer:new{ + face = Font:getFace("smallinfofont"), + width = nil, + height = nil, + show_parent = nil, + item = nil, + forgot_button = nil, + got_it_button = nil, + more_button = nil, + layout = nil +} +--[[-- + item: { + checked_func: Block, + review_count: interger, + word: Text + book_title: TEXT + create_time: Integer + review_time: Integer + due_time: Integer, + got_it_callback: function + remove_callback: function + is_dim: BOOL + } +--]]-- + +local point_widget_height = point_widget:getSize().h +local point_widget_width = point_widget:getSize().w +local word_height = TextWidget:new{text = " ", face = word_face}:getSize().h +local subtitle_height = TextWidget:new{text = " ", face = subtitle_face}:getSize().h + + +function VocabItemWidget:init() + self.layout = {} + self.dimen = Geom:new{w = self.width, h = self.height} + self.ges_events.Tap = { + GestureRange:new{ + ges = "tap", + range = self.dimen, + } + } + self.ges_events.Hold = { + GestureRange:new{ + ges = "hold", + range = self.dimen, + } + } + self.v_spacer = VerticalSpan:new{width = math.floor((self.height - word_height - subtitle_height)/2)} + self.point_v_spacer = VerticalSpan:new{width = (self.v_spacer.width + word_height/2) - point_widget_height/2 } + self.margin_span = HorizontalSpan:new{ width = Size.padding.large } + self:initItemWidget() +end + +function VocabItemWidget:initItemWidget() + for i = 1, #self.layout do self.layout[i] = nil end + + local word_widget = Button:new{ + text = self.item.word, + bordersize = 0, + callback = function() self:onTap() end + } + if self.item.is_dim then + word_widget.label_widget.fgcolor = dim_color + end + + table.insert(self.layout, word_widget) + + if self.item.review_count < 5 then + self.more_button = Button:new{ + text = "⋮", + padding = Size.padding.button, + callback = function() self:showMore() end, + width = ellipsis_button_width, + bordersize = 0, + show_parent = self + } + else + self.more_button = Button:new{ + icon = "exit", + icon_width = star_width, + icon_height = star_width, + bordersize = 0, + radius = 0, + padding = (ellipsis_button_width - star_width)/2, + callback = function() + self:remover() + end, + } + end + + + local right_side_width + local right_widget + if self.item.due_time <= os.time() then + right_side_width = review_button_width * 2 + Size.padding.large * 2 + ellipsis_button_width + + self.forgot_button = Button:new{ + text = _("Forgot"), + width = review_button_width, + max_width = review_button_width, + radius = Size.radius.button, + callback = function() + self:onForgot() + end, + show_parent = self, + -- no_focus = true + } + + self.got_it_button = Button:new{ + text = _("Got it"), + radius = Size.radius.button, + callback = function() + self:onGotIt() + end, + width = review_button_width, + max_width = review_button_width, + show_parent = self, + -- no_focus = true + } + + right_widget = HorizontalGroup:new{ + dimen = Geom:new{ w = 0, h = self.height }, + self.margin_span, + self.forgot_button, + self.margin_span, + self.got_it_button, + self.more_button, + } + table.insert(self.layout, self.forgot_button) + table.insert(self.layout, self.got_it_button) + table.insert(self.layout, self.more_button) + else + local star = Button:new{ + icon = "check", + icon_width = star_width, + icon_height = star_width, + bordersize = 0, + radius = 0, + margin = 0, + show_parent = self, + enabled = false, + no_focus = true, + } + right_side_width = Size.padding.large * 3 + self.item.review_count * (star:getSize().w) + + if self.item.review_count > 0 then + right_widget = HorizontalGroup:new { + dimen = Geom:new{w=0, h = self.height} + } + for i=1, self.item.review_count, 1 do + table.insert(right_widget, star) + end + else + star:free() + right_widget = HorizontalGroup:new{ + dimen = Geom:new{w=0, h = self.height}, + HorizontalSpan:new {width = Size.padding.default } + } + end + table.insert(right_widget, self.margin_span) + table.insert(right_widget, self.more_button) + table.insert(self.layout, self.more_button) + end + + local text_max_width = self.width - point_widget_width - right_side_width + + local subtitle_prefix = TextWidget:new{ + text = BD.mirroredUILayout() and self:getTimeSinceDue() .. _("From ") or self:getTimeSinceDue() .. _("From ") , + face = subtitle_face, + fgcolor = subtitle_color + } + + self[1] = FrameContainer:new{ + padding = 0, + bordersize = 0, + HorizontalGroup:new{ + dimen = Geom:new{ + w = self.width, + h = self.height, + }, + HorizontalGroup:new{ + dimen = Geom:new{ + w = self.width - right_side_width, + h = self.height, + }, + VerticalGroup:new{ + dimen = Geom:new{w = point_widget_width, h = self.height}, + self.point_v_spacer, + point_widget, + VerticalSpan:new { width = self.height - point_widget_height - self.point_v_spacer.width} + }, + VerticalGroup:new{ + dimen = Geom:new{ + w = text_max_width, + h = self.height, + }, + self.v_spacer, + LeftContainer:new{ + dimen = Geom:new{w = text_max_width, h = word_height}, + word_widget + }, + LeftContainer:new{ + dimen = Geom:new{w = text_max_width, h = math.floor(self.height - word_height - self.v_spacer.width*2.2)}, + HorizontalGroup:new{ + subtitle_prefix, + TextWidget:new{ + text = self.item.book_title, + face = subtitle_italic_face, + max_width = math.ceil(math.max(5,text_max_width - subtitle_prefix:getSize().w - Size.padding.fullscreen)), + fgcolor = subtitle_color + } + } + }, + self.v_spacer + } + + }, + RightContainer:new{ + dimen = Geom:new{ w = right_side_width+Size.padding.default, h = self.height}, + right_widget + } + }, + } +end + +function VocabItemWidget:getTimeSinceDue() + if self.item.review_count >= 8 then return "" end + + local elapsed = os.time() - self.item.due_time + local abs = math.abs(elapsed) + local readable_time + + local rounding = elapsed > 0 and math.floor or math.ceil + if abs < 60 then + readable_time = abs .. C_("Time", "s") + elseif abs < 3600 then + readable_time = string.format("%d"..C_("Time", "m"), rounding(abs/60)) + elseif abs < 3600 * 24 then + readable_time = string.format("%d"..C_("Time", "h"), rounding(abs/3600)) + else + readable_time = string.format("%d"..C_("Time", "d"), rounding(abs/3600/24)) + end + + if elapsed < 0 then + return " " .. readable_time .. " | " --hourglass + else + return readable_time .. " | " + end +end + +function VocabItemWidget:remover() + self.item.remove_callback(self.item) + self.show_parent:removeAt(self.index) +end + +function VocabItemWidget:resetProgress() + self.item.review_count = 0 + self.item.due_time = os.time() + self.item.review_time = self.item.due_time + self:initItemWidget() + UIManager:setDirty(self.show_parent, function() + return "ui", self[1].dimen end) +end + +function VocabItemWidget:removeAndClose() + self:remover() + UIManager:close(self.dialogue) +end + +function VocabItemWidget:showMore() + local dialogue = WordInfoDialog:new{ + title = self.item.word, + book_title = self.item.book_title, + dates = _("Added on") .. " " .. os.date("%Y-%m-%d", self.item.create_time) .. " | " .. + _("Review scheduled at") .. " " .. os.date("%Y-%m-%d %H:%M", self.item.due_time), + remove_callback = function() + self:remover() + end, + reset_callback = function() + self:resetProgress() + end, + show_parent = self + } + + UIManager:show(dialogue) +end + +function VocabItemWidget:onTap(_, ges) + if self.item.callback then + self.item.callback(self.item) + end + + return true +end + +function VocabItemWidget:onHold() + if self.item.callback then + self.item.callback(self.item) + end + return true +end + +function VocabItemWidget:onGotIt() + self.item.got_it_callback(self.item) + self.item.is_dim = true + self:initItemWidget() + UIManager:setDirty(self.show_parent, function() + return "ui", self[1].dimen end) +end + +function VocabItemWidget:onForgot(no_lookup) + self.item.forgot_callback(self.item) + self.item.is_dim = false + self:initItemWidget() + UIManager:setDirty(self.show_parent, function() + return "ui", self[1].dimen end) + if not no_lookup and self.item.callback then + self.item.callback(self.item) + end +end + + + +--[[-- +Container widget. Same as sortwidget +--]]-- +local VocabularyBuilderWidget = FocusManager:new{ + title = "", + width = nil, + height = nil, + -- index for the first item to show + show_page = 1, + -- table of items + item_table = nil, -- mandatory (array) + callback = nil, +} + +function VocabularyBuilderWidget:init() + self.layout = {} + + self.dimen = Geom:new{ + w = self.width or Screen:getWidth(), + h = self.height or Screen:getHeight(), + } + if Device:hasKeys() then + self.key_events.Close = { { Device.input.group.Back }, doc = "close dialog" } + self.key_events.NextPage = { { Device.input.group.PgFwd}, doc = "next page"} + self.key_events.PrevPage = { { Device.input.group.PgBack}, doc = "prev page"} + end + if Device:isTouchDevice() then + self.ges_events.Swipe = { + GestureRange:new{ + ges = "swipe", + range = self.dimen, + } + } + end + local padding = Size.padding.large + self.width_widget = self.dimen.w - 2 * padding + self.item_width = self.dimen.w - 2 * padding + self.footer_center_width = math.floor(self.width_widget * 32 / 100) + self.footer_button_width = math.floor(self.width_widget * 12 / 100) + self.item_height = Screen:scaleBySize(72) + -- group for footer + local chevron_left = "chevron.left" + local chevron_right = "chevron.right" + local chevron_first = "chevron.first" + local chevron_last = "chevron.last" + if BD.mirroredUILayout() then + chevron_left, chevron_right = chevron_right, chevron_left + chevron_first, chevron_last = chevron_last, chevron_first + end + self.footer_left = Button:new{ + icon = chevron_left, + width = self.footer_button_width, + callback = function() self:prevPage() end, + bordersize = 0, + radius = 0, + show_parent = self, + } + self.footer_right = Button:new{ + icon = chevron_right, + width = self.footer_button_width, + callback = function() self:nextPage() end, + bordersize = 0, + radius = 0, + show_parent = self, + } + self.footer_first_up = Button:new{ + icon = chevron_first, + width = self.footer_button_width, + callback = function() + self:goToPage(1) + end, + bordersize = 0, + radius = 0, + show_parent = self, + } + self.footer_last_down = Button:new{ + icon = chevron_last, + width = self.footer_button_width, + callback = function() + self:goToPage(self.pages) + end, + bordersize = 0, + radius = 0, + show_parent = self, + } + + self.footer_page = Button:new{ + text = "", + hold_input = { + title = _("Enter page number"), + hint_func = function() + return "(" .. "1 - " .. self.pages .. ")" + end, + type = "number", + deny_blank_input = true, + callback = function(input) + local page = tonumber(input) + if page and page >= 1 and page <= self.pages then + self:goToPage(page) + end + end, + ok_text = _("Go to page"), + }, + call_hold_input_on_tap = true, + bordersize = 0, + margin = 0, + text_font_face = "pgfont", + text_font_bold = false, + width = self.footer_center_width, + show_parent = self, + } + self.page_info = HorizontalGroup:new{ + self.footer_first_up, + self.footer_left, + self.footer_page, + self.footer_right, + self.footer_last_down, + } + + local bottom_line = LineWidget:new{ + dimen = Geom:new{ w = self.item_width, h = Size.line.thick }, + background = Blitbuffer.COLOR_LIGHT_GRAY, + } + local vertical_footer = VerticalGroup:new{ + bottom_line, + self.page_info, + } + local footer = BottomContainer:new{ + dimen = self.dimen:copy(), + vertical_footer, + } + -- setup title bar + self.title_bar = TitleBar:new{ + width = self.dimen.w, + align = "center", + title_face = Font:getFace("smallinfofontbold"), + bottom_line_color = Blitbuffer.COLOR_LIGHT_GRAY, + with_bottom_line = true, + bottom_line_h_padding = padding, + left_icon = "appbar.menu", + left_icon_tap_callback = function() self:showMenu() end, + title = self.title, + close_callback = function() self:onClose() end, + show_parent = self, + } + + -- setup main content + self.item_margin = math.floor(self.item_height / 8) + local line_height = self.item_height + self.item_margin + local content_height = self.dimen.h - self.title_bar:getHeight() - vertical_footer:getSize().h - padding + self.items_per_page = math.floor(content_height / line_height) + self.item_margin = self.item_margin + math.floor((content_height - self.items_per_page * line_height ) / self.items_per_page ) + self.pages = math.ceil(DB:selectCount() / self.items_per_page) + self.main_content = VerticalGroup:new{} + + self:_populateItems() + + local frame_content = FrameContainer:new{ + height = self.dimen.h, + padding = 0, + bordersize = 0, + background = Blitbuffer.COLOR_WHITE, + VerticalGroup:new{ + self.title_bar, + self.main_content, + }, + } + local content = OverlapGroup:new{ + dimen = self.dimen:copy(), + frame_content, + footer, + } + -- assemble page + self[1] = FrameContainer:new{ + height = self.dimen.h, + padding = 0, + bordersize = 0, + background = Blitbuffer.COLOR_WHITE, + content + } +end + +function VocabularyBuilderWidget:nextPage() + local new_page = math.min(self.show_page+1, self.pages) + if new_page > self.show_page then + self.show_page = new_page + self:_populateItems() + end +end + +function VocabularyBuilderWidget:prevPage() + local new_page = math.max(self.show_page-1, 1) + if new_page < self.show_page then + self.show_page = new_page + self:_populateItems() + end +end + +function VocabularyBuilderWidget:goToPage(page) + self.show_page = page + self:_populateItems() +end + +function VocabularyBuilderWidget:moveItem(diff) + local move_to = diff + if move_to > 0 and move_to <= #self.item_table then + self.show_page = math.ceil(move_to / self.items_per_page) + self:_populateItems() + end +end + +function VocabularyBuilderWidget:removeAt(index) + if index > #self.item_table then return end + table.remove(self.item_table, index) + self.show_page = math.ceil(math.min(index, #self.item_table) / self.items_per_page) + self.pages = math.ceil(#self.item_table / self.items_per_page) + self:_populateItems() +end + +-- make sure self.item_margin and self.item_height are set before calling this +function VocabularyBuilderWidget:_populateItems() + self.main_content:clear() + self.layout = {{self.title_bar.left_button, self.title_bar.right_button}} -- title + local idx_offset = (self.show_page - 1) * self.items_per_page + local page_last + if idx_offset + self.items_per_page <= #self.item_table then + page_last = idx_offset + self.items_per_page + else + page_last = #self.item_table + end + + if self.select_items_callback then + self.select_items_callback(self.item_table ,idx_offset, page_last) + end + + for idx = idx_offset + 1, page_last do + table.insert(self.main_content, VerticalSpan:new{ width = self.item_margin / (idx == idx_offset+1 and 2 or 1) }) + if #self.item_table == 0 or not self.item_table[idx].word then break end + local item = VocabItemWidget:new{ + height = self.item_height, + width = self.item_width, + item = self.item_table[idx], + index = idx, + show_parent = self, + } + table.insert(self.layout, #self.layout, item.layout) + table.insert( + self.main_content, + item + ) + end + self.footer_page:setText(T(_("Page %1 of %2"), self.show_page, self.pages), self.footer_center_width) + if self.pages > 1 then + self.footer_page:enable() + else + self.footer_page:disableWithoutDimming() + end + if self.pages == 0 then + self.footer_page:setText(_("No items"), self.footer_center_width) + self.footer_first_up:hide() + self.footer_last_down:hide() + self.footer_left:hide() + self.footer_right:hide() + end + local chevron_first = "chevron.first" + local chevron_last = "chevron.last" + if BD.mirroredUILayout() then + chevron_first, chevron_last = chevron_last, chevron_first + end + + self.footer_first_up:setIcon(chevron_first, self.footer_button_width) + self.footer_last_down:setIcon(chevron_last, self.footer_button_width) + self.footer_left:enableDisable(self.show_page > 1) + self.footer_right:enableDisable(self.show_page < self.pages) + self.footer_first_up:enableDisable(self.show_page > 1) + self.footer_last_down:enableDisable(self.show_page < self.pages) + UIManager:setDirty(self, function() + return "ui", self.dimen + end) +end + +function VocabularyBuilderWidget:gotItFromDict(word) + for i = 1, #self.main_content, 1 do + if self.main_content[i].item and self.main_content[i].item.word == word then + self.main_content[i]:onGotIt() + return + end + end +end + +function VocabularyBuilderWidget:forgotFromDict(word) + for i = 1, #self.main_content, 1 do + if self.main_content[i].item and self.main_content[i].item.word == word then + self.main_content[i]:onForgot(true) + return + end + end +end + +function VocabularyBuilderWidget:resetItems() + for i, item in ipairs(self.item_table) do + if self.item_table[i].word then -- selected from DB + self.item_table[i] = { + callback = self.item_table[i].callback + } + end + end + self:_populateItems() +end + +function VocabularyBuilderWidget:showMenu() + UIManager:show(MenuDialog:new{ + clean_callback = function() + self.item_table = {} + self.pages = 0 + self:_populateItems() + end, + reset_callback = function() + self:resetItems() + end, + }) +end + +function VocabularyBuilderWidget:onShow() + UIManager:setDirty(self, "flashui") +end + +function VocabularyBuilderWidget:onNextPage() + self:nextPage() + return true +end + +function VocabularyBuilderWidget:onPrevPage() + self:prevPage() + return true +end + +function VocabularyBuilderWidget:onSwipe(arg, ges_ev) + local direction = BD.flipDirectionIfMirroredUILayout(ges_ev.direction) + if direction == "west" then + self:onNextPage() + elseif direction == "east" then + self:onPrevPage() + elseif direction == "south" then + -- Allow easier closing with swipe down + self:onClose() + elseif direction == "north" then + -- no use for now + do end -- luacheck: ignore 541 + else -- diagonal swipe + -- trigger full refresh + UIManager:setDirty(nil, "full") + -- a long diagonal swipe may also be used for taking a screenshot, + -- so let it propagate + return false + end +end + +function VocabularyBuilderWidget:onClose() + DB:batchUpdateItems(self.item_table) + UIManager:close(self) + -- UIManager:setDirty(self, "ui") + return true +end + +function VocabularyBuilderWidget:onCancel() + self:goToPage(self.show_page) + return true +end + +function VocabularyBuilderWidget:onReturn() + return self:onClose() +end + + +--[[-- +Item shown in main menu +--]]-- +local VocabBuilder = WidgetContainer:new{ + name = "vocabulary_builder", + is_doc_only = false +} + +function VocabBuilder:init() + self.ui.menu:registerToMainMenu(self) +end + +function VocabBuilder:addToMainMenu(menu_items) + menu_items.vocabulary_builder = { + text = _("Vocabulary builder"), + keep_menu_open = true, + callback = function() + local vocab_items = {} + for i = 1, DB:selectCount() do + table.insert(vocab_items, { + callback = function(item) + -- custom button table + local tweak_buttons_func + if item.due_time <= os.time() then + tweak_buttons_func = function(buttons) + local tweaked_button_count = 0 + local early_break + for j = 1, #buttons do + for k = 1, #buttons[j] do + if buttons[j][k].id == "highlight" and not buttons[j][k].enabled then + buttons[j][k] = { + id = "got_it", + text = _("Got it"), + callback = function() + self.builder_widget:gotItFromDict(item.word) + UIManager:sendEvent(Event:new("Close")) + end + } + if tweaked_button_count == 1 then + early_break = true + break + end + tweaked_button_count = tweaked_button_count + 1 + elseif buttons[j][k].id == "search" and not buttons[j][k].enabled then + buttons[j][k] = { + id = "forgot", + text = _("Forgot"), + callback = function() + self.builder_widget:forgotFromDict(item.word) + UIManager:sendEvent(Event:new("Close")) + end + } + if tweaked_button_count == 1 then + early_break = true + break + end + tweaked_button_count = tweaked_button_count + 1 + end + end + if early_break then break end + end + end + end + + self.builder_widget.current_lookup_word = item.word + self.ui:handleEvent(Event:new("LookupWord", item.word, true, nil, nil, nil, tweak_buttons_func)) + end + }) + end + + self.builder_widget = VocabularyBuilderWidget:new{ + title = _("Vocabulary builder"), + item_table = vocab_items, + select_items_callback = function(items, start_idx, end_idx) + DB:select_items(items, start_idx, end_idx) + end + } + + UIManager:show(self.builder_widget) + end + } +end + +-- Event sent by readerdictionary "WordLookedUp" +function VocabBuilder:onWordLookedUp(word, title) + if not settings.enabled then return end + if self.builder_widget and self.builder_widget.current_lookup_word == word then return true end + + DB:insertOrUpdate({ + book_title = title, + time = os.time(), + word = word + }) + return true +end + +return VocabBuilder