Split actions out into separate files
John Smith

John Smith commited on 2023-133 18:45:37
Showing 13 changed files, with 570 additions and 599 deletions.


Instead of being clumped into actions.lua, each action/option is
implemented in a separate file, one of
- addcards.lua
- correction.lua
- help.lua
- leeches.lua
- query.lua
- reviewtty.lua
- statistics.lua
- toggle.lua
- vocalreview.lua
... ...
@@ -1,613 +1,23 @@
1
---[[
2
-    gets the score (again/hard/good/easy/correction) from a string by first character
3
-    scores are numbers, higher is easier, -1 is correction
4
-]]
5
-local function succfromstr(s)
6
-    local f = s:sub(1, 1):lower()
7
-    if f == "0" or f == "a" then
8
-        return 0
9
-    elseif f == "1" or f == "h" then
10
-        return 0.5
11
-    elseif f == "2" or f == "g" then
12
-        return 1
13
-    elseif f == "3" or f == "e" then
14
-        return 1.5
15
-    elseif f == "-" or f == "c" then
16
-        return -1
17
-    end
18
-    return 1
19
-end
20
-
21 1
 --[[
22 2
     clears the terminal
23 3
     implementation may be platform-specific
24 4
 ]]
25
-local function clear()
5
+function clear()
26 6
     -- ANSI
27 7
     io.write("\x1b[H\x1b[J")
28 8
 end
29 9
 
30
---[[
31
-    show progress thru a review session, conditioned on the user
32
-    configuring that to happen
33
-]]
34
-local function showprogress(k, n, conf)
35
-    if conf:get("ShowProgress") then
36
-        io.write(string.format("(%d/%d) ", k, n))
37
-    end
38
-end
39
-
40
---[[
41
-    display text with images, inserted with `IMG[path]`
42
-    implementation may be platform-specific
43
-    `basepath` points to the base directory of the images
44
-]]
45
-local function imgify(text, basepath, ind, climn, conf)
46
-    local fh = io.popen("tput lines")
47
-    local tl = tonumber(fh:read("a"))
48
-    io.write(("\n"):rep(math.floor(0.4 * tl)))
49
-    showprogress(ind, climn, conf)
50
-    if conf:get("BoldContext") then
51
-        local csi, cei = text:find("^[^:]+:")
52
-        if csi then
53
-            local cpt = text:sub(csi, cei)
54
-            io.write("\x1b[1m")
55
-            io.write(cpt)
56
-            io.write("\x1b[0m")
57
-            text = text:sub(cei + 1)
58
-        end
59
-    end
60
-    -- adapted from https://stackoverflow.com/a/1579673
61
-    local IMG_PATTERN = "IMG%[([^%]]+)%]"
62
-    local PATTERN = "(.-)" .. IMG_PATTERN
63
-    -- Linux/dwm
64
-    local DISPIMG = function(path, st)
65
-        if path and #path > 0 then
66
-            os.execute(string.format(
67
-                "/usr/local/bin/iv -t '%s' %s%s &",
68
-                st:gsub("'", "\""):gsub(IMG_PATTERN, ""), basepath, path
69
-            ))
70
-        end
71
-    end
72
-    local lastend = 1
73
-    local mstart, mend, prevtext, imgpath = text:find(PATTERN)
74
-    io.write(prevtext or "")
75
-    DISPIMG(imgpath, text)
76
-    while mstart do
77
-        lastend = mend + 1
78
-        mstart, mend, prevtext, imgpath = text:find(PATTERN, lastend)
79
-        io.write(prevtext or "")
80
-        DISPIMG(imgpath, text)
81
-    end
82
-    if lastend <= #text then
83
-        io.write(text:sub(lastend))
84
-    end
85
-end
86
-
87
---[[
88
-    rounds `x` to `n` places (default 0) after the radix point
89
-    in base `b` (default 10)
90
-]]
91
-local function round(x, n, b)
92
-    local b = b or 10
93
-    local n = n or 0
94
-    if n == 0 then
95
-        return math.tointeger(math.floor(x * b ^ n) / b ^ n)
96
-    else
97
-        return math.floor(x * b ^ n) / b ^ n
98
-    end
99
-end
100
-
101
---[[
102
-    return human-friendly string to present a duration (input in seconds),
103
-    e.g. "44:37", "4.8h", "11d", "8.3m", "17.4y"
104
-]]
105
-local function disptime(ts)
106
-    local YDAY = 365.24
107
-    local MDAY = YDAY / 12
108
-    local DSEC = 86400
109
-    local HSEC = 3600
110
-    if ts >= 3 * YDAY * DSEC then
111
-        return round(ts / (YDAY * DSEC), 1) .. "y"
112
-    elseif ts >= 4 * MDAY * DSEC then
113
-        return round(ts / (MDAY * DSEC), 1) .. "m"
114
-    elseif ts >= 10 * DSEC then
115
-        return round(ts / DSEC) .. "d"
116
-    elseif ts >= 1.5 * DSEC then
117
-        return round(ts / DSEC, 1) .. "d"
118
-    elseif ts >= 8 * HSEC then
119
-        return round(ts / HSEC) .. "h"
120
-    elseif ts >= HSEC then
121
-        return round(ts / HSEC, 1) .. "h"
122
-    else
123
-        return string.format("%d:%02d", round(ts / 60), round(ts % 60))
124
-    end
125
-end
126
-
127 10
 --[[
128 11
     check if standard input is a terminal
129 12
 ]]
130
-local function isatty()
13
+function isatty()
131 14
     local succ = os.execute("tty >/dev/null")
132 15
     return succ
133 16
 end
134 17
 
