paperlint 14.1.0 → 14.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +6 -1
- package/dist/skills/build-label-page/scripts/label-page-core.d.ts +70 -32
- package/docs/testing.md +12 -0
- package/eslint.config.mjs +17 -0
- package/package.json +2 -1
- package/skills/build-label-page/SKILL.md +19 -3
- package/skills/build-label-page/SKILL.md.spec.ts +18 -2
- package/skills/build-label-page/references/closed-choice-generator.md +86 -8
- package/skills/build-label-page/scripts/fixtures/v7-cards-items.json +138 -0
- package/skills/build-label-page/scripts/label-page-answerers.test.ts +381 -0
- package/skills/build-label-page/scripts/label-page-blocks.test.ts +277 -1
- package/skills/build-label-page/scripts/label-page-browser.test.ts +761 -10
- package/skills/build-label-page/scripts/label-page-core.js +311 -67
- package/skills/build-label-page/scripts/label-page-core.test.ts +49 -10
- package/skills/build-label-page/scripts/label-page-core.ts +511 -87
- package/skills/build-label-page/scripts/label-page-presets.test.ts +1 -1
- package/skills/build-label-page/scripts/label-page.js +5 -0
- package/skills/build-label-page/scripts/label-page.ts +5 -0
- package/skills/build-label-page/scripts/page-template.html +488 -62
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
|
+
import * as R from "remeda";
|
|
10
11
|
import { z } from "zod";
|
|
11
12
|
import { describeRule, } from "./rule-descriptions.js";
|
|
12
13
|
import { applyPreset } from "./label-page-presets.js";
|
|
@@ -97,6 +98,9 @@ const readJson = (path) => {
|
|
|
97
98
|
// prettier-ignore
|
|
98
99
|
const PAGE_FIELDS = ["group", "title", "body", "code", "context", "file"];
|
|
99
100
|
const PRIVATE_FIELDS = ["source_id"];
|
|
101
|
+
// Who of the page's `answerers` an item is shown to: tells the page whom to show it, nothing about
|
|
102
|
+
// the answer or the source.
|
|
103
|
+
const ANSWERERS_FIELD = "answerers";
|
|
100
104
|
// The linter rules an item names ("read as ruff `TID253`"): the page shows each with the linter's
|
|
101
105
|
// own description, looked up at build time (rule-descriptions.ts), never typed into items.json.
|
|
102
106
|
const RULES_FIELD = "rules";
|
|
@@ -107,6 +111,9 @@ const UNIT_FIELD = "unit";
|
|
|
107
111
|
const READINGS_FIELD = "readings";
|
|
108
112
|
// Content blocks (#380): generic, on any kind. Every label and sentence a block shows comes from
|
|
109
113
|
// items.json (or a preset, label-page-presets.ts); the page knows only the block's shape.
|
|
114
|
+
// `quote`, `terms` and `answer_notes` (#392): the words a card asks about, shown as a quote with
|
|
115
|
+
// its source; tap-to-see notes on terms in that quote and in the excerpt's annotation; and a sub-line
|
|
116
|
+
// under an answer that says what the answer means on this card.
|
|
110
117
|
const BLOCK_FIELDS = [
|
|
111
118
|
"situation",
|
|
112
119
|
"excerpt",
|
|
@@ -114,6 +121,9 @@ const BLOCK_FIELDS = [
|
|
|
114
121
|
"more",
|
|
115
122
|
"table",
|
|
116
123
|
"repeat",
|
|
124
|
+
"quote",
|
|
125
|
+
"terms",
|
|
126
|
+
"answer_notes",
|
|
117
127
|
];
|
|
118
128
|
const ALLOWED_FIELDS = [
|
|
119
129
|
...PAGE_FIELDS,
|
|
@@ -121,6 +131,7 @@ const ALLOWED_FIELDS = [
|
|
|
121
131
|
UNIT_FIELD,
|
|
122
132
|
READINGS_FIELD,
|
|
123
133
|
...BLOCK_FIELDS,
|
|
134
|
+
ANSWERERS_FIELD,
|
|
124
135
|
...PRIVATE_FIELDS,
|
|
125
136
|
];
|
|
126
137
|
const FAMILIARITY = [
|
|
@@ -147,6 +158,11 @@ const RESERVED_NAMES = [
|
|
|
147
158
|
"__lookupSetter__",
|
|
148
159
|
];
|
|
149
160
|
const NAME_PATTERN = `(?!(?:${RESERVED_NAMES.join("|")})$)[A-Za-z0-9_-]+`;
|
|
161
|
+
/**
|
|
162
|
+
* With `answerers`, a document's id is `<answerer>~<id>`: `~` is outside NAME_PATTERN, so no
|
|
163
|
+
* answerer's name, item id, group or practice id can hold it, and the id splits at its first `~`.
|
|
164
|
+
*/
|
|
165
|
+
const SEP = "~";
|
|
150
166
|
const SOURCES = ["page", "chat"];
|
|
151
167
|
const SOURCES_SHOWN = `(${SOURCES.map(quoted).join(", ")})`;
|
|
152
168
|
const string = (required = true, pattern, why) => pattern === undefined
|
|
@@ -173,9 +189,9 @@ const array = (items, required = true, min = 0) => ({
|
|
|
173
189
|
min,
|
|
174
190
|
items,
|
|
175
191
|
});
|
|
176
|
-
const mapping = (values, keyPattern, min = 0) => ({
|
|
192
|
+
const mapping = (values, keyPattern, min = 0, required = true) => ({
|
|
177
193
|
kind: "map",
|
|
178
|
-
required
|
|
194
|
+
required,
|
|
179
195
|
min,
|
|
180
196
|
key_pattern: keyPattern,
|
|
181
197
|
values,
|
|
@@ -206,14 +222,42 @@ const CARD_FIELDS = {
|
|
|
206
222
|
}, true, false),
|
|
207
223
|
[READINGS_FIELD]: obj({ a: string(), b: string() }, true, false),
|
|
208
224
|
file: string(false),
|
|
209
|
-
situation: optional({ summary: string(), question: string() }),
|
|
225
|
+
situation: optional({ summary: string(false), question: string() }),
|
|
210
226
|
excerpt: optional({
|
|
211
227
|
file: string(false),
|
|
228
|
+
// the file's language, after its path: "path · Python"
|
|
229
|
+
lang: string(false),
|
|
212
230
|
url: LINK,
|
|
213
|
-
|
|
231
|
+
// the header of an extra gutter column; each line's `gutter` is its value there ("depth")
|
|
232
|
+
gutter_header: string(false),
|
|
233
|
+
lines: array(obj({
|
|
234
|
+
n: integer(),
|
|
235
|
+
text: text(),
|
|
236
|
+
mark: boolean(),
|
|
237
|
+
gutter: string(false),
|
|
238
|
+
// a line shown only to place the code (an enclosing block's opener): drawn faint
|
|
239
|
+
context: boolean(false),
|
|
240
|
+
}), true, 1),
|
|
241
|
+
// one short fact, drawn in a box right under the marked line, pointing at it
|
|
242
|
+
annotation: string(false),
|
|
214
243
|
caption: string(false),
|
|
215
244
|
mark_label: string(false),
|
|
216
245
|
}),
|
|
246
|
+
quote: optional({ text: string(), source: string(false) }),
|
|
247
|
+
terms: array(obj({
|
|
248
|
+
term: string(),
|
|
249
|
+
note: array(obj({
|
|
250
|
+
title: string(false),
|
|
251
|
+
says: string(false),
|
|
252
|
+
quote: string(false),
|
|
253
|
+
code: string(false),
|
|
254
|
+
example: string(false),
|
|
255
|
+
url: string(true, "https?://\\S+", "an http(s) link"),
|
|
256
|
+
url_label: string(),
|
|
257
|
+
}), true, 1),
|
|
258
|
+
}), false, 1),
|
|
259
|
+
// question id → option value → the sub-line under that answer's button on this card
|
|
260
|
+
answer_notes: mapping(mapping(string(), NAME_PATTERN, 1), NAME_PATTERN, 1, false),
|
|
217
261
|
references: optional({
|
|
218
262
|
summary: string(),
|
|
219
263
|
entries: array(obj({
|
|
@@ -272,9 +316,10 @@ const itemSchema = (kind) => obj({
|
|
|
272
316
|
source_id: string(),
|
|
273
317
|
...Object.fromEntries(PAGE_FIELDS.filter((f) => f !== "file").map((f) => [
|
|
274
318
|
f,
|
|
275
|
-
string(f === "group"
|
|
319
|
+
string(f === "group"),
|
|
276
320
|
])),
|
|
277
321
|
...cardFields(kind),
|
|
322
|
+
[ANSWERERS_FIELD]: array(string(true, NAME_PATTERN), false, 1),
|
|
278
323
|
}, false);
|
|
279
324
|
// `icon` (an emoji) tells the buttons apart at a glance. No coloured side stripe on a button: it
|
|
280
325
|
// reads as decoration, not meaning (the build-label-page look test refuses one).
|
|
@@ -289,7 +334,7 @@ const OPTION_SCHEMA = obj({
|
|
|
289
334
|
const practiceSchema = (kind) => obj({
|
|
290
335
|
...Object.fromEntries(PAGE_FIELDS.filter((f) => f !== "group" && f !== "file").map((f) => [
|
|
291
336
|
f,
|
|
292
|
-
string(
|
|
337
|
+
string(false),
|
|
293
338
|
])),
|
|
294
339
|
...cardFields(kind),
|
|
295
340
|
expected: mapping(string(), NAME_PATTERN, 1),
|
|
@@ -310,6 +355,10 @@ const configSchema = (kind) => obj({
|
|
|
310
355
|
seed: integer(),
|
|
311
356
|
groups: mapping(string(), NAME_PATTERN, 1), // ids become store document ids
|
|
312
357
|
labeller_knows: array(string(true, NAME_PATTERN), false),
|
|
358
|
+
// Two or more people answer the same page, each under their own name (doc ids `<name>~<id>`).
|
|
359
|
+
answerers: array(string(true, NAME_PATTERN), false, 2),
|
|
360
|
+
// An item's answer saves only once the item has been on screen this many seconds.
|
|
361
|
+
min_seconds: integer(false),
|
|
313
362
|
// true: a "how well do you know {group}" question per group, for a study that filters on it
|
|
314
363
|
// (rule 5). Left out, none is asked: an approval page's author has never seen the repositories.
|
|
315
364
|
familiarity: boolean(false),
|
|
@@ -325,6 +374,9 @@ const configSchema = (kind) => obj({
|
|
|
325
374
|
line_part: string(false),
|
|
326
375
|
repeat: string(false),
|
|
327
376
|
practice_example: string(false),
|
|
377
|
+
hidden: string(false),
|
|
378
|
+
hidden_one: string(false),
|
|
379
|
+
wait: string(false),
|
|
328
380
|
}),
|
|
329
381
|
items: array(itemSchema(kind), true, 1),
|
|
330
382
|
});
|
|
@@ -495,8 +547,35 @@ function checkPractice(cfg) {
|
|
|
495
547
|
fail(`${where}.${missing}: every question needs its expected answer`);
|
|
496
548
|
});
|
|
497
549
|
}
|
|
550
|
+
/** The first entry of `names` that repeats an earlier one, refused by its path. */
|
|
551
|
+
function checkDistinct(names, where) {
|
|
552
|
+
const at = names.findIndex((x, i) => names.indexOf(x) !== i);
|
|
553
|
+
if (at !== -1)
|
|
554
|
+
fail(`${where}[${String(at)}]: ${repr(names[at])} is repeated`);
|
|
555
|
+
}
|
|
556
|
+
/** Each answerer once; an item's own list a subset of the page's; a dwell of a second or more. */
|
|
557
|
+
function checkAnswerers(cfg) {
|
|
558
|
+
if ((cfg.min_seconds ?? 1) < 1)
|
|
559
|
+
fail(`min_seconds: ${repr(cfg.min_seconds)} — must be 1 or more`);
|
|
560
|
+
const all = cfg.answerers;
|
|
561
|
+
if (all !== undefined)
|
|
562
|
+
checkDistinct(all, "answerers");
|
|
563
|
+
cfg.items.forEach((item, n) => {
|
|
564
|
+
const where = `items[${String(n)}].answerers`;
|
|
565
|
+
const own = item.answerers;
|
|
566
|
+
if (own === undefined)
|
|
567
|
+
return;
|
|
568
|
+
if (all === undefined)
|
|
569
|
+
return fail(`${where}: the page has no \`answerers\` — add the page's list, or take this out`);
|
|
570
|
+
const k = own.findIndex((who) => !R.isIncludedIn(who, all));
|
|
571
|
+
if (k !== -1)
|
|
572
|
+
fail(`${where}[${String(k)}]: ${repr(own[k])} is not in \`answerers\` (${all.join(", ")})`);
|
|
573
|
+
checkDistinct(own, where);
|
|
574
|
+
});
|
|
575
|
+
}
|
|
498
576
|
function checkConfig(cfg) {
|
|
499
577
|
checkGroups(cfg);
|
|
578
|
+
checkAnswerers(cfg);
|
|
500
579
|
if (!asksFamiliarity(cfg) && cfg.familiarity_question !== undefined) {
|
|
501
580
|
fail(`familiarity_question: ${repr(cfg.familiarity_question)} is never asked, because \`familiarity\` is not true — remove it, or set "familiarity": true`);
|
|
502
581
|
}
|
|
@@ -558,7 +637,7 @@ const SITUATION_TEXTS = ["summary", "question"];
|
|
|
558
637
|
const lineOf = (card) => card.excerpt?.lines
|
|
559
638
|
.filter((l) => l.mark)
|
|
560
639
|
.map((l) => ({ n: l.n, text: l.text.trim() }))[0];
|
|
561
|
-
const usesLine = (card) => SITUATION_TEXTS.some((f) => card.situation?.[f]
|
|
640
|
+
const usesLine = (card) => SITUATION_TEXTS.some((f) => card.situation?.[f]?.includes(LINE_TOKEN));
|
|
562
641
|
/**
|
|
563
642
|
* What the block schema cannot say: an excerpt marks a line, `{line}` has a marked line to stand for,
|
|
564
643
|
* and a repeat sits inside its count.
|
|
@@ -567,7 +646,7 @@ function checkBlocks(cfg) {
|
|
|
567
646
|
cardsOf(cfg).forEach(([at, card]) => {
|
|
568
647
|
if (card.excerpt !== undefined && !card.excerpt.lines.some((l) => l.mark))
|
|
569
648
|
fail(`${at}.excerpt.lines: no line is marked — mark the line the card is about (\`mark: true\`)`);
|
|
570
|
-
const token = SITUATION_TEXTS.find((f) => card.situation?.[f]
|
|
649
|
+
const token = SITUATION_TEXTS.find((f) => card.situation?.[f]?.includes(LINE_TOKEN));
|
|
571
650
|
if (token !== undefined && card.excerpt === undefined)
|
|
572
651
|
fail(`${at}.situation.${token}: \`${LINE_TOKEN}\` names the excerpt's marked line, and the card has no \`excerpt\` — add it, or write the ${token} without \`${LINE_TOKEN}\``);
|
|
573
652
|
const r = card.repeat;
|
|
@@ -575,6 +654,61 @@ function checkBlocks(cfg) {
|
|
|
575
654
|
fail(`${at}.repeat.index: ${String(r.index)} is not between 1 and ${String(r.of)}`);
|
|
576
655
|
});
|
|
577
656
|
}
|
|
657
|
+
/**
|
|
658
|
+
* The texts a term may open a note from, the words asked about first: the quote, its source, then
|
|
659
|
+
* the excerpt's annotation. The page looks a term up in the same texts, split the same way at
|
|
660
|
+
* backticks, so a term the build finds is a term the page draws.
|
|
661
|
+
*/
|
|
662
|
+
const termTexts = (card) => [card.quote?.text, card.quote?.source, card.excerpt?.annotation].filter(R.isDefined);
|
|
663
|
+
const findsTerm = (card, term) => termTexts(card).some((t) => t.split("`").some((part) => part.includes(term)));
|
|
664
|
+
/**
|
|
665
|
+
* What the schema cannot say about the #392 blocks: a card says something (`body` or `quote`); each
|
|
666
|
+
* term occurs in a text that can carry it, and each note entry shows a quote or a snippet; an answer
|
|
667
|
+
* note names a question of the page and one of its options.
|
|
668
|
+
*/
|
|
669
|
+
function checkCardText(cfg) {
|
|
670
|
+
const options = new Map(cfg.questions.map((q) => [q.id, q.options.map((o) => o.value)]));
|
|
671
|
+
cardsOf(cfg).forEach(([at, card]) => {
|
|
672
|
+
if (card.body === undefined && card.quote === undefined)
|
|
673
|
+
fail(`${at}.body: is required and missing — a card shows its \`body\`, or its words as a \`quote\``);
|
|
674
|
+
checkTerms(at, card);
|
|
675
|
+
checkAnswerNotes(at, card, options);
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* One term's text inside another's on the same card (or the same text twice): the page draws one
|
|
680
|
+
* button for the words, so the other term's note would never open.
|
|
681
|
+
*/
|
|
682
|
+
function checkTermsDoNotOverlap(at, card) {
|
|
683
|
+
const texts = (card.terms ?? []).map((t) => t.term);
|
|
684
|
+
const named = (k) => `${at}.terms[${String(k)}]`;
|
|
685
|
+
texts.forEach((inner, k) => {
|
|
686
|
+
const j = texts.findIndex((outer, m) => m !== k && outer.includes(inner) && (outer !== inner || m > k));
|
|
687
|
+
if (j !== -1)
|
|
688
|
+
fail(`${named(k)}.term: ${repr(inner)} is inside ${named(j)}.term ${repr(texts[j])} — the page draws one of two overlapping terms, so the other note never opens`);
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
function checkTerms(at, card) {
|
|
692
|
+
checkTermsDoNotOverlap(at, card);
|
|
693
|
+
(card.terms ?? []).forEach((t, k) => {
|
|
694
|
+
const where = `${at}.terms[${String(k)}]`;
|
|
695
|
+
if (!findsTerm(card, t.term))
|
|
696
|
+
fail(`${where}.term: ${repr(t.term)} is not in the card's annotation, quote or quote source — a note opens from words the card shows`);
|
|
697
|
+
const bare = t.note.findIndex((e) => e.quote === undefined && e.code === undefined);
|
|
698
|
+
if (bare !== -1)
|
|
699
|
+
fail(`${where}.note[${String(bare)}]: has neither \`quote\` nor \`code\` — a note shows its source's own words`);
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
function checkAnswerNotes(at, card, options) {
|
|
703
|
+
Object.entries(card.answer_notes ?? {}).forEach(([qid, notes]) => {
|
|
704
|
+
const values = options.get(qid);
|
|
705
|
+
if (values === undefined)
|
|
706
|
+
return fail(`${at}.answer_notes.${qid}: not a question of this page (${[...options.keys()].join(", ")})`);
|
|
707
|
+
const stray = Object.keys(notes).find((v) => !values.includes(v));
|
|
708
|
+
if (stray !== undefined)
|
|
709
|
+
fail(`${at}.answer_notes.${qid}.${stray}: not one of ${values.join(", ")}`);
|
|
710
|
+
});
|
|
711
|
+
}
|
|
578
712
|
/** Every card of the config, items then practice, by path. */
|
|
579
713
|
const cardsOf = (cfg) => [
|
|
580
714
|
...cfg.items.map((i, n) => [`items[${String(n)}]`, i]),
|
|
@@ -671,7 +805,7 @@ const situationShown = (item) => item.situation === undefined
|
|
|
671
805
|
? {}
|
|
672
806
|
: {
|
|
673
807
|
situation: {
|
|
674
|
-
summary: item.situation.summary,
|
|
808
|
+
...R.pickBy({ summary: item.situation.summary }, R.isDefined),
|
|
675
809
|
question: item.situation.question,
|
|
676
810
|
...(usesLine(item) ? { line: lineOf(item) } : {}),
|
|
677
811
|
},
|
|
@@ -745,7 +879,12 @@ const DEFAULT_LABELS = {
|
|
|
745
879
|
line_part: "{line} · part {part} of {of}",
|
|
746
880
|
repeat: "Card {index} of {of}",
|
|
747
881
|
practice_example: "",
|
|
882
|
+
hidden: "lines {from}\u2013{to}",
|
|
883
|
+
hidden_one: "line {from}",
|
|
884
|
+
wait: "\u23f3 You can answer in {s} s",
|
|
748
885
|
};
|
|
886
|
+
/** The answerers and the dwell, only when items.json sets them: a page without them is as it was. */
|
|
887
|
+
const twoKeys = (cfg) => R.pickBy({ answerers: cfg.answerers, min_seconds: cfg.min_seconds }, R.isDefined);
|
|
749
888
|
/** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
|
|
750
889
|
function payloadOf(cfg, page, described) {
|
|
751
890
|
const used = Object.keys(cfg.groups).filter((g) => page.items.some((i) => i.group === g));
|
|
@@ -759,18 +898,22 @@ function payloadOf(cfg, page, described) {
|
|
|
759
898
|
: "",
|
|
760
899
|
familiarity: asksFamiliarity(cfg) ? FAMILIARITY : [],
|
|
761
900
|
allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
|
|
901
|
+
...twoKeys(cfg),
|
|
762
902
|
questions: cfg.questions,
|
|
763
903
|
practice: {
|
|
764
904
|
round: practiceRound(cfg),
|
|
765
905
|
items: practiceShown(cfg, described),
|
|
766
906
|
},
|
|
767
|
-
items: page.items.map((i) => (
|
|
768
|
-
id: page.idOf(i),
|
|
769
|
-
...shownFields(i),
|
|
770
|
-
...rulesShown(i, described),
|
|
771
|
-
})),
|
|
907
|
+
items: page.items.map((i) => itemShown(i, page.idOf(i), described)),
|
|
772
908
|
};
|
|
773
909
|
}
|
|
910
|
+
/** One item as its card shows it, and whom it is shown to when not every answerer. */
|
|
911
|
+
const itemShown = (i, id, described) => ({
|
|
912
|
+
id,
|
|
913
|
+
...shownFields(i),
|
|
914
|
+
...rulesShown(i, described),
|
|
915
|
+
...R.pickBy({ answerers: i.answerers }, R.isDefined),
|
|
916
|
+
});
|
|
774
917
|
/** Every id the key keeps (on the page now, or kept from an earlier build), with its source and group. */
|
|
775
918
|
function idsOf(cfg, page) {
|
|
776
919
|
const shown = new Set(page.items.map(page.idOf));
|
|
@@ -781,15 +924,14 @@ function idsOf(cfg, page) {
|
|
|
781
924
|
]),
|
|
782
925
|
...cfg.items.map((i) => [i.source_id, i.group]),
|
|
783
926
|
]);
|
|
927
|
+
const bySource = R.indexBy(cfg.items, (i) => i.source_id);
|
|
784
928
|
const byId = [...page.ids].sort(([, a], [, b]) => byText(a, b));
|
|
785
|
-
return
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
},
|
|
792
|
-
]));
|
|
929
|
+
return R.pullObject(byId, ([, nid]) => nid, ([sid, nid]) => ({
|
|
930
|
+
source_id: sid,
|
|
931
|
+
group: String(groupOf.get(sid)),
|
|
932
|
+
on_page: shown.has(nid),
|
|
933
|
+
...R.pickBy({ answerers: bySource[sid]?.answerers }, R.isDefined),
|
|
934
|
+
}));
|
|
793
935
|
}
|
|
794
936
|
/** Every round of a key's practice; a key from before `rounds` has only its own round. */
|
|
795
937
|
const roundsIn = (p) => p.rounds ?? { [String(p.round)]: p.expected };
|
|
@@ -806,6 +948,7 @@ function keyOf(cfg, page, earlier) {
|
|
|
806
948
|
return {
|
|
807
949
|
study: cfg.study,
|
|
808
950
|
seed: cfg.seed,
|
|
951
|
+
...twoKeys(cfg),
|
|
809
952
|
questions: cfg.questions.map((q) => q.id),
|
|
810
953
|
// What counts as an answer: the same lists the page was given (`allowedValues`). `export`
|
|
811
954
|
// checks every stored answer and familiarity against them, so the build's options decide.
|
|
@@ -838,6 +981,7 @@ export function prepareBuild(configPath, outDir) {
|
|
|
838
981
|
checkItemFields(cfg);
|
|
839
982
|
checkUnits(cfg);
|
|
840
983
|
checkBlocks(cfg);
|
|
984
|
+
checkCardText(cfg);
|
|
841
985
|
checkNoSourceShown(cfg);
|
|
842
986
|
const described = describeRules(cfg, dirname(configPath));
|
|
843
987
|
mkdirSync(outDir, { recursive: true });
|
|
@@ -855,6 +999,10 @@ function assertKey(_value) {
|
|
|
855
999
|
// key.json is this script's own output; what an older build left out is checked where it is read.
|
|
856
1000
|
}
|
|
857
1001
|
const ANSWER_DOC_FIELDS = ["answers", "reasons", "savedAt", "source"];
|
|
1002
|
+
/** A page built with `min_seconds` also stores how long the item was on screen before its first save. */
|
|
1003
|
+
const answerDocFields = (key) => key.min_seconds === undefined
|
|
1004
|
+
? ANSWER_DOC_FIELDS
|
|
1005
|
+
: ["answers", "reasons", "savedAt", "seconds", "source"];
|
|
858
1006
|
const GROUP_DOC_FIELDS = ["familiarity", "savedAt", "source"];
|
|
859
1007
|
const DOC_FIELD_TYPES = [
|
|
860
1008
|
["answers", isObject, "an object {question: value}"],
|
|
@@ -869,6 +1017,11 @@ const DOC_FIELD_TYPES = [
|
|
|
869
1017
|
(v) => v === null || typeof v === "string",
|
|
870
1018
|
"a timestamp string or null",
|
|
871
1019
|
],
|
|
1020
|
+
[
|
|
1021
|
+
"seconds",
|
|
1022
|
+
(v) => v === null || (typeof v === "number" && Number.isInteger(v) && v >= 0),
|
|
1023
|
+
"a whole number of seconds or null",
|
|
1024
|
+
],
|
|
872
1025
|
];
|
|
873
1026
|
const COLLECTIONS = ["answers", "groups", "practice"];
|
|
874
1027
|
const COLLECTION_NAMES = COLLECTIONS;
|
|
@@ -888,15 +1041,18 @@ const practiceDocs = (key) => {
|
|
|
888
1041
|
// Each round's own questions: a later round may have dropped or renamed one.
|
|
889
1042
|
return Object.entries(roundsIn(p)).flatMap(([r, expected]) => Object.entries(expected).map(([pid, answers]) => [`r${r}-${pid}`, Object.keys(answers)]));
|
|
890
1043
|
};
|
|
891
|
-
|
|
892
|
-
|
|
1044
|
+
/** Who an item is shown to: its own list, else every answerer of the page. */
|
|
1045
|
+
const shownTo = (all, meta) => meta.answerers ?? all;
|
|
1046
|
+
/** The names of a page without answerers, or those of one answerer's documents. */
|
|
1047
|
+
function knownNames(key, whose) {
|
|
1048
|
+
const entries = Object.entries(key.ids).filter(([, m]) => whose.who === undefined || shownTo(whose.all, m).includes(whose.who));
|
|
893
1049
|
const shown = entries.filter(([, m]) => m.on_page);
|
|
894
1050
|
const practice = practiceDocs(key);
|
|
895
1051
|
return {
|
|
896
1052
|
answers: {
|
|
897
1053
|
known: entries.map(([nid]) => [nid, key.questions]),
|
|
898
1054
|
onPage: shown.map(([nid]) => nid),
|
|
899
|
-
fields:
|
|
1055
|
+
fields: answerDocFields(key),
|
|
900
1056
|
},
|
|
901
1057
|
groups: {
|
|
902
1058
|
known: entries.map(([, m]) => [m.group, key.questions]),
|
|
@@ -906,7 +1062,7 @@ function knownNames(key) {
|
|
|
906
1062
|
practice: {
|
|
907
1063
|
known: practice,
|
|
908
1064
|
onPage: practice.map(([n]) => n),
|
|
909
|
-
fields:
|
|
1065
|
+
fields: answerDocFields(key),
|
|
910
1066
|
},
|
|
911
1067
|
};
|
|
912
1068
|
}
|
|
@@ -925,10 +1081,28 @@ function checkDumpShape(dump) {
|
|
|
925
1081
|
if (stray !== undefined)
|
|
926
1082
|
fail(`${stray}: the store has no such collection (expected ${COLLECTIONS.join(", ")})`);
|
|
927
1083
|
}
|
|
928
|
-
|
|
1084
|
+
/**
|
|
1085
|
+
* A document's answerer and its name without it. A page without answerers has none; with them, an
|
|
1086
|
+
* id without `~`, or before it a name the page does not have, is refused.
|
|
1087
|
+
*/
|
|
1088
|
+
function answererOf(where, docId, key) {
|
|
1089
|
+
const all = key.answerers;
|
|
1090
|
+
if (all === undefined)
|
|
1091
|
+
return { name: docId };
|
|
1092
|
+
const at = docId.indexOf(SEP);
|
|
1093
|
+
if (at === -1)
|
|
1094
|
+
return fail(`${where}: this page has answerers (${all.join(", ")}), so a document's id is <answerer>${SEP}<id>`);
|
|
1095
|
+
const who = docId.slice(0, at);
|
|
1096
|
+
if (!all.includes(who))
|
|
1097
|
+
fail(`${where}: ${repr(who)} is not an answerer of this page (${all.join(", ")})`);
|
|
1098
|
+
return { who, all, name: docId.slice(at + 1) };
|
|
1099
|
+
}
|
|
1100
|
+
function checkDocName(where, { who, name }, kind, names) {
|
|
929
1101
|
const hit = names.known.find(([n]) => n === name);
|
|
930
1102
|
if (hit === undefined) {
|
|
931
|
-
return fail(`${where}: the key does not know this ${NOUN[kind]}
|
|
1103
|
+
return fail(`${where}: the key does not know this ${NOUN[kind]}${who === undefined ? "" : ` for ${who}`} (${R.unique(names.known.map(([n]) => n))
|
|
1104
|
+
.sort()
|
|
1105
|
+
.join(", ")})`);
|
|
932
1106
|
}
|
|
933
1107
|
if (!names.onPage.includes(name)) {
|
|
934
1108
|
fail(`${where}: stored, but no longer on the page (a rebuild left it out), so the export would drop it; rebuild with it on the page, or take it out of the dump on purpose`);
|
|
@@ -983,13 +1157,16 @@ function checkDocAnswers(where, doc, questions) {
|
|
|
983
1157
|
*/
|
|
984
1158
|
function checkDump(dump, key) {
|
|
985
1159
|
checkDumpShape(dump);
|
|
986
|
-
|
|
1160
|
+
// A page without answerers knows one set of names; each answerer has their own.
|
|
1161
|
+
const plain = knownNames(key, {});
|
|
987
1162
|
for (const kind of COLLECTIONS) {
|
|
988
1163
|
const collection = dump[kind];
|
|
989
|
-
for (const [
|
|
990
|
-
const where = `${kind}/${
|
|
991
|
-
const
|
|
992
|
-
|
|
1164
|
+
for (const [docId, doc] of sortedEntries(isObject(collection) ? collection : {})) {
|
|
1165
|
+
const where = `${kind}/${docId}`;
|
|
1166
|
+
const named = answererOf(where, docId, key);
|
|
1167
|
+
const names = (named.who === undefined ? plain : knownNames(key, named))[kind];
|
|
1168
|
+
const questions = checkDocName(where, named, kind, names);
|
|
1169
|
+
checkDocFields(where, doc, kind, names.fields);
|
|
993
1170
|
checkDocAnswers(where, doc, questions);
|
|
994
1171
|
}
|
|
995
1172
|
}
|
|
@@ -1042,8 +1219,8 @@ function answersOf(doc, ctx) {
|
|
|
1042
1219
|
const given = Object.fromEntries(raw.map(([q, v]) => [q, has(invalid, q) ? null : v]));
|
|
1043
1220
|
return { given, invalid };
|
|
1044
1221
|
}
|
|
1045
|
-
/** One
|
|
1046
|
-
function
|
|
1222
|
+
/** One stored answer: its answers, what is invalid, the reasons, and whether it is done. */
|
|
1223
|
+
function answerOf(doc, ctx) {
|
|
1047
1224
|
const { given, invalid } = answersOf(doc, ctx);
|
|
1048
1225
|
const reasons = Object.fromEntries(Object.entries(textsOf(doc["reasons"])).filter(([, r]) => r.trim()));
|
|
1049
1226
|
const needsWhy = ctx.allowed.needs_why ?? {};
|
|
@@ -1051,9 +1228,6 @@ function labelOf(nid, meta, doc, ctx) {
|
|
|
1051
1228
|
.filter(([q, v]) => v !== null && listed(needsWhy, q, v) && !has(reasons, q))
|
|
1052
1229
|
.map(([q]) => q);
|
|
1053
1230
|
return {
|
|
1054
|
-
id: nid,
|
|
1055
|
-
source_id: meta.source_id,
|
|
1056
|
-
group: meta.group,
|
|
1057
1231
|
answers: given,
|
|
1058
1232
|
invalid_answers: invalid,
|
|
1059
1233
|
reasons,
|
|
@@ -1061,12 +1235,23 @@ function labelOf(nid, meta, doc, ctx) {
|
|
|
1061
1235
|
complete: Object.values(given).every((v) => v !== null) && !unexplained.length,
|
|
1062
1236
|
source: textOf(doc["source"]),
|
|
1063
1237
|
saved_at: doc["savedAt"] ?? null,
|
|
1238
|
+
// Only a page built with `min_seconds` stores how long the item was on screen.
|
|
1239
|
+
...R.pick({ seconds: doc["seconds"] ?? null }, secondsOf(ctx.key)),
|
|
1064
1240
|
};
|
|
1065
1241
|
}
|
|
1242
|
+
/** The `seconds` field, on a page built with `min_seconds`; none on any other. */
|
|
1243
|
+
const secondsOf = (key) => key.min_seconds === undefined ? [] : ["seconds"];
|
|
1244
|
+
/** One item of the labels file of a page without answerers. */
|
|
1245
|
+
const labelOf = (nid, meta, doc, ctx) => ({
|
|
1246
|
+
id: nid,
|
|
1247
|
+
source_id: meta.source_id,
|
|
1248
|
+
group: meta.group,
|
|
1249
|
+
...answerOf(doc, ctx),
|
|
1250
|
+
});
|
|
1066
1251
|
/** Same rule as for item answers: a familiarity outside the options is marked, not counted as rated. */
|
|
1067
|
-
function groupsOf(groupIds, groups, allowed) {
|
|
1252
|
+
function groupsOf(groupIds, groups, allowed, prefix = "") {
|
|
1068
1253
|
return Object.fromEntries(groupIds.map((g) => {
|
|
1069
|
-
const doc = docOf(groups, g);
|
|
1254
|
+
const doc = docOf(groups, `${prefix}${g}`);
|
|
1070
1255
|
const fam = textOf(doc["familiarity"]);
|
|
1071
1256
|
const bad = fam !== null && !allowed.familiarity.includes(fam);
|
|
1072
1257
|
return [
|
|
@@ -1104,9 +1289,9 @@ function reportGroups(groups, allowed) {
|
|
|
1104
1289
|
}
|
|
1105
1290
|
}
|
|
1106
1291
|
/** Each practice item of the key's round: what was answered first, what was expected, whether they match. */
|
|
1107
|
-
function scorePractice(dump, round, all) {
|
|
1292
|
+
function scorePractice(dump, round, all, prefix) {
|
|
1108
1293
|
return Object.entries(all).map(([pid, expected]) => {
|
|
1109
|
-
const doc = docOf(dump["practice"],
|
|
1294
|
+
const doc = docOf(dump["practice"], `${prefix}r${round}-${pid}`);
|
|
1110
1295
|
const answers = isObject(doc["answers"]) ? doc["answers"] : {};
|
|
1111
1296
|
const matched = Object.entries(expected).every(([q, v]) => answers[q] === v);
|
|
1112
1297
|
return {
|
|
@@ -1118,8 +1303,8 @@ function scorePractice(dump, round, all) {
|
|
|
1118
1303
|
};
|
|
1119
1304
|
});
|
|
1120
1305
|
}
|
|
1121
|
-
/** One file per round: a second round exported the same day keeps the first. */
|
|
1122
|
-
const practiceFile = (study, round, day) => `${study}-practice-r${String(round)}-${day}.json`;
|
|
1306
|
+
/** One file per round (and answerer): a second round exported the same day keeps the first. */
|
|
1307
|
+
const practiceFile = (study, round, day, who) => `${study}-practice-r${String(round)}${who === undefined ? "" : `-${who}`}-${day}.json`;
|
|
1123
1308
|
/**
|
|
1124
1309
|
* Every practice round the store holds answers for, each scored against its own expected answers
|
|
1125
1310
|
* into its own file; never into the labels. The key's own round is reported even unanswered.
|
|
@@ -1128,56 +1313,99 @@ function exportPractice(dump, key, target, day) {
|
|
|
1128
1313
|
const p = key.practice;
|
|
1129
1314
|
if (p === undefined)
|
|
1130
1315
|
return;
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
.forEach(([round, expected]) => {
|
|
1134
|
-
|
|
1135
|
-
target,
|
|
1136
|
-
day,
|
|
1137
|
-
current: Number(round) === p.round,
|
|
1316
|
+
const rounds = R.sortBy(R.entries(roundsIn(p)), ([r]) => Number(r));
|
|
1317
|
+
(key.answerers ?? [undefined]).forEach((who) => {
|
|
1318
|
+
rounds.forEach(([round, expected]) => {
|
|
1319
|
+
exportRound(key.study, Number(round), scorePractice(dump, round, expected, who === undefined ? "" : `${who}${SEP}`), { target, day, current: Number(round) === p.round, who });
|
|
1138
1320
|
});
|
|
1139
1321
|
});
|
|
1140
1322
|
}
|
|
1141
1323
|
/** One round's scored practice to `<study>-practice-r<round>-<date>.json`, and one line about it. */
|
|
1142
1324
|
function exportRound(study, round, items, at) {
|
|
1325
|
+
const named = `practice round ${String(round)}${at.who === undefined ? "" : ` (${at.who})`}`;
|
|
1143
1326
|
if (!items.some((i) => Object.keys(i.answers).length)) {
|
|
1144
1327
|
if (at.current)
|
|
1145
|
-
say(
|
|
1328
|
+
say(`${named}: not answered`);
|
|
1146
1329
|
return;
|
|
1147
1330
|
}
|
|
1148
1331
|
const missed = items.filter((i) => !i.matched).map((i) => i.id);
|
|
1149
1332
|
const matched = items.length - missed.length;
|
|
1150
|
-
const path = join(at.target, practiceFile(study, round, at.day));
|
|
1333
|
+
const path = join(at.target, practiceFile(study, round, at.day, at.who));
|
|
1151
1334
|
writeJson(path, {
|
|
1152
1335
|
study,
|
|
1153
1336
|
round,
|
|
1337
|
+
...R.pickBy({ answerer: at.who }, R.isDefined),
|
|
1154
1338
|
exported: at.day,
|
|
1155
1339
|
matched,
|
|
1156
1340
|
total: items.length,
|
|
1157
1341
|
items,
|
|
1158
1342
|
});
|
|
1159
|
-
say(
|
|
1343
|
+
say(`${named}: ${String(matched)} of ${String(items.length)} matched${missed.length ? `; missed ${missed.join(", ")}` : ""} -> ${path}`);
|
|
1160
1344
|
}
|
|
1345
|
+
/** The counts of one set of answers and groups. */
|
|
1346
|
+
const tally = (items, groups) => ({
|
|
1347
|
+
groups,
|
|
1348
|
+
complete_items: items.filter((i) => i.complete).length,
|
|
1349
|
+
invalid_items: items.filter((i) => Object.keys(i.invalid_answers).length)
|
|
1350
|
+
.length,
|
|
1351
|
+
invalid_groups: Object.values(groups).filter((g) => g.invalid_familiarity !== null).length,
|
|
1352
|
+
total_items: items.length,
|
|
1353
|
+
});
|
|
1354
|
+
/** The familiarity of the groups of `onPage`, or none on a page that asks none. */
|
|
1355
|
+
const groupsFor = (onPage, dump, ctx, prefix = "") => ctx.allowed.familiarity.length
|
|
1356
|
+
? groupsOf(R.unique(onPage.map(([, m]) => m.group)), dump["groups"], ctx.allowed, prefix)
|
|
1357
|
+
: {};
|
|
1161
1358
|
/** The labels file: every item on the page with its answers, the groups, and the counts. */
|
|
1162
1359
|
function labelsOf(dump, ctx, day) {
|
|
1163
1360
|
const onPage = Object.entries(ctx.key.ids).filter(([, m]) => m.on_page);
|
|
1164
1361
|
const items = onPage.map(([nid, meta]) => labelOf(nid, meta, docOf(dump["answers"], nid), ctx));
|
|
1165
|
-
const groups = ctx.allowed.familiarity.length
|
|
1166
|
-
? groupsOf([...new Set(onPage.map(([, m]) => m.group))], dump["groups"], ctx.allowed)
|
|
1167
|
-
: {};
|
|
1168
1362
|
return {
|
|
1169
1363
|
study: ctx.key.study,
|
|
1170
1364
|
exported: day,
|
|
1171
1365
|
questions: ctx.key.questions,
|
|
1172
1366
|
items,
|
|
1173
|
-
|
|
1174
|
-
complete_items: items.filter((i) => i.complete).length,
|
|
1175
|
-
invalid_items: items.filter((i) => Object.keys(i.invalid_answers).length)
|
|
1176
|
-
.length,
|
|
1177
|
-
invalid_groups: Object.values(groups).filter((g) => g.invalid_familiarity !== null).length,
|
|
1178
|
-
total_items: items.length,
|
|
1367
|
+
...tally(items, groupsFor(onPage, dump, ctx)),
|
|
1179
1368
|
};
|
|
1180
1369
|
}
|
|
1370
|
+
/**
|
|
1371
|
+
* The labels file of a page with answerers: each item with every answerer it is shown to and their
|
|
1372
|
+
* answers apart (`by_answerer`), and each answerer's groups and counts (`by_answerer` at the top).
|
|
1373
|
+
*/
|
|
1374
|
+
function answererLabelsOf(dump, ctx, day, all) {
|
|
1375
|
+
const onPage = Object.entries(ctx.key.ids).filter(([, m]) => m.on_page);
|
|
1376
|
+
const docFor = (who, nid) => answerOf(docOf(dump["answers"], `${who}${SEP}${nid}`), ctx);
|
|
1377
|
+
const items = onPage.map(([nid, meta]) => ({
|
|
1378
|
+
id: nid,
|
|
1379
|
+
source_id: meta.source_id,
|
|
1380
|
+
group: meta.group,
|
|
1381
|
+
answerers: shownTo(all, meta),
|
|
1382
|
+
by_answerer: R.fromKeys(shownTo(all, meta), (who) => docFor(who, nid)),
|
|
1383
|
+
}));
|
|
1384
|
+
return {
|
|
1385
|
+
study: ctx.key.study,
|
|
1386
|
+
exported: day,
|
|
1387
|
+
questions: ctx.key.questions,
|
|
1388
|
+
answerers: all,
|
|
1389
|
+
items,
|
|
1390
|
+
by_answerer: R.fromKeys(all, (who) => tallyOf({ who, all }, onPage, dump, ctx)),
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
/** One answerer's groups and counts, over the items shown to them. */
|
|
1394
|
+
function tallyOf({ who, all }, onPage, dump, ctx) {
|
|
1395
|
+
const mine = onPage.filter(([, m]) => shownTo(all, m).includes(who));
|
|
1396
|
+
return tally(mine.map(([nid]) => answerOf(docOf(dump["answers"], `${who}${SEP}${nid}`), ctx)), groupsFor(mine, dump, ctx, `${who}${SEP}`));
|
|
1397
|
+
}
|
|
1398
|
+
/** Each answerer's items and groups, named `<answerer>~<id>` in the report, as in the store. */
|
|
1399
|
+
function reportAnswerers(out, allowed) {
|
|
1400
|
+
R.forEachObj(out.by_answerer, (t, who) => {
|
|
1401
|
+
reportLabels(out.items.flatMap((i) => {
|
|
1402
|
+
const a = i.by_answerer[who];
|
|
1403
|
+
return a === undefined ? [] : [{ id: `${who}${SEP}${i.id}`, ...a }];
|
|
1404
|
+
}), allowed);
|
|
1405
|
+
reportGroups(R.mapKeys(t.groups, (g) => `${who}${SEP}${g}`), allowed);
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
const completeLine = (t) => `${String(t.complete_items)} of ${String(t.total_items)} items complete`;
|
|
1181
1409
|
export function exportLabels(dumpPath, keyPath, outDir, day) {
|
|
1182
1410
|
const dump = readJson(dumpPath);
|
|
1183
1411
|
const key = readJson(keyPath);
|
|
@@ -1186,12 +1414,28 @@ export function exportLabels(dumpPath, keyPath, outDir, day) {
|
|
|
1186
1414
|
assertAllowed(allowed, key, keyPath);
|
|
1187
1415
|
checkDump(dump, key);
|
|
1188
1416
|
checkNoFamiliarity(dump, allowed);
|
|
1189
|
-
const
|
|
1417
|
+
const ctx = { key, allowed };
|
|
1418
|
+
const all = key.answerers;
|
|
1419
|
+
const out = all === undefined
|
|
1420
|
+
? labelsOf(dump, ctx, day)
|
|
1421
|
+
: answererLabelsOf(dump, ctx, day, all);
|
|
1190
1422
|
mkdirSync(outDir, { recursive: true });
|
|
1191
1423
|
const path = join(outDir, `${key.study}-labels-${day}.json`);
|
|
1192
1424
|
writeJson(path, out);
|
|
1193
1425
|
exportPractice(dump, key, outDir, day);
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1426
|
+
reportExport(path, out, allowed);
|
|
1427
|
+
}
|
|
1428
|
+
/** What the export wrote, and every item and group it did not count. */
|
|
1429
|
+
function reportExport(path, out, allowed) {
|
|
1430
|
+
if ("by_answerer" in out) {
|
|
1431
|
+
say(`wrote ${path}: ${R.entries(out.by_answerer)
|
|
1432
|
+
.map(([who, t]) => `${who}: ${completeLine(t)}`)
|
|
1433
|
+
.join("; ")}`);
|
|
1434
|
+
reportAnswerers(out, allowed);
|
|
1435
|
+
}
|
|
1436
|
+
else {
|
|
1437
|
+
say(`wrote ${path}: ${completeLine(out)}`);
|
|
1438
|
+
reportLabels(out.items, allowed);
|
|
1439
|
+
reportGroups(out.groups, allowed);
|
|
1440
|
+
}
|
|
1197
1441
|
}
|