-
Notifications
You must be signed in to change notification settings - Fork 8
/
ankiconnect.lua
351 lines (327 loc) · 13.7 KB
/
ankiconnect.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
local http = require("socket.http")
local socket = require("socket")
local socketutil = require("socketutil")
local logger = require("logger")
local json = require("rapidjson")
local ltn12 = require("ltn12")
local util = require("util")
local Font = require("ui/font")
local UIManager = require("ui/uimanager")
local ConfirmBox = require("ui/widget/confirmbox")
local InfoMessage = require("ui/widget/infomessage")
local NetworkMgr = require("ui/network/manager")
local DataStorage = require("datastorage")
local Translator = require("ui/translator")
local forvo = require("forvo")
local u = require("lua_utils/utils")
local conf = require("configuration")
local AnkiConnect = {
settings_dir = DataStorage:getSettingsDir(),
}
--[[
LuaSocket returns somewhat cryptic errors sometimes
- user forgets to add the HTTP prefix -> schemedefs nil
- user uses HTTPS instead of HTTP -> wantread
We can prevent this by modifying/adding the scheme when it's wrong/missing
--]]
function AnkiConnect:get_url()
local url = conf.url:get_value()
if self.last_url == url then
return (assert(self.valid_url, "URL was not validated yet, we should not get here"))
end
local valid_url = url
local _, scheme_end_idx, scheme, ssl = url:find("^(http(s?)://)")
if not scheme then
valid_url = 'http://'..url
elseif ssl then
valid_url = 'http://'..url:sub(scheme_end_idx+1, #url)
end
self.last_url = url
self.valid_url = valid_url
if url ~= valid_url then
logger.info(("Corrected URL from '%s' to '%s'"):format(url, valid_url))
end
return valid_url
end
function AnkiConnect:with_timeout(timeout, func)
socketutil:set_timeout(timeout)
local res = { func() } -- store all values returned by function
socketutil:reset_timeout()
return unpack(res)
end
function AnkiConnect:is_running()
if not self.wifi_connected then
return false, "WiFi disconnected."
end
local result, code, headers = self:with_timeout(1, function() return http.request(self:get_url()) end)
logger.dbg(string.format("AnkiConnect#is_running = code: %s, headers: %s, result: %s", code, headers, result))
return code == 200, string.format("Unable to reach AnkiConnect.\n%s", result or code)
end
function AnkiConnect:post_request(json_payload)
logger.dbg("AnkiConnect#post_request: building POST request with payload: ", json_payload)
local output_sink = {} -- contains data returned by request
local request = {
url = self:get_url(),
method = "POST",
headers = {
["Content-Type"] = "application/json",
["Content-Length"] = #json_payload,
},
sink = ltn12.sink.table(output_sink),
source = ltn12.source.string(json_payload),
}
local code, headers, status = socket.skip(1, http.request(request))
logger.info(string.format("AnkiConnect#post_request: code: %s, header: %s, status: %s\n", code, headers, status))
local result = table.concat(output_sink)
return result, self:get_request_error(code, result)
end
function AnkiConnect:get_request_error(http_return_code, request_data)
if http_return_code ~= 200 then
return string.format("Invalid return code: %s.", http_return_code)
else
local json_err = json.decode(request_data)['error']
-- this turns a json NULL in a userdata instance, actual error will be a string
if type(json_err) == "string" then
return json_err
end
end
end
function AnkiConnect:set_translated_context(_, context)
local result = Translator:translate(context, Translator:getTargetLanguage(), Translator:getSourceLanguage())
logger.info(("Queried translation: '%s' -> '%s'"):format(context, result))
return true, result
end
function AnkiConnect:set_forvo_audio(field, word, language)
logger.info(("Querying Forvo audio for '%s' in language: %s"):format(word, language))
local ok, forvo_url = forvo.get_pronunciation_url(word, language)
if not ok then
return false, "Could not connect to forvo."
end
return true, forvo_url and {
url = forvo_url,
filename = string.format("forvo_%s.ogg", word),
fields = { field }
} or nil
end
function AnkiConnect:set_image_data(field, img_path)
if not img_path then
return true
end
local _,filename = util.splitFilePathName(img_path)
local img_f = io.open(img_path, 'rb')
if not img_f then
return true
end
local data = forvo.base64e(img_f:read("*a"))
logger.info(("added %d bytes of base64 encoded data"):format(#data))
os.remove(img_path)
return true, {
data = data,
filename = filename,
fields = { field }
}
end
function AnkiConnect:handle_callbacks(note, on_err_func)
local field_callbacks = note.params.note._field_callbacks
for param, mod in pairs(field_callbacks) do
if mod.field_name then
local _, ok, result_or_err = pcall(self[mod.func], self, mod.field_name, unpack(mod.args))
if not ok then
return on_err_func(result_or_err)
end
if param == "fields" then
note.params.note.fields[mod.field_name] = result_or_err
else
assert(note.params.note[param] == nil, ("unexpected result: note property '%s' was already present!"):format(param))
note.params.note[param] = result_or_err
end
end
end
return true
end
function AnkiConnect:sync_offline_notes()
if NetworkMgr:willRerunWhenOnline(function() self:sync_offline_notes() end) then
return
end
local can_sync, err = self:is_running()
if not can_sync then
return self:show_popup(string.format("Synchronizing failed!\n%s", err), 3, true)
end
local synced, failed, errs = {}, {}, u.defaultdict(0)
for _,note in ipairs(self.local_notes) do
local callback_ok = self:handle_callbacks(note, function(callback_err)
errs[callback_err] = errs[callback_err] + 1
end)
if callback_ok then
-- we have to remove the _field_callbacks field before saving the note so anki-connect doesn't complain
note.params.note._field_callbacks = nil
local _, request_err = self:post_request(json.encode(note))
if request_err then
errs[request_err] = errs[request_err] + 1
-- if it failed we want reinsert the _field_callbacks field
note.params.note._field_callbacks = note.params.note._field_callbacks
end
end
table.insert(callback_ok and synced or failed, note)
end
self.local_notes = failed
local failed_as_json = {}
for _,note in ipairs(failed) do
table.insert(failed_as_json, json.encode(note))
local id = note.params.note.fields[conf.word_field:get_value()]
if id then
self.local_notes[id] = true
end
end
-- called even when there's no failed notes, this way it also gets rid of the notes which we managed to sync, no need to keep those around
u.open_file(self.notes_filename, 'w', function(f) f:write(table.concat(failed_as_json, '\n')) end)
local sync_message_parts = {}
if #synced > 0 then
-- if any notes were synced succesfully, reset the latest added note (since it's not actually latest anymore)
-- no point in saving the actual latest synced note, since the user won't know which note that was anyway
self.latest_synced_note = nil
table.insert(sync_message_parts, ("Finished synchronizing %d note(s)."):format(#synced))
end
if #failed > 0 then
table.insert(sync_message_parts, ("%d note(s) failed to sync:"):format(#failed))
for error_msg, count in pairs(errs) do
table.insert(sync_message_parts, (" - %s (%d)"):format(error_msg, count))
end
return UIManager:show(ConfirmBox:new {
text = table.concat(sync_message_parts, "\n"),
icon = "notice-warning",
font = Font:getFace("smallinfofont", 9),
ok_text = "Discard failures",
cancel_text = "Keep",
ok_callback = function()
os.remove(self.notes_filename)
self.local_notes = {}
end
})
end
self:show_popup(table.concat(sync_message_parts, " "), 3, true)
end
function AnkiConnect:show_popup(text, timeout, show_always)
-- don't reinform the user for something we already showed them
if not (show_always or false) and self.last_message_text == text then
return
end
logger.info(("Displaying popup with message: '%s'"):format(text))
self.last_message_text = text
UIManager:show(InfoMessage:new { text = text, timeout = timeout })
end
function AnkiConnect:delete_latest_note()
local latest = self.latest_synced_note
if not latest then
return
end
if latest.state == "online" then
local can_sync, err = self:is_running()
if not can_sync then
return self:show_popup(("Could not delete synced note: %s"):format(err), 3, true)
end
-- don't use rapidjson, the anki note ids are 64bit integers, they are turned into different numbers by the json library
-- presumably because 32 vs 64 bit architecture
local delete_request = ([[{"action": "deleteNotes", "version": 6, "params": {"notes": [%d]} }]]):format(latest.id)
local _, err = self:post_request(delete_request)
if err then
return self:show_popup(("Couldn't delete note: %s!"):format(err), 3, true)
end
self:show_popup(("Removed note (id: %s)"):format(latest.id), 3, true)
else
table.remove(self.local_notes, #self.local_notes)
self.local_notes[latest.id] = nil
local entries_on_disk = {}
u.open_file(self.notes_filename, 'r', function(f)
for line in f:lines() do
table.insert(entries_on_disk, line)
end
end)
table.remove(entries_on_disk)
u.open_file(self.notes_filename, 'w', function(f)
f:write(table.concat(entries_on_disk, '\n'))
if #entries_on_disk > 0 then
f:write('\n')
end
end)
self:show_popup(("Removed note (word: %s)"):format(latest.id), 3, true)
end
self.latest_synced_note = nil
end
function AnkiConnect:add_note(anki_note)
local ok, note = pcall(anki_note.build, anki_note)
if not ok then
return self:show_popup(string.format("Error while creating note:\n\n%s", note), 10, true)
end
local can_sync, err = self:is_running()
if not can_sync then
return self:store_offline(note, err)
end
if #self.local_notes > 0 then
UIManager:show(ConfirmBox:new {
text = "There are offline notes which can be synced!",
ok_text = "Synchronize",
cancel_text = "Cancel",
ok_callback = function()
self:sync_offline_notes()
end
})
end
local callback_ok = self:handle_callbacks(note, function(callback_err)
return self:store_offline(note, callback_err)
end)
if callback_ok then
note.params.note._field_callbacks = nil
end
local result, request_err = self:post_request(json.encode(note))
if request_err then
return self:show_popup(string.format("Error while synchronizing note:\n\n%s", request_err), 3, true)
end
self.latest_synced_note = { state = "online", id = json.decode(result).result }
self.last_message_text = "" -- if we manage to sync once, a following error should be shown again
logger.info("note added succesfully: " .. result)
end
function AnkiConnect:store_offline(note, reason, show_always)
-- word stored as key as well so we can have a simple duplicate check for offline notes
local id = note.params.note.fields[conf.word_field:get_value()]
if self.local_notes[id] then
return self:show_popup("Cannot store duplicate note offline!", 6, true)
end
self.local_notes[id] = true
table.insert(self.local_notes, note)
u.open_file(self.notes_filename, 'a', function(f) f:write(json.encode(note) .. '\n') end)
self.latest_synced_note = { state = "offline", id = id }
return self:show_popup(string.format("%s\nStored note offline", reason), 3, show_always or false)
end
function AnkiConnect:load_notes()
u.open_file(self.notes_filename, 'r', function(f)
for note_json in f:lines() do
local note, err = json.decode(note_json)
assert(note, ("Could not parse note '%s': %s"):format(note_json, err))
table.insert(self.local_notes, note)
-- store unique identifier in local_notes tabel for basic duplicates check
--local note_id = note.params.note.fields[conf.word_field:get_value()]
-- when the user creates notes with different settings then the current word_field might not be present
-- on all locally stored notes, we'll just not have the duplicates check for these
if note_id then
self.local_notes[note_id] = true
end
end
end)
logger.dbg(string.format("Loaded %d notes from disk.", #self.local_notes))
end
-- [[
-- required args:
-- * url: to connect to remote AnkiConnect session
-- * ui: necessary to get context of word in AnkiNote
-- ]]
function AnkiConnect:new(opts)
-- NetworkMgr func is device dependent, assume it's true when not implemented.
self.wifi_connected = NetworkMgr.isWifiOn and NetworkMgr:isWifiOn() or true
-- contains notes which we could not sync yet
self.local_notes = {}
-- path of notes stored locally when WiFi isn't available
self.notes_filename = self.settings_dir .. "/anki.koplugin_notes.json"
return setmetatable({} , { __index = self })
end
return AnkiConnect