Move Lua source code into src/ directory
John Smith

John Smith commited on 2023-133 18:56:28
Showing 15 changed files, with 1079 additions and 0 deletions.

... ...
@@ -0,0 +1,23 @@
1
+--[[
2
+    clears the terminal
3
+    implementation may be platform-specific
4
+]]
5
+function clear()
6
+    -- ANSI
7
+    io.write("\x1b[H\x1b[J")
8
+end
9
+
10
+--[[
11
+    check if standard input is a terminal
12
+]]
13
+function isatty()
14
+    local succ = os.execute("tty >/dev/null")
15
+    return succ
16
+end
17
+
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")
21
+end
22
+
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 } 
... ...
@@ -0,0 +1,39 @@
1
+--[[
2
+    deck loading and saving functions (working with Recfiles)
3
+]]
4
+
5
+local Prompt = dofile(SRC_DIR .. "prompt.lua")
6
+local recrw = dofile(SRC_DIR .. "recrw.lua")
7
+
8
+--[[
9
+    load a deck (a table of tables of `Prompt`s) from a Recfile
10
+]]
11
+local function loaddeck(fname, conf)
12
+    local fh = io.open(fname)
13
+    if not fh then return nil end
14
+    local rt = fh:read "a"
15
+    if not rt then return nil end
16
+    fh:close()
17
+    local pr = recrw.read(rt)
18
+    if not pr then return nil end
19
+    local ep = Prompt:fromrecs(pr, conf)
20
+    return ep
21
+end
22
+
23
+--[[
24
+    save a deck (a table of tables of `Prompt`s) to a Recfile
25
+]]
26
+local function savedeck(fname, deck)
27
+    local rrf = {}
28
+    for i, cg in ipairs(deck) do
29
+        rrf[i] = Prompt:torec(cg)
30
+    end
31
+    local wot = recrw.write(rrf)
32
+    local fh = io.open(fname, "w")
33
+    if not fh then io.write("couldn't open output\n") end
34
+    local wr, err = fh:write(wot)
35
+    if not wr then io.write("couldn't write to output\n") end
36
+    fh:close()
37
+end
38
+
39
+return { load = loaddeck, save = savedeck }
... ...
@@ -0,0 +1,136 @@
1
+#!/usr/bin/env lua
2
+--[[
3
+    the main user interface for memoire
4
+]]
5
+
6
+SRC_DIR = arg[0]:match("^.*/") or ""
7
+
8
+Prompt = dofile(SRC_DIR .. "prompt.lua")
9
+review = dofile(SRC_DIR .. "review.lua")
10
+loader = dofile(SRC_DIR .. "loader.lua")
11
+recrw = dofile(SRC_DIR .. "recrw.lua")
12
+ACTIONS = dofile(SRC_DIR .. "actions.lua")
13
+
14
+--[[
15
+    check action based on first command-line argument;
16
+    if nothing valid given, default to help
17
+]]
18
+local action = nil
19
+if arg[1] then
20
+    local fc = arg[1]:sub(1, 1)
21
+    action = ACTIONS[fc] or ACTIONS.h
22
+else
23
+    action = ACTIONS.h
24
+end
25
+
26
+-- load settings from config Recfile
27
+local confdir = os.getenv("HOME") .. "/.config/memoire/"
28
+local config = { DeckFile = { "" } }
29
+
30
+do
31
+local fh = io.open(confdir .. "config.rec")
32
+if fh then
33
+    local ft = fh:read("a")
34
+    local ct = recrw.read(ft)
35
+    if ct and ct[1] then config = ct[1]
36
+    else io.write("Erroneous configuration file\n") end
37
+else
38
+    io.write("Could not open configuration file in " .. confdir .. "\n")
39
+end
40
+end
41
+
42
+--[[
43
+    gets config option `name`; if multiple values were given,
44
+    provide the `ind`th one (or the first, if `ind` is not given)
45
+    return `nil` if no such option available
46
+]]
47
+function config:get(name, ind)
48
+    return (self[name] or {})[ind or 1]
49
+end
50
+
51
+if not config.DeckFile then
52
+    io.write("No deck files specified; aborting\n")
53
+    os.exit(true, true)
54
+end
55
+
56
+if #config.DeckFile > 1 then
57
+    io.write("Select deck to use (by number):\n")
58
+    for i, n in ipairs(config.DeckFile) do
59
+       io.write(string.format("\t%d. %s\n", i, n))
60
+    end
61
+    local rl = io.read("l")
62
+    local si = tonumber(rl)
63
+    if si and si <= #config.DeckFile and si >= 1 then
64
+        deckfname = config.DeckFile[si]
65
+    elseif si then
66
+        io.write("Selected deck out of range; aborting\n")
67
+        os.exit(true, true)
68
+    else
69
+        io.write("Input not a number; aborting")
70
+        os.exit(true, true)
71
+    end
72
+else
73
+    deckfname = config.DeckFile[1]
74
+end
75
+
76
+--[[
77
+    returns four tables:
78
+    question-text, question-lang, answer-text, answer-lang
79
+]]
80
+defaultprspeak = function(pr)
81
+    return {(pr.text:gsub("%{([^{}]+)%}", "%1"):gsub("%%A", "WHAT"))}, {false}, {"ANSWER: " .. pr.ans}, {false}
82
+end
83
+prspeak = defaultprspeak
84
+do
85
+local fh = io.open(confdir .. "prspeak.lua")
86
+if fh then
87
+    fh:close()
88
+    prspeak = dofile(confdir .. "prspeak.lua")
89
+end
90
+end
91
+
92
+deckfname = os.getenv("HOME") .. "/" .. deckfname
93
+
94
+math.randomseed(os.time())
95
+
96
+-- do requested action
97
+if action.nd then
98
+    local deck = loader.load(deckfname, config)
99
+    if deck then
100
+        if action.ni then
101
+            local ip
102
+            if arg[2] then
103
+                local a, b = arg[2]:match("(%d+):(%d+)")
104
+                if a and b then
105
+                    -- a and b are valid numbers because they came from a match
106
+                    ip = { tonumber(a), tonumber(b) }
107
+                else
108
+                    ip = tonumber(arg[2])
109
+                    if ip then
110
+                        ip = { ip, 1 }
111
+                    end
112
+                end
113
+            end
114
+            if ip then
115
+                deck = action.fn(config, deck, deckfname, ip)
116
+            else
117
+                io.write("Bad prompt index (must be cardnum:promptnum)\n")
118
+            end
119
+        elseif action.nq then
120
+            local qs
121
+            if arg[2] then
122
+                qs = arg[2]
123
+            end
124
+            deck = action.fn(config, deck, deckfname, qs)
125
+        else
126
+            deck = action.fn(config, deck, deckfname)
127
+        end
128
+        if deck then
129
+            loader.save(deckfname, deck)
130
+        end
131
+    else
132
+        io.write("Failed to load deck\n")
133
+    end
134
+else
135
+    action.fn(config)
136
+end
... ...
@@ -0,0 +1,112 @@
1
+--[[
2
+    a prompt consists of prompt text (with an answer-insertion point),
3
+    a correct answer, the review timestamp, the review delay,
4
+    counts of successful and failed reviews,
5
+    a creation timestamp, and an ease measurement
6
+
7
+    timestamps are seconds from the Unix epoch
8
+    review delay is in seconds
9
+]]
10
+
11
+local Prompt = {}
12
+Prompt.__index = Prompt
13
+
14
+--[[
15
+    the answer-insertion point is indicated with a "%A" in the prompt text
16
+]]
17
+function Prompt:new(pt, ca, rts, rd, sc, fc, ct, ez)
18
+    local o = {}
19
+    setmetatable(o, self)
20
+    o.text = pt
21
+    o.ans = ca
22
+    o.time = tonumber(rts)
23
+    o.delay = tonumber(rd)
24
+    o.succ = tonumber(sc)
25
+    o.fail = tonumber(fc)
26
+    o.created = tonumber(ct)
27
+    o.ease = tonumber(ez)
28
+    return o
29
+end
30
+
31
+function Prompt:__tostring()
32
+    return string.format(
33
+        "\"%s\" (\"%s\") %d -> %d+%d*%f %d/%d",
34
+        self.text, self.ans, self.created or 0,
35
+        self.time, math.floor(self.delay), self.ease,
36
+        self.succ, self.fail
37
+    )
38
+end
39
+
40
+function Prompt:retention()
41
+    return self.succ / (self.succ + self.fail)
42
+end
43
+
44
+--[[
45
+    derive the `PromptText` used to create this `Prompt`
46
+]]
47
+function Prompt:spt()
48
+    return self.text:gsub("%%A", "{" .. self.ans:gsub("%%", "%%%%") .. "}")
49
+end
50
+
51
+--[[
52
+    extracts `Prompt`s from a record-based deck
53
+    returns a table of tables of `Prompt`s
54
+]]
55
+function Prompt:fromrecs(recs, conf)
56
+    local ret = {}
57
+    for i, rec in ipairs(recs) do
58
+        -- only bother with records with `PromptText`
59
+        if rec.PromptText then
60
+            ret[i] = {}
61
+            local opt = rec.PromptText[1]
62
+            local m, n = opt:find "%{[^%}]+%}"
63
+            local ctr = 1
64
+            while m do
65
+                local mpt = opt:sub(1, m - 1) .. "%A" .. opt:sub(n + 1)
66
+                table.insert(ret[i], Prompt:new(
67
+                    mpt,
68
+                    opt:sub(m + 1, n - 1),
69
+                    (rec.LastReview or {})[ctr] or (rec.LastReview or {})[1],
70
+                    (rec.LastDelay or {})[ctr] or 15,
71
+                    (rec.Successes or {})[ctr] or 0,
72
+                    (rec.Failures or {})[ctr] or 0,
73
+                    (rec.Created or {})[1],
74
+                    (rec.Ease or {})[ctr] or conf:get("StartingEase") or "2.5"
75
+                ))
76
+                m, n = opt:find("%{[^%}]+%}", n)
77
+                ctr = ctr + 1
78
+            end
79
+        end
80
+    end
81
+    return ret
82
+end
83
+
84
+--[[
85
+    serialises an ordered table of `Prompt`s derived from the same `PromptText` to a record
86
+    returns (on success) a table mapping labels to value-lists
87
+    returns (on failure) nil
88
+]]
89
+function Prompt:torec(prompts)
90
+    if prompts[1].deleted then
91
+        return {}
92
+    end
93
+    local fspt = prompts[1]:spt()
94
+    local ret = { PromptText = { fspt }, LastReview = {}, LastDelay = {}, Successes = {}, Failures = {}, Ease = {} }
95
+    if prompts[1].created then
96
+        ret.Created = { prompts[1].created }
97
+    end
98
+    for i, pr in ipairs(prompts) do
99
+        if pr:spt() ~= fspt then
100
+            -- not from the same `PromptText`
101
+            return nil
102
+        end
103
+        ret.LastReview[i] = pr.time
104
+        ret.LastDelay[i] = pr.delay
105
+        ret.Successes[i] = pr.succ
106
+        ret.Failures[i] = pr.fail
107
+        ret.Ease[i] = pr.ease
108
+    end
109
+    return ret
110
+end
111
+
112
+return Prompt
... ...
@@ -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 }
... ...
@@ -0,0 +1,106 @@
1
+--[[
2
+    a Recfile is (to simplify) a sequence of records
3
+    a record is a sequence of fields
4
+    a field is a label-value pair
5
+    a label is an identifier
6
+    a value is a string
7
+]]
8
+
9
+--[[
10
+    converts text from a Recfile into a sequence of records
11
+    returns (on success) a table of tables of pairs of labels and value-lists
12
+    returns (on failure) nil
13
+]]
14
+local function text2recs(str)
15
+    local ret = {}
16
+    local cr = {}
17
+    local cl = nil
18
+    local cv = nil
19
+    -- Rec is line-based
20
+    for line in str:gmatch "([^\n]*)\n" do
21
+        -- continuation lines
22
+        if cv and cv:sub(-1) == "\\" then
23
+            cv = cv:sub(1, -2) .. line
24
+        elseif line:sub(1, 1) == "+" then
25
+            cv = cv .. "\n" .. line:gsub("^%+ ?", "")
26
+        -- blank lines separate records
27
+        elseif line:match "^%s*$" then
28
+            -- add field to record if non-empty
29
+            if cl then
30
+                if cr[cl] then
31
+                    table.insert(cr[cl], cv)
32
+                else
33
+                    cr[cl] = { cv }
34
+                end
35
+            end
36
+            cl = nil
37
+            cv = nil
38
+            -- add record to return value if non-empty
39
+            if next(cr) then
40
+                table.insert(ret, cr)
41
+            end
42
+            cr = {}
43
+        -- lines starting with # are comments
44
+        elseif line:sub(1, 1) == "#" then
45
+            if cl then
46
+                if cr[cl] then
47
+                    table.insert(cr[cl], cv)
48
+                else
49
+                    cr[cl] = { cv }
50
+                end
51
+            end
52
+            cl = nil
53
+            cv = nil
54
+        -- new field
55
+        else
56
+            if cl then
57
+                if cr[cl] then
58
+                    table.insert(cr[cl], cv)
59
+                else
60
+                    cr[cl] = { cv }
61
+                end
62
+            end
63
+            cl, cv = line:match "^([%a%%][%w_]*):%s(.*)"
64
+            -- no match? bad field
65
+            if not cl then return nil end
66
+        end
67
+    end
68
+    if cl then
69
+        if cr[cl] then
70
+            table.insert(cr[cl], cv)
71
+        else
72
+            cr[cl] = { cv }
73
+        end
74
+    end
75
+    if next(cr) then
76
+        table.insert(ret, cr)
77
+    end
78
+    return ret
79
+end
80
+
81
+--[[
82
+    converts a sequence of records into text for a Recfile
83
+    returns (on success) a string
84
+    returns (on failure) nil
85
+]]
86
+local function recs2text(recs, si, ei)
87
+    local ret = ""
88
+    si = si or 1
89
+    ei = ei or #recs
90
+    --[[
91
+        use recursive (binary-splitting) string building
92
+        this makes stuff way more efficient
93
+    ]]
94
+    if si == ei then
95
+        for label, vl in pairs(recs[si]) do
96
+            for j, value in ipairs(vl) do
97
+                ret = ret .. string.format("\n%s: %s", label, tostring(value):gsub("\n", "\n+ "))
98
+            end
99
+        end
100
+        return ret .. "\n"
101
+    else
102
+        return recs2text(recs, si, (si + ei) // 2) .. recs2text(recs, (si + ei) // 2 + 1, ei)
103
+    end
104
+end
105
+
106
+return { read = text2recs, write = recs2text }
... ...
@@ -0,0 +1,120 @@
1
+--[[
2
+    stuff to help with the review mechanics
3
+]]
4
+
5
+--[[
6
+    determines the delay until next review (in seconds)
7
+    when `Prompt` `prompt` is reviewed at timestamp `revtime`
8
+    with review success `revsucc`
9
+]]
10
+local function schedule(prompt, revtime, revsucc, conf)
11
+    if revsucc and revsucc > 0 then
12
+        local ad = revtime - (prompt.time or revtime)
13
+        local adm = ad + math.max(40000 - 0.5 * ad, 0)
14
+        -- SM2, as explained by Bjornstad
15
+        -- https://controlaltbackspace.org/memory/spaced-repetition-from-the-ground-up/
16
+        if revsucc < 0.8 then
17
+            return (conf:get("HardRatio") or 1.2) * revsucc * 2 * adm
18
+        else
19
+            return prompt.ease * (1 + 2 * ((conf:get("EasyRatio") or 1.3) - 1) * (revsucc - 1)) * adm
20
+        end
21
+    else
22
+        return 15
23
+    end
24
+end
25
+
26
+--[[
27
+    manipulates `Prompt` `prompt` in-place as it should be
28
+    when reviewed at timestamp `revtime` with review success `revsucc`
29
+]]
30
+local function update(prompt, revtime, revsucc, conf)
31
+    if revsucc and revsucc > 0 then
32
+        prompt.succ = (prompt.succ or 0) + 1
33
+    else
34
+        prompt.fail = (prompt.fail or 0) + 1
35
+    end
36
+    -- allowed random variation, as a fraction of the "optimal" duration
37
+    local ARV = conf:get("AllowedRandomVariation") or 0.1
38
+    prompt.delay = schedule(prompt, revtime, revsucc, conf) * ((1 - ARV) + 2 * ARV * math.random())
39
+    prompt.ease = prompt.ease + ({ -0.2, -0.15, 0, 0.15 })[2 * revsucc + 1]
40
+    prompt.time = revtime
41
+end
42
+
43
+--[[
44
+    rates the importance of `Prompt` `prompt` as an additive bias
45
+    based on the rules given in `conf`
46
+]]
47
+local function importance(prompt, conf)
48
+    pt = conf["PriorityText"] or {}
49
+    pb = conf["PriorityBias"] or {}
50
+    s = 0
51
+    for i, t in ipairs(pt) do
52
+        if prompt:spt():find(t) then
53
+            s = s + pb[i]
54
+        end
55
+    end
56
+    return s
57
+end
58
+
59
+--[[
60
+    checks if `Prompt` `prompt` should be reviewed now,
61
+    returning `false` if not and a number to indicate urgency if it should
62
+]]
63
+local function should(prompt, conf)
64
+    local ct = os.time()
65
+    if not prompt.delay then return -1 end
66
+    if prompt.delay >= 0 and ct - (prompt.time or 0) >= (prompt.delay or 0) then
67
+        return (ct - (prompt.time or 0)) / prompt.delay / prompt.ease / (prompt.succ + 1) + importance(prompt, conf) + 0.1 * math.random()
68
+    end
69
+    return false
70
+end
71
+
72
+local function alwaystrue(x)
73
+    return true
74
+end
75
+
76
+--[[
77
+    returns a table of pairs of integers to index the deck
78
+    to get prompts to be reviewed now;
79
+    prompts are shuffled in order
80
+    if a filter function is provided, filters the list accordingly,
81
+    calling the filter function on each `Prompt`
82
+]]
83
+local function toreviewlist(deck, conf, filter)
84
+    local ctrt = {}
85
+    local ff = filter or alwaystrue
86
+    for i, cg in ipairs(deck) do
87
+        for j, pr in ipairs(cg) do
88
+            local sc = should(pr, conf)
89
+            if sc and ff(pr) then
90
+                table.insert(ctrt, { i, j, sc or 0 })
91
+            end
92
+        end
93
+    end
94
+    table.sort(ctrt, function(a, b)
95
+        return a[3] > b[3]
96
+    end)
97
+    return ctrt
98
+end
99
+
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