135
---[[
136
-    return number indicating how much of a leech the prompt is
137
-]]
138
-local function badness(pr)
139
-    return pr.fail / (pr.fail + pr.succ) * math.log(pr.fail)
140
-end
141
-
142
---[[
143
-    generate and return text-based vertical histogram,
144
-    with bins of given width across interval from max to min,
145
-    transforming values to numbers via func,
146
-    scaling bar-length by scale
147
-]]
148
-local function histogram(list, min, max, width, func, scale)
149
-    local bins = {}
150
-    local oorc = 0
151
-    local prc = 0
152
-    for _, v in ipairs(list) do
153
-        local n = func(v)
154
-        if n == n and n >= min and n < max + width then
155
-            local k = math.floor((n - min) / width) + 1
156
-            bins[k] = (bins[k] or 0) + 1
157
-        else
158
-            oorc = oorc + 1
159
-        end
160
-        prc = prc + 1
161
-    end
162
-    local s = ""
163
-    for n = min, max, width do
164
-        local k = math.floor((n - min) / width) + 1
165
-        s = string.format("%s%.2f\t%s %d\n", s, n, ("\u{2588}"):rep(math.ceil(scale * (bins[k] or 0))), bins[k] or 0)
166
-    end
167
-    return s .. string.format("(%d out of range)\n", oorc)
168
-end
169
-
170
---[[
171
-    returns a not-necessarily-meaningful number
172
-    iff `s` has mismathched curly-brackets
173
-]]
174
-local function mismatched(s)
175
-    return ("}" .. s):find("}[^{]+}") or (s .. "{"):find("{[^}]+{")
176
-end
177
-
178
---[[
179
-    presents `text` thru text-to-speech,
180
-    with language/voice code `lang` (`false` or `nil` for default)
181
-    `text` and `lang` may also both be parallel tables
182
-    implementation may be platform-specific
183
-]]
184
-local function speak(text, conf, lang)
185
-    if type(text) == "string" and (type(lang) == "string" or type(lang) == "nil") then
186
-        text = {text}
187
-        lang = {lang}
188
-    end
189
-    for i, t in ipairs(text) do
190
-        os.execute(string.format(
191
-            "espeak %s %s %q",
192
-            conf:get("EspeakFlags") or "",
193
-            lang[i] and ("-v " .. lang[i]) or "",
194
-            t
195
-        ))
196
-    end
197
-end
198
-
199
-local actions = {
200
-    a = {
201
-        desc = "(a)dd card (from stdin)",
202
-        nd = true,
203
-        fn = function(config, deck)
204
-            if isatty() then
205
-                io.write("Reading prompts; press Ctrl-D to stop\n")
206
-            end
207
-            local rt = io.read("a")
208
-            local cardctr = 0
209
-            local prctr = 0
210
-            for pt in rt:gmatch("(..-)\n\n") do
211
-                local nc = {}
212
-                -- these Prompts will just be converted to Rec, so we don't need to exclude answers from prompt-text
213
-                for _re in pt:gmatch("%{[^%}]+%}") do
214
-                    table.insert(nc, Prompt:new(pt, "", os.time(), 30, 0, 0, os.time(), config:get("StartingEase") or 2.5))
215
-                    prctr = prctr + 1
216
-                end
217
-                if mismatched(pt) then
218
-                    io.write(string.format("Prompt %d:1 (\"%s\") contains mismatched braces\n", #deck + 1, pt))
219
-                end
220
-                if #nc >= 1 then
221
-                    table.insert(deck, nc)
222
-                    cardctr = cardctr + 1
223
-                else
224
-                    io.write(string.format("Prompt \"%s\" contains no answers (check braces)\n", pt))
225
-                end
226
-            end
227
-            io.write(string.format(
228
-                "Added %d cards (%d prompts)\n",
229
-                cardctr,
230
-                prctr
231
-            ))
232
-            if cardctr > 0 then
233
-                return deck
234
-            end
235
-        end,
236
-    },
237
-
238
-    c = {
239
-        desc = "(c)orrect card (with card:prompt index)",
240
-        nd = true,
241
-        ni = true,
242
-        fn = function(config, deck, _, ip)
243
-            local sp = deck[ip[1]][ip[2]]
244
-            local rt = sp:spt()
245
-            local fname = "memoire_cc.tmp"
246
-            local fh = io.open(fname, "w")
247
-            fh:write(rt)
248
-            fh:close()
249
-            os.execute(string.format(
250
-                "%s %q",
251
-                os.getenv("EDITOR") or "vi",
252
-                fname
253
-            ))
254
-            fh = io.open(fname, "r")
255
-            local nt = fh:read("a"):gsub("%s+$", ""):gsub("^%s+", "")
256
-            fh:close()
257
-            os.remove(fname)
258
-            if nt ~= sp:spt() and nt ~= "" then
259
-                local cardrec = Prompt:torec(deck[ip[1]])
260
-                cardrec.PromptText = { nt }
261
-                deck[ip[1]] = Prompt:fromrecs({ cardrec }, config)[1]
262
-                return deck
263
-            end
264
-        end,
265
-    },
266
-
267
-    h = {
268
-        desc = "(h)elp",
269
-        nd = false,
270
-        fn = function()
271
-            io.write(string.format("Usage: %s ACTION\n\nACTION may be one of\n", arg[0]))
272
-            local sal = {}
273
-            for _, v in pairs(ACTIONS) do
274
-                table.insert(sal, v.desc)
275
-            end
276
-            table.sort(sal)
277
-            for _, v in ipairs(sal) do
278
-                io.write(string.format("\t%s\n", v))
279
-            end
280
-        end
281
-    },
282
-
283
-    l = {
284
-        desc = "(l)eech search",
285
-        nd = true,
286
-        fn = function(config, deck)
287
-            local leechqueue = {}
288
-            for i, cg in ipairs(deck) do
289
-                for j, pr in ipairs(cg) do
290
-                    if pr.ease < 2 and pr.delay >= 0 then
291
-                        table.insert(leechqueue, { i, j })
292
-                    end
293
-                end
294
-            end
295
-            table.sort(leechqueue, function(a, b)
296
-                return badness(deck[a[1]][a[2]]) > badness(deck[b[1]][b[2]])
297
-            end)
298
-            io.write(string.format("Detected %d leeches:\n", #leechqueue))
299
-            for _, ij in ipairs(leechqueue) do
300
-                io.write(string.format("%d:%d %s\n", ij[1], ij[2], deck[ij[1]][ij[2]]))
301
-            end
302
-        end,
303
-    },
304
-
305
-    q = {
306
-        desc = "(q)uery deck for cards (case-sensitive)",
307
-        nd = true,
308
-        nq = true,
309
-        fn = function(config, deck, deckfname, qs)
310
-            local mcl = {}
311
-            for i, card in ipairs(deck) do
312
-                if card[1]:spt():find(qs) then
313
-                    table.insert(mcl, i)
314
-                end
315
-            end
316
-            if #mcl >= 1 then
317
-                io.write(string.format("Found %d match%s for %q:\n", #mcl, #mcl == 1 and "" or "es", qs))
318
-            else
319
-                io.write(string.format("No matches for %q\n", qs))
320
-            end
321
-            for _, i in ipairs(mcl) do
322
-                io.write(string.format("%d %s\n", i, deck[i][1]:spt()))
323
-            end
324
-        end,
325
-    },
326
-
327
-    r = {
328
-        desc = "(r)eview sesion",
329
-        nd = true,
330
-        fn = function(config, deck, deckfname)
331
-            if not isatty() then
332
-                io.write("Review should be done in the terminal\n")
333
-                return nil
334
-            end
335
-            -- collect list of index-pairs for prompts to be reviewed this session
336
-            local ctrt = review.list(deck, config)
337
-            io.write(string.format(
338
-                "%d prompts are ready for review.\nHow many to review in this session? ", #ctrt
339
-            ))
340
-            io.flush()
341
-            local climt = io.read("l")
342
-            local climn = tonumber(climt)
343
-            if not climn then
344
-                io.write("Input not a number; aborting\n")
345
-                return nil
346
-            end
347
-            climn = math.min(climn, #ctrt)
348
-            local lrc = 0
349
-            local editqueue = {}
350
-            local start = os.time()
351
-            -- review prompts in this session's set
352
-            for ind, ip in ipairs(ctrt) do
353
-                if ind > climn then
354
-                    break
355
-                end
356
-                local pr = deck[ip[1]][ip[2]]
357
-                -- remove braces for display
358
-                local pst = pr.text:gsub("%{([^{}]+)%}", "%1")
359
-                local st = os.time()
360
-                imgify(pst:gsub("%%A", "___"), deckfname:match(".*/") or "", ind, climn, config)
361
-                io.write("\n")
362
-                io.read("l")
363
-                clear()
364
-                imgify(
365
-                    pst:gsub("%%A", "\x1b[1m" .. (pr.ans:gsub("%%", "%%%%")) .. "\x1b[0m"),
366
-                    deckfname:match(".*/") or "",
367
-                    ind,
368
-                    climn,
369
-                    config
370
-                )
371
-                io.write(string.format(
372
-                    "\nAgain / Hard (%s) / Good (%s) / Easy (%s) / Correction ",
373
-                    disptime(review.schedule(pr, st, 0.5, config)),
374
-                    disptime(review.schedule(pr, st, 1, config)),
375
-                    disptime(review.schedule(pr, st, 1.5, config))
376
-                ))
377
-                io.flush()
378
-                local resp = io.read("l")
379
-                local sc = succfromstr(resp)
380
-                if sc == -1 then
381
-                    table.insert(editqueue, ip)
382
-                else
383
-                    if sc == 0 then
384
-                        lrc = lrc + 1
385
-                    end
386
-                    review.update(deck[ip[1]][ip[2]], st, sc, config)
387
-                end
388
-                clear()
389
-                if resp:sub(#resp):lower() == "q" then
390
-                    climn = ind
391
-                    break
392
-                end
393
-            end
394
-            if #ctrt > 0 then
395
-                local endt = os.time()
396
-                io.write(string.format(
397
-                    "Reviewed %d prompts in %d seconds\n",
398
-                    math.min(#ctrt, climn),
399
-                    endt - start
400
-                ))
401
-                local s
402
-                if lrc == 1 then s = "" else s = "s" end
403
-                io.write(string.format(
404
-                    "(%d lapse%s, %f seconds per card)\n",
405
-                    lrc, s, (endt - start) / math.min(#ctrt, climn)
406
-                ))
407
-            end
408
-            if #editqueue > 0 then
409
-                io.write(string.format("The following cards are marked for correction:\n"))
410
-                for _, ip in ipairs(editqueue) do
411
-                    io.write(string.format("%d:%d %s\n", ip[1], ip[2], deck[ip[1]][ip[2]]))
412
-                end
413
-            end
414
-            if climn > 0 then
415
-                return deck
416
-            end
417
-        end,
418
-    },
419
-
420
-    s = {
421
-        desc = "(s)tatistics (with optional query)",
422
-        nd = true,
423
-        nq = true,
424
-        fn = function(config, deck, deckfname, qs)
425
-            local tcc = 0
426
-            local tsc = 0
427
-            local tfc = 0
428
-            local spc = 0
429
-            local expdeck = {}
430
-            for _, card in ipairs(deck) do
431
-                local rtc
432
-                for _, pr in ipairs(card) do
433
-                    if not qs or (pr.text:find(qs) or pr.ans:find(qs)) then
434
-                        rtc = true
435
-                        tsc = tsc + pr.succ
436
-                        tfc = tfc + pr.fail
437
-                        if pr.delay < 0 then
438
-                            spc = spc + 1
439
-                        else
440
-                            table.insert(expdeck, pr)
441
-                        end
442
-                    end
443
-                end
444
-                if rtc then
445
-                    tcc = tcc + 1
446
-                end
447
-            end
448
-            local qfn
449
-            if qs then
450
-                qfn = string.format(" (filtered with query %q)", qs)
451
-            else
452
-                qfn = ""
453
-            end
454
-            io.write(string.format(
455
-                "%s%s has %d cards and %d total prompts\n",
456
-                deckfname, qfn, tcc, #expdeck + spc
457
-            ))
458
-            io.write(string.format(
459
-                "(average %.2f prompts per card, %d/%.1f%% prompts suspended).\n",
460
-                (#expdeck + spc) / tcc, spc, 100 * spc / (#expdeck + spc)
461
-            ))
462
-            io.write(string.format(
463
-                "The deck's prompts have been reviewed a total of %d times\n",
464
-                tsc + tfc
465
-            ))
466
-            io.write(string.format(
467
-                "(average %.2f reviews per prompt), overall retention %.2f%%.\n",
468
-                (tsc + tfc) / (#expdeck + spc), 100 * tsc / (tsc + tfc)
469
-            ))
470
-            io.write("All further data excludes suspended prompts.\n")
471
-            io.write("\nEase\n" .. histogram(expdeck, 1.0, 3.3, 0.1, function(pr) return pr.ease end, 0.03))
472
-            io.write("\nLog2-delay (seconds)\n" .. histogram(expdeck, 15, 28, 0.5, function(pr) return math.log(pr.delay, 2) end, 0.1))
473
-            io.write("\nDelay-last review ratio\n" .. histogram(expdeck, 0, 2, 0.1, function(pr) return (os.time() - pr.time) / pr.delay end, 0.1))
474
-            io.write("\nRetention (%)\n" .. histogram(expdeck, 30, 100, 10, function(pr) return 100 * pr:retention() end, 0.02))
475
-            io.write("\nReview counts\n" .. histogram(expdeck, 0, 20, 2, function(pr) return pr.succ + pr.fail end, 0.03))
476
-            io.write("\nLog2-age (seconds)\n" .. histogram(expdeck, 18, 28, 0.5, function(pr) return math.log(os.time() - (pr.created or 2 * os.time()), 2) end, 0.05))
477
-            io.write("\nPrompt-count\n" .. histogram(deck, 1, 10, 1, function(card) return #card end, 0.03))
478
-            local cpcs = {}
479
-            for _, card in ipairs(deck) do
480
-                local cp = card[1].text:match("([^:]+):") or ""
481
-                cpcs[cp] = (cpcs[cp] or 0) + 1
482
-            end
483
-            local scl = {}
484
-            for l, c in pairs(cpcs) do
485
-                table.insert(scl, { l = l, c = c })
486
-            end
487
-            table.sort(scl, function(a, b) return a.c > b.c end)
488
-            for k, lc in ipairs(scl) do
489
-                scl[k] = string.format("%s (%d)", lc.l ~= "" and lc.l or "(none)", lc.c)
490
-            end
491
-            io.write("\nMost common contexts: " .. table.concat(scl, ", ", 1, math.min(20, #scl)) .. "\n")
492
-            table.sort(expdeck, function(a, b) return a.time + a.delay < b.time + b.delay end)
493
-            io.write("\nUpcoming due prompts wrt days from now:\n")
494
-            local ppi = 1
495
-            local mpi = 1
496
-            for dc = 1,100 do
497
-                for uc = ppi,#expdeck do
498
-                    if expdeck[uc].time + expdeck[uc].delay - os.time() > dc * 86400 then
499
-                        mpi = uc
500
-                        break
501
-                    end
502
-                end
503
-                if dc <= 10 or dc % 10 == 0 then
504
-                    io.write(string.format("%d\t%s %d\n", dc, ("\u{2588}"):rep(math.ceil((mpi - ppi) / 10)), mpi - ppi))
505
-                end
506
-                ppi = mpi
507
-            end
508
-        end
509
-    },
510
-
511
-    t = {
512
-        desc = "(t)oggle suspension of prompt (with card:prompt index)",
513
-        nd = true,
514
-        ni = true,
515
-        fn = function(config, deck, _, ip)
516
-            local sp = deck[ip[1]][ip[2]]
517
-            local dir
518
-            if sp.delay < 0 then
519
-                dir = "Restoring"
520
-            else
521
-                dir = "Suspending"
522
-            end
523
-            io.write(string.format("%s %s\n", dir, sp))
524
-            sp.delay = -sp.delay
525
-            return deck
526
-        end,
527
-    },
528
-
529
-    v = {
530
-        desc = "(v)ocal review session",
531
-        nd = true,
532
-        fn = function(config, deck, deckfname)
533
-            if not isatty() then
534
-                io.write("Review should be done in the terminal\n")
535
-                return nil
536
-            end
537
-            -- collect list of index-pairs for prompts to be reviewed this session
538
-            local ctrt = review.list(deck, config, function(pr) return not pr:spt():find("IMG%[") end)
539
-            speak(string.format(
540
-                "%d prompts to review; how many in this session? ", #ctrt
541
-            ), config)
542
-            local climt = io.read("l")
543
-            local climn = tonumber(climt)
544
-            if not climn then
545
-                speak("Input not a number; aborting", conf)
546
-                return nil
547
-            end
548
-            climn = math.min(climn, #ctrt)
549
-            local lrc = 0
550
-            local editqueue = {}
551
-            local start = os.time()
552
-            -- review prompts in this session's set
553
-            for ind, ip in ipairs(ctrt) do
554
-                if ind > climn then
555
-                    break
556
-                end
557
-                local pr = deck[ip[1]][ip[2]]
558
-                -- remove braces for display
559
-                local pst = pr.text:gsub("%{([^{}]+)%}", "%1")
560
-                local st = os.time()
561
-                io.write(pst .. "\n" .. ind .. "/" .. climn .. "\n")
562
-                local qt, ql, at, al = prspeak(pr)
563
-                local heard = false
564
-                while not heard do
565
-                    speak(qt, config, ql)
566
-                    if io.read("l") ~= "r" then heard = true end
567
-                end
568
-                speak(at, config, al)
569
-                speak("hard, good, easy?", config)
570
-                local resp = io.read("l")
571
-                local sc = succfromstr(resp)
572
-                if sc == -1 then
573
-                    table.insert(editqueue, ip)
574
-                else
575
-                    if sc == 0 then
576
-                        lrc = lrc + 1
577
-                    end
578
-                    review.update(deck[ip[1]][ip[2]], st, sc, config)
579
-                end
580
-                clear()
581
-                if resp:sub(#resp):lower() == "q" then
582
-                    climn = ind
583
-                    break
584
-                end
585
-            end
586
-            if #ctrt > 0 then
587
-                local endt = os.time()
588
-                io.write(string.format(
589
-                    "Reviewed %d prompts in %d seconds\n",
590
-                    math.min(#ctrt, climn),
591
-                    endt - start
592
-                ))
593
-                local s
594
-                if lrc == 1 then s = "" else s = "s" end
595
-                io.write(string.format(
596
-                    "(%d lapse%s, %f seconds per card)\n",
597
-                    lrc, s, (endt - start) / math.min(#ctrt, climn)
598
-                ))
599
-            end
600
-            if #editqueue > 0 then
601
-                io.write(string.format("The following cards are marked for correction:\n"))
602
-                for _, ip in ipairs(editqueue) do
603
-                    io.write(string.format("%d:%d %s\n", ip[1], ip[2], deck[ip[1]][ip[2]]))
604
-                end
605
-            end
606
-            if climn > 0 then
607
-                return deck
18
+local actions = {}
19
+for _, fname in ipairs({"addcards", "correction", "help", "leeches", "query", "reviewtty", "statistics", "toggle", "vocalreview"}) do
20
+    actions[fname:sub(1, 1)] = dofile(SRC_DIR .. fname .. ".lua")
608 21
 end
609
-        end,
610
-    },
611
-}
612 22
 
613 23
 return actions
... ...
@@ -0,0 +1,44 @@
1
+--[[
2
+    returns a not-necessarily-meaningful number
3
+    iff `s` has mismathched curly-brackets
4
+]]
5
+local function mismatched(s)
6
+    return ("}" .. s):find("}[^{]+}") or (s .. "{"):find("{[^}]+{")
7
+end
8
+
9
+local function fn(config, deck)
10
+    if isatty() then
11
+        io.write("Reading prompts; press Ctrl-D to stop\n")
12
+    end
13
+    local rt = io.read("a")
14
+    local cardctr = 0
15
+    local prctr = 0
16
+    for pt in rt:gmatch("(..-)\n\n") do
17
+        local nc = {}
18
+        -- these Prompts will just be converted to Rec, so we don't need to exclude answers from prompt-text
19
+        for _re in pt:gmatch("%{[^%}]+%}") do
20
+            table.insert(nc, Prompt:new(pt, "", os.time(), 30, 0, 0, os.time(), config:get("StartingEase") or 2.5))
21
+            prctr = prctr + 1
22
+        end
23
+        if mismatched(pt) then
24
+            io.write(string.format("Prompt %d:1 (\"%s\") contains mismatched braces\n", #deck + 1, pt))
25
+        end
26
+        if #nc >= 1 then
27
+            table.insert(deck, nc)
28
+            cardctr = cardctr + 1
29
+        else
30
+            io.write(string.format("Prompt \"%s\" contains no answers (check braces)\n", pt))
31
+        end
32
+    end
33
+    io.write(string.format(
34
+        "Added %d cards (%d prompts)\n",
35
+        cardctr,
36
+        prctr
37
+    ))
38
+    if cardctr > 0 then
39
+        return deck
40
+    end
41
+end
42
+
43
+return { desc = "(a)dd card (from stdin)", nd = true, fn = fn }
44
+
... ...
@@ -0,0 +1,25 @@
1
+local function fn(config, deck, _, ip)
2
+    local sp = deck[ip[1]][ip[2]]
3
+    local rt = sp:spt()
4
+    local fname = "memoire_cc.tmp"
5
+    local fh = io.open(fname, "w")
6
+    fh:write(rt)
7
+    fh:close()
8
+    os.execute(string.format(
9
+        "%s %q",
10
+        os.getenv("EDITOR") or "vi",
11
+        fname
12
+    ))
13
+    fh = io.open(fname, "r")
14
+    local nt = fh:read("a"):gsub("%s+$", ""):gsub("^%s+", "")
15
+    fh:close()
16
+    os.remove(fname)
17
+    if nt ~= sp:spt() and nt ~= "" then
18
+        local cardrec = Prompt:torec(deck[ip[1]])
19
+        cardrec.PromptText = { nt }
20
+        deck[ip[1]] = Prompt:fromrecs({ cardrec }, config)[1]
21
+        return deck
22
+    end
23
+end
24
+
25
+return { desc = "(c)orrect card (with card:prompt index)", nd = true, ni = true, fn = fn }
... ...
@@ -0,0 +1,13 @@
1
+local function fn()
2
+    io.write(string.format("Usage: %s ACTION\n\nACTION may be one of\n", arg[0]))
3
+    local sal = {}
4
+    for _, v in pairs(ACTIONS) do
5
+        table.insert(sal, v.desc)
6
+    end
7
+    table.sort(sal)
8
+    for _, v in ipairs(sal) do
9
+        io.write(string.format("\t%s\n", v))
10
+    end
11
+end
12
+
13
+return { desc = "(h)elp", nd = false, fn = fn } 
... ...
@@ -0,0 +1,26 @@
1
+--[[
2
+    return number indicating how much of a leech the prompt is
3
+]]
4
+local function badness(pr)
5
+    return pr.fail / (pr.fail + pr.succ) * math.log(pr.fail)
6
+end
7
+
8
+local function fn(config, deck)
9
+    local leechqueue = {}
10
+    for i, cg in ipairs(deck) do
11
+        for j, pr in ipairs(cg) do
12
+            if pr.ease < 2 and pr.delay >= 0 then
13
+                table.insert(leechqueue, { i, j })
14
+            end
15
+        end
16
+    end
17
+    table.sort(leechqueue, function(a, b)
18
+        return badness(deck[a[1]][a[2]]) > badness(deck[b[1]][b[2]])
19
+    end)
20
+    io.write(string.format("Detected %d leeches:\n", #leechqueue))
21
+    for _, ij in ipairs(leechqueue) do
22
+        io.write(string.format("%d:%d %s\n", ij[1], ij[2], deck[ij[1]][ij[2]]))
23
+    end
24
+end
25
+
26
+return { desc = "(l)eech search", nd = true, fn = fn } 
... ...
@@ -2,8 +2,6 @@
2 2
     deck loading and saving functions (working with Recfiles)
3 3
 ]]
4 4
 
5
-local SRC_DIR = arg[0]:match("^.*/") or ""
6
-
7 5
 local Prompt = dofile(SRC_DIR .. "prompt.lua")
8 6
 local recrw = dofile(SRC_DIR .. "recrw.lua")
9 7
 
... ...
@@ -3,7 +3,7 @@
3 3
     the main user interface for memoire
4 4
 ]]
5 5
 
6
-local SRC_DIR = arg[0]:match("^.*/") or ""
6
+SRC_DIR = arg[0]:match("^.*/") or ""
7 7
 
8 8
 Prompt = dofile(SRC_DIR .. "prompt.lua")
9 9
 review = dofile(SRC_DIR .. "review.lua")
... ...
@@ -0,0 +1,18 @@
1
+local function fn(config, deck, deckfname, qs)
2
+    local mcl = {}
3
+    for i, card in ipairs(deck) do
4
+        if card[1]:spt():find(qs) then
5
+            table.insert(mcl, i)
6
+        end
7
+    end
8
+    if #mcl >= 1 then
9
+        io.write(string.format("Found %d match%s for %q:\n", #mcl, #mcl == 1 and "" or "es", qs))
10
+    else
11
+        io.write(string.format("No matches for %q\n", qs))
12
+    end
13
+    for _, i in ipairs(mcl) do
14
+        io.write(string.format("%d %s\n", i, deck[i][1]:spt()))
15
+    end
16
+end
17
+
18
+return { desc = "(q)uery deck for cards (case-sensitive)", nd = true, nq = true, fn = fn }
... ...
@@ -97,4 +97,24 @@ local function toreviewlist(deck, conf, filter)
97 97
     return ctrt
98 98
 end
99 99
 
100
-return { schedule = schedule, update = update, should = should, list = toreviewlist }
100
+--[[
101
+    gets the score (again/hard/good/easy/correction) from a string by first character
102
+    scores are numbers, higher is easier, -1 is correction
103
+]]
104
+local function succfromstr(s)
105
+    local f = s:sub(1, 1):lower()
106
+    if f == "0" or f == "a" then
107
+        return 0
108
+    elseif f == "1" or f == "h" then
109
+        return 0.5
110
+    elseif f == "2" or f == "g" then
111
+        return 1
112
+    elseif f == "3" or f == "e" then
113
+        return 1.5
114
+    elseif f == "-" or f == "c" then
115
+        return -1
116
+    end
117
+    return 1
118
+end
119
+
120
+return { schedule = schedule, update = update, should = should, list = toreviewlist, succfromstr = succfromstr }
... ...
@@ -0,0 +1,187 @@
1
+--[[
2
+    show progress thru a review session, conditioned on the user
3
+    configuring that to happen
4
+]]
5
+local function showprogress(k, n, conf)
6
+    if conf:get("ShowProgress") then
7
+        io.write(string.format("(%d/%d) ", k, n))
8
+    end
9
+end
10
+
11
+--[[
12
+    display text with images, inserted with `IMG[path]`
13
+    implementation may be platform-specific
14
+    `basepath` points to the base directory of the images
15
+]]
16
+local function imgify(text, basepath, ind, climn, conf)
17
+    local fh = io.popen("tput lines")
18
+    local tl = tonumber(fh:read("a"))
19
+    io.write(("\n"):rep(math.floor(0.4 * tl)))
20
+    showprogress(ind, climn, conf)
21
+    if conf:get("BoldContext") then
22
+        local csi, cei = text:find("^[^:]+:")
23
+        if csi then
24
+            local cpt = text:sub(csi, cei)
25
+            io.write("\x1b[1m")
26
+            io.write(cpt)
27
+            io.write("\x1b[0m")
28
+            text = text:sub(cei + 1)
29
+        end
30
+    end
31
+    -- adapted from https://stackoverflow.com/a/1579673
32
+    local IMG_PATTERN = "IMG%[([^%]]+)%]"
33
+    local PATTERN = "(.-)" .. IMG_PATTERN
34
+    -- Linux/dwm
35
+    local DISPIMG = function(path, st)
36
+        if path and #path > 0 then
37
+            os.execute(string.format(
38
+                "/usr/local/bin/iv -t '%s' %s%s &",
39
+                st:gsub("'", "\""):gsub(IMG_PATTERN, ""), basepath, path
40
+            ))
41
+        end
42
+    end
43
+    local lastend = 1
44
+    local mstart, mend, prevtext, imgpath = text:find(PATTERN)
45
+    io.write(prevtext or "")
46
+    DISPIMG(imgpath, text)
47
+    while mstart do
48
+        lastend = mend + 1
49
+        mstart, mend, prevtext, imgpath = text:find(PATTERN, lastend)
50
+        io.write(prevtext or "")
51
+        DISPIMG(imgpath, text)
52
+    end
53
+    if lastend <= #text then
54
+        io.write(text:sub(lastend))
55
+    end
56
+end
57
+
58
+--[[
59
+    rounds `x` to `n` places (default 0) after the radix point
60
+    in base `b` (default 10)
61
+]]
62
+local function round(x, n, b)
63
+    local b = b or 10
64
+    local n = n or 0
65
+    if n == 0 then
66
+        return math.tointeger(math.floor(x * b ^ n) / b ^ n)
67
+    else
68
+        return math.floor(x * b ^ n) / b ^ n
69
+    end
70
+end
71
+
72
+--[[
73
+    return human-friendly string to present a duration (input in seconds),
74
+    e.g. "44:37", "4.8h", "11d", "8.3m", "17.4y"
75
+]]
76
+local function disptime(ts)
77
+    local YDAY = 365.24
78
+    local MDAY = YDAY / 12
79
+    local DSEC = 86400
80
+    local HSEC = 3600
81
+    if ts >= 3 * YDAY * DSEC then
82
+        return round(ts / (YDAY * DSEC), 1) .. "y"
83
+    elseif ts >= 4 * MDAY * DSEC then
84
+        return round(ts / (MDAY * DSEC), 1) .. "m"
85
+    elseif ts >= 10 * DSEC then
86
+        return round(ts / DSEC) .. "d"
87
+    elseif ts >= 1.5 * DSEC then
88
+        return round(ts / DSEC, 1) .. "d"
89
+    elseif ts >= 8 * HSEC then
90
+        return round(ts / HSEC) .. "h"
91
+    elseif ts >= HSEC then
92
+        return round(ts / HSEC, 1) .. "h"
93
+    else
94
+        return string.format("%d:%02d", round(ts / 60), round(ts % 60))
95
+    end
96
+end
97
+
98
+local function fn(config, deck, deckfname)
99
+    if not isatty() then
100
+        io.write("Review should be done in the terminal\n")
101
+        return nil
102
+    end
103
+    -- collect list of index-pairs for prompts to be reviewed this session
104
+    local ctrt = review.list(deck, config)
105
+    io.write(string.format(
106
+        "%d prompts are ready for review.\nHow many to review in this session? ", #ctrt
107
+    ))
108
+    io.flush()
109
+    local climt = io.read("l")
110
+    local climn = tonumber(climt)
111
+    if not climn then
112
+        io.write("Input not a number; aborting\n")
113
+        return nil
114
+    end
115
+    climn = math.min(climn, #ctrt)
116
+    local lrc = 0
117
+    local editqueue = {}
118
+    local start = os.time()
119
+    -- review prompts in this session's set
120
+    for ind, ip in ipairs(ctrt) do
121
+        if ind > climn then
122
+            break
123
+        end
124
+        local pr = deck[ip[1]][ip[2]]
125
+        -- remove braces for display
126
+        local pst = pr.text:gsub("%{([^{}]+)%}", "%1")
127
+        local st = os.time()
128
+        imgify(pst:gsub("%%A", "___"), deckfname:match(".*/") or "", ind, climn, config)
129
+        io.write("\n")
130
+        io.read("l")
131
+        clear()
132
+        imgify(
133
+            pst:gsub("%%A", "\x1b[1m" .. (pr.ans:gsub("%%", "%%%%")) .. "\x1b[0m"),
134
+            deckfname:match(".*/") or "",
135
+            ind,
136
+            climn,
137
+            config
138
+        )
139
+        io.write(string.format(
140
+            "\nAgain / Hard (%s) / Good (%s) / Easy (%s) / Correction ",
141
+            disptime(review.schedule(pr, st, 0.5, config)),
142
+            disptime(review.schedule(pr, st, 1, config)),
143
+            disptime(review.schedule(pr, st, 1.5, config))
144
+        ))
145
+        io.flush()
146
+        local resp = io.read("l")
147
+        local sc = review.succfromstr(resp)
148
+        if sc == -1 then
149
+            table.insert(editqueue, ip)
150
+        else
151
+            if sc == 0 then
152
+                lrc = lrc + 1
153
+            end
154
+            review.update(deck[ip[1]][ip[2]], st, sc, config)
155
+        end
156
+        clear()
157
+        if resp:sub(#resp):lower() == "q" then
158
+            climn = ind
159
+            break
160
+        end
161
+    end
162
+    if #ctrt > 0 then
163
+        local endt = os.time()
164
+        io.write(string.format(
165
+            "Reviewed %d prompts in %d seconds\n",
166
+            math.min(#ctrt, climn),
167
+            endt - start
168
+        ))
169
+        local s
170
+        if lrc == 1 then s = "" else s = "s" end
171
+        io.write(string.format(
172
+            "(%d lapse%s, %f seconds per card)\n",
173
+            lrc, s, (endt - start) / math.min(#ctrt, climn)
174
+        ))
175
+    end
176
+    if #editqueue > 0 then
177
+        io.write(string.format("The following cards are marked for correction:\n"))
178
+        for _, ip in ipairs(editqueue) do
179
+            io.write(string.format("%d:%d %s\n", ip[1], ip[2], deck[ip[1]][ip[2]]))
180
+        end
181
+    end
182
+    if climn > 0 then
183
+        return deck
184
+    end
185
+end
186
+
187
+return { desc = "(r)eview sesion", nd = true, fn = fn }
... ...
@@ -0,0 +1,115 @@
1
+--[[
2
+    generate and return text-based vertical histogram,
3
+    with bins of given width across interval from max to min,
4
+    transforming values to numbers via func,
5
+    scaling bar-length by scale
6
+]]
7
+local function histogram(list, min, max, width, func, scale)
8
+    local bins = {}
9
+    local oorc = 0
10
+    local prc = 0
11
+    for _, v in ipairs(list) do
12
+        local n = func(v)
13
+        if n == n and n >= min and n < max + width then
14
+            local k = math.floor((n - min) / width) + 1
15
+            bins[k] = (bins[k] or 0) + 1
16
+        else
17
+            oorc = oorc + 1
18
+        end
19
+        prc = prc + 1
20
+    end
21
+    local s = ""
22
+    for n = min, max, width do
23
+        local k = math.floor((n - min) / width) + 1
24
+        s = string.format("%s%.2f\t%s %d\n", s, n, ("\u{2588}"):rep(math.ceil(scale * (bins[k] or 0))), bins[k] or 0)
25
+    end
26
+    return s .. string.format("(%d out of range)\n", oorc)
27
+end
28
+
29
+local function fn(config, deck, deckfname, qs)
30
+    local tcc = 0
31
+    local tsc = 0
32
+    local tfc = 0
33
+    local spc = 0
34
+    local expdeck = {}
35
+    for _, card in ipairs(deck) do
36
+        local rtc
37
+        for _, pr in ipairs(card) do
38
+            if not qs or (pr.text:find(qs) or pr.ans:find(qs)) then
39
+                rtc = true
40
+                tsc = tsc + pr.succ
41
+                tfc = tfc + pr.fail
42
+                if pr.delay < 0 then
43
+                    spc = spc + 1
44
+                else
45
+                    table.insert(expdeck, pr)
46
+                end
47
+            end
48
+        end
49
+        if rtc then
50
+            tcc = tcc + 1
51
+        end
52
+    end
53
+    local qfn
54
+    if qs then
55
+        qfn = string.format(" (filtered with query %q)", qs)
56
+    else
57
+        qfn = ""
58
+    end
59
+    io.write(string.format(
60
+        "%s%s has %d cards and %d total prompts\n",
61
+        deckfname, qfn, tcc, #expdeck + spc
62
+    ))
63
+    io.write(string.format(
64
+        "(average %.2f prompts per card, %d/%.1f%% prompts suspended).\n",
65
+        (#expdeck + spc) / tcc, spc, 100 * spc / (#expdeck + spc)
66
+    ))
67
+    io.write(string.format(
68
+        "The deck's prompts have been reviewed a total of %d times\n",
69
+        tsc + tfc
70
+    ))
71
+    io.write(string.format(
72
+        "(average %.2f reviews per prompt), overall retention %.2f%%.\n",
73
+        (tsc + tfc) / (#expdeck + spc), 100 * tsc / (tsc + tfc)
74
+    ))
75
+    io.write("All further data excludes suspended prompts.\n")
76
+    io.write("\nEase\n" .. histogram(expdeck, 1.0, 3.3, 0.1, function(pr) return pr.ease end, 0.03))
77
+    io.write("\nLog2-delay (seconds)\n" .. histogram(expdeck, 15, 28, 0.5, function(pr) return math.log(pr.delay, 2) end, 0.1))
78
+    io.write("\nDelay-last review ratio\n" .. histogram(expdeck, 0, 2, 0.1, function(pr) return (os.time() - pr.time) / pr.delay end, 0.1))
79
+    io.write("\nRetention (%)\n" .. histogram(expdeck, 30, 100, 10, function(pr) return 100 * pr:retention() end, 0.02))
80
+    io.write("\nReview counts\n" .. histogram(expdeck, 0, 20, 2, function(pr) return pr.succ + pr.fail end, 0.03))
81
+    io.write("\nLog2-age (seconds)\n" .. histogram(expdeck, 18, 28, 0.5, function(pr) return math.log(os.time() - (pr.created or 2 * os.time()), 2) end, 0.05))
82
+    io.write("\nPrompt-count\n" .. histogram(deck, 1, 10, 1, function(card) return #card end, 0.03))
83
+    local cpcs = {}
84
+    for _, card in ipairs(deck) do
85
+        local cp = card[1].text:match("([^:]+):") or ""
86
+        cpcs[cp] = (cpcs[cp] or 0) + 1
87
+    end
88
+    local scl = {}
89
+    for l, c in pairs(cpcs) do
90
+        table.insert(scl, { l = l, c = c })
91
+    end
92
+    table.sort(scl, function(a, b) return a.c > b.c end)
93
+    for k, lc in ipairs(scl) do
94
+        scl[k] = string.format("%s (%d)", lc.l ~= "" and lc.l or "(none)", lc.c)
95
+    end
96
+    io.write("\nMost common contexts: " .. table.concat(scl, ", ", 1, math.min(20, #scl)) .. "\n")
97
+    table.sort(expdeck, function(a, b) return a.time + a.delay < b.time + b.delay end)
98
+    io.write("\nUpcoming due prompts wrt days from now:\n")
99
+    local ppi = 1
100
+    local mpi = 1
101
+    for dc = 1,100 do
102
+        for uc = ppi,#expdeck do
103
+            if expdeck[uc].time + expdeck[uc].delay - os.time() > dc * 86400 then
104
+                mpi = uc
105
+                break
106
+            end
107
+        end
108
+        if dc <= 10 or dc % 10 == 0 then
109
+            io.write(string.format("%d\t%s %d\n", dc, ("\u{2588}"):rep(math.ceil((mpi - ppi) / 10)), mpi - ppi))
110
+        end
111
+        ppi = mpi
112
+    end
113
+end
114
+
115
+return { desc = "(s)tatistics (with optional query)", nd = true, nq = true, fn = fn }
... ...
@@ -0,0 +1,14 @@
1
+local function fn(config, deck, _, ip)
2
+    local sp = deck[ip[1]][ip[2]]
3
+    local dir
4
+    if sp.delay < 0 then
5
+        dir = "Restoring"
6
+    else
7
+        dir = "Suspending"
8
+    end
9
+    io.write(string.format("%s %s\n", dir, sp))
10
+    sp.delay = -sp.delay
11
+    return deck
12
+end
13
+
14
+return { desc = "(t)oggle suspension of prompt (with card:prompt index)", nd = true, ni = true, fn = fn }
... ...
@@ -0,0 +1,101 @@
1
+--[[
2
+    presents `text` thru text-to-speech,
3
+    with language/voice code `lang` (`false` or `nil` for default)
4
+    `text` and `lang` may also both be parallel tables
5
+    implementation may be platform-specific
6
+]]
7
+local function speak(text, conf, lang)
8
+    if type(text) == "string" and (type(lang) == "string" or type(lang) == "nil") then
9
+        text = {text}
10
+        lang = {lang}
11
+    end
12
+    for i, t in ipairs(text) do
13
+        os.execute(string.format(
14
+            "espeak %s %s %q",
15
+            conf:get("EspeakFlags") or "",
16
+            lang[i] and ("-v " .. lang[i]) or "",
17
+            t
18
+        ))
19
+    end
20
+end
21
+
22
+local function fn(config, deck, deckfname)
23
+    if not isatty() then
24
+        io.write("Review should be done in the terminal\n")
25
+        return nil
26
+    end
27
+    -- collect list of index-pairs for prompts to be reviewed this session
28
+    local ctrt = review.list(deck, config, function(pr) return not pr:spt():find("IMG%[") end)
29
+    speak(string.format(
30
+        "%d prompts to review; how many in this session? ", #ctrt
31
+    ), config)
32
+    local climt = io.read("l")
33
+    local climn = tonumber(climt)
34
+    if not climn then
35
+        speak("Input not a number; aborting", conf)
36
+        return nil
37
+    end
38
+    climn = math.min(climn, #ctrt)
39
+    local lrc = 0
40
+    local editqueue = {}
41
+    local start = os.time()
42
+    -- review prompts in this session's set
43
+    for ind, ip in ipairs(ctrt) do
44
+        if ind > climn then
45
+            break
46
+        end
47
+        local pr = deck[ip[1]][ip[2]]
48
+        -- remove braces for display
49
+        local pst = pr.text:gsub("%{([^{}]+)%}", "%1")
50
+        local st = os.time()
51
+        io.write(pst .. "\n" .. ind .. "/" .. climn .. "\n")
52
+        local qt, ql, at, al = prspeak(pr)
53
+        local heard = false
54
+        while not heard do
55
+            speak(qt, config, ql)
56
+            if io.read("l") ~= "r" then heard = true end
57
+        end
58
+        speak(at, config, al)
59
+        speak("hard, good, easy?", config)
60
+        local resp = io.read("l")
61
+        local sc = review.succfromstr(resp)
62
+        if sc == -1 then
63
+            table.insert(editqueue, ip)
64
+        else
65
+            if sc == 0 then
66
+                lrc = lrc + 1
67
+            end
68
+            review.update(deck[ip[1]][ip[2]], st, sc, config)
69
+        end
70
+        clear()
71
+        if resp:sub(#resp):lower() == "q" then
72
+            climn = ind
73
+            break
74
+        end
75
+    end
76
+    if #ctrt > 0 then
77
+        local endt = os.time()
78
+        io.write(string.format(
79
+            "Reviewed %d prompts in %d seconds\n",
80
+            math.min(#ctrt, climn),
81
+            endt - start
82
+        ))
83
+        local s
84
+        if lrc == 1 then s = "" else s = "s" end
85
+        io.write(string.format(
86
+            "(%d lapse%s, %f seconds per card)\n",
87
+            lrc, s, (endt - start) / math.min(#ctrt, climn)
88
+        ))
89
+    end
90
+    if #editqueue > 0 then
91
+        io.write(string.format("The following cards are marked for correction:\n"))
92
+        for _, ip in ipairs(editqueue) do
93
+            io.write(string.format("%d:%d %s\n", ip[1], ip[2], deck[ip[1]][ip[2]]))
94
+        end
95
+    end
96
+    if climn > 0 then
97
+        return deck
98
+    end
99
+end
100
+
101
+return { desc = "(v)ocal review session", nd = true, fn = fn }
0 102