")))
171 | end
172 | }
173 | end
174 |
175 | function AnkiNote:build()
176 | local fields = {
177 | [conf.word_field:get_value()] = self.popup_dict.word,
178 | [conf.def_field:get_value()] = self:get_definition()
179 | }
180 | local optional_fields = {
181 | [conf.context_field] = function() return self:get_word_context() end,
182 | [conf.meta_field] = function() return self:get_metadata() end,
183 | }
184 | for opt,fn in pairs(optional_fields) do
185 | local field_name = opt:get_value()
186 | if field_name then
187 | fields[field_name] = fn()
188 | end
189 | end
190 | local note = {
191 | deckName = conf.deckName:get_value(),
192 | modelName = conf.modelName:get_value(),
193 | fields = fields,
194 | options = {
195 | allowDuplicate = conf.allow_dupes:get_value(),
196 | duplicateScope = conf.dupe_scope:get_value(),
197 | },
198 | tags = self.tags,
199 | }
200 | return {
201 | -- actual table passed to anki-connect later
202 | data = self:run_extensions(note),
203 | -- some fields require an internet connection, which we may not have at this point
204 | -- all info needed to populate them is stored as a callback, which is called when a connection is available
205 | field_callbacks = {
206 | audio = {
207 | func = "set_forvo_audio",
208 | field_name = conf.audio_field:get_value(),
209 | args = { self.popup_dict.word, self:get_language() }
210 | },
211 | picture = {
212 | func = "set_image_data",
213 | field_name = conf.image_field:get_value(),
214 | args = { self:get_picture_context() }
215 | },
216 | fields = {
217 | func = "set_translated_context",
218 | field_name = conf.translated_context_field:get_value(),
219 | args = { fields[conf.context_field:get_value()] or self:get_word_context(), self:get_language() }
220 | },
221 | },
222 | -- used as id to detect duplicates when storing notes offline
223 | identifier = conf.word_field:get_value()
224 | }
225 | end
226 |
227 | function AnkiNote:get_language()
228 | local ifo_lang = self.selected_dict.ifo_lang
229 | local language = ifo_lang and ifo_lang.lang_in or rawget(self.ui.document._anki_metadata, 'language')
230 | if not language then
231 | local selected_dict_name = self.popup_dict.results[self.popup_dict.dict_index].dict
232 | local document_title = rawget(self.ui.document._anki_metadata, "title")
233 | error(LANG_NOT_SET_ERROR:format(self.popup_dict.word, selected_dict_name, document_title), 0)
234 | end
235 | return language
236 | end
237 |
238 | function AnkiNote:init_context_buffer(size)
239 | logger.info(("(re)initializing context buffer with size: %d"):format(size))
240 | if self.prev_context_table and self.next_context_table then
241 | logger.info(("before reinit: prev table = %d, next table = %d"):format(#self.prev_context_table, #self.next_context_table))
242 | end
243 | local skipped_chars = u.to_set(util.splitToChars(("\n\r")))
244 | local prev_c, next_c = self.ui.highlight:getSelectedWordContext(size)
245 | -- pass trimmed word context along to be modified
246 | prev_c = prev_c .. self.word_trim.before
247 | next_c = self.word_trim.after .. next_c
248 | self.prev_context_table = {}
249 | for _, ch in ipairs(util.splitToChars(prev_c)) do
250 | if not skipped_chars[ch] then table.insert(self.prev_context_table, ch) end
251 | end
252 | self.next_context_table = {}
253 | for _, ch in ipairs(util.splitToChars(next_c)) do
254 | if not skipped_chars[ch] then table.insert(self.next_context_table, ch) end
255 | end
256 | logger.info(("after reinit: prev table = %d, next table = %d"):format(#self.prev_context_table, #self.next_context_table))
257 | end
258 |
259 | function AnkiNote:set_custom_context(pre_s, pre_c, post_s, post_c)
260 | self.context = { pre_s, pre_c, post_s, post_c }
261 | end
262 |
263 | function AnkiNote:add_tags(tags)
264 | for _,t in ipairs(tags) do
265 | table.insert(self.tags, t)
266 | end
267 | end
268 |
269 | -- each user extension gets access to the AnkiNote table as well
270 | function AnkiNote:load_extensions()
271 | self.extensions = {}
272 | local extension_set = u.to_set(conf.enabled_extensions:get_value())
273 | for _, ext_filename in ipairs(self.ext_modules) do
274 | if extension_set[ext_filename] then
275 | local module = self.ext_modules[ext_filename]
276 | table.insert(self.extensions, setmetatable(module, { __index = function(t, v) return rawget(t, v) or self[v] end }))
277 | end
278 | end
279 | end
280 |
281 | -- This function should be called before using the 'class' at all
282 | function AnkiNote:extend(opts)
283 | -- dict containing various settings about the current state
284 | self.ui = opts.ui
285 | -- used to save screenshots in (CBZ only)
286 | self.settings_dir = opts.settings_dir
287 | -- used to store extension functions to run
288 | self.ext_modules = opts.ext_modules
289 | return self
290 | end
291 |
292 | function AnkiNote:new(popup_dict)
293 | local new = {
294 | context_size = 50,
295 | popup_dict = popup_dict,
296 | selected_dict = popup_dict.results[popup_dict.dict_index],
297 | -- indicates that popup_dict relates to word in book
298 | -- this can still be set to false later when the user looks up a word in a book, but then modifies the looked up word
299 | contextual_lookup = self.ui.highlight.selected_text ~= nil,
300 | word_trim = { before = "", after = "" },
301 | tags = { "KOReader" },
302 | }
303 | local new_mt = {}
304 | function new_mt.__index(t, v)
305 | return rawget(t, v) or self[v]
306 | end
307 |
308 | local note = setmetatable(new, new_mt)
309 | note:set_word_trim()
310 | note:load_extensions()
311 | -- TODO this can be delayed
312 | if note.contextual_lookup then
313 | note:init_context_buffer(note.context_size)
314 | note:set_custom_context(tonumber(conf.prev_sentence_count:get_value()), 0, tonumber(conf.next_sentence_count:get_value()), 0)
315 | end
316 | return note
317 | end
318 |
319 | return AnkiNote
320 |
--------------------------------------------------------------------------------
/customcontextmenu.lua:
--------------------------------------------------------------------------------
1 | local Blitbuffer = require("ffi/blitbuffer")
2 | local Button = require("ui/widget/button")
3 | local Device = require("device")
4 | local FrameContainer = require("ui/widget/container/framecontainer")
5 | local FocusManager = require("ui/widget/focusmanager")
6 | local Geom = require("ui/geometry")
7 | local GestureRange = require("ui/gesturerange")
8 | local HorizontalGroup = require("ui/widget/horizontalgroup")
9 | local MovableContainer = require("ui/widget/container/movablecontainer")
10 | local ScrollHtmlWidget = require("ui/widget/scrollhtmlwidget")
11 | local Size = require("ui/size")
12 | local UIManager = require("ui/uimanager")
13 | local VerticalGroup = require("ui/widget/verticalgroup")
14 | local VerticalSpan = require("ui/widget/verticalspan")
15 | local WidgetContainer = require("ui/widget/container/widgetcontainer")
16 | local Screen = Device.screen
17 | local config = require("anki_configuration")
18 |
19 | local CustomContextMenu = FocusManager:extend{}
20 |
21 | local function make_button(text, width, cb, enabled_func)
22 | enabled_func = enabled_func or function() return true end
23 | return Button:new{
24 | text = text,
25 | radius = 0,
26 | margin = 2,
27 | enabled_func = enabled_func,
28 | width = width,
29 | show_parent = CustomContextMenu,
30 | callback = cb,
31 | }
32 | end
33 |
34 | function CustomContextMenu:init()
35 | self:reset() -- first call is just for initializing
36 | self.font_size = 19
37 | local screen_width = Screen:getWidth()
38 | local screen_height = Screen:getHeight()
39 |
40 | if Device:hasKeys() then
41 | self.key_events.Close = { { Device.input.group.Back } }
42 | end
43 | if Device:isTouchDevice() then
44 | self.ges_events.Tap = {
45 | GestureRange:new{
46 | ges = "tap",
47 | range = Geom:new{
48 | x = 0, y = 0,
49 | w = screen_width,
50 | h = screen_height,
51 | }
52 | },
53 | }
54 | end
55 |
56 | local row_span = VerticalSpan:new{ width = Size.padding.fullscreen }
57 | local frame_width = math.floor(math.min(screen_width, screen_height) * 0.85)
58 | local frame_height = math.floor(math.min(screen_width, screen_height) * 0.50)
59 | local frame_border_size = Size.border.window
60 | local frame_padding = Size.padding.fullscreen
61 | local inner_width = frame_width - 2 * (frame_border_size + frame_padding)
62 | local inner_height = frame_height - 2 * (frame_border_size + frame_padding)
63 |
64 | self.scroll_widget = ScrollHtmlWidget:new{
65 | default_font_size = Screen:scaleBySize(self.font_size),
66 | width = inner_width,
67 | height = inner_height,
68 | dialog = self,
69 | }
70 | local button_span_unit_width = Size.span.horizontal_small
71 | local larger_span_units = 3 -- 3 x small span width
72 | local nb_span_units = 2 + 2*larger_span_units
73 | local btn_width = math.floor( ((inner_width+frame_padding) - nb_span_units * button_span_unit_width) * (1/6))
74 |
75 | -- create some helper functions
76 | local update = function(opts)
77 | self.prev_c_cnt = opts.prev_c or self.prev_c_cnt
78 | self.prev_s_cnt = opts.prev_s or self.prev_s_cnt
79 | self.next_c_cnt = opts.next_c or self.next_c_cnt
80 | self.next_s_cnt = opts.next_s or self.next_s_cnt
81 | self:update_context()
82 | end
83 | local can_prepend = function() return self.note.has_prepended_content end
84 | local can_append = function() return self.note.has_appended_content end
85 | local prev_c_inc = function(inc) update({ prev_c = self.prev_c_cnt + inc}) end
86 | local next_c_inc = function(inc) update({ next_c = self.next_c_cnt + inc}) end
87 | -- char counter is reset to 0 when sentence count is changed
88 | local prev_s_inc = function(inc) update({ prev_c = 0, prev_s = self.prev_s_cnt + inc}) end
89 | local next_s_inc = function(inc) update({ next_c = 0, next_s = self.next_s_cnt + inc}) end
90 |
91 | local remove_prev_sentence = make_button("⏩", btn_width, function() prev_s_inc(-1) end, can_prepend)
92 | local remove_prev_char = make_button("1-", btn_width, function() prev_c_inc(-1) end, can_prepend)
93 | local append_prev_char = make_button("+1", btn_width, function() prev_c_inc(1) end)
94 | local append_prev_sentence = make_button("⏪", btn_width, function() prev_s_inc(1) end)
95 | local reset_prev = make_button("Reset", btn_width*2, function() self:reset_prev(); return self:update_context() end)
96 |
97 | local remove_next_sentence = make_button("⏪", btn_width, function() next_s_inc(-1) end, can_append)
98 | local remove_next_char = make_button("-1", btn_width, function() next_c_inc(-1) end, can_append)
99 | local append_next_char = make_button("1+", btn_width, function() next_c_inc(1) end)
100 | local append_next_sentence = make_button("⏩", btn_width, function() next_s_inc(1) end)
101 | local reset_next = make_button("Reset", btn_width*2, function() self:reset_next(); self:update_context() end)
102 |
103 | self.top_row = HorizontalGroup:new{
104 | align = "center",
105 | append_prev_sentence,
106 | append_prev_char,
107 | reset_prev,
108 | remove_prev_char,
109 | remove_prev_sentence,
110 | }
111 |
112 | self.bottom_row = HorizontalGroup:new{
113 | align = "center",
114 | remove_next_sentence,
115 | remove_next_char,
116 | reset_next,
117 | append_next_char,
118 | append_next_sentence,
119 | }
120 |
121 | self.confirm_row = HorizontalGroup:new{
122 | align = "center",
123 | make_button("Cancel", btn_width*2, function() self:onClose() end),
124 | make_button("Save with custom context", btn_width*4, self.on_save_cb),
125 | }
126 |
127 | self.context_menu = FrameContainer:new{
128 | margin = 0,
129 | bordersize = frame_border_size,
130 | padding = Size.padding.default,
131 | radius = Size.radius.window,
132 | background = Blitbuffer.COLOR_WHITE,
133 | VerticalGroup:new{
134 | align = "center",
135 | self.top_row,
136 | self.scroll_widget,
137 | row_span,
138 | self.bottom_row,
139 | self.confirm_row,
140 | }
141 | }
142 | self.movable = MovableContainer:new{
143 | self.context_menu,
144 | }
145 | self[1] = WidgetContainer:new{
146 | align = "center",
147 | dimen = Geom:new{
148 | x = 0, y = 0,
149 | w = screen_width,
150 | h = screen_height,
151 | },
152 | self.movable,
153 | self.confirm_row,
154 | }
155 | self:update_context()
156 | end
157 |
158 | function CustomContextMenu:onClose()
159 | UIManager:close(self)
160 | end
161 |
162 | -- used to assure we do a repaint when the menu is closed
163 | function CustomContextMenu:onCloseWidget()
164 | UIManager:setDirty(nil, function()
165 | return "flashui", self.context_menu.dimen
166 | end)
167 | end
168 |
169 | -- used to repaint widget when the text is changed
170 | function CustomContextMenu:onShow()
171 | UIManager:setDirty(self, function()
172 | return "ui", self.movable.dimen
173 | end)
174 | end
175 |
176 | function CustomContextMenu:onTap(_, ges_ev)
177 | if not ges_ev.pos:intersectWith(self.context_menu.dimen) then
178 | self:onClose()
179 | end
180 | end
181 |
182 | function CustomContextMenu:reset()
183 | self:reset_prev()
184 | self:reset_next()
185 | end
186 |
187 | function CustomContextMenu:reset_prev()
188 | self.prev_s_cnt = tonumber(config.prev_sentence_count:get_value())
189 | self.prev_c_cnt = 0
190 | end
191 |
192 | function CustomContextMenu:reset_next()
193 | self.next_s_cnt = tonumber(config.next_sentence_count:get_value())
194 | self.next_c_cnt = 0
195 | end
196 |
197 | function CustomContextMenu:update_context()
198 | local prev, next_ = self.note:get_custom_context(self.prev_s_cnt, self.prev_c_cnt, self.next_s_cnt, self.next_c_cnt)
199 | local css = [[
200 | h2 {
201 | display: inline;
202 | }
203 | .lookupword {
204 | display: inline;
205 | font-weight: bold;
206 | background-color: red;
207 | text-align: center;
208 | }
209 | @page {
210 | margin: 0;
211 | font-family: 'Noto Sans CJK';
212 | }
213 | ]]
214 | local context_fmt = '
'
215 | local context = context_fmt:format(prev, self.note.popup_dict.word, next_)
216 |
217 | self[1]:free()
218 | self.scroll_widget.htmlbox_widget:setContent(context, css, Screen:scaleBySize(self.font_size))
219 | self.scroll_widget:resetScroll()
220 | self:onShow()
221 | end
222 |
223 | return CustomContextMenu
224 |
--------------------------------------------------------------------------------
/extensions/EXT_dict_edit.lua:
--------------------------------------------------------------------------------
1 | local conf = require("anki_configuration")
2 | local DictEdit = {
3 | description = "This extension can be used to replace certain patterns in specific dictionaries.",
4 | enabled_dictionaries = {
5 | ["新明解国語辞典 第五版"] = true,
6 | ["スーパー大辞林 3.0"] = true,
7 | },
8 | patterns = {
9 | '%[[0-9]%]',
10 | '%[[0-9]%]:%[0-9%]'
11 | }
12 | }
13 |
14 | function DictEdit:run(note)
15 | local selected_dict = self.popup_dict.results[self.popup_dict.dict_index].dict
16 | if not self.enabled_dictionaries[selected_dict] then
17 | return note
18 | end
19 | local def = note.fields[conf.def_field:get_value()]
20 | for _,pattern in ipairs(self.patterns) do
21 | def = def:gsub(pattern, '')
22 | end
23 | note.fields[conf.def_field:get_value()] = def
24 | return note
25 | end
26 |
27 | return DictEdit
28 |
--------------------------------------------------------------------------------
/extensions/EXT_dict_word_lookup.lua:
--------------------------------------------------------------------------------
1 | local conf = require("anki_configuration")
2 | local CustomWordLookup = {
3 | description = "This plugin modifies the default addon behavior. Instead of saving the word selected in the book, it selects the headword in the dictionary entry itself."
4 | }
5 |
6 | function CustomWordLookup:run(note)
7 | if not self.popup_dict.is_extended then
8 | self.popup_dict.results = require("langsupport/ja/dictwrapper").extend_dictionaries(self.popup_dict.results, self.conf)
9 | self.popup_dict.is_extended = true
10 | end
11 | local selected = self.popup_dict.results[self.popup_dict.dict_index]
12 |
13 | -- TODO pick the kanji representation which matches the one we looked up
14 | local parsed_word = selected:get_kanji_words()[1] or selected:get_kana_words()[1]
15 | if parsed_word then
16 | note.fields[conf.word_field:get_value()] = parsed_word
17 | end
18 | return note
19 | end
20 |
21 | return CustomWordLookup
22 |
--------------------------------------------------------------------------------
/extensions/EXT_multi_def.lua:
--------------------------------------------------------------------------------
1 | local logger = require("logger")
2 | local u = require("lua_utils/utils")
3 |
4 | local MultiDefinition = {
5 | description = "When trying to make the monolingual transition, it can be helpful to create a card with the language in your target language, while still also inserting the definition in your native language in a separate field.",
6 | -- key: dictionary name as displayed in KOreader (received from dictionary's .ifo file)
7 | -- value: field on the note this dictionary entry should be sent to
8 | dict_field_map = {
9 | -- the below example sends dictionary entries from 'JMdict' to the field 'SentEng' on the anki note
10 | -- ["JMdict Rev. 1.9"] = "SentEng",
11 | }
12 | }
13 |
14 | function MultiDefinition:convert_dict_to_HTML(dictionaries)
15 | return self:convert_to_HTML {
16 | entries = dictionaries,
17 | class = "definition",
18 | build = function(entry, entry_template)
19 | -- TODO should we run the `definition_editor.lua` on this definition too?
20 | local def = entry.definition
21 | if entry.is_html then -- try adding dict name to opening div tag (if present)
22 | -- gsub wrapped in () so it only gives us the first result, and discards the index (2nd arg.)
23 | return (def:gsub("(
")))
26 | end
27 | }
28 | end
29 |
30 | function MultiDefinition:run(note)
31 | if not self.popup_dict.is_extended then
32 | self.popup_dict.results = require("langsupport/ja/dictwrapper").extend_dictionaries(self.popup_dict.results, self.conf)
33 | self.popup_dict.is_extended = true
34 | end
35 |
36 | local selected_dict = self.popup_dict.results[self.popup_dict.dict_index]
37 | -- map of note fields with all dictionary entries which should be combined and saved in said field
38 | local field_dict_map = u.defaultdict(function() return {} end)
39 | for idx, result in ipairs(self.popup_dict.results) do
40 | -- don't add definitions where the dict word does not match the selected dict's word
41 | -- e.g.: 罵る vs 罵り -> noun vs verb -> we only add defs for the one we selected
42 | -- the info will be mostly the same, and the pitch accent might differ between noun and verb form
43 | if selected_dict:get_kana_words():contains_any(result:get_kana_words()) then
44 | logger.info(("EXT: multi_definition: handling result: %s"):format(result:as_string()))
45 | local is_selected = idx == self.popup_dict.dict_index
46 | local field = not is_selected and self.dict_field_map[result.dict]
47 | if field then
48 | local field_defs = field_dict_map[field]
49 | -- make sure that the selected dictionary is always inserted in the beginning
50 | table.insert(field_defs, is_selected and 1 or #field_defs+1, result)
51 | end
52 | else
53 | local skip_msg = "Skipping %s dict entry: kana word '%s' ~= selected dict word '%s'"
54 | logger.info(skip_msg:format(result.dict, result:get_kana_words(), selected_dict:get_kana_words()))
55 | end
56 | end
57 | for field, dicts in pairs(field_dict_map) do
58 | note.fields[field] = self:convert_dict_to_HTML(dicts)
59 | end
60 | return note
61 | end
62 |
63 | return MultiDefinition
64 |
--------------------------------------------------------------------------------
/extensions/EXT_pitch_accent.lua:
--------------------------------------------------------------------------------
1 | local logger = require("logger")
2 | local util = require("util")
3 | local u = require("lua_utils/utils")
4 |
5 | local PitchAccent = {
6 | description = [[
7 | Some definitions contain pitch accent information.
8 | e.g. さけ・ぶ [2]【叫ぶ】
9 | this extension extracts the [2] from the definition's headword and stores it as a html representation and/or a number.
10 | ]],
11 | -- These 2 fields should be modified to point to the desired field on the card
12 | field_pitch_html = 'VocabPitchPattern',
13 | field_pitch_num = 'VocabPitchNum',
14 |
15 | -- bunch of DOM element templates used to display pitch accent
16 | pitch_pattern = "%s",
17 | mark_accented = "%s",
18 | mark_downstep = "%s",
19 | unmarked_char = "%s",
20 | pitch_downstep_pattern = "(%[([0-9])%])",
21 | }
22 |
23 | function PitchAccent:convert_pitch_to_HTML(accents)
24 | local converter = nil
25 | if #accents == 0 then
26 | converter = function(_) return nil end
27 | elseif #accents == 1 then
28 | converter = function(field) return accents[1][field] end
29 | else
30 | converter = function(field) return self:convert_to_HTML {
31 | entries = accents,
32 | class = "pitch",
33 | build = function(accent) return string.format("
%s", accent[field]) end
34 | }
35 | end
36 | end
37 | return converter("pitch_num"), converter("pitch_accent")
38 | end
39 |
40 | function PitchAccent:split_morae(word)
41 | local small_aeio = u.to_set(util.splitToChars("ゅゃぃぇょゃ"))
42 | local morae = u.defaultdict(function() return {} end)
43 | for _,ch in ipairs(util.splitToChars(word)) do
44 | local is_small = small_aeio[ch] or false
45 | table.insert(morae[is_small and #morae or #morae+1], ch)
46 | end
47 | logger.info(("EXT: PitchAccent#split_morae(): split word %s into %d morae: "):format(word, #morae))
48 | return morae
49 | end
50 |
51 | local function get_first_line(linestring)
52 | local start_idx = linestring:find('\n', 1, true)
53 | return start_idx and linestring:sub(1, start_idx + 1) or linestring
54 | end
55 |
56 | function PitchAccent:get_pitch_downsteps(dict_result)
57 | return string.gmatch(get_first_line(dict_result.definition), self.pitch_downstep_pattern)
58 | end
59 |
60 |
61 | function PitchAccent:get_pitch_accents(dict_result)
62 | local _morae = nil
63 | local function get_morae()
64 | if not _morae then
65 | _morae = self:split_morae(dict_result:get_kana_words()[1])
66 | end
67 | return _morae
68 | end
69 |
70 | local function _convert(downstep)
71 | local pitch_visual = {}
72 | local is_heiban = downstep == "0"
73 | for idx, mora in ipairs(get_morae()) do
74 | local marking = nil
75 | if is_heiban then
76 | marking = idx == 1 and self.unmarked_char or self.mark_accented
77 | else
78 | if idx == tonumber(downstep) then
79 | marking = self.mark_downstep
80 | else
81 | marking = idx < tonumber(downstep) and self.mark_accented or self.unmarked_char
82 | end
83 | end
84 | -- when dealing with the downstep mora, we want the downstep to appear only on the last char of the mora
85 | local is_downstep = marking == self.mark_downstep
86 | logger.dbg("EXT: PitchAccent#get_pitch_accent(): determined marking for mora: ", idx, table.concat(mora), marking)
87 | for _, ch in ipairs(mora) do
88 | table.insert(pitch_visual, (is_downstep and self.mark_accented or marking):format(ch))
89 | end
90 | if is_downstep then
91 | pitch_visual[#pitch_visual] = self.mark_downstep:format(mora[#mora])
92 | end
93 | end
94 | return self.pitch_pattern:format(table.concat(pitch_visual))
95 | end
96 |
97 | local downstep_iter = self:get_pitch_downsteps(dict_result)
98 | return function(iter)
99 | local with_brackets, downstep = iter()
100 | if downstep then
101 | return with_brackets, _convert(downstep)
102 | end
103 | end, downstep_iter
104 | end
105 |
106 | function PitchAccent:run(note)
107 | if not self.popup_dict.is_extended then
108 | self.popup_dict.results = require("langsupport/ja/dictwrapper").extend_dictionaries(self.popup_dict.results, self.conf)
109 | self.popup_dict.is_extended = true
110 | end
111 | local selected = self.popup_dict.results[self.popup_dict.dict_index]
112 |
113 | local pitch_accents = {}
114 | for _, result in ipairs(self.popup_dict.results) do
115 | if selected:get_kana_words():contains_any(result:get_kana_words()) then
116 | for num, accent in self:get_pitch_accents(result) do
117 | if not pitch_accents[num] then
118 | pitch_accents[num] = true -- add as k/v pair too so we can detect uniqueness
119 | table.insert(pitch_accents, { pitch_num = num, pitch_accent = accent })
120 | end
121 | end
122 | end
123 | end
124 | local pitch_num, pitch_accent = self:convert_pitch_to_HTML(pitch_accents)
125 | note.fields[self.field_pitch_num] = pitch_num
126 | note.fields[self.field_pitch_html] = pitch_accent
127 | return note
128 | end
129 |
130 | return PitchAccent
131 |
--------------------------------------------------------------------------------
/extensions/README.md:
--------------------------------------------------------------------------------
1 | # Extensions
2 |
3 | Custom behavior for note creation.
4 |
5 | Any .lua file present in the ./extensions folder will be loaded by the add-on, provided the filename starts with the "EXT_" prefix.
6 |
7 | ## Format
8 |
9 | An extension has the following format:
10 |
11 | ```lua
12 | local CustomExtension = {
13 | definition = "This extension does a thing to the note!" -- this can be left out
14 | }
15 | -- this is called when user creates a note
16 | function CustomExtension:run(note)
17 | -- make some additions to note we are about to save..
18 | return note
19 | end
20 | return CustomExtension
21 | ```
22 |
23 | ### The `note` parameter
24 |
25 | the `run` function shown above takes a `note` parameter.
26 | This parameter is a Lua table containing all the data that will be sent to Anki.
27 | The contents of this table are based on the 'note' parameter in the JSON request that is sent to anki-connect.
28 | An example can be seen in the [documentation](https://github.com/FooSoft/anki-connect#addnote) of the `addNote` action.
29 | The 'note' parameter in the example request has the same fields as the Lua table parameter.
30 |
31 | ### AnkiNote context
32 |
33 | On loading the user extensions, the user is also given access to the AnkiNote table as well (see ankinote.lua).
34 |
35 | This code can be accessed through the `self` parameter.
36 |
37 | In the example below, the extension prints the dictionary name received through this paramter.
38 |
39 | ```lua
40 | local Extension = {}
41 | function Extension:run(note)
42 | local selected_dict = self.popup_dict.results[self.popup_dict.dict_index].dict
43 | print(("Currently selected dictionary: %s"):format(selected_dict))
44 | return note
45 | end
46 | return Extension
47 | ```
48 |
49 | ### User configuration
50 |
51 | The user config can be accessed by importing the `anki_configuration.lua` module.
52 |
53 | The example below gets the entry for the `word_field` from the user config, and then saves it in the custom "word" and "key" fields.
54 |
55 | ```lua
56 | local conf = require("anki_configuration")
57 |
58 | local JP_mining_note = {}
59 |
60 | function JP_mining_note:run(note)
61 | local vocab_word = note.fields[conf.word_field:get_value()]
62 | note.fields["word"] = vocab_word
63 | note.fields["key"] = vocab_word
64 | return note
65 | end
66 | return JP_mining_note
67 | ```
68 |
--------------------------------------------------------------------------------
/forvo.lua:
--------------------------------------------------------------------------------
1 | --[[
2 | Copyright: Ren Tatsumoto and contributors
3 | License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html
4 |
5 | Utils for downloading pronunciations from Forvo
6 | ]]
7 |
8 | local http = require("socket.http")
9 | local socket = require("socket")
10 | local ltn12 = require("ltn12")
11 | local socketutil = require("socketutil")
12 |
13 |
14 | local function GET(url)
15 | local sink = {}
16 | socketutil:set_timeout(socketutil.LARGE_BLOCK_TIMEOUT, socketutil.LARGE_TOTAL_TIMEOUT)
17 | local request = {
18 | url = url,
19 | method = "GET",
20 | headers = {
21 | ['User-Agent'] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
22 | ['Host'] = 'forvo.com',
23 | ['Accept-Language'] = "en-US,en;q=0.9",
24 | ['Accept'] = "*/*"
25 | },
26 | sink = ltn12.sink.table(sink),
27 | }
28 | local code, _, status = socket.skip(1, http.request(request))
29 | if code == 200 then
30 | return table.concat(sink)
31 | end
32 | -- Special handling for 403 error (likely rate limit or access restriction)
33 | if code == 403 then
34 | return false, "FORVO_403"
35 | end
36 | return false, ("[%d]: %s"):format(code or -1, status or "")
37 | end
38 |
39 | -- http://lua-users.org/wiki/BaseSixtyFour
40 | -- character table string
41 | local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
42 | local function base64e(data)
43 | return ((data:gsub('.', function(x)
44 | local r,b='',x:byte()
45 | for i=8,1,-1 do r=r..(b%2^i-b%2^(i-1)>0 and '1' or '0') end
46 | return r;
47 | end)..'0000'):gsub('%d%d%d?%d?%d?%d?', function(x)
48 | if (#x < 6) then return '' end
49 | local c=0
50 | for i=1,6 do c=c+(x:sub(i,i)=='1' and 2^(6-i) or 0) end
51 | return b:sub(c+1,c+1)
52 | end)..({ '', '==', '=' })[#data%3+1])
53 | end
54 |
55 | local function base64d(data)
56 | data = string.gsub(data, '[^'..b..'=]', '')
57 | return (data:gsub('.', function(x)
58 | if (x == '=') then return '' end
59 | local r,f='',(b:find(x)-1)
60 | for i=6,1,-1 do r=r..(f%2^i-f%2^(i-1)>0 and '1' or '0') end
61 | return r;
62 | end):gsub('%d%d%d?%d?%d?%d?%d?%d?', function(x)
63 | if (#x ~= 8) then return '' end
64 | local c=0
65 | for i=1,8 do c=c+(x:sub(i,i)=='1' and 2^(8-i) or 0) end
66 | return string.char(c)
67 | end))
68 | end
69 |
70 |
71 | local function url_encode(url)
72 | -- https://gist.github.com/liukun/f9ce7d6d14fa45fe9b924a3eed5c3d99
73 | local char_to_hex = function(c)
74 | return string.format("%%%02X", string.byte(c))
75 | end
76 | if url == nil then
77 | return
78 | end
79 | url = url:gsub("\n", "\r\n")
80 | url = url:gsub("([^%w _%%%-%.~])", char_to_hex)
81 | url = url:gsub(" ", "+")
82 | return url
83 | end
84 |
85 | local function get_pronunciation_url(word, language)
86 | local forvo_url = ('https://forvo.com/search/%s/%s'):format(url_encode(word), language)
87 | local forvo_page, err = GET(forvo_url)
88 | if not forvo_page then
89 | return false, err
90 | end
91 | local play_params = string.match(forvo_page, "Play%((.-)%);")
92 |
93 | local word_url = nil
94 | if play_params then
95 | local iter = string.gmatch(play_params, "'(.-)'")
96 | local formats = { mp3 = iter(), ogg = iter() }
97 | word_url = string.format('https://audio00.forvo.com/%s/%s', "ogg", base64d(formats["ogg"]))
98 | end
99 | return true, word_url
100 | end
101 |
102 | return {
103 | get_pronunciation_url = get_pronunciation_url,
104 | base64e = base64e,
105 | }
106 |
--------------------------------------------------------------------------------
/langsupport/ja/dictwrapper.lua:
--------------------------------------------------------------------------------
1 | local util = require("util")
2 | local List = require("lua_utils.list")
3 | -- utility which wraps a dictionary sub-entry (the popup shown when looking up a word)
4 | -- with some extra functionality which isn't there by default
5 | DictEntryWrapper = {
6 | -- currently unused but might come in handy, scavenged from yomichan
7 | kana = 'うゔ-かが-きぎ-くぐ-けげ-こご-さざ-しじ-すず-せぜ-そぞ-ただ-ちぢ-つづ-てで-とど-はばぱひびぴふぶぷへべぺほぼぽワヷ-ヰヸ-ウヴ-ヱヹ-ヲヺ-カガ-キギ-クグ-ケゲ-コゴ-サザ-シジ-スズ-セゼ-ソゾ-タダ-チヂ-ツヅ-テデ-トド-ハバパヒビピフブプヘベペホボポ',
8 | kana_word_pattern = "(.*)【.*】",
9 | kanji_word_pattern = "【(.*)】",
10 | kanji_sep_chr = '・',
11 | -- A pattern can be provided which for each dictionary extracts the kana reading(s) of the word which was looked up.
12 | -- This is used to determine which dictionary entries should be added to the card (e.g. 帰り vs 帰る: if the noun was selected, the verb is skipped)
13 | -- if no pattern is provided for a given dictionary, we fall back on the patterns listed above
14 | kana_pattern = {
15 | -- key: dictionary name as displayed in KOreader (received from dictionary's .ifo file)
16 | -- value: a table containing 2 entries:
17 | -- 1) the dictionary field to look for the kana reading in (either 'word' or 'description')
18 | -- 2) a pattern which should return the kana reading(s) (the pattern will be looked for multiple times!)
19 | ["JMdict Rev. 1.9"] = {"definition", "(.-)"},
20 | },
21 | -- A pattern can be provided which for each dictionary extracts the kanji reading(s) of the word which was looked up.
22 | -- This is used to store in the `word_field` defined above
23 | kanji_pattern = {
24 | -- key: dictionary name as displayed in KOreader (received from dictionary's .ifo file)
25 | -- value: a table containing 2 entries:
26 | -- 1) the dictionary field to look for the kanji in (either 'word' or 'description')
27 | -- 2) a pattern which should return the kanji
28 | ["JMdict Rev. 1.9"] = {"word", ".*"},
29 | }
30 | }
31 |
32 |
33 | function DictEntryWrapper.extend_dictionaries(results, config)
34 | local extended = {}
35 | for idx,dict in ipairs(results) do
36 | extended[idx] = DictEntryWrapper:new{
37 | dict = dict,
38 | conf = config
39 | }
40 | end
41 | return extended
42 | end
43 |
44 | function DictEntryWrapper:new(opts)
45 | self.conf = opts.conf
46 |
47 | local index = function(table, k)
48 | return rawget(table, k) or rawget(self, k) or rawget(table.dictionary, k)
49 | end
50 | local kana_dictionary_field, kana_pattern = unpack(self.kana_pattern[opts.dict.dict] or {})
51 | local kanji_dictionary_field, kanji_pattern = unpack(self.kanji_pattern[opts.dict.dict] or {})
52 | local data = {
53 | dictionary = opts.dict,
54 | kana_pattern = kana_pattern or self.kana_word_pattern,
55 | kana_dict_field = kana_dictionary_field or "word",
56 | kanji_pattern = kanji_pattern or self.kanji_word_pattern,
57 | kanji_dict_field = kanji_dictionary_field or "word",
58 | }
59 | return setmetatable(data, { __index = function(table, k) return index(table, k) end })
60 | end
61 |
62 | function DictEntryWrapper:get_kana_words()
63 | local entries = List:from_iter(self.dictionary[self.kana_dict_field]:gmatch(self.kana_pattern))
64 | -- if the pattern doesn't match, return the plain word, chances are it's already in kana
65 | return entries:is_empty() and List:new({self.dictionary.word}) or entries
66 | end
67 |
68 | function DictEntryWrapper:get_kanji_words()
69 | local kanji_entries_str = self.dictionary[self.kanji_dict_field]:match(self.kanji_pattern)
70 | local brackets = { ['('] = 0, [')'] = 0, ['('] = 0, [')'] = 0 }
71 | -- word entries often look like this: ある【有る・在る】
72 | -- the kanji_match_pattern will give us: 有る・在る
73 | -- these 2 entries still need to be separated
74 | local kanji_entries, current = {}, {}
75 | for _,ch in pairs(util.splitToChars(kanji_entries_str)) do
76 | if ch == self.kanji_sep_chr then
77 | table.insert(kanji_entries, table.concat(current))
78 | current = {}
79 | elseif brackets[ch] then
80 | -- some entries look like this: '振(り)方', the brackets should be ignored
81 | else
82 | table.insert(current, ch)
83 | end
84 | end
85 | if #current > 0 then
86 | table.insert(kanji_entries, table.concat(current))
87 | end
88 | return List:new(kanji_entries)
89 | end
90 |
91 | function DictEntryWrapper:as_string()
92 | local fmt_string = "DictEntryWrapper: (%s) word: %s, kana: %s, kanji: %s"
93 | return fmt_string:format(self.dictionary.dict, self.dictionary.word, self:get_kana_words(), self:get_kanji_words())
94 | end
95 |
96 | return DictEntryWrapper
97 |
--------------------------------------------------------------------------------
/lua_utils/list.lua:
--------------------------------------------------------------------------------
1 | local List = {}
2 | local list_mt = {
3 | __index = function(t, k) return rawget(List, k) or rawget(t:get(), k) end,
4 | __tostring = function(x) return "['" .. table.concat(x:get(), "', '") .. "']" end
5 | }
6 |
7 | function List:new(table)
8 | local data = table
9 | for _,x in ipairs(data) do data[x] = true end
10 | return setmetatable({ _data = table }, list_mt)
11 | end
12 |
13 | function List:from_iter(iter)
14 | local data = {}
15 | for x in iter do
16 | table.insert(data, x)
17 | data[x] = true
18 | end
19 | return setmetatable({ _data = data }, list_mt)
20 | end
21 |
22 | function List:get()
23 | return rawget(self, "_data")
24 | end
25 |
26 | function List:size() return #self:get() end
27 |
28 | function List:is_empty() return self:size() == 0 end
29 |
30 | function List:contains(item)
31 | return self:get()[item] ~= nil
32 | end
33 |
34 | function List:contains_any(other)
35 | for _,x in ipairs(self:get()) do
36 | if other:contains(x) then return true end
37 | end
38 | return false
39 | end
40 |
41 | function List:group_by(grouping_func)
42 | local grouped_mt = { __index = function(t, k) return rawget(t,k) or rawset(t, k, {})[k] end }
43 | local grouped = setmetatable({}, grouped_mt)
44 | for _,x in ipairs(self:get()) do
45 | table.insert(grouped[grouping_func(x)], x)
46 | end
47 | return self:new(grouped)
48 | end
49 |
50 | function List:add(item)
51 | assert(self:get()[item] == nil, "Item already present!")
52 | table.insert(self:get(), item)
53 | self:get()[item] = true
54 | end
55 |
56 | function List:remove(item)
57 | local index = nil
58 | for idx,v in ipairs(self:get()) do
59 | if v == item then
60 | index = idx
61 | break
62 | end
63 | end
64 | if index then
65 | table.remove(self:get(), index)
66 | self:get()[item] = nil
67 | end
68 | end
69 |
70 | return List
71 |
--------------------------------------------------------------------------------
/lua_utils/utils.lua:
--------------------------------------------------------------------------------
1 | local utils = {}
2 |
3 | function utils.get_extension(filename)
4 | return filename:match("%.([%a]+)$")
5 | end
6 |
7 | function utils.read_file(filename, line_parser)
8 | local fn_not_found = "ERROR: file %q was not found!"
9 | line_parser = line_parser or function(x) return x end
10 | local f, data = io.open(filename, 'r'), {}
11 | assert(f, fn_not_found:format(filename))
12 | for line in f:lines("*l") do
13 | table.insert(data, line_parser(line))
14 | end
15 | return data
16 | end
17 |
18 | function utils.open_file(filename, mode, callback)
19 | local f = io.open(filename, mode)
20 | if not f then
21 | return
22 | end
23 | local res = callback(f)
24 | f:close()
25 | return res
26 | end
27 |
28 | function utils.split(input, sep, is_regex)
29 | local splits, last_idx, plain = {}, 1, true
30 | local function add_substring(from, to)
31 | local split = input:sub(from,to)
32 | if #split > 0 then
33 | splits[#splits+1] = split
34 | end
35 | end
36 | if is_regex == true then
37 | plain = false
38 | end
39 |
40 | while true do
41 | local s,e = input:find(sep, last_idx, plain)
42 | if s == nil then
43 | break
44 | end
45 | add_substring(last_idx, s-1)
46 | last_idx = e+1
47 | end
48 | add_substring(last_idx, #input)
49 | return splits
50 | end
51 |
52 | function utils.defaultdict(func)
53 | local f = type(func) == 'function' and func or function() return func end
54 | local mt = { __index = function(t, idx) return rawget(t, idx) or rawset(t, idx, f())[idx] end }
55 | return setmetatable({}, mt)
56 | end
57 |
58 | function utils.table_to_set(t, in_place)
59 | local t_ = (in_place or true) and t or {}
60 | for i,v in ipairs(t) do
61 | assert(utils.is_numeric(v) == false, "Table t should not contain numeric values!")
62 | t_[v] = i
63 | end
64 | return t_
65 | end
66 |
67 | function utils.path_exists(path)
68 | local f = io.open(path, 'r')
69 | if f then
70 | f:close()
71 | return true
72 | end
73 | return false
74 | end
75 |
76 | function utils.run_cmd(cmd)
77 | local output = {}
78 | local f = io.popen(cmd, 'r')
79 | for line in f:lines("*l") do
80 | table.insert(output, line)
81 | end
82 | f:close()
83 | return output
84 | end
85 |
86 | function utils.iterate_cmd(cmd)
87 | local output = {}
88 | local f = io.popen(cmd, 'r')
89 | for line in f:lines("*l") do
90 | table.insert(output, line)
91 | end
92 | f:close()
93 | return function()
94 | return table.remove(output, 1)
95 | end
96 | end
97 |
98 | function utils.strip_path(path)
99 | local stripped = string.match(path, "^.*/([^/]+)$")
100 | return stripped or path
101 | end
102 |
103 | function utils.dir_name(path)
104 | return utils.run_cmd(string.format("dirname %q", path))[1]
105 | end
106 |
107 |
108 | function utils.is_numeric_int(s)
109 | return string.match(s, "^%d+$") ~= nil
110 | end
111 |
112 | function utils.is_numeric(str)
113 | return string.match(str, "^-?[%d%.]+$")
114 | end
115 |
116 | function utils.to_set(list)
117 | local set = {}
118 | for _,v in pairs(list) do
119 | set[v] = true
120 | end
121 | return set
122 | end
123 |
124 |
125 | return utils
126 |
--------------------------------------------------------------------------------
/main.lua:
--------------------------------------------------------------------------------
1 | local ButtonDialog = require("ui/widget/buttondialog")
2 | local CustomContextMenu = require("customcontextmenu")
3 | local DataStorage = require("datastorage")
4 | local DictQuickLookup = require("ui/widget/dictquicklookup")
5 | local InfoMessage = require("ui/widget/infomessage")
6 | local LuaSettings = require("luasettings")
7 | local MenuBuilder = require("menubuilder")
8 | local RadioButtonWidget = require("ui/widget/radiobuttonwidget")
9 | local Widget = require("ui/widget/widget")
10 | local UIManager = require("ui/uimanager")
11 | local logger = require("logger")
12 | local util = require("util")
13 | local _ = require("gettext")
14 |
15 | local lfs = require("libs/libkoreader-lfs")
16 | local AnkiConnect = require("ankiconnect")
17 | local AnkiNote = require("ankinote")
18 | local Configuration = require("anki_configuration")
19 |
20 | local AnkiWidget = Widget:extend {
21 | known_document_profiles = LuaSettings:open(DataStorage:getSettingsDir() .. "/anki_profiles.lua"),
22 | anki_note = nil,
23 | anki_connect = nil,
24 | }
25 |
26 | function AnkiWidget:show_profiles_widget(opts)
27 | local buttons = {}
28 | for name, _ in pairs(Configuration.profiles) do
29 | table.insert(buttons, { { text = name, provider = name, checked = Configuration:is_active(name) } })
30 | end
31 | if #buttons == 0 then
32 | local msg = [[Failed to load profiles, there are none available, create a profile first. See the README on GitHub for more details.]]
33 | return UIManager:show(InfoMessage:new { text = msg, timeout = 4 })
34 | end
35 |
36 | self.profile_change_widget = RadioButtonWidget:new{
37 | title_text = opts.title_text,
38 | info_text = opts.info_text,
39 | cancel_text = "Cancel",
40 | ok_text = "Accept",
41 | width_factor = 0.9,
42 | radio_buttons = buttons,
43 | callback = function(radio)
44 | local profile = radio.provider:gsub(".lua$", "", 1)
45 | Configuration:load_profile(profile)
46 | self.profile_change_widget:onClose()
47 | local _, file_name = util.splitFilePathName(self.ui.document.file)
48 | self.known_document_profiles:saveSetting(file_name, profile)
49 | opts.cb()
50 | end,
51 | }
52 | UIManager:show(self.profile_change_widget)
53 | end
54 |
55 | function AnkiWidget:show_config_widget()
56 | local note_count = #self.anki_connect.local_notes
57 | local with_custom_tags_cb = function()
58 | self.current_note:add_tags(Configuration.custom_tags:get_value())
59 | self.anki_connect:add_note(self.current_note)
60 | self.config_widget:onClose()
61 | end
62 | self.config_widget = ButtonDialog:new {
63 | buttons = {
64 | {{ text = ("Sync (%d) offline note(s)"):format(note_count), id = "sync", enabled = note_count > 0, callback = function() self.anki_connect:sync_offline_notes() end }},
65 | {{ text = "Add with custom tags", id = "custom_tags", callback = with_custom_tags_cb }},
66 | {{
67 | text = "Add with custom context",
68 | id = "custom_context",
69 | enabled = self.current_note.contextual_lookup,
70 | callback = function() self:set_profile(function() return self:show_custom_context_widget() end) end
71 | }},
72 | {{
73 | text = "Delete latest note",
74 | id = "note_delete",
75 | enabled = self.anki_connect.latest_synced_note ~= nil,
76 | callback = function()
77 | self.anki_connect:delete_latest_note()
78 | self.config_widget:onClose()
79 | end
80 | }},
81 | {{
82 | text = "Change profile",
83 | id = "profile_change",
84 | callback = function()
85 | self:show_profiles_widget {
86 | title_text = "Change user profile",
87 | info_text = "Use a different profile",
88 | cb = function() end
89 | }
90 | end
91 | }}
92 | },
93 | }
94 | UIManager:show(self.config_widget)
95 | end
96 |
97 | function AnkiWidget:show_custom_context_widget()
98 | local function on_save_cb()
99 | local m = self.context_menu
100 | self.current_note:set_custom_context(m.prev_s_cnt, m.prev_c_cnt, m.next_s_cnt, m.next_c_cnt)
101 | self.anki_connect:add_note(self.current_note)
102 | self.context_menu:onClose()
103 | self.config_widget:onClose()
104 | end
105 | self.context_menu = CustomContextMenu:new{
106 | note = self.current_note, -- to extract context out of
107 | on_save_cb = on_save_cb, -- called when saving note with updated context
108 | }
109 | UIManager:show(self.context_menu)
110 | end
111 |
112 | -- [[
113 | -- This function name is not chosen at random. There are 2 places where this function is called:
114 | -- - frontend/apps/filemanager/filemanagermenu.lua
115 | -- - frontend/apps/reader/modules/readermenu.lua
116 | -- These call the function `pcall(widget.addToMainMenu, widget, self.menu_items)` which lets other widgets add
117 | -- items to the dictionary menu
118 | -- ]]
119 | function AnkiWidget:addToMainMenu(menu_items)
120 | -- TODO an option to create a new profile (based on existing ones) would be cool
121 | local builder = MenuBuilder:new{
122 | extensions = self.extensions,
123 | ui = self.ui
124 | }
125 | menu_items.anki_settings = { text = ("Anki Settings"), sub_item_table = builder:build() }
126 | end
127 |
128 | function AnkiWidget:load_extensions()
129 | self.extensions = {} -- contains filenames by numeric index, loaded modules by value
130 | local ext_directory = DataStorage:getFullDataDir() .. "/plugins/anki.koplugin/extensions/"
131 |
132 | for file in lfs.dir(ext_directory) do
133 | if file:match("^EXT_.*%.lua") then
134 | table.insert(self.extensions, file)
135 | local ext_module = assert(loadfile(ext_directory .. file))()
136 | self.extensions[file] = ext_module
137 | end
138 | end
139 | table.sort(self.extensions)
140 | end
141 |
142 | -- This function is called automatically for all tables extending from Widget
143 | function AnkiWidget:init()
144 | self:load_extensions()
145 | self.anki_connect = AnkiConnect:new {
146 | ui = self.ui
147 | }
148 | self.anki_note = AnkiNote:extend {
149 | ui = self.ui,
150 | ext_modules = self.extensions
151 | }
152 |
153 | -- this holds the latest note created by the user!
154 | self.current_note = nil
155 |
156 | self.ui.menu:registerToMainMenu(self)
157 | self:handle_events()
158 | end
159 |
160 | function AnkiWidget:extend_doc_settings(filepath, document_properties)
161 | local _, file = util.splitFilePathName(filepath)
162 | local file_pattern = "^%[([^%]]-)%]_(.-)_%[([^%]]-)%]%.[^%.]+"
163 | local f_author, f_title, f_extra = file:match(file_pattern)
164 | local file_properties = {
165 | title = f_title,
166 | author = f_author,
167 | description = f_extra,
168 | }
169 | local get_prop = function(property)
170 | local d_p, f_p = document_properties[property], file_properties[property]
171 | local d_len, f_len = d_p and #d_p or 0, f_p and #f_p or 0
172 | -- if our custom f_p match is more exact, pick that one
173 | -- e.g. for PDF the title is usually the full filename
174 | local f_p_more_precise = d_len == 0 or d_len > f_len and f_len ~= 0
175 | return f_p_more_precise and f_p or d_p
176 | end
177 | local metadata = {
178 | title = get_prop('display_title') or get_prop('title'),
179 | author = get_prop('author') or get_prop('authors'),
180 | description = get_prop('description'),
181 | current_page = function() return self.ui.view.state.page end,
182 | language = document_properties.language,
183 | pages = function() return document_properties.pages or self.ui.doc_settings:readSetting("doc_pages") end
184 | }
185 | local metadata_mt = {
186 | __index = function(t, k) return rawget(t, k) or "N/A" end
187 | }
188 | logger.dbg("AnkiWidget:extend_doc_settings#", filepath, document_properties, metadata)
189 | self.ui.document._anki_metadata = setmetatable(metadata, metadata_mt)
190 | end
191 |
192 | function AnkiWidget:set_profile(callback)
193 | local _, file_name = util.splitFilePathName(self.ui.document.file)
194 | local user_profile = self.known_document_profiles:readSetting(file_name)
195 | if user_profile and Configuration.profiles[user_profile] then
196 | local ok, err = pcall(Configuration.load_profile, Configuration, user_profile)
197 | if not ok then
198 | return UIManager:show(InfoMessage:new { text = ("Could not load profile %s: %s"):format(user_profile, err), timeout = 4 })
199 | end
200 | return callback()
201 | end
202 |
203 | local info_text = "Choose the profile to link with this document."
204 | if user_profile then
205 | info_text = ("Document was associated with the non-existing profile '%s'.\nPlease pick a different profile to link with this document."):format(user_profile)
206 | end
207 |
208 | self:show_profiles_widget {
209 | title_text = "Set user profile",
210 | info_text = info_text,
211 | cb = function()
212 | callback()
213 | end
214 | }
215 | end
216 |
217 | function AnkiWidget:handle_events()
218 | -- these all return false so that the event goes up the chain, other widgets might wanna react to these events
219 | self.onCloseWidget = function()
220 | self.known_document_profiles:close()
221 | Configuration:save()
222 | end
223 |
224 | self.onSuspend = function()
225 | Configuration:save()
226 | end
227 |
228 | self.onNetworkConnected = function()
229 | self.anki_connect.wifi_connected = true
230 | end
231 |
232 | self.onNetworkDisconnected = function()
233 | self.anki_connect.wifi_connected = false
234 | end
235 |
236 | self.onReaderReady = function(obj, doc_settings)
237 | self.anki_connect:load_notes()
238 | -- Insert new button in the popup dictionary to allow adding anki cards
239 | -- TODO disable button if lookup was not contextual
240 | DictQuickLookup.tweak_buttons_func = function(popup_dict, buttons)
241 | self.add_to_anki_btn = {
242 | id = "add_to_anki",
243 | text = _("Add to Anki"),
244 | font_bold = true,
245 | callback = function()
246 | self:set_profile(function()
247 | self.current_note = self.anki_note:new(popup_dict)
248 | self.anki_connect:add_note(self.current_note)
249 | end)
250 | end,
251 | hold_callback = function()
252 | self:set_profile(function()
253 | self.current_note = self.anki_note:new(popup_dict)
254 | self:show_config_widget()
255 | end)
256 | end,
257 | }
258 | table.insert(buttons, 1, { self.add_to_anki_btn })
259 | end
260 | local filepath = doc_settings.data.doc_path
261 | self:extend_doc_settings(filepath, self.ui.bookinfo:getDocProps(filepath, doc_settings.doc_props))
262 | end
263 |
264 | self.onBookMetadataChanged = function(obj, updated_props)
265 | local filepath = updated_props.filepath
266 | self:extend_doc_settings(filepath, self.ui.bookinfo:getDocProps(filepath, updated_props.doc_props))
267 | end
268 | end
269 |
270 | function AnkiWidget:onDictButtonsReady(popup_dict, buttons)
271 | if self.ui and not self.ui.document then
272 | return
273 | end
274 | self.add_to_anki_btn = {
275 | id = "add_to_anki",
276 | text = _("Add to Anki"),
277 | font_bold = true,
278 | callback = function()
279 | self:set_profile(function()
280 | self.current_note = self.anki_note:new(popup_dict)
281 | self.anki_connect:add_note(self.current_note)
282 | end)
283 | end,
284 | hold_callback = function()
285 | self:set_profile(function()
286 | self.current_note = self.anki_note:new(popup_dict)
287 | self:show_config_widget()
288 | end)
289 | end,
290 | }
291 | table.insert(buttons, 1, { self.add_to_anki_btn })
292 | end
293 |
294 | return AnkiWidget
295 |
--------------------------------------------------------------------------------
/menubuilder.lua:
--------------------------------------------------------------------------------
1 | local ConfirmBox = require("ui/widget/confirmbox")
2 | local UIManager = require("ui/uimanager")
3 | local InfoMessage = require("ui/widget/infomessage")
4 | local InputDialog = require("ui/widget/inputdialog")
5 | local MultiInputDialog = require("ui/widget/multiinputdialog")
6 | local util = require("util")
7 | local List = require("lua_utils.list")
8 | local config = require("anki_configuration")
9 |
10 | local general_settings = { "generic_settings", "General Settings" }
11 | local note_settings = { "note_settings", "Anki Note Settings" }
12 | local dictionary_settings = { "dictionary_settings", "Dictionary Settings" }
13 |
14 | -- 'raw' entries containing the strings displayed in the menu
15 | -- keys in the list should match the id of the underlying config option
16 | local menu_entries = {
17 | {
18 | id = "url",
19 | group = general_settings,
20 | name = "AnkiConnect URL",
21 | description = "The URL anki_connect is listening on.",
22 | },
23 | {
24 | id = "api_key",
25 | group = general_settings,
26 | name = "AnkiConnect API key",
27 | description = "An optional API key to secure the connection.",
28 | },
29 | {
30 | id = "deckName",
31 | group = general_settings,
32 | name = "Anki Deckname",
33 | description = "The name of the deck the new notes should be added to.",
34 | },
35 | {
36 | id = "modelName",
37 | group = general_settings,
38 | name = "Anki Note Type",
39 | description = "The Anki note type our cards should use.",
40 | },
41 | {
42 | id = "allow_dupes",
43 | group = general_settings,
44 | name = "Allow Duplicates",
45 | description = "Allow creation of duplicate notes",
46 | conf_type = "bool",
47 | },
48 | {
49 | id = "dupe_scope",
50 | group = general_settings,
51 | name = "Duplicate Scope",
52 | description = "Anki Scope in which to look for duplicates",
53 | conf_type = "text",
54 | },
55 | {
56 | id = "custom_tags",
57 | group = general_settings,
58 | name = "Custom Note Tags",
59 | description = "Provide custom tags to be added to a note.",
60 | conf_type = "list",
61 | },
62 | {
63 | id = "word_field",
64 | group = note_settings,
65 | name = "Word Field",
66 | description = "Anki field for selected word.",
67 | },
68 | {
69 | id = "context_field",
70 | group = note_settings,
71 | name = "Context Field",
72 | description = "Anki field for sentence selected word occured in.",
73 | },
74 | {
75 | id = "translated_context_field",
76 | group = note_settings,
77 | name = "Translated Context Field",
78 | description = "Anki Field for the translation of the sentence the selected word occured in."
79 | },
80 | {
81 | id = "def_field",
82 | group = note_settings,
83 | name = "Glossary Field",
84 | description = "Anki field for dictionary glossary.",
85 | },
86 | {
87 | id = "meta_field",
88 | group = note_settings,
89 | name = "Metadata Field",
90 | description = "Anki field to store metadata about the current book.",
91 | },
92 | {
93 | id = "audio_field",
94 | group = note_settings,
95 | name = "Forvo Audio Field",
96 | description = "Anki field to store Forvo audio in.",
97 | },
98 | {
99 | id = "img_field",
100 | group = note_settings,
101 | name = "Image Field",
102 | description = "Anki field to store image in (used for CBZ only).",
103 | },
104 | {
105 | id = "enabled_extensions",
106 | group = general_settings,
107 | name = "Extensions",
108 | description = "Custom scripts to modify created notes.",
109 | conf_type = "checklist",
110 | default_values = function(self) return self.extensions end,
111 | },
112 | {
113 | id = "prev_sentence_count",
114 | group = note_settings,
115 | name = "Previous Sentence Count",
116 | description = "Amount of sentences to prepend to the word looked up.",
117 | },
118 | {
119 | id = "next_sentence_count",
120 | group = note_settings,
121 | name = "Next Sentence Count",
122 | description = "Amount of sentences to append to the word looked up.",
123 | },
124 | --[[ TODO: we may wanna move this to the extension and insert it back in the menu somehow
125 | {
126 | id = "dict_field_map",
127 | group = dictionary_settings,
128 | name = "Dictionary Map",
129 | description = "List of key/value pairs linking a dictionary with a field on the note type",
130 | conf_type = "map",
131 | default_values = function(menubuilder) return menubuilder.ui.dictionary.enabled_dict_names end,
132 | new_entry_value = "Note field to send the definition to",
133 | },
134 | ]]
135 | }
136 | for i,x in ipairs(menu_entries) do menu_entries[x.id] = i end
137 |
138 | local MenuBuilder = {}
139 | local MenuConfigOpt = {
140 | user_conf = nil, -- the underlying ConfigOpt which this menu option configures
141 | menu_entry = nil, -- pretty name for display purposes
142 | conf_type = "text", -- default value for optional conf_type field
143 | }
144 |
145 | function MenuConfigOpt:new(o)
146 | local new_ = { idx = o.idx, enabled = o.enabled } -- idx is used to sort the entries so they are displayed in a consistent order
147 | for k,v in pairs(o.user_conf) do new_[k] = v end
148 | for k,v in pairs(o.menu_entry) do new_[k] = v end
149 | local function index(t, k)
150 | return rawget(t, k) or self[k]
151 | or o.user_conf[k] -- necessary to be able to call opt:get_value()
152 | or MenuBuilder[k] -- necessary to get access to ui (passed in via menubuilder)
153 | end
154 | return setmetatable(new_, { __index = index })
155 | end
156 |
157 | local function build_single_dialog(title, input, hint, description, callback)
158 | local input_dialog -- saved first so we can reference it in the callbacks
159 | input_dialog = InputDialog:new {
160 | title = title,
161 | input = input,
162 | input_hint = hint,
163 | description = description,
164 | buttons = {{
165 | { text = "Cancel", id = "cancel", callback = function() UIManager:close(input_dialog) end },
166 | { text = "Save", id = "save", callback = function() callback(input_dialog) end },
167 | }},
168 | }
169 | return input_dialog
170 | end
171 |
172 | function MenuConfigOpt:build_single_dialog()
173 | local callback = function(dialog)
174 | self:update_value(dialog:getInputText())
175 | UIManager:close(dialog)
176 | end
177 | local input_dialog = build_single_dialog(self.name, self:get_value_nodefault(), self.name, self.description, callback)
178 | UIManager:show(input_dialog)
179 | input_dialog:onShowKeyboard()
180 | end
181 |
182 | function MenuConfigOpt:build_multi_dialog()
183 | local fields = {}
184 | for k,v in pairs(self:get_value_nodefault() or {}) do
185 | table.insert(fields, { description = k, text = v })
186 | end
187 |
188 | local multi_dialog
189 | multi_dialog = MultiInputDialog:new {
190 | title = self.name,
191 | description = self.description,
192 | fields = fields,
193 | buttons = {{
194 | { text = "Cancel", id = "cancel", callback = function() UIManager:close(multi_dialog) end },
195 | { text = "Save", id = "save", callback = function()
196 | local new = {}
197 | for idx,v in ipairs(multi_dialog:getFields()) do
198 | new[fields[idx].description] = v
199 | end
200 | self:update_value(new)
201 | UIManager:close(multi_dialog)
202 | end},
203 | }
204 | },
205 | }
206 | UIManager:show(multi_dialog)
207 | multi_dialog:onShowKeyboard()
208 | end
209 |
210 | function MenuConfigOpt:build_list_dialog()
211 | local callback = function(dialog)
212 | local new_tags = {}
213 | for tag in util.gsplit(dialog:getInputText(), ",") do
214 | table.insert(new_tags, tag)
215 | end
216 | self:update_value(new_tags)
217 | UIManager:close(dialog)
218 | end
219 | local description = self.description.."\nMultiple values can be listed, separated by a comma."
220 | local input_dialog = build_single_dialog(self.name,table.concat(self:get_value_nodefault() or {}, ","), self.name, description, callback)
221 | UIManager:show(input_dialog)
222 | input_dialog:onShowKeyboard()
223 | end
224 |
225 | function MenuConfigOpt:build_checklist()
226 | local menu_items = {}
227 | for _, list_item in ipairs(self:default_values()) do
228 | table.insert(menu_items, {
229 | text = list_item,
230 | checked_func = function() return List:new(self:get_value_nodefault() or {}):contains(list_item) end,
231 | hold_callback = function()
232 | UIManager:show(InfoMessage:new { text = self.extensions[list_item].description, timeout = nil })
233 | end,
234 | callback = function()
235 | local l = List:new(self:get_value_nodefault() or {})
236 | if l:contains(list_item) then
237 | l:remove(list_item)
238 | else
239 | l:add(list_item)
240 | end
241 | self:update_value(l:get())
242 | end
243 | })
244 | end
245 | return menu_items
246 | end
247 |
248 | function MenuConfigOpt:build_map_dialog()
249 | local function is_enabled(k)
250 | return (self:get_value_nodefault() or {})[k] ~= nil
251 | end
252 | -- called when enabling or updating a value in the map
253 | local function update_map_entry(entry_key)
254 | local new = self:get_value_nodefault() or {}
255 | local cb = function(dialog)
256 | new[entry_key] = dialog:getInputText()
257 | self:update_value(new)
258 | UIManager:close(dialog)
259 | end
260 | local input_dialog = build_single_dialog(entry_key, new[entry_key] or "", nil, self.new_entry_value, cb)
261 | UIManager:show(input_dialog)
262 | input_dialog:onShowKeyboard()
263 | end
264 |
265 | local sub_item_table = {}
266 | local values = self.default_values
267 | if type(values) == "function" then
268 | values = values(self)
269 | end
270 | for _,entry_key in ipairs(values) do
271 | local activate_menu = {
272 | text = "Activate",
273 | keep_menu_open = true,
274 | checked_func = function() return is_enabled(entry_key) end,
275 | callback = function()
276 | local new = self:get_value_nodefault() or {}
277 | if is_enabled(entry_key) then
278 | new[entry_key] = nil
279 | self:update_value(new)
280 | else
281 | -- this is hack to make the menu toggle update
282 | new[entry_key] = ""
283 | self:update_value(new)
284 | update_map_entry(entry_key)
285 | end
286 | end
287 | }
288 | local edit_menu = {
289 | text = "Edit",
290 | keep_menu_open = true,
291 | enabled_func = function() return is_enabled(entry_key) end,
292 | callback = function() return update_map_entry(entry_key) end,
293 | }
294 | local menu_item = {
295 | text = entry_key,
296 | checked_func = function() return is_enabled(entry_key) end,
297 | keep_menu_open = true,
298 | sub_item_table = {
299 | activate_menu,
300 | edit_menu,
301 | }
302 | }
303 | table.insert(sub_item_table, menu_item)
304 | end
305 | return sub_item_table
306 | end
307 |
308 | function MenuBuilder:new(opts)
309 | self.ui = opts.ui -- needed to get the enabled dictionaries
310 | self.extensions = opts.extensions
311 | return self
312 | end
313 |
314 | function MenuBuilder:build()
315 | local profiles = {}
316 | for name, p in pairs(config.profiles) do
317 | local menu_options = {}
318 | for _, setting in ipairs(config) do
319 | local user_conf = setting:copy {
320 | profile = p,
321 | value = p.data[setting.id]
322 | }
323 | local idx = menu_entries[setting.id]
324 | local entry = menu_entries[idx]
325 | if entry then
326 | table.insert(menu_options, MenuConfigOpt:new{ user_conf = user_conf, menu_entry = entry, idx = idx, enabled = p.data[setting.id] ~= nil })
327 | end
328 | end
329 | table.sort(menu_options, function(a,b) return a.idx < b.idx end)
330 |
331 | -- contains data as expected to be passed along to main config widget
332 | local sub_item_table = {}
333 | local grouping_func = function(x) return x.group[2] end
334 | local group_order = { ["General Settings"] = 1, ["Anki Note Settings"] = 2, ["Dictionary Settings"] = 3 }
335 | for group, group_entries in pairs(List:new(menu_options):group_by(grouping_func):get()) do
336 | local menu_group = {}
337 | for _,opt in ipairs(group_entries) do
338 | table.insert(menu_group, self:convert_opt(opt))
339 | end
340 | table.insert(sub_item_table, { text = group, sub_item_table = menu_group })
341 | end
342 | table.sort(sub_item_table, function(a,b) return group_order[a.text] < group_order[b.text] end)
343 | table.insert(profiles, { text = name, sub_item_table = sub_item_table })
344 | end
345 | return profiles
346 | end
347 |
348 | function MenuBuilder:convert_opt(opt)
349 | local sub_item_entry = {
350 | text = opt.name,
351 | keep_menu_open = true,
352 | --enabled_func = function() return opt.enabled end,
353 | hold_callback = function()
354 | -- no point in allowing deleting of stuff in the default profile
355 | if opt.profile.name == "default" then return end
356 | UIManager:show(ConfirmBox:new{
357 | text = "Do you want to delete this setting from the current profile?",
358 | ok_callback = function()
359 | opt:delete()
360 | end
361 | })
362 | end
363 | }
364 | if opt.conf_type == "text" then
365 | sub_item_entry['callback'] = function() return opt:build_single_dialog() end
366 | elseif opt.conf_type == "table" then
367 | sub_item_entry['callback'] = function() return opt:build_multi_dialog() end
368 | elseif opt.conf_type == "bool" then
369 | sub_item_entry['checked_func'] = function() return opt:get_value_nodefault() == true end
370 | sub_item_entry['callback'] = function() return opt:update_value(not opt:get_value_nodefault()) end
371 | elseif opt.conf_type == "list" then
372 | sub_item_entry['callback'] = function() return opt:build_list_dialog() end
373 | elseif opt.conf_type == "checklist" then
374 | sub_item_entry['sub_item_table'] = opt:build_checklist()
375 | elseif opt.conf_type == "map" then
376 | sub_item_entry['sub_item_table'] = opt:build_map_dialog()
377 | else -- TODO multitable
378 | sub_item_entry['enabled_func'] = function() return false end
379 | end
380 | return sub_item_entry
381 | end
382 |
383 | return MenuBuilder
384 |
--------------------------------------------------------------------------------
/profiles/README.md:
--------------------------------------------------------------------------------
1 | # Profiles
2 |
3 | The plugin is configured via profiles. Each profile is a `.lua` file with a single table containing all user configurable settings.
4 |
5 | To use the plugin, copy the code snippet below and save it in a new file, this file can be named whatever you want, as long as it has a `.lua` suffix.
6 |
7 | It is also possible to define a default profile (this should be called `default.lua`) containing the entries that remain the same for all profiles.
8 | You can then define multiple other profiles (e.g. `en.lua`, `jp.lua`, etc.) which contain *only* the fields that differ.
9 |
10 | ```lua
11 | -- This file contains all the user configurable options
12 | -- Entries which aren't marked as REQUIRED can be ommitted completely
13 | local Config = {
14 | ----------------------------------------------
15 | ---- [[ GENERAL CONFIGURATION OPTIONS ]] -----
16 | ----------------------------------------------
17 | -- This refers to the IP address of the PC ankiconnect is running on
18 | -- Remember to expose the port ankiconnect listens on so we can connect to it
19 | -- [REQUIRED] The ankiconnect settings also need to be updated to not only listen on the loopback address
20 | url = "http://localhost:8765",
21 | -- [REQUIRED] name of the anki deck
22 | deckName = "日本::3 - Mining Deck",
23 | -- [REQUIRED] note type of the notes that should be created
24 | modelName = "Japanese sentences",
25 | -- Each note created by the plugin will have the tag 'KOReader', it is possible to add other custom tags
26 | -- A card with custom tags can be created by pressing and holding the 'Add to Anki' button, which pops up a menu with some extra options.
27 | custom_tags = { "NEEDS_WORK" },
28 |
29 | -- It is possible to toggle whether duplicate notes can be created. This can be of use if your note type contains the full sentence as first field (meaning this gets looked at for uniqueness)
30 | -- When multiple unknown words are present, it won't be possible to add both in this case, because the sentence would be the same.
31 | allow_dupes = false,
32 | -- The scope where ankiconnect will look to to find duplicates
33 | dupe_scope = "deck",
34 | -- api key - extra authentication supported by ankiconnect, see https://git.foosoft.net/alex/anki-connect#authentication
35 | -- this is totally optional and probably unnecessary, unless you expose anki-connect on the public network for some reason
36 | api_key = nil,
37 |
38 |
39 | ----------------------------------------------
40 | --- [[ NOTE FIELD CONFIGURATION OPTIONS ]] ---
41 | ----------------------------------------------
42 | -- [REQUIRED] The field name where the word which was looked up in a dictionary will be sent to.
43 | word_field = "VocabKanji",
44 |
45 | -- The field name where the sentence in which the word we looked up occurred will be sent to.
46 | context_field = "SentKanji",
47 |
48 | -- Translation of the context field
49 | translated_context_field = "SentEng",
50 |
51 | -- Amount of sentences which are prepended to the word looked up. Set this to 1 to complete the current sentence.
52 | prev_sentence_count = 1,
53 |
54 | -- Amount of sentences which are appended to the word looked up. Set this to 1 to complete the current sentence.
55 | next_sentence_count = 1,
56 |
57 | -- [REQUIRED] The field name where the dictionary definition will be sent to.
58 | def_field = "VocabDef",
59 |
60 | -- The field name where metadata (book source, page number, ...) will be sent to.
61 | -- This metadata is parsed from the EPUB's metadata, or from the filename
62 | meta_field = "Notes",
63 |
64 | -- The plugin can query Forvo for audio of the word you just looked up.
65 | -- The field name where the audio will be sent to.
66 | audio_field = "VocabAudio",
67 |
68 | -- list of extensions which should be enabled, by default they are all off
69 | -- an extension is turned on by listing its filename in the table below
70 | -- existing extensions are listed below, remove the leading -- to enable them
71 | enabled_extensions = {
72 | --"EXT_dict_edit.lua",
73 | --"EXT_dict_word_lookup.lua",
74 | --"EXT_multi_def.lua",
75 | --"EXT_pitch_accent.lua"
76 | }
77 | }
78 | return Config
79 | ```
80 |
--------------------------------------------------------------------------------