mirror of
https://github.com/koreader/koreader.git
synced 2025-12-24 12:14:05 +01:00
1689 lines
65 KiB
Lua
1689 lines
65 KiB
Lua
local BD = require("ui/bidi")
|
||
local ButtonDialog = require("ui/widget/buttondialog")
|
||
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")
|
||
local InputDialog = require("ui/widget/inputdialog")
|
||
local JSON = require("json")
|
||
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")
|
||
local Utf8Proc = require("ffi/utf8proc")
|
||
local ffi = require("ffi")
|
||
local C = ffi.C
|
||
local ffiUtil = require("ffi/util")
|
||
local lfs = require("libs/libkoreader-lfs")
|
||
local logger = require("logger")
|
||
local time = require("ui/time")
|
||
local util = require("util")
|
||
local _ = require("gettext")
|
||
local Input = Device.input
|
||
local T = ffiUtil.template
|
||
local android = Device:isAndroid() and require("android")
|
||
|
||
-- We'll store the list of available dictionaries as a module local
|
||
-- so we only have to look for them on the first :init()
|
||
local available_ifos = nil
|
||
local lookup_history = nil
|
||
|
||
local function getIfosInDir(path)
|
||
-- Get all the .ifo under directory path.
|
||
-- Don't walk into "res/" subdirectories, as per Stardict specs, they
|
||
-- may contain possibly many resource files (image, audio files...)
|
||
-- that could slow down our walk here.
|
||
local ifos = {}
|
||
local ok, iter, dir_obj = pcall(lfs.dir, path)
|
||
if ok then
|
||
for name in iter, dir_obj do
|
||
if name ~= "." and name ~= ".." and name ~= "res" then
|
||
local fullpath = path.."/"..name
|
||
local attributes = lfs.attributes(fullpath)
|
||
if attributes ~= nil then
|
||
if attributes.mode == "directory" then
|
||
local dirifos = getIfosInDir(fullpath) -- recurse
|
||
for _, ifo in pairs(dirifos) do
|
||
table.insert(ifos, ifo)
|
||
end
|
||
elseif fullpath:match("%.ifo$") then
|
||
table.insert(ifos, fullpath)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
return ifos
|
||
end
|
||
|
||
local ReaderDictionary = InputContainer:extend{
|
||
data_dir = nil,
|
||
lookup_msg = _("Searching dictionary for:\n%1"),
|
||
}
|
||
|
||
-- For a HTML dict, one can specify a specific stylesheet
|
||
-- in a file named as the .ifo with a .css extension
|
||
local function readDictionaryCss(path)
|
||
local f = io.open(path, "r")
|
||
if not f then
|
||
return nil
|
||
end
|
||
|
||
local content = f:read("*all")
|
||
f:close()
|
||
return content
|
||
end
|
||
|
||
-- For a HTML dict, one can specify a function called on
|
||
-- the raw returned definition to "fix" the HTML if needed
|
||
-- (as MuPDF, used for rendering, is quite sensitive to the
|
||
-- HTML quality) in a file named as the .ifo with a .lua
|
||
-- extension, containing for example:
|
||
-- return function(html)
|
||
-- html = html:gsub("<hr>", "<hr/>")
|
||
-- return html
|
||
-- end
|
||
local function getDictionaryFixHtmlFunc(path)
|
||
if lfs.attributes(path, "mode") == "file" then
|
||
local ok, func = pcall(dofile, path)
|
||
if ok and func then
|
||
return func
|
||
else
|
||
logger.warn("Dict's user provided file failed:", func)
|
||
end
|
||
end
|
||
end
|
||
|
||
function ReaderDictionary:init()
|
||
self:registerKeyEvents()
|
||
|
||
self.disable_lookup_history = G_reader_settings:isTrue("disable_lookup_history")
|
||
self.dicts_order = G_reader_settings:readSetting("dicts_order", {})
|
||
self.dicts_disabled = G_reader_settings:readSetting("dicts_disabled", {})
|
||
self.disable_fuzzy_search_fm = G_reader_settings:isTrue("disable_fuzzy_search")
|
||
|
||
if self.ui then
|
||
self.ui.menu:registerToMainMenu(self)
|
||
end
|
||
self.data_dir = G_defaults:readSetting("STARDICT_DATA_DIR") or
|
||
os.getenv("STARDICT_DATA_DIR") or
|
||
DataStorage:getDataDir() .. "/data/dict"
|
||
|
||
-- Show the "Searching..." InfoMessage after this delay
|
||
self.lookup_msg_delay = 0.5
|
||
-- Allow quick interruption or dismiss of search result window
|
||
-- with tap if done before this delay. After this delay, the
|
||
-- result window is shown and dismiss prevented for a few 100ms
|
||
self.quick_dismiss_before_delay = time.s(3)
|
||
|
||
-- Gather info about available dictionaries
|
||
if not available_ifos then
|
||
available_ifos = {}
|
||
logger.dbg("Getting list of dictionaries")
|
||
local ifo_files = getIfosInDir(self.data_dir)
|
||
local dict_ext = self.data_dir.."_ext"
|
||
if lfs.attributes(dict_ext, "mode") == "directory" then
|
||
local extifos = getIfosInDir(dict_ext)
|
||
for _, ifo in pairs(extifos) do
|
||
table.insert(ifo_files, ifo)
|
||
end
|
||
end
|
||
for _, ifo_file in pairs(ifo_files) do
|
||
local f = io.open(ifo_file, "r")
|
||
if f then
|
||
local content = f:read("*all")
|
||
f:close()
|
||
local dictname = content:match("\nbookname=(.-)\r?\n")
|
||
local is_html = content:find("sametypesequence=h", 1, true) ~= nil
|
||
local lang_in, lang_out = content:match("lang=(%a+)-?(%a*)\r?\n?")
|
||
-- sdcv won't use dict that don't have a bookname=
|
||
if dictname then
|
||
table.insert(available_ifos, {
|
||
file = ifo_file,
|
||
name = dictname,
|
||
is_html = is_html,
|
||
css = readDictionaryCss(ifo_file:gsub("%.ifo$", ".css")),
|
||
fix_html_func = getDictionaryFixHtmlFunc(ifo_file:gsub("%.ifo$", ".lua")),
|
||
lang = lang_in and { lang_in = lang_in, lang_out = lang_out },
|
||
})
|
||
end
|
||
end
|
||
end
|
||
logger.dbg("found", #available_ifos, "dictionaries")
|
||
self:sortAvailableIfos()
|
||
end
|
||
-- Prepare the -u options to give to sdcv the dictionary order and if some are disabled
|
||
self:updateSdcvDictNamesOptions()
|
||
|
||
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()
|
||
if Device:hasKeyboard() then
|
||
self.key_events.ShowDictionaryLookup = { { "Alt", "D" }, { "Ctrl", "D" } }
|
||
end
|
||
end
|
||
|
||
function ReaderDictionary:sortAvailableIfos()
|
||
table.sort(available_ifos, function(lifo, rifo)
|
||
local lord = self.dicts_order[lifo.file]
|
||
local rord = self.dicts_order[rifo.file]
|
||
|
||
-- Both ifos without an explicit position -> lexical comparison
|
||
if lord == rord then
|
||
return ffiUtil.strcoll(lifo.name, rifo.name)
|
||
end
|
||
|
||
-- Ifos without an explicit position come last.
|
||
return lord ~= nil and (rord == nil or lord < rord)
|
||
end)
|
||
end
|
||
|
||
|
||
function ReaderDictionary:updateSdcvDictNamesOptions()
|
||
-- We cannot tell sdcv which dictionaries to ignore, but we
|
||
-- can tell it which dictionaries to use, by using multiple
|
||
-- -u <dictname> options.
|
||
-- The order of the -u options controls the dictionary order
|
||
-- that sdcv uses to order its results.
|
||
|
||
self.enabled_dict_names = {}
|
||
|
||
-- First, insert any preferred dicts, even if globally disabled
|
||
-- (this might allow enabling a dict only for a specific book,
|
||
-- while keeping it disabled for all others)
|
||
local preferred_names_already_in = {}
|
||
if self.preferred_dictionaries then
|
||
for _, name in ipairs(self.preferred_dictionaries) do
|
||
table.insert(self.enabled_dict_names, name)
|
||
preferred_names_already_in[name] = true
|
||
end
|
||
end
|
||
|
||
local dicts_disabled = G_reader_settings:readSetting("dicts_disabled")
|
||
for _, ifo in pairs(available_ifos) do
|
||
if not dicts_disabled[ifo.file] and not preferred_names_already_in[ifo.name] then
|
||
table.insert(self.enabled_dict_names, ifo.name)
|
||
end
|
||
end
|
||
end
|
||
|
||
function ReaderDictionary:addToMainMenu(menu_items)
|
||
local is_docless = self.ui == nil or self.ui.document == nil
|
||
menu_items.search_settings = { -- submenu with Dict, Wiki, Translation settings
|
||
text = _("Settings"),
|
||
}
|
||
menu_items.dictionary_lookup = {
|
||
text = _("Dictionary lookup"),
|
||
callback = function()
|
||
self:onShowDictionaryLookup()
|
||
end,
|
||
}
|
||
menu_items.dictionary_lookup_history = {
|
||
text = _("Dictionary lookup history"),
|
||
enabled_func = function()
|
||
return lookup_history:has("lookup_history")
|
||
end,
|
||
callback = function()
|
||
local lookup_history_table = lookup_history:readSetting("lookup_history")
|
||
local kv_pairs = {}
|
||
local previous_title
|
||
for i = #lookup_history_table, 1, -1 do
|
||
local value = lookup_history_table[i]
|
||
if value.book_title ~= previous_title then
|
||
table.insert(kv_pairs, { value.book_title..":", "" })
|
||
end
|
||
previous_title = value.book_title
|
||
table.insert(kv_pairs, {
|
||
os.date("%Y-%m-%d %H:%M:%S", value.time),
|
||
value.word,
|
||
callback = function()
|
||
-- Word had been cleaned before being added to history
|
||
self:onLookupWord(value.word, true)
|
||
end
|
||
})
|
||
end
|
||
UIManager:show(KeyValuePage:new{
|
||
title = _("Dictionary lookup history"),
|
||
value_overflow_align = "right",
|
||
kv_pairs = kv_pairs,
|
||
})
|
||
end,
|
||
}
|
||
menu_items.dictionary_settings = {
|
||
text = _("Dictionary settings"),
|
||
sub_item_table = {
|
||
{
|
||
keep_menu_open = true,
|
||
text_func = function()
|
||
local nb_available, nb_enabled, nb_disabled = self:getNumberOfDictionaries()
|
||
local nb_str = nb_available
|
||
if nb_disabled > 0 then
|
||
nb_str = nb_enabled .. "/" .. nb_available
|
||
end
|
||
return T(_("Manage dictionaries: %1"), nb_str)
|
||
end,
|
||
enabled_func = function()
|
||
return self:getNumberOfDictionaries() > 0
|
||
end,
|
||
callback = function(touchmenu_instance)
|
||
self:showDictionariesMenu(function()
|
||
if touchmenu_instance then touchmenu_instance:updateItems() end
|
||
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()
|
||
local text = _("Enable fuzzy search")
|
||
if G_reader_settings:nilOrFalse("disable_fuzzy_search") then
|
||
text = text .. " ★"
|
||
end
|
||
return text
|
||
end,
|
||
checked_func = function()
|
||
if self.ui.doc_settings then
|
||
return not self.disable_fuzzy_search
|
||
end
|
||
return not self.disable_fuzzy_search_fm
|
||
end,
|
||
callback = function()
|
||
if self.ui.doc_settings then
|
||
self.disable_fuzzy_search = not self.disable_fuzzy_search
|
||
self.ui.doc_settings:saveSetting("disable_fuzzy_search", self.disable_fuzzy_search)
|
||
else
|
||
self.disable_fuzzy_search_fm = not self.disable_fuzzy_search_fm
|
||
end
|
||
end,
|
||
hold_callback = function(touchmenu_instance)
|
||
self:toggleFuzzyDefault(touchmenu_instance)
|
||
end,
|
||
separator = true,
|
||
},
|
||
{
|
||
text = _("Dictionary lookup history"),
|
||
checked_func = function()
|
||
return not self.disable_lookup_history
|
||
end,
|
||
sub_item_table = {
|
||
{
|
||
text = _("Enable 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()
|
||
end,
|
||
})
|
||
end,
|
||
},
|
||
},
|
||
separator = true,
|
||
},
|
||
{ -- setting used by dictquicklookup
|
||
text = _("Large window"),
|
||
checked_func = function()
|
||
return G_reader_settings:isTrue("dict_largewindow")
|
||
end,
|
||
callback = function()
|
||
G_reader_settings:flipNilOrFalse("dict_largewindow")
|
||
end,
|
||
},
|
||
{ -- setting used by dictquicklookup
|
||
text = _("Justify text"),
|
||
checked_func = function()
|
||
return G_reader_settings:nilOrTrue("dict_justify")
|
||
end,
|
||
callback = function()
|
||
G_reader_settings:flipNilOrTrue("dict_justify")
|
||
end,
|
||
},
|
||
{ -- setting used by dictquicklookup
|
||
text_func = function()
|
||
local font_size = G_reader_settings:readSetting("dict_font_size") or 20
|
||
return T(_("Font size: %1"), font_size)
|
||
end,
|
||
callback = function(touchmenu_instance)
|
||
local SpinWidget = require("ui/widget/spinwidget")
|
||
local font_size = G_reader_settings:readSetting("dict_font_size") or 20
|
||
local items_font = SpinWidget:new{
|
||
value = font_size,
|
||
value_min = 8,
|
||
value_max = 32,
|
||
default_value = 20,
|
||
title_text = _("Dictionary font size"),
|
||
callback = function(spin)
|
||
G_reader_settings:saveSetting("dict_font_size", spin.value)
|
||
if touchmenu_instance then touchmenu_instance:updateItems() end
|
||
end,
|
||
}
|
||
UIManager:show(items_font)
|
||
end,
|
||
keep_menu_open = true,
|
||
}
|
||
}
|
||
}
|
||
if not is_docless then
|
||
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."),
|
||
enabled_func = function()
|
||
-- we allow to use preferred dictionaries even if no dictionaries are enabled globally (see self:updateSdcvDictNamesOptions)
|
||
return #self.enabled_dict_names > 1 or #self.preferred_dictionaries > 0
|
||
end,
|
||
callback = function(touchmenu_instance)
|
||
self:showPreferredDictsDialog(touchmenu_instance)
|
||
end,
|
||
})
|
||
end
|
||
if Device:canExternalDictLookup() then
|
||
local function genExternalDictItems()
|
||
local items_table = {}
|
||
for i, v in ipairs(Device:getExternalDictLookupList()) do
|
||
local setting = v[1]
|
||
local dict_name = v[2]
|
||
local is_enabled = v[3]
|
||
table.insert(items_table, {
|
||
text = dict_name,
|
||
checked_func = function()
|
||
return setting == G_reader_settings:readSetting("external_dict_lookup_method")
|
||
end,
|
||
enabled_func = function()
|
||
return is_enabled == true
|
||
end,
|
||
callback = function()
|
||
G_reader_settings:saveSetting("external_dict_lookup_method", v[1])
|
||
end,
|
||
})
|
||
end
|
||
return items_table
|
||
end
|
||
table.insert(menu_items.dictionary_settings.sub_item_table, 1, {
|
||
text = _("Use external dictionary"),
|
||
checked_func = function()
|
||
return G_reader_settings:isTrue("external_dict_lookup")
|
||
end,
|
||
callback = function()
|
||
G_reader_settings:flipNilOrFalse("external_dict_lookup")
|
||
end,
|
||
})
|
||
table.insert(menu_items.dictionary_settings.sub_item_table, 2, {
|
||
text_func = function()
|
||
local display_name = _("none")
|
||
local ext_id = G_reader_settings:readSetting("external_dict_lookup_method")
|
||
for i, v in ipairs(Device:getExternalDictLookupList()) do
|
||
if v[1] == ext_id then
|
||
display_name = v[2]
|
||
break
|
||
end
|
||
end
|
||
return T(_("Dictionary: %1"), display_name)
|
||
end,
|
||
enabled_func = function()
|
||
return G_reader_settings:isTrue("external_dict_lookup")
|
||
end,
|
||
sub_item_table = genExternalDictItems(),
|
||
separator = true,
|
||
})
|
||
end
|
||
end
|
||
|
||
function ReaderDictionary:showPreferredDictsDialog(touchmenu_instance)
|
||
local dialog
|
||
local buttons = {}
|
||
local disabled_buttons = {} -- store disabled dict buttons separately
|
||
local update_sdcv = true
|
||
|
||
local function saveAndRefresh()
|
||
self:onSaveSettings()
|
||
if update_sdcv then self:updateSdcvDictNamesOptions() end
|
||
UIManager:close(dialog)
|
||
if #self.enabled_dict_names == 0 then
|
||
-- This is an edge case where we have a preferred dictionary but no globally enabled ones.
|
||
-- If we un-prefer said dict, we would end up with an empty dialog, so close up shop and go home.
|
||
touchmenu_instance:updateItems()
|
||
return
|
||
end
|
||
self:showPreferredDictsDialog(touchmenu_instance)
|
||
end
|
||
|
||
local function makeButtonEntry(dict, is_enabled)
|
||
local is_preferred = false
|
||
local pref_num = 0
|
||
for i, pref_dict in ipairs(self.preferred_dictionaries) do
|
||
if pref_dict == dict then
|
||
is_preferred = true
|
||
pref_num = i
|
||
break
|
||
end
|
||
end
|
||
|
||
local button_text = dict
|
||
if is_preferred and is_enabled then
|
||
-- Add circled number (U+2460...2473) at start
|
||
local symbol = util.unicodeCodepointToUtf8(0x245F + (pref_num < 20 and pref_num or 20))
|
||
button_text = symbol .. " " .. button_text
|
||
elseif not is_enabled then
|
||
-- Add circled x (U+2297) at start for disabled dictionaries
|
||
button_text = "⊗ " .. button_text
|
||
end
|
||
|
||
return {
|
||
{
|
||
align = "left",
|
||
text = button_text,
|
||
callback = function()
|
||
if not is_enabled then return end -- No toggle for disabled dicts
|
||
if is_preferred then
|
||
for i, pref_dict in ipairs(self.preferred_dictionaries) do
|
||
if pref_dict == dict then
|
||
table.remove(self.preferred_dictionaries, i)
|
||
break
|
||
end
|
||
end
|
||
else
|
||
table.insert(self.preferred_dictionaries, dict)
|
||
end
|
||
saveAndRefresh()
|
||
end,
|
||
hold_callback = function()
|
||
if not is_enabled then -- re-enable dictionary
|
||
self.doc_disabled_dicts[dict] = nil
|
||
else -- disable dictionary for this book
|
||
self.doc_disabled_dicts[dict] = true
|
||
end
|
||
update_sdcv = false
|
||
saveAndRefresh()
|
||
end,
|
||
}
|
||
}
|
||
end
|
||
|
||
-- Process enabled dictionaries first.
|
||
for _, dict in ipairs(self.enabled_dict_names) do
|
||
if not self.doc_disabled_dicts[dict] then
|
||
table.insert(buttons, makeButtonEntry(dict, true))
|
||
else
|
||
table.insert(disabled_buttons, makeButtonEntry(dict, false))
|
||
end
|
||
end
|
||
|
||
-- Append disabled dictionaries at the bottom of the list.
|
||
for _, btn in ipairs(disabled_buttons) do
|
||
table.insert(buttons, btn)
|
||
end
|
||
|
||
table.insert(buttons, {
|
||
{
|
||
text = _("Reset"),
|
||
callback = function()
|
||
self.doc_disabled_dicts = {}
|
||
self.preferred_dictionaries = {}
|
||
saveAndRefresh()
|
||
end,
|
||
}
|
||
})
|
||
|
||
dialog = ButtonDialog:new{
|
||
title = _("Select preferred dictionaries"),
|
||
title_align = "center",
|
||
shrink_unneeded_width = true,
|
||
buttons = buttons,
|
||
}
|
||
UIManager:show(dialog)
|
||
end
|
||
|
||
function ReaderDictionary:onLookupWord(word, is_sane, boxes, highlight, link, dict_close_callback)
|
||
logger.dbg("dict lookup word:", word, boxes)
|
||
-- escape quotes and other funny characters in word
|
||
word = self:cleanSelection(word, is_sane)
|
||
logger.dbg("dict stripped word:", word)
|
||
|
||
self.highlight = highlight
|
||
local disable_fuzzy_search
|
||
if self.ui.doc_settings then
|
||
disable_fuzzy_search = self.disable_fuzzy_search
|
||
else
|
||
disable_fuzzy_search = self.disable_fuzzy_search_fm
|
||
end
|
||
|
||
-- Wrapped through Trapper, as we may be using Trapper:dismissablePopen() in it
|
||
Trapper:wrap(function()
|
||
self:stardictLookup(word, self.enabled_dict_names, not disable_fuzzy_search, boxes, link, dict_close_callback)
|
||
end)
|
||
return true
|
||
end
|
||
|
||
function ReaderDictionary:onHtmlDictionaryLinkTapped(dictionary, link)
|
||
if not link.uri then
|
||
return
|
||
end
|
||
|
||
-- The protocol is either "bword" or there is no protocol, only the word.
|
||
-- https://github.com/koreader/koreader/issues/3588#issuecomment-357088125
|
||
local url_prefix = "bword://"
|
||
local word
|
||
if link.uri:sub(1,url_prefix:len()) == url_prefix then
|
||
word = link.uri:sub(url_prefix:len() + 1)
|
||
elseif link.uri:find("://") then
|
||
return
|
||
else
|
||
word = link.uri
|
||
end
|
||
|
||
if word == "" then
|
||
return
|
||
end
|
||
|
||
local link_box = Geom:new{
|
||
x = link.x0,
|
||
y = link.y0,
|
||
w = math.abs(link.x1 - link.x0),
|
||
h = math.abs(link.y1 - link.y0),
|
||
}
|
||
|
||
-- Only the first dictionary window stores the highlight, this way the highlight
|
||
-- is only removed when there are no more dictionary windows open.
|
||
self.highlight = nil
|
||
|
||
-- Wrapped through Trapper, as we may be using Trapper:dismissablePopen() in it
|
||
Trapper:wrap(function()
|
||
self:stardictLookup(word, {dictionary}, false, {link_box}, nil)
|
||
end)
|
||
end
|
||
|
||
--- Gets number of available, enabled, and disabled dictionaries
|
||
-- @treturn int nb_available
|
||
-- @treturn int nb_enabled
|
||
-- @treturn int nb_disabled
|
||
function ReaderDictionary:getNumberOfDictionaries()
|
||
local nb_available = #available_ifos
|
||
local nb_enabled = 0
|
||
local nb_disabled = 0
|
||
for _, ifo in pairs(available_ifos) do
|
||
if self.dicts_disabled[ifo.file] then
|
||
nb_disabled = nb_disabled + 1
|
||
else
|
||
nb_enabled = nb_enabled + 1
|
||
end
|
||
end
|
||
return nb_available, nb_enabled, nb_disabled
|
||
end
|
||
|
||
function ReaderDictionary:_genDownloadDictionariesMenu()
|
||
local downloadable_dicts = require("ui/data/dictionaries")
|
||
local IsoLanguage = require("ui/data/isolanguage")
|
||
local languages = {}
|
||
|
||
for i = 1, #downloadable_dicts do
|
||
local dict = downloadable_dicts[i]
|
||
if not dict.ifo_lang then
|
||
-- this only needs to happen the first time this function is called
|
||
local ifo_in = IsoLanguage:getBCPLanguageTag(dict.lang_in)
|
||
local ifo_out = IsoLanguage:getBCPLanguageTag(dict.lang_out)
|
||
dict.ifo_lang = ("%s-%s"):format(ifo_in, ifo_out)
|
||
dict.lang_in = IsoLanguage:getLocalizedLanguage(dict.lang_in)
|
||
dict.lang_out = IsoLanguage:getLocalizedLanguage(dict.lang_out)
|
||
end
|
||
local dict_lang_in = dict.lang_in
|
||
local dict_lang_out = dict.lang_out
|
||
if not languages[dict_lang_in] then
|
||
languages[dict_lang_in] = {}
|
||
end
|
||
table.insert(languages[dict_lang_in], dict)
|
||
if not languages[dict_lang_out] then
|
||
languages[dict_lang_out] = {}
|
||
end
|
||
table.insert(languages[dict_lang_out], dict)
|
||
end
|
||
|
||
-- remove duplicates
|
||
for lang_key,lang in pairs(languages) do
|
||
local hash = {}
|
||
local res = {}
|
||
for k,v in ipairs(lang) do
|
||
if not hash[v.name] then
|
||
res[#res+1] = v
|
||
hash[v.name] = true
|
||
end
|
||
end
|
||
languages[lang_key] = res
|
||
end
|
||
|
||
local menu_items = {}
|
||
for lang_key, available_langs in ffiUtil.orderedPairs(languages) do
|
||
table.insert(menu_items, {
|
||
keep_menu_open = true,
|
||
text = lang_key,
|
||
callback = function()
|
||
self:showDownload(available_langs)
|
||
end
|
||
})
|
||
end
|
||
|
||
return menu_items
|
||
end
|
||
|
||
function ReaderDictionary:showDictionariesMenu(changed_callback)
|
||
-- Work on local copy, save to settings only when SortWidget is closed with the accept button
|
||
local dicts_disabled = util.tableDeepCopy(self.dicts_disabled)
|
||
|
||
local sort_items = {}
|
||
for _, ifo in pairs(available_ifos) do
|
||
table.insert(sort_items, {
|
||
text = ifo.name,
|
||
callback = function()
|
||
if dicts_disabled[ifo.file] then
|
||
dicts_disabled[ifo.file] = nil
|
||
else
|
||
dicts_disabled[ifo.file] = true
|
||
end
|
||
end,
|
||
checked_func = function()
|
||
return not dicts_disabled[ifo.file]
|
||
end,
|
||
ifo = ifo,
|
||
})
|
||
end
|
||
|
||
local sort_widget = SortWidget:new{
|
||
title = _("Manage installed dictionaries"),
|
||
item_table = sort_items,
|
||
callback = function()
|
||
-- Update both references to point to that new object
|
||
self.dicts_disabled = dicts_disabled
|
||
G_reader_settings:saveSetting("dicts_disabled", self.dicts_disabled)
|
||
|
||
-- Write back the sorted items array to dicts_order
|
||
local dicts_order = {}
|
||
for i, sort_item in ipairs(sort_items) do
|
||
dicts_order[sort_item.ifo.file] = i
|
||
end
|
||
self.dicts_order = dicts_order
|
||
G_reader_settings:saveSetting("dicts_order", self.dicts_order)
|
||
|
||
self:sortAvailableIfos()
|
||
|
||
self:updateSdcvDictNamesOptions()
|
||
|
||
UIManager:setDirty(nil, "ui")
|
||
changed_callback()
|
||
end
|
||
}
|
||
UIManager:show(sort_widget)
|
||
end
|
||
|
||
local function dictDirsEmpty(dict_dirs)
|
||
for _, dict_dir in ipairs(dict_dirs) do
|
||
if not util.isEmptyDir(dict_dir) then
|
||
return false
|
||
end
|
||
end
|
||
return true
|
||
end
|
||
|
||
local function getAvailableIfoByName(dictionary_name)
|
||
for _, ifo in ipairs(available_ifos) do
|
||
if ifo.name == dictionary_name then
|
||
return ifo
|
||
end
|
||
end
|
||
|
||
return nil
|
||
end
|
||
|
||
local function tidyMarkup(results)
|
||
local cdata_tag = "<!%[CDATA%[(.-)%]%]>"
|
||
local format_escape = "&[29Ib%+]{(.-)}"
|
||
for _, result in ipairs(results) do
|
||
local ifo = getAvailableIfoByName(result.dict)
|
||
if ifo and ifo.lang then
|
||
result.ifo_lang = ifo.lang
|
||
end
|
||
if ifo and ifo.is_html then
|
||
local dict_path = util.splitFilePathName(ifo.file)
|
||
result.is_html = ifo.is_html
|
||
result.css = ifo.css
|
||
if ifo.fix_html_func then
|
||
local ok, fixed_definition = pcall(ifo.fix_html_func, result.definition, dict_path)
|
||
if ok then
|
||
result.definition = fixed_definition
|
||
else
|
||
logger.warn("Dict's user provided function failed:", fixed_definition)
|
||
end
|
||
end
|
||
|
||
local res_dir = dict_path .. "res"
|
||
if lfs.attributes(res_dir, "mode") == "directory" then
|
||
result.dictionary_resource_directory = res_dir
|
||
end
|
||
else
|
||
local def = result.definition
|
||
-- preserve the <br> tag for line break
|
||
def = def:gsub("<[bB][rR] ?/?>", "\n")
|
||
-- parse CDATA text in XML
|
||
if def:find(cdata_tag) then
|
||
def = def:gsub(cdata_tag, "%1")
|
||
-- ignore format strings
|
||
while def:find(format_escape) do
|
||
def = def:gsub(format_escape, "%1")
|
||
end
|
||
end
|
||
-- convert any htmlentities (>, "...)
|
||
def = util.htmlEntitiesToUtf8(def)
|
||
-- ignore all markup tags
|
||
def = def:gsub("%b<>", "")
|
||
-- strip all leading empty lines/spaces
|
||
def = def:gsub("^%s+", "")
|
||
result.definition = def
|
||
end
|
||
end
|
||
return results
|
||
end
|
||
|
||
function ReaderDictionary:cleanSelection(text, is_sane)
|
||
-- Will be used by ReaderWikipedia too
|
||
if not text then
|
||
return ""
|
||
end
|
||
-- crengine does now a much better job at finding word boundaries, but
|
||
-- some cleanup is still needed for selection we get from other engines
|
||
-- (example: pdf selection "qu’autrefois," will be cleaned to "autrefois")
|
||
--
|
||
-- Replace no-break space with regular space
|
||
text = text:gsub("\u{00A0}", ' ')
|
||
-- Trim any space at start or end
|
||
text = text:gsub("^%s+", "")
|
||
text = text:gsub("%s+$", "")
|
||
if not is_sane then
|
||
-- Replace extended quote (included in the general puncturation range)
|
||
-- with plain ascii quote (for french words like "aujourd’hui")
|
||
text = text:gsub("\u{2019}", "'") -- Right single quotation mark
|
||
-- Strip punctuation characters around selection
|
||
text = util.stripPunctuation(text)
|
||
-- Note: although it seems innocuous to use a character class [·|↑] to perform a single gsub,
|
||
-- doing so will cause byte corruption in some languages (e.g. Greek).
|
||
-- In some dictionaries, both interpuncts and pipes are used to delimiter syllables.
|
||
text = text:gsub("·", "") -- interpunct
|
||
text = text:gsub("|", "") -- pipe
|
||
text = text:gsub("↑", "") -- and up arrow, used in some dictionaries to indicate related words
|
||
-- Strip some common english grammatical construct
|
||
text = text:gsub("'s$", '') -- english possessive
|
||
-- Strip some common french grammatical constructs
|
||
text = text:gsub("^[LSDMNTlsdmnt]'", '') -- french l' s' t'...
|
||
text = text:gsub("^[Qq][Uu]'", '') -- french qu'
|
||
-- There may be a need to remove some (all?) diacritical marks
|
||
-- https://en.wikipedia.org/wiki/Combining_character#Unicode_ranges
|
||
-- see discussion at https://github.com/koreader/koreader/issues/1649
|
||
-- Commented for now, will have to be checked by people who read
|
||
-- languages and texts that use them.
|
||
-- text = text:gsub("\204[\128-\191]", '') -- U+0300 to U+033F
|
||
-- text = text:gsub("\205[\128-\175]", '') -- U+0340 to U+036F
|
||
-- Trim any space now at start or end after above changes
|
||
text = text:gsub("^%s+", "")
|
||
text = text:gsub("%s+$", "")
|
||
end
|
||
return text
|
||
end
|
||
|
||
function ReaderDictionary:showLookupInfo(word, show_delay)
|
||
local text = T(self.lookup_msg, word)
|
||
self.lookup_progress_msg = InfoMessage:new{
|
||
text = text,
|
||
show_delay = show_delay,
|
||
}
|
||
UIManager:show(self.lookup_progress_msg)
|
||
end
|
||
|
||
function ReaderDictionary:dismissLookupInfo()
|
||
if self.lookup_progress_msg then
|
||
UIManager:close(self.lookup_progress_msg)
|
||
end
|
||
self.lookup_progress_msg = nil
|
||
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
|
||
self:showSearchWithPresetDialog(preset_names, self.dictionary_lookup_dialog, text)
|
||
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 = buttons,
|
||
}
|
||
UIManager:show(self.dictionary_lookup_dialog)
|
||
self.dictionary_lookup_dialog:onShowKeyboard()
|
||
return true
|
||
end
|
||
|
||
function ReaderDictionary:showSearchWithPresetDialog(preset_names, input_dialog, word, boxes, link, dict_close_callback)
|
||
if not preset_names then
|
||
preset_names = Presets.getPresets(self.preset_obj)
|
||
end
|
||
local current_dict_state = self:buildPreset()
|
||
local button_dialog, buttons = nil, {}
|
||
for _, preset_name in ipairs(preset_names) do
|
||
table.insert(buttons, {
|
||
{
|
||
align = "left",
|
||
text = preset_name,
|
||
callback = function()
|
||
self:loadPreset(self.preset_obj.presets[preset_name], true)
|
||
UIManager:close(button_dialog)
|
||
UIManager:close(input_dialog)
|
||
self:onLookupWord(word, true, boxes, self.highlight, link,
|
||
function()
|
||
self:loadPreset(current_dict_state, true)
|
||
if dict_close_callback then dict_close_callback() end
|
||
end
|
||
)
|
||
end,
|
||
}
|
||
})
|
||
end
|
||
button_dialog = ButtonDialog:new{
|
||
buttons = buttons,
|
||
shrink_unneeded_width = true,
|
||
}
|
||
if input_dialog then input_dialog:onCloseKeyboard() end
|
||
UIManager:show(button_dialog)
|
||
end
|
||
|
||
function ReaderDictionary:rawSdcv(words, dict_names, fuzzy_search, lookup_progress_msg)
|
||
-- Allow for two sdcv calls : one in the classic data/dict, and
|
||
-- another one in data/dict_ext if it exists
|
||
-- We could put in data/dict_ext dictionaries with a great number of words
|
||
-- but poor definitions as a fall back. If these were in data/dict,
|
||
-- they would prevent fuzzy searches in other dictories with better
|
||
-- definitions, and masks such results. This way, we can get both.
|
||
local dict_dirs = {self.data_dir}
|
||
local dict_ext = self.data_dir.."_ext"
|
||
if lfs.attributes(dict_ext, "mode") == "directory" then
|
||
table.insert(dict_dirs, dict_ext)
|
||
end
|
||
-- early exit if no dictionaries
|
||
if dictDirsEmpty(dict_dirs) then
|
||
return false, nil
|
||
end
|
||
local all_results = {}
|
||
local lookup_cancelled = false
|
||
for _, dict_dir in ipairs(dict_dirs) do
|
||
if lookup_cancelled then
|
||
break -- don't do any more lookup on additional dict_dirs
|
||
end
|
||
|
||
local args = {
|
||
android and (android.nativeLibraryDir .. "/libsdcv.so") or "./sdcv",
|
||
"--utf8-input",
|
||
"--utf8-output",
|
||
"--json-output",
|
||
"--non-interactive",
|
||
"--data-dir", dict_dir,
|
||
}
|
||
if not fuzzy_search then
|
||
table.insert(args, "--exact-search")
|
||
end
|
||
if dict_names then
|
||
for _, opt in pairs(dict_names) do
|
||
table.insert(args, "-u")
|
||
table.insert(args, opt)
|
||
end
|
||
end
|
||
table.insert(args, "--") -- prevent words starting with a "-" to be interpreted as a sdcv option
|
||
util.arrayAppend(args, words)
|
||
|
||
local cmd = util.shell_escape(args)
|
||
-- cmd = "sleep 7 ; " .. cmd -- uncomment to simulate long lookup time
|
||
|
||
-- Some sdcv lookups, when using fuzzy search with many dictionaries
|
||
-- and a really bad selected text, can take up to 10 seconds.
|
||
-- It is nice to be able to cancel it when noticing wrong text was
|
||
-- selected.
|
||
-- Because sdcv starts outputting its output only at the end when it has
|
||
-- done its work, we can use Trapper:dismissablePopen() to cancel it as
|
||
-- long as we are waiting for output.
|
||
-- When fuzzy search is enabled, we have a lookup_progress_msg that can
|
||
-- be used to catch a tap and trigger cancellation.
|
||
-- When fuzzy search is disabled, we provide false instead so an
|
||
-- invisible non-event-forwarding TrapWidget is used to catch a tap
|
||
-- and trigger cancellation (invisible so there's no need for repaint
|
||
-- and refresh with the usually fast non-fuzzy search lookups).
|
||
-- We must ensure we will have some output to be readable (if no
|
||
-- definition found, sdcv will output some message on stderr, and
|
||
-- let stdout empty) by appending an "echo":
|
||
cmd = cmd .. "; echo"
|
||
-- NOTE: Bionic doesn't support rpath, but does honor LD_LIBRARY_PATH...
|
||
-- Give it a shove so it can actually find the STL.
|
||
if android then
|
||
C.setenv("LD_LIBRARY_PATH", android.nativeLibraryDir, 1)
|
||
end
|
||
local completed, results_str = Trapper:dismissablePopen(cmd, lookup_progress_msg)
|
||
if android then
|
||
-- NOTE: It's unset by default, so this is perfectly fine.
|
||
C.unsetenv("LD_LIBRARY_PATH")
|
||
end
|
||
lookup_cancelled = not completed
|
||
if results_str and results_str ~= "\n" then -- \n is when lookup was cancelled
|
||
-- sdcv can return multiple results if we passed multiple words to
|
||
-- the cmdline. In this case, the lookup results for each word are
|
||
-- newline separated. The JSON output doesn't contain raw newlines
|
||
-- so it's safe to split. Ideally luajson would support jsonl but
|
||
-- unfortunately it doesn't and it also seems to decode the last
|
||
-- object rather than the first one if there are multiple.
|
||
local result_word_idx = 0
|
||
for _, entry_str in ipairs(util.splitToArray(results_str, "\n")) do
|
||
result_word_idx = result_word_idx + 1
|
||
local ok, results = pcall(JSON.decode, entry_str)
|
||
if not ok or not results then
|
||
logger.warn("JSON data cannot be decoded", results)
|
||
-- Need to insert an empty table so that the word entries
|
||
-- match up to the result entries (so that callers can
|
||
-- batch lookups to reduce the cost of bulk lookups while
|
||
-- still being able to figure out which lookup came from
|
||
-- which word).
|
||
results = {}
|
||
end
|
||
if all_results[result_word_idx] then
|
||
util.arrayAppend(all_results[result_word_idx], results)
|
||
else
|
||
table.insert(all_results, results)
|
||
end
|
||
end
|
||
if result_word_idx ~= #words then
|
||
logger.warn("sdcv returned a different number of results than the number of words")
|
||
end
|
||
end
|
||
end
|
||
return lookup_cancelled, all_results
|
||
end
|
||
|
||
function ReaderDictionary:startSdcv(word, dict_names, fuzzy_search)
|
||
local words = {word}
|
||
-- If a word starts with a capital letter, add lowercase version to words array.
|
||
if not fuzzy_search then
|
||
local lowercased = Utf8Proc.lowercase(word, false)
|
||
if word ~= lowercased then
|
||
table.insert(words, lowercased)
|
||
end
|
||
end
|
||
|
||
if self.ui.languagesupport and self.ui.languagesupport:hasActiveLanguagePlugins() then
|
||
-- Get any other candidates from any language-specific plugins we have.
|
||
-- We prefer the originally selected word first (in case there is a
|
||
-- dictionary entry for whatever text the user selected).
|
||
local candidates = self.ui.languagesupport:extraDictionaryFormCandidates(word)
|
||
if candidates then
|
||
util.arrayAppend(words, candidates)
|
||
end
|
||
end
|
||
|
||
-- If every word contains a CJK character, every word candidate is
|
||
-- (probably) a CJK word. We don't want fuzzy searching in this case
|
||
-- because sdcv cannot handle CJK text properly when fuzzy searching (with
|
||
-- Japanese, it returns hundreds of useless results).
|
||
local shouldnt_fuzzy_search = true
|
||
for _, w in ipairs(words) do
|
||
if not util.hasCJKChar(w) then
|
||
shouldnt_fuzzy_search = false
|
||
break
|
||
end
|
||
end
|
||
if shouldnt_fuzzy_search then
|
||
logger.dbg("disabling fuzzy searching for all-CJK word search:", words)
|
||
fuzzy_search = false
|
||
end
|
||
|
||
local lookup_cancelled, results = self:rawSdcv(words, dict_names, fuzzy_search, self.lookup_progress_msg or false)
|
||
if results == nil then -- no dictionaries found
|
||
return {
|
||
{
|
||
dict = "",
|
||
word = word,
|
||
definition = _([[No dictionaries installed. Please search for "Dictionary support" in the KOReader Wiki to get more information about installing new dictionaries.]]),
|
||
}
|
||
}
|
||
else -- flatten any possible results
|
||
local flat_results = {}
|
||
local seen_results = {}
|
||
-- Flatten the array, removing any duplicates we may have gotten (sdcv
|
||
-- may do multiple queries, in fixed mode then in fuzzy mode, and the
|
||
-- language-specific plugin may have also returned multiple equivalent
|
||
-- results).
|
||
local h
|
||
for _, term_results in ipairs(results) do
|
||
for _, r in ipairs(term_results) do
|
||
h = r.dict .. r.word .. r.definition
|
||
if seen_results[h] == nil then
|
||
table.insert(flat_results, r)
|
||
seen_results[h] = true
|
||
end
|
||
end
|
||
end
|
||
results = flat_results
|
||
end
|
||
if #results == 0 then -- no results found
|
||
-- dummy results
|
||
results = {
|
||
{
|
||
dict = _("Not available"),
|
||
word = word,
|
||
definition = lookup_cancelled and _("Dictionary lookup interrupted.") or _("No results."),
|
||
no_result = true,
|
||
lookup_cancelled = lookup_cancelled,
|
||
}
|
||
}
|
||
end
|
||
if lookup_cancelled then
|
||
-- Also put this as a k/v into the results array: when using dict_ext,
|
||
-- we may get results from the 1st lookup, and have interrupted the 2nd.
|
||
results.lookup_cancelled = true
|
||
end
|
||
return results
|
||
end
|
||
|
||
function ReaderDictionary:stardictLookup(word, dict_names, fuzzy_search, boxes, link, dict_close_callback)
|
||
if word == "" then
|
||
return
|
||
end
|
||
|
||
local book_title = self.ui.doc_props and self.ui.doc_props.display_title or _("Dictionary lookup")
|
||
|
||
-- 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(),
|
||
word = word,
|
||
})
|
||
end
|
||
|
||
if Device:canExternalDictLookup() and G_reader_settings:isTrue("external_dict_lookup") then
|
||
Device:doExternalDictLookup(word, G_reader_settings:readSetting("external_dict_lookup_method"), function()
|
||
if self.highlight then
|
||
local clear_id = self.highlight:getClearId()
|
||
UIManager:scheduleIn(G_defaults:readSetting("DELAY_CLEAR_HIGHLIGHT_S"), function()
|
||
self.highlight:clear(clear_id)
|
||
end)
|
||
end
|
||
|
||
if dict_close_callback then
|
||
dict_close_callback()
|
||
end
|
||
end)
|
||
return
|
||
end
|
||
|
||
-- Before starting the search, remove any dictionaries that were disabled for *this* book.
|
||
if dict_names and self.doc_disabled_dicts then
|
||
local filtered_names = {}
|
||
for _, name in ipairs(dict_names) do
|
||
if not self.doc_disabled_dicts[name] then
|
||
table.insert(filtered_names, name)
|
||
end
|
||
end
|
||
dict_names = filtered_names
|
||
end
|
||
|
||
-- If the user disabled all the dictionaries, go away.
|
||
if dict_names and #dict_names == 0 then
|
||
-- Dummy result
|
||
local nope = {
|
||
{
|
||
dict = _("Not available"),
|
||
word = word,
|
||
definition = _("There are no enabled dictionaries.\nPlease check the 'Dictionary settings' menu."),
|
||
no_result = true,
|
||
lookup_cancelled = false,
|
||
}
|
||
}
|
||
self:showDict(word, nope, boxes, link, dict_close_callback)
|
||
return
|
||
end
|
||
|
||
self:showLookupInfo(word, self.lookup_msg_delay)
|
||
|
||
self._lookup_start_time = UIManager:getTime()
|
||
local results = self:startSdcv(word, dict_names, fuzzy_search)
|
||
local function lookupCancelled()
|
||
if self.highlight then
|
||
self.highlight:clear()
|
||
end
|
||
if dict_close_callback then
|
||
dict_close_callback()
|
||
end
|
||
end
|
||
if results and results.lookup_cancelled
|
||
and (time.now() - self._lookup_start_time) <= self.quick_dismiss_before_delay then
|
||
-- If interrupted quickly just after launch, don't display anything
|
||
-- (this might help avoiding refreshes and the need to dismiss
|
||
-- after accidental long-press when holding a device).
|
||
lookupCancelled()
|
||
return
|
||
end
|
||
-- Intercept "No results" to offer alternative search methods (e.g., fuzzy search to non-fussy people)
|
||
if results and results[1].no_result then
|
||
local handled = self:showNoResultsDialog(word, dict_names, fuzzy_search, boxes, link, dict_close_callback, lookupCancelled)
|
||
if handled then return end
|
||
end
|
||
|
||
self:showDict(word, tidyMarkup(results), boxes, link, dict_close_callback)
|
||
end
|
||
|
||
function ReaderDictionary:showDict(word, results, boxes, link, dict_close_callback)
|
||
if results and results[1] then
|
||
logger.dbg("showing quick lookup window", #DictQuickLookup.window_list+1, ":", word, results)
|
||
self.dict_window = DictQuickLookup:new{
|
||
ui = self.ui,
|
||
highlight = self.highlight,
|
||
dialog = self.dialog,
|
||
-- original lookup word
|
||
word = word,
|
||
-- selected link, if any
|
||
selected_link = link,
|
||
results = results,
|
||
word_boxes = boxes,
|
||
preferred_dictionaries = self.preferred_dictionaries,
|
||
-- differentiate between dict and wiki
|
||
is_wiki = self.is_wiki,
|
||
refresh_callback = function()
|
||
if self.view then
|
||
-- update info in footer (time, battery, etc)
|
||
self.view.footer:onUpdateFooter()
|
||
end
|
||
end,
|
||
html_dictionary_link_tapped_callback = function(dictionary, html_link)
|
||
self:onHtmlDictionaryLinkTapped(dictionary, html_link)
|
||
end,
|
||
dict_close_callback = dict_close_callback,
|
||
}
|
||
if self.lookup_progress_msg then
|
||
-- If we have a lookup InfoMessage that ended up being displayed, make
|
||
-- it *not* refresh on close if it is hidden by our DictQuickLookup
|
||
-- to avoid refreshes competition and possible glitches
|
||
local msg_dimen = self.lookup_progress_msg:getVisibleArea()
|
||
if msg_dimen then -- not invisible
|
||
local dict_dimen = self.dict_window:getInitialVisibleArea()
|
||
if dict_dimen and dict_dimen:contains(msg_dimen) then
|
||
self.lookup_progress_msg.no_refresh_on_close = true
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
self:dismissLookupInfo()
|
||
if results and results[1] then
|
||
UIManager:show(self.dict_window)
|
||
if not results.lookup_cancelled and self._lookup_start_time
|
||
and (time.now() - self._lookup_start_time) > self.quick_dismiss_before_delay then
|
||
-- If the search took more than a few seconds to be done, discard
|
||
-- queued and upcoming input events to avoid a voluntary dismissal
|
||
-- (because the user felt the result would not come) to kill the
|
||
-- result that finally came and is about to be displayed
|
||
Input:inhibitInputUntil(true)
|
||
end
|
||
end
|
||
end
|
||
|
||
function ReaderDictionary:showNoResultsDialog(word, dict_names, fuzzy_search, boxes, link, dict_close_callback, lookupCancelled)
|
||
self:dismissLookupInfo() -- Close the "Searching..." message
|
||
local preset_names = Presets.getPresets(self.preset_obj)
|
||
local has_presets = preset_names and #preset_names > 0
|
||
if fuzzy_search and not has_presets then return false end -- fall through to showing empty results
|
||
|
||
local preset_button = has_presets and {
|
||
text = _("Search with preset"),
|
||
callback = function(dialog)
|
||
local new_word = dialog:getInputText()
|
||
if new_word == "" or new_word:match("^%s*$") then return end
|
||
self:showSearchWithPresetDialog(preset_names, dialog, new_word, boxes, link, dict_close_callback)
|
||
end,
|
||
} or nil
|
||
|
||
-- Determine the primary action based on what's available
|
||
local description, primary_action
|
||
if not fuzzy_search then
|
||
description = _("Would you like to use fuzzy search?")
|
||
primary_action = {
|
||
text = _("Fuzzy search"),
|
||
is_enter_default = true,
|
||
callback = function(dialog)
|
||
local new_word = dialog:getInputText()
|
||
if new_word == "" then return end
|
||
UIManager:close(dialog)
|
||
-- Re-run the lookup with the (possibly edited) word and fuzzy enabled.
|
||
self:stardictLookup(new_word, dict_names, true, boxes, link, dict_close_callback)
|
||
end,
|
||
}
|
||
elseif has_presets then
|
||
description = _("Would you like to search with a preset?")
|
||
primary_action = preset_button
|
||
primary_action.is_enter_default = true
|
||
end
|
||
|
||
local buttons = {}
|
||
-- Add preset button as an additional option (when fuzzy is the primary action)
|
||
if not fuzzy_search and has_presets then
|
||
table.insert(buttons, { preset_button })
|
||
end
|
||
table.insert(buttons, {
|
||
{
|
||
text = _("Cancel"),
|
||
id = "close",
|
||
callback = function(dialog)
|
||
UIManager:close(dialog)
|
||
UIManager:scheduleIn(G_defaults:readSetting("DELAY_CLEAR_HIGHLIGHT_S"), function() lookupCancelled() end)
|
||
end,
|
||
},
|
||
primary_action,
|
||
})
|
||
local dialog
|
||
dialog = InputDialog:new{
|
||
title = _("No results found"),
|
||
input = word, -- Pre-fills the dialog with the selected word
|
||
input_type = "text",
|
||
description = description,
|
||
buttons = buttons,
|
||
}
|
||
|
||
-- Wire up callbacks with the dialog instance
|
||
for _, row in ipairs(buttons) do
|
||
for _, button in ipairs(row) do
|
||
if button.callback then
|
||
local original_callback = button.callback
|
||
button.callback = function()
|
||
original_callback(dialog)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
UIManager:show(dialog)
|
||
dialog:onShowKeyboard()
|
||
return true
|
||
end
|
||
|
||
function ReaderDictionary:showDownload(downloadable_dicts)
|
||
local kv_pairs = {}
|
||
for dummy, dict in ipairs(downloadable_dicts) do
|
||
table.insert(kv_pairs, {dict.name, "",
|
||
callback = function()
|
||
local connect_callback = function()
|
||
self:downloadDictionaryPrep(dict)
|
||
end
|
||
NetworkMgr:runWhenOnline(connect_callback)
|
||
end})
|
||
local lang
|
||
if dict.lang_in == dict.lang_out then
|
||
lang = string.format(" %s", dict.lang_in)
|
||
else
|
||
lang = string.format(" %s–%s", dict.lang_in, dict.lang_out)
|
||
end
|
||
table.insert(kv_pairs, {lang, ""})
|
||
table.insert(kv_pairs, {" ".._("License"), dict.license})
|
||
table.insert(kv_pairs, {" ".._("Entries"), dict.entries, separator = true})
|
||
end
|
||
self.download_window = KeyValuePage:new{
|
||
title = _("Tap dictionary name to download"),
|
||
kv_pairs = kv_pairs,
|
||
}
|
||
UIManager:show(self.download_window)
|
||
end
|
||
|
||
function ReaderDictionary:downloadDictionaryPrep(dict, size)
|
||
local dummy, filename = util.splitFilePathName(dict.url)
|
||
local download_location = string.format("%s/%s", self.data_dir, filename)
|
||
|
||
if lfs.attributes(download_location) then
|
||
UIManager:show(ConfirmBox:new{
|
||
text = _("File already exists. Overwrite?"),
|
||
ok_text = _("Overwrite"),
|
||
ok_callback = function()
|
||
self:downloadDictionary(dict, download_location)
|
||
end,
|
||
})
|
||
else
|
||
self:downloadDictionary(dict, download_location)
|
||
end
|
||
end
|
||
|
||
function ReaderDictionary:downloadDictionary(dict, download_location, continue)
|
||
continue = continue or false
|
||
local socket = require("socket")
|
||
local socketutil = require("socketutil")
|
||
local http = socket.http
|
||
local ltn12 = require("ltn12")
|
||
|
||
if not continue then
|
||
local file_size
|
||
-- Skip body & code args
|
||
socketutil:set_timeout()
|
||
local headers = socket.skip(2, http.request{
|
||
method = "HEAD",
|
||
url = dict.url,
|
||
--redirect = true,
|
||
})
|
||
socketutil:reset_timeout()
|
||
--logger.dbg(headers)
|
||
file_size = headers and headers["content-length"]
|
||
|
||
if file_size then
|
||
UIManager:show(ConfirmBox:new{
|
||
text = T(_("Dictionary filesize is %1 (%2 bytes). Continue with download?"), util.getFriendlySize(file_size), util.getFormattedSize(file_size)),
|
||
ok_text = _("Download"),
|
||
ok_callback = function()
|
||
-- call ourselves with continue = true
|
||
self:downloadDictionary(dict, download_location, true)
|
||
end,
|
||
})
|
||
return
|
||
else
|
||
logger.dbg("ReaderDictionary: Request failed; response headers:", headers)
|
||
UIManager:show(InfoMessage:new{
|
||
text = _("Failed to fetch dictionary. Are you online?"),
|
||
--timeout = 3,
|
||
})
|
||
return false
|
||
end
|
||
else
|
||
UIManager:nextTick(function()
|
||
UIManager:show(InfoMessage:new{
|
||
text = _("Downloading…"),
|
||
timeout = 3,
|
||
})
|
||
end)
|
||
end
|
||
|
||
socketutil:set_timeout(socketutil.FILE_BLOCK_TIMEOUT, socketutil.FILE_TOTAL_TIMEOUT)
|
||
local code, headers, status = socket.skip(1, http.request{
|
||
url = dict.url,
|
||
sink = ltn12.sink.file(io.open(download_location, "w")),
|
||
})
|
||
socketutil:reset_timeout()
|
||
if code == 200 then
|
||
logger.dbg("file downloaded to", download_location)
|
||
else
|
||
logger.dbg("ReaderDictionary: Request failed:", status or code)
|
||
logger.dbg("ReaderDictionary: Response headers:", headers)
|
||
UIManager:show(InfoMessage:new{
|
||
text = _("Could not save file to:\n") .. BD.filepath(download_location),
|
||
--timeout = 3,
|
||
})
|
||
return false
|
||
end
|
||
|
||
-- stable target directory is needed so we can look through the folder later
|
||
local dict_path = self.data_dir .. "/" .. dict.name
|
||
util.makePath(dict_path)
|
||
local ok, error = Device:unpackArchive(download_location, dict_path, true)
|
||
|
||
if ok then
|
||
if dict.ifo_lang then
|
||
self:extendIfoWithLanguage(dict_path, dict.ifo_lang)
|
||
end
|
||
available_ifos = false
|
||
self:init()
|
||
UIManager:show(InfoMessage:new{
|
||
text = _("Dictionary downloaded:\n") .. dict.name,
|
||
})
|
||
return true
|
||
else
|
||
UIManager:show(InfoMessage:new{
|
||
text = _("Dictionary failed to download:\n") .. string.format("%s\n%s", dict.name, error),
|
||
})
|
||
return false
|
||
end
|
||
end
|
||
|
||
function ReaderDictionary:extendIfoWithLanguage(dictionary_location, ifo_lang)
|
||
local function cb(path, filename)
|
||
if util.getFileNameSuffix(filename) == "ifo" then
|
||
local fmt_string = "lang=%s"
|
||
local f = io.open(path, "a+")
|
||
if f then
|
||
local ifo = f:read("a*")
|
||
if ifo[#ifo] ~= "\n" then
|
||
fmt_string = "\n" .. fmt_string
|
||
end
|
||
f:write(fmt_string:format(ifo_lang))
|
||
f:close()
|
||
end
|
||
end
|
||
end
|
||
util.findFiles(dictionary_location, cb)
|
||
end
|
||
|
||
function ReaderDictionary:onReadSettings(config)
|
||
self.preferred_dictionaries = config:readSetting("preferred_dictionaries") or {}
|
||
if #self.preferred_dictionaries == 0 then
|
||
-- Legacy setting, when only one dict could be set as default/first to show
|
||
local default_dictionary = config:readSetting("default_dictionary")
|
||
if default_dictionary then
|
||
table.insert(self.preferred_dictionaries, default_dictionary)
|
||
config:delSetting("default_dictionary")
|
||
end
|
||
end
|
||
if #self.preferred_dictionaries > 0 then
|
||
self:updateSdcvDictNamesOptions()
|
||
end
|
||
if config:has("disable_fuzzy_search") then
|
||
self.disable_fuzzy_search = config:isTrue("disable_fuzzy_search")
|
||
else
|
||
self.disable_fuzzy_search = G_reader_settings:isTrue("disable_fuzzy_search")
|
||
end
|
||
-- Disabled dictionary list for this book
|
||
self.doc_disabled_dicts = config:readSetting("disabled_dicts") or {}
|
||
end
|
||
|
||
function ReaderDictionary:onSaveSettings()
|
||
if self.ui.doc_settings then
|
||
self.ui.doc_settings:saveSetting("preferred_dictionaries", next(self.preferred_dictionaries) and self.preferred_dictionaries or nil)
|
||
self.ui.doc_settings:saveSetting("disabled_dicts", next(self.doc_disabled_dicts) and self.doc_disabled_dicts or nil)
|
||
end
|
||
end
|
||
|
||
function ReaderDictionary:onTogglePreferredDict(dict)
|
||
if not self.preferred_dictionaries then
|
||
-- Invoked from FileManager: no preferred dict to manage
|
||
return true
|
||
end
|
||
local removed = false
|
||
for idx, name in ipairs(self.preferred_dictionaries) do
|
||
if dict == name then
|
||
removed = true
|
||
table.remove(self.preferred_dictionaries, idx)
|
||
break
|
||
end
|
||
end
|
||
if not removed then -- insert it as first
|
||
table.insert(self.preferred_dictionaries, 1, dict)
|
||
end
|
||
UIManager:show(InfoMessage:new{
|
||
text = removed and T(_("%1 is no longer a preferred dictionary for this document."), dict)
|
||
or T(_("%1 is now the preferred dictionary for this document."), dict),
|
||
timeout = 2,
|
||
})
|
||
self:updateSdcvDictNamesOptions()
|
||
return true
|
||
end
|
||
|
||
function ReaderDictionary:toggleFuzzyDefault(touchmenu_instance)
|
||
local disable_fuzzy_search = G_reader_settings:isTrue("disable_fuzzy_search")
|
||
UIManager:show(MultiConfirmBox:new{
|
||
text = T(
|
||
disable_fuzzy_search
|
||
and _([[
|
||
Would you like to enable or disable fuzzy search by default?
|
||
|
||
Fuzzy search can match epuisante, épuisante and épuisantes to épuisant, even if only the latter has an entry in the dictionary. It can be disabled to improve performance, but it might be worthwhile to look into disabling unneeded dictionaries before disabling fuzzy search.
|
||
|
||
The current default (★) is disabled.]])
|
||
or _([[
|
||
Would you like to enable or disable fuzzy search by default?
|
||
|
||
Fuzzy search can match epuisante, épuisante and épuisantes to épuisant, even if only the latter has an entry in the dictionary. It can be disabled to improve performance, but it might be worthwhile to look into disabling unneeded dictionaries before disabling fuzzy search.
|
||
|
||
The current default (★) is enabled.]])
|
||
),
|
||
choice1_text_func = function()
|
||
return disable_fuzzy_search and _("Disable (★)") or _("Disable")
|
||
end,
|
||
choice1_callback = function()
|
||
G_reader_settings:makeTrue("disable_fuzzy_search")
|
||
touchmenu_instance:updateItems()
|
||
end,
|
||
choice2_text_func = function()
|
||
return disable_fuzzy_search and _("Enable") or _("Enable (★)")
|
||
end,
|
||
choice2_callback = function()
|
||
G_reader_settings:makeFalse("disable_fuzzy_search")
|
||
touchmenu_instance:updateItems()
|
||
end,
|
||
})
|
||
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
|