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 {
|
|
12
13
|
describeRule,
|
|
@@ -106,6 +107,9 @@ const readJson = (path: string): Json => {
|
|
|
106
107
|
// prettier-ignore
|
|
107
108
|
const PAGE_FIELDS = ["group", "title", "body", "code", "context", "file"] as const;
|
|
108
109
|
const PRIVATE_FIELDS = ["source_id"] as const;
|
|
110
|
+
// Who of the page's `answerers` an item is shown to: tells the page whom to show it, nothing about
|
|
111
|
+
// the answer or the source.
|
|
112
|
+
const ANSWERERS_FIELD = "answerers";
|
|
109
113
|
// The linter rules an item names ("read as ruff `TID253`"): the page shows each with the linter's
|
|
110
114
|
// own description, looked up at build time (rule-descriptions.ts), never typed into items.json.
|
|
111
115
|
const RULES_FIELD = "rules";
|
|
@@ -116,6 +120,9 @@ const UNIT_FIELD = "unit";
|
|
|
116
120
|
const READINGS_FIELD = "readings";
|
|
117
121
|
// Content blocks (#380): generic, on any kind. Every label and sentence a block shows comes from
|
|
118
122
|
// items.json (or a preset, label-page-presets.ts); the page knows only the block's shape.
|
|
123
|
+
// `quote`, `terms` and `answer_notes` (#392): the words a card asks about, shown as a quote with
|
|
124
|
+
// its source; tap-to-see notes on terms in that quote and in the excerpt's annotation; and a sub-line
|
|
125
|
+
// under an answer that says what the answer means on this card.
|
|
119
126
|
const BLOCK_FIELDS = [
|
|
120
127
|
"situation",
|
|
121
128
|
"excerpt",
|
|
@@ -123,6 +130,9 @@ const BLOCK_FIELDS = [
|
|
|
123
130
|
"more",
|
|
124
131
|
"table",
|
|
125
132
|
"repeat",
|
|
133
|
+
"quote",
|
|
134
|
+
"terms",
|
|
135
|
+
"answer_notes",
|
|
126
136
|
] as const;
|
|
127
137
|
const ALLOWED_FIELDS: readonly string[] = [
|
|
128
138
|
...PAGE_FIELDS,
|
|
@@ -130,6 +140,7 @@ const ALLOWED_FIELDS: readonly string[] = [
|
|
|
130
140
|
UNIT_FIELD,
|
|
131
141
|
READINGS_FIELD,
|
|
132
142
|
...BLOCK_FIELDS,
|
|
143
|
+
ANSWERERS_FIELD,
|
|
133
144
|
...PRIVATE_FIELDS,
|
|
134
145
|
];
|
|
135
146
|
|
|
@@ -157,6 +168,11 @@ const RESERVED_NAMES = [
|
|
|
157
168
|
"__lookupSetter__",
|
|
158
169
|
];
|
|
159
170
|
const NAME_PATTERN = `(?!(?:${RESERVED_NAMES.join("|")})$)[A-Za-z0-9_-]+`;
|
|
171
|
+
/**
|
|
172
|
+
* With `answerers`, a document's id is `<answerer>~<id>`: `~` is outside NAME_PATTERN, so no
|
|
173
|
+
* answerer's name, item id, group or practice id can hold it, and the id splits at its first `~`.
|
|
174
|
+
*/
|
|
175
|
+
const SEP = "~";
|
|
160
176
|
const SOURCES = ["page", "chat"];
|
|
161
177
|
const SOURCES_SHOWN = `(${SOURCES.map(quoted).join(", ")})`;
|
|
162
178
|
|
|
@@ -217,9 +233,10 @@ const mapping = (
|
|
|
217
233
|
values: SchemaNode,
|
|
218
234
|
keyPattern: string,
|
|
219
235
|
min = 0,
|
|
236
|
+
required = true,
|
|
220
237
|
): SchemaNode => ({
|
|
221
238
|
kind: "map",
|
|
222
|
-
required
|
|
239
|
+
required,
|
|
223
240
|
min,
|
|
224
241
|
key_pattern: keyPattern,
|
|
225
242
|
values,
|
|
@@ -263,14 +280,59 @@ const CARD_FIELDS: Readonly<Record<string, SchemaNode>> = {
|
|
|
263
280
|
),
|
|
264
281
|
[READINGS_FIELD]: obj({ a: string(), b: string() }, true, false),
|
|
265
282
|
file: string(false),
|
|
266
|
-
situation: optional({ summary: string(), question: string() }),
|
|
283
|
+
situation: optional({ summary: string(false), question: string() }),
|
|
267
284
|
excerpt: optional({
|
|
268
285
|
file: string(false),
|
|
286
|
+
// the file's language, after its path: "path · Python"
|
|
287
|
+
lang: string(false),
|
|
269
288
|
url: LINK,
|
|
270
|
-
|
|
289
|
+
// the header of an extra gutter column; each line's `gutter` is its value there ("depth")
|
|
290
|
+
gutter_header: string(false),
|
|
291
|
+
lines: array(
|
|
292
|
+
obj({
|
|
293
|
+
n: integer(),
|
|
294
|
+
text: text(),
|
|
295
|
+
mark: boolean(),
|
|
296
|
+
gutter: string(false),
|
|
297
|
+
// a line shown only to place the code (an enclosing block's opener): drawn faint
|
|
298
|
+
context: boolean(false),
|
|
299
|
+
}),
|
|
300
|
+
true,
|
|
301
|
+
1,
|
|
302
|
+
),
|
|
303
|
+
// one short fact, drawn in a box right under the marked line, pointing at it
|
|
304
|
+
annotation: string(false),
|
|
271
305
|
caption: string(false),
|
|
272
306
|
mark_label: string(false),
|
|
273
307
|
}),
|
|
308
|
+
quote: optional({ text: string(), source: string(false) }),
|
|
309
|
+
terms: array(
|
|
310
|
+
obj({
|
|
311
|
+
term: string(),
|
|
312
|
+
note: array(
|
|
313
|
+
obj({
|
|
314
|
+
title: string(false),
|
|
315
|
+
says: string(false),
|
|
316
|
+
quote: string(false),
|
|
317
|
+
code: string(false),
|
|
318
|
+
example: string(false),
|
|
319
|
+
url: string(true, "https?://\\S+", "an http(s) link"),
|
|
320
|
+
url_label: string(),
|
|
321
|
+
}),
|
|
322
|
+
true,
|
|
323
|
+
1,
|
|
324
|
+
),
|
|
325
|
+
}),
|
|
326
|
+
false,
|
|
327
|
+
1,
|
|
328
|
+
),
|
|
329
|
+
// question id → option value → the sub-line under that answer's button on this card
|
|
330
|
+
answer_notes: mapping(
|
|
331
|
+
mapping(string(), NAME_PATTERN, 1),
|
|
332
|
+
NAME_PATTERN,
|
|
333
|
+
1,
|
|
334
|
+
false,
|
|
335
|
+
),
|
|
274
336
|
references: optional({
|
|
275
337
|
summary: string(),
|
|
276
338
|
entries: array(
|
|
@@ -352,10 +414,11 @@ const itemSchema = (kind: Kind): SchemaNode =>
|
|
|
352
414
|
...Object.fromEntries(
|
|
353
415
|
PAGE_FIELDS.filter((f) => f !== "file").map((f) => [
|
|
354
416
|
f,
|
|
355
|
-
string(f === "group"
|
|
417
|
+
string(f === "group"),
|
|
356
418
|
]),
|
|
357
419
|
),
|
|
358
420
|
...cardFields(kind),
|
|
421
|
+
[ANSWERERS_FIELD]: array(string(true, NAME_PATTERN), false, 1),
|
|
359
422
|
},
|
|
360
423
|
false, // an unlisted item field is the leak guard's refusal, with its own message
|
|
361
424
|
);
|
|
@@ -374,7 +437,7 @@ const practiceSchema = (kind: Kind): SchemaNode =>
|
|
|
374
437
|
...Object.fromEntries(
|
|
375
438
|
PAGE_FIELDS.filter((f) => f !== "group" && f !== "file").map((f) => [
|
|
376
439
|
f,
|
|
377
|
-
string(
|
|
440
|
+
string(false),
|
|
378
441
|
]),
|
|
379
442
|
),
|
|
380
443
|
...cardFields(kind),
|
|
@@ -397,6 +460,10 @@ const configSchema = (kind: Kind): SchemaNode =>
|
|
|
397
460
|
seed: integer(),
|
|
398
461
|
groups: mapping(string(), NAME_PATTERN, 1), // ids become store document ids
|
|
399
462
|
labeller_knows: array(string(true, NAME_PATTERN), false),
|
|
463
|
+
// Two or more people answer the same page, each under their own name (doc ids `<name>~<id>`).
|
|
464
|
+
answerers: array(string(true, NAME_PATTERN), false, 2),
|
|
465
|
+
// An item's answer saves only once the item has been on screen this many seconds.
|
|
466
|
+
min_seconds: integer(false),
|
|
400
467
|
// true: a "how well do you know {group}" question per group, for a study that filters on it
|
|
401
468
|
// (rule 5). Left out, none is asked: an approval page's author has never seen the repositories.
|
|
402
469
|
familiarity: boolean(false),
|
|
@@ -412,6 +479,9 @@ const configSchema = (kind: Kind): SchemaNode =>
|
|
|
412
479
|
line_part: string(false),
|
|
413
480
|
repeat: string(false),
|
|
414
481
|
practice_example: string(false),
|
|
482
|
+
hidden: string(false),
|
|
483
|
+
hidden_one: string(false),
|
|
484
|
+
wait: string(false),
|
|
415
485
|
}),
|
|
416
486
|
items: array(itemSchema(kind), true, 1),
|
|
417
487
|
});
|
|
@@ -563,17 +633,44 @@ interface CodeLine {
|
|
|
563
633
|
readonly n: number;
|
|
564
634
|
readonly text: string;
|
|
565
635
|
readonly mark: boolean;
|
|
636
|
+
readonly gutter?: string;
|
|
637
|
+
readonly context?: boolean;
|
|
638
|
+
}
|
|
639
|
+
/** One entry of a term's note: a verbatim quote or snippet, and the link to where it is from. */
|
|
640
|
+
interface NoteEntry {
|
|
641
|
+
readonly title?: string;
|
|
642
|
+
readonly says?: string;
|
|
643
|
+
readonly quote?: string;
|
|
644
|
+
readonly code?: string;
|
|
645
|
+
readonly example?: string;
|
|
646
|
+
readonly url: string;
|
|
647
|
+
readonly url_label: string;
|
|
648
|
+
}
|
|
649
|
+
interface Term {
|
|
650
|
+
readonly term: string;
|
|
651
|
+
readonly note: readonly NoteEntry[];
|
|
566
652
|
}
|
|
567
653
|
/** The content blocks (#380), each optional on any kind. */
|
|
568
654
|
interface Blocks {
|
|
569
|
-
readonly situation?: {
|
|
655
|
+
readonly situation?: {
|
|
656
|
+
readonly summary?: string;
|
|
657
|
+
readonly question: string;
|
|
658
|
+
};
|
|
570
659
|
readonly excerpt?: {
|
|
571
660
|
readonly file?: string;
|
|
661
|
+
readonly lang?: string;
|
|
572
662
|
readonly url?: string;
|
|
663
|
+
readonly gutter_header?: string;
|
|
573
664
|
readonly lines: readonly CodeLine[];
|
|
665
|
+
readonly annotation?: string;
|
|
574
666
|
readonly caption?: string;
|
|
575
667
|
readonly mark_label?: string;
|
|
576
668
|
};
|
|
669
|
+
readonly quote?: { readonly text: string; readonly source?: string };
|
|
670
|
+
readonly terms?: readonly Term[];
|
|
671
|
+
readonly answer_notes?: Readonly<
|
|
672
|
+
Record<string, Readonly<Record<string, string>>>
|
|
673
|
+
>;
|
|
577
674
|
readonly references?: {
|
|
578
675
|
readonly summary: string;
|
|
579
676
|
readonly entries: readonly {
|
|
@@ -609,12 +706,16 @@ interface Blocks {
|
|
|
609
706
|
type Card = {
|
|
610
707
|
readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
|
|
611
708
|
} & Blocks & {
|
|
612
|
-
readonly body
|
|
709
|
+
readonly body?: string;
|
|
613
710
|
readonly rules?: readonly RuleRef[];
|
|
614
711
|
readonly unit?: Unit;
|
|
615
712
|
readonly readings?: { readonly a: string; readonly b: string };
|
|
616
713
|
};
|
|
617
|
-
type Item = Card & {
|
|
714
|
+
type Item = Card & {
|
|
715
|
+
readonly source_id: string;
|
|
716
|
+
readonly group: string;
|
|
717
|
+
readonly answerers?: readonly string[];
|
|
718
|
+
};
|
|
618
719
|
type Practice = Card & {
|
|
619
720
|
readonly expected: Readonly<Record<string, string>>;
|
|
620
721
|
readonly why: string;
|
|
@@ -627,6 +728,8 @@ interface Config {
|
|
|
627
728
|
readonly seed: number;
|
|
628
729
|
readonly groups: Readonly<Record<string, string>>;
|
|
629
730
|
readonly labeller_knows?: readonly string[];
|
|
731
|
+
readonly answerers?: readonly string[];
|
|
732
|
+
readonly min_seconds?: number;
|
|
630
733
|
readonly familiarity?: boolean;
|
|
631
734
|
readonly familiarity_question?: string;
|
|
632
735
|
readonly questions: readonly Question[];
|
|
@@ -744,8 +847,37 @@ function checkPractice(cfg: Config): void {
|
|
|
744
847
|
fail(`${where}.${missing}: every question needs its expected answer`);
|
|
745
848
|
});
|
|
746
849
|
}
|
|
850
|
+
/** The first entry of `names` that repeats an earlier one, refused by its path. */
|
|
851
|
+
function checkDistinct(names: readonly string[], where: string): void {
|
|
852
|
+
const at = names.findIndex((x, i) => names.indexOf(x) !== i);
|
|
853
|
+
if (at !== -1)
|
|
854
|
+
fail(`${where}[${String(at)}]: ${repr(names[at])} is repeated`);
|
|
855
|
+
}
|
|
856
|
+
/** Each answerer once; an item's own list a subset of the page's; a dwell of a second or more. */
|
|
857
|
+
function checkAnswerers(cfg: Config): void {
|
|
858
|
+
if ((cfg.min_seconds ?? 1) < 1)
|
|
859
|
+
fail(`min_seconds: ${repr(cfg.min_seconds)} — must be 1 or more`);
|
|
860
|
+
const all = cfg.answerers;
|
|
861
|
+
if (all !== undefined) checkDistinct(all, "answerers");
|
|
862
|
+
cfg.items.forEach((item, n) => {
|
|
863
|
+
const where = `items[${String(n)}].answerers`;
|
|
864
|
+
const own = item.answerers;
|
|
865
|
+
if (own === undefined) return;
|
|
866
|
+
if (all === undefined)
|
|
867
|
+
return fail(
|
|
868
|
+
`${where}: the page has no \`answerers\` — add the page's list, or take this out`,
|
|
869
|
+
);
|
|
870
|
+
const k = own.findIndex((who) => !R.isIncludedIn(who, all));
|
|
871
|
+
if (k !== -1)
|
|
872
|
+
fail(
|
|
873
|
+
`${where}[${String(k)}]: ${repr(own[k])} is not in \`answerers\` (${all.join(", ")})`,
|
|
874
|
+
);
|
|
875
|
+
checkDistinct(own, where);
|
|
876
|
+
});
|
|
877
|
+
}
|
|
747
878
|
function checkConfig(cfg: Config): void {
|
|
748
879
|
checkGroups(cfg);
|
|
880
|
+
checkAnswerers(cfg);
|
|
749
881
|
if (!asksFamiliarity(cfg) && cfg.familiarity_question !== undefined) {
|
|
750
882
|
fail(
|
|
751
883
|
`familiarity_question: ${repr(cfg.familiarity_question)} is never asked, because \`familiarity\` is not true — remove it, or set "familiarity": true`,
|
|
@@ -847,7 +979,7 @@ const lineOf = (card: Card) =>
|
|
|
847
979
|
.filter((l) => l.mark)
|
|
848
980
|
.map((l) => ({ n: l.n, text: l.text.trim() }))[0];
|
|
849
981
|
const usesLine = (card: Card): boolean =>
|
|
850
|
-
SITUATION_TEXTS.some((f) => card.situation?.[f]
|
|
982
|
+
SITUATION_TEXTS.some((f) => card.situation?.[f]?.includes(LINE_TOKEN));
|
|
851
983
|
/**
|
|
852
984
|
* What the block schema cannot say: an excerpt marks a line, `{line}` has a marked line to stand for,
|
|
853
985
|
* and a repeat sits inside its count.
|
|
@@ -859,7 +991,7 @@ function checkBlocks(cfg: Config): void {
|
|
|
859
991
|
`${at}.excerpt.lines: no line is marked — mark the line the card is about (\`mark: true\`)`,
|
|
860
992
|
);
|
|
861
993
|
const token = SITUATION_TEXTS.find((f) =>
|
|
862
|
-
card.situation?.[f]
|
|
994
|
+
card.situation?.[f]?.includes(LINE_TOKEN),
|
|
863
995
|
);
|
|
864
996
|
if (token !== undefined && card.excerpt === undefined)
|
|
865
997
|
fail(
|
|
@@ -872,6 +1004,88 @@ function checkBlocks(cfg: Config): void {
|
|
|
872
1004
|
);
|
|
873
1005
|
});
|
|
874
1006
|
}
|
|
1007
|
+
/**
|
|
1008
|
+
* The texts a term may open a note from, the words asked about first: the quote, its source, then
|
|
1009
|
+
* the excerpt's annotation. The page looks a term up in the same texts, split the same way at
|
|
1010
|
+
* backticks, so a term the build finds is a term the page draws.
|
|
1011
|
+
*/
|
|
1012
|
+
const termTexts = (card: Card): readonly string[] =>
|
|
1013
|
+
[card.quote?.text, card.quote?.source, card.excerpt?.annotation].filter(
|
|
1014
|
+
R.isDefined,
|
|
1015
|
+
);
|
|
1016
|
+
const findsTerm = (card: Card, term: string): boolean =>
|
|
1017
|
+
termTexts(card).some((t) => t.split("`").some((part) => part.includes(term)));
|
|
1018
|
+
/**
|
|
1019
|
+
* What the schema cannot say about the #392 blocks: a card says something (`body` or `quote`); each
|
|
1020
|
+
* term occurs in a text that can carry it, and each note entry shows a quote or a snippet; an answer
|
|
1021
|
+
* note names a question of the page and one of its options.
|
|
1022
|
+
*/
|
|
1023
|
+
function checkCardText(cfg: Config): void {
|
|
1024
|
+
const options = new Map(
|
|
1025
|
+
cfg.questions.map((q) => [q.id, q.options.map((o) => o.value)]),
|
|
1026
|
+
);
|
|
1027
|
+
cardsOf(cfg).forEach(([at, card]) => {
|
|
1028
|
+
if (card.body === undefined && card.quote === undefined)
|
|
1029
|
+
fail(
|
|
1030
|
+
`${at}.body: is required and missing — a card shows its \`body\`, or its words as a \`quote\``,
|
|
1031
|
+
);
|
|
1032
|
+
checkTerms(at, card);
|
|
1033
|
+
checkAnswerNotes(at, card, options);
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* One term's text inside another's on the same card (or the same text twice): the page draws one
|
|
1038
|
+
* button for the words, so the other term's note would never open.
|
|
1039
|
+
*/
|
|
1040
|
+
function checkTermsDoNotOverlap(at: string, card: Card): void {
|
|
1041
|
+
const texts = (card.terms ?? []).map((t) => t.term);
|
|
1042
|
+
const named = (k: number) => `${at}.terms[${String(k)}]`;
|
|
1043
|
+
texts.forEach((inner, k) => {
|
|
1044
|
+
const j = texts.findIndex(
|
|
1045
|
+
(outer, m) =>
|
|
1046
|
+
m !== k && outer.includes(inner) && (outer !== inner || m > k),
|
|
1047
|
+
);
|
|
1048
|
+
if (j !== -1)
|
|
1049
|
+
fail(
|
|
1050
|
+
`${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`,
|
|
1051
|
+
);
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
function checkTerms(at: string, card: Card): void {
|
|
1055
|
+
checkTermsDoNotOverlap(at, card);
|
|
1056
|
+
(card.terms ?? []).forEach((t, k) => {
|
|
1057
|
+
const where = `${at}.terms[${String(k)}]`;
|
|
1058
|
+
if (!findsTerm(card, t.term))
|
|
1059
|
+
fail(
|
|
1060
|
+
`${where}.term: ${repr(t.term)} is not in the card's annotation, quote or quote source — a note opens from words the card shows`,
|
|
1061
|
+
);
|
|
1062
|
+
const bare = t.note.findIndex(
|
|
1063
|
+
(e) => e.quote === undefined && e.code === undefined,
|
|
1064
|
+
);
|
|
1065
|
+
if (bare !== -1)
|
|
1066
|
+
fail(
|
|
1067
|
+
`${where}.note[${String(bare)}]: has neither \`quote\` nor \`code\` — a note shows its source's own words`,
|
|
1068
|
+
);
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
function checkAnswerNotes(
|
|
1072
|
+
at: string,
|
|
1073
|
+
card: Card,
|
|
1074
|
+
options: ReadonlyMap<string, readonly string[]>,
|
|
1075
|
+
): void {
|
|
1076
|
+
Object.entries(card.answer_notes ?? {}).forEach(([qid, notes]) => {
|
|
1077
|
+
const values = options.get(qid);
|
|
1078
|
+
if (values === undefined)
|
|
1079
|
+
return fail(
|
|
1080
|
+
`${at}.answer_notes.${qid}: not a question of this page (${[...options.keys()].join(", ")})`,
|
|
1081
|
+
);
|
|
1082
|
+
const stray = Object.keys(notes).find((v) => !values.includes(v));
|
|
1083
|
+
if (stray !== undefined)
|
|
1084
|
+
fail(
|
|
1085
|
+
`${at}.answer_notes.${qid}.${stray}: not one of ${values.join(", ")}`,
|
|
1086
|
+
);
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
875
1089
|
/** Every card of the config, items then practice, by path. */
|
|
876
1090
|
const cardsOf = (cfg: Config) => [
|
|
877
1091
|
...cfg.items.map((i, n) => [`items[${String(n)}]`, i] as const),
|
|
@@ -948,6 +1162,8 @@ interface KeyEntry {
|
|
|
948
1162
|
readonly source_id: string;
|
|
949
1163
|
readonly group: string;
|
|
950
1164
|
readonly on_page: boolean;
|
|
1165
|
+
/** Who the item is shown to, when not every answerer of the page. */
|
|
1166
|
+
readonly answerers?: readonly string[];
|
|
951
1167
|
}
|
|
952
1168
|
interface OldKey {
|
|
953
1169
|
readonly seed: number;
|
|
@@ -1006,7 +1222,7 @@ const situationShown = (item: Card) =>
|
|
|
1006
1222
|
? {}
|
|
1007
1223
|
: {
|
|
1008
1224
|
situation: {
|
|
1009
|
-
summary: item.situation.summary,
|
|
1225
|
+
...R.pickBy({ summary: item.situation.summary }, R.isDefined),
|
|
1010
1226
|
question: item.situation.question,
|
|
1011
1227
|
...(usesLine(item) ? { line: lineOf(item) } : {}),
|
|
1012
1228
|
},
|
|
@@ -1101,7 +1317,16 @@ const DEFAULT_LABELS: Readonly<Record<string, string>> = {
|
|
|
1101
1317
|
line_part: "{line} · part {part} of {of}",
|
|
1102
1318
|
repeat: "Card {index} of {of}",
|
|
1103
1319
|
practice_example: "",
|
|
1320
|
+
hidden: "lines {from}\u2013{to}",
|
|
1321
|
+
hidden_one: "line {from}",
|
|
1322
|
+
wait: "\u23f3 You can answer in {s} s",
|
|
1104
1323
|
};
|
|
1324
|
+
/** The answerers and the dwell, only when items.json sets them: a page without them is as it was. */
|
|
1325
|
+
const twoKeys = (cfg: Config) =>
|
|
1326
|
+
R.pickBy(
|
|
1327
|
+
{ answerers: cfg.answerers, min_seconds: cfg.min_seconds },
|
|
1328
|
+
R.isDefined,
|
|
1329
|
+
);
|
|
1105
1330
|
/** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
|
|
1106
1331
|
function payloadOf(
|
|
1107
1332
|
cfg: Config,
|
|
@@ -1121,18 +1346,22 @@ function payloadOf(
|
|
|
1121
1346
|
: "",
|
|
1122
1347
|
familiarity: asksFamiliarity(cfg) ? FAMILIARITY : [],
|
|
1123
1348
|
allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
|
|
1349
|
+
...twoKeys(cfg),
|
|
1124
1350
|
questions: cfg.questions,
|
|
1125
1351
|
practice: {
|
|
1126
1352
|
round: practiceRound(cfg),
|
|
1127
1353
|
items: practiceShown(cfg, described),
|
|
1128
1354
|
},
|
|
1129
|
-
items: page.items.map((i) => (
|
|
1130
|
-
id: page.idOf(i),
|
|
1131
|
-
...shownFields(i),
|
|
1132
|
-
...rulesShown(i, described),
|
|
1133
|
-
})),
|
|
1355
|
+
items: page.items.map((i) => itemShown(i, page.idOf(i), described)),
|
|
1134
1356
|
};
|
|
1135
1357
|
}
|
|
1358
|
+
/** One item as its card shows it, and whom it is shown to when not every answerer. */
|
|
1359
|
+
const itemShown = (i: Item, id: string, described: Described) => ({
|
|
1360
|
+
id,
|
|
1361
|
+
...shownFields(i),
|
|
1362
|
+
...rulesShown(i, described),
|
|
1363
|
+
...R.pickBy({ answerers: i.answerers }, R.isDefined),
|
|
1364
|
+
});
|
|
1136
1365
|
export type Payload = ReturnType<typeof payloadOf>;
|
|
1137
1366
|
/** Every id the key keeps (on the page now, or kept from an earlier build), with its source and group. */
|
|
1138
1367
|
function idsOf(cfg: Config, page: ReturnType<typeof itemsOnPage>): JsonObject {
|
|
@@ -1144,16 +1373,17 @@ function idsOf(cfg: Config, page: ReturnType<typeof itemsOnPage>): JsonObject {
|
|
|
1144
1373
|
]),
|
|
1145
1374
|
...cfg.items.map((i): [string, string] => [i.source_id, i.group]),
|
|
1146
1375
|
]);
|
|
1376
|
+
const bySource = R.indexBy(cfg.items, (i) => i.source_id);
|
|
1147
1377
|
const byId = [...page.ids].sort(([, a], [, b]) => byText(a, b));
|
|
1148
|
-
return
|
|
1149
|
-
byId
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
},
|
|
1156
|
-
|
|
1378
|
+
return R.pullObject(
|
|
1379
|
+
byId,
|
|
1380
|
+
([, nid]) => nid,
|
|
1381
|
+
([sid, nid]) => ({
|
|
1382
|
+
source_id: sid,
|
|
1383
|
+
group: String(groupOf.get(sid)),
|
|
1384
|
+
on_page: shown.has(nid),
|
|
1385
|
+
...R.pickBy({ answerers: bySource[sid]?.answerers }, R.isDefined),
|
|
1386
|
+
}),
|
|
1157
1387
|
);
|
|
1158
1388
|
}
|
|
1159
1389
|
/** Round → its practice items' expected answers. */
|
|
@@ -1178,6 +1408,7 @@ function keyOf(
|
|
|
1178
1408
|
return {
|
|
1179
1409
|
study: cfg.study,
|
|
1180
1410
|
seed: cfg.seed,
|
|
1411
|
+
...twoKeys(cfg),
|
|
1181
1412
|
questions: cfg.questions.map((q) => q.id),
|
|
1182
1413
|
// What counts as an answer: the same lists the page was given (`allowedValues`). `export`
|
|
1183
1414
|
// checks every stored answer and familiarity against them, so the build's options decide.
|
|
@@ -1212,6 +1443,7 @@ export function prepareBuild(configPath: string, outDir: string) {
|
|
|
1212
1443
|
checkItemFields(cfg);
|
|
1213
1444
|
checkUnits(cfg);
|
|
1214
1445
|
checkBlocks(cfg);
|
|
1446
|
+
checkCardText(cfg);
|
|
1215
1447
|
checkNoSourceShown(cfg);
|
|
1216
1448
|
const described = describeRules(cfg, dirname(configPath));
|
|
1217
1449
|
mkdirSync(outDir, { recursive: true });
|
|
@@ -1229,6 +1461,8 @@ export function prepareBuild(configPath: string, outDir: string) {
|
|
|
1229
1461
|
interface Key {
|
|
1230
1462
|
readonly study: string;
|
|
1231
1463
|
readonly questions: readonly string[];
|
|
1464
|
+
readonly answerers?: readonly string[];
|
|
1465
|
+
readonly min_seconds?: number;
|
|
1232
1466
|
readonly allowed?: Json;
|
|
1233
1467
|
readonly practice?: {
|
|
1234
1468
|
readonly round: number;
|
|
@@ -1246,6 +1480,11 @@ function assertKey(_value: Json): asserts _value is Json & Key {
|
|
|
1246
1480
|
}
|
|
1247
1481
|
|
|
1248
1482
|
const ANSWER_DOC_FIELDS = ["answers", "reasons", "savedAt", "source"];
|
|
1483
|
+
/** A page built with `min_seconds` also stores how long the item was on screen before its first save. */
|
|
1484
|
+
const answerDocFields = (key: Key): readonly string[] =>
|
|
1485
|
+
key.min_seconds === undefined
|
|
1486
|
+
? ANSWER_DOC_FIELDS
|
|
1487
|
+
: ["answers", "reasons", "savedAt", "seconds", "source"];
|
|
1249
1488
|
const GROUP_DOC_FIELDS = ["familiarity", "savedAt", "source"];
|
|
1250
1489
|
const DOC_FIELD_TYPES: readonly (readonly [
|
|
1251
1490
|
string,
|
|
@@ -1264,6 +1503,12 @@ const DOC_FIELD_TYPES: readonly (readonly [
|
|
|
1264
1503
|
(v) => v === null || typeof v === "string",
|
|
1265
1504
|
"a timestamp string or null",
|
|
1266
1505
|
],
|
|
1506
|
+
[
|
|
1507
|
+
"seconds",
|
|
1508
|
+
(v) =>
|
|
1509
|
+
v === null || (typeof v === "number" && Number.isInteger(v) && v >= 0),
|
|
1510
|
+
"a whole number of seconds or null",
|
|
1511
|
+
],
|
|
1267
1512
|
];
|
|
1268
1513
|
const COLLECTIONS = ["answers", "groups", "practice"] as const;
|
|
1269
1514
|
type Collection = (typeof COLLECTIONS)[number];
|
|
@@ -1297,15 +1542,29 @@ interface Names {
|
|
|
1297
1542
|
readonly onPage: readonly string[];
|
|
1298
1543
|
readonly fields: readonly string[];
|
|
1299
1544
|
}
|
|
1300
|
-
|
|
1301
|
-
|
|
1545
|
+
/** Who an item is shown to: its own list, else every answerer of the page. */
|
|
1546
|
+
const shownTo = (all: readonly string[], meta: KeyEntry): readonly string[] =>
|
|
1547
|
+
meta.answerers ?? all;
|
|
1548
|
+
/** A document's answerer, and the page's answerers; none on a page without them. */
|
|
1549
|
+
type Whose =
|
|
1550
|
+
| { readonly who?: undefined }
|
|
1551
|
+
| { readonly who: string; readonly all: readonly string[] };
|
|
1552
|
+
/** The names of a page without answerers, or those of one answerer's documents. */
|
|
1553
|
+
function knownNames(
|
|
1554
|
+
key: Key,
|
|
1555
|
+
whose: Whose,
|
|
1556
|
+
): Readonly<Record<Collection, Names>> {
|
|
1557
|
+
const entries = Object.entries(key.ids).filter(
|
|
1558
|
+
([, m]) =>
|
|
1559
|
+
whose.who === undefined || shownTo(whose.all, m).includes(whose.who),
|
|
1560
|
+
);
|
|
1302
1561
|
const shown = entries.filter(([, m]) => m.on_page);
|
|
1303
1562
|
const practice = practiceDocs(key);
|
|
1304
1563
|
return {
|
|
1305
1564
|
answers: {
|
|
1306
1565
|
known: entries.map(([nid]) => [nid, key.questions] as const),
|
|
1307
1566
|
onPage: shown.map(([nid]) => nid),
|
|
1308
|
-
fields:
|
|
1567
|
+
fields: answerDocFields(key),
|
|
1309
1568
|
},
|
|
1310
1569
|
groups: {
|
|
1311
1570
|
known: entries.map(([, m]) => [m.group, key.questions] as const),
|
|
@@ -1315,7 +1574,7 @@ function knownNames(key: Key): Readonly<Record<Collection, Names>> {
|
|
|
1315
1574
|
practice: {
|
|
1316
1575
|
known: practice,
|
|
1317
1576
|
onPage: practice.map(([n]) => n),
|
|
1318
|
-
fields:
|
|
1577
|
+
fields: answerDocFields(key),
|
|
1319
1578
|
},
|
|
1320
1579
|
};
|
|
1321
1580
|
}
|
|
@@ -1340,16 +1599,43 @@ function checkDumpShape(dump: Json): asserts dump is JsonObject {
|
|
|
1340
1599
|
`${stray}: the store has no such collection (expected ${COLLECTIONS.join(", ")})`,
|
|
1341
1600
|
);
|
|
1342
1601
|
}
|
|
1602
|
+
/**
|
|
1603
|
+
* A document's answerer and its name without it. A page without answerers has none; with them, an
|
|
1604
|
+
* id without `~`, or before it a name the page does not have, is refused.
|
|
1605
|
+
*/
|
|
1606
|
+
function answererOf(
|
|
1607
|
+
where: string,
|
|
1608
|
+
docId: string,
|
|
1609
|
+
key: Key,
|
|
1610
|
+
): Whose & { readonly name: string } {
|
|
1611
|
+
const all = key.answerers;
|
|
1612
|
+
if (all === undefined) return { name: docId };
|
|
1613
|
+
const at = docId.indexOf(SEP);
|
|
1614
|
+
if (at === -1)
|
|
1615
|
+
return fail(
|
|
1616
|
+
`${where}: this page has answerers (${all.join(", ")}), so a document's id is <answerer>${SEP}<id>`,
|
|
1617
|
+
);
|
|
1618
|
+
const who = docId.slice(0, at);
|
|
1619
|
+
if (!all.includes(who))
|
|
1620
|
+
fail(
|
|
1621
|
+
`${where}: ${repr(who)} is not an answerer of this page (${all.join(", ")})`,
|
|
1622
|
+
);
|
|
1623
|
+
return { who, all, name: docId.slice(at + 1) };
|
|
1624
|
+
}
|
|
1343
1625
|
function checkDocName(
|
|
1344
1626
|
where: string,
|
|
1345
|
-
name: string,
|
|
1627
|
+
{ who, name }: Whose & { readonly name: string },
|
|
1346
1628
|
kind: Collection,
|
|
1347
1629
|
names: Names,
|
|
1348
1630
|
): readonly string[] {
|
|
1349
1631
|
const hit = names.known.find(([n]) => n === name);
|
|
1350
1632
|
if (hit === undefined) {
|
|
1351
1633
|
return fail(
|
|
1352
|
-
`${where}: the key does not know this ${NOUN[kind]}
|
|
1634
|
+
`${where}: the key does not know this ${NOUN[kind]}${who === undefined ? "" : ` for ${who}`} (${R.unique(
|
|
1635
|
+
names.known.map(([n]) => n),
|
|
1636
|
+
)
|
|
1637
|
+
.sort()
|
|
1638
|
+
.join(", ")})`,
|
|
1353
1639
|
);
|
|
1354
1640
|
}
|
|
1355
1641
|
if (!names.onPage.includes(name)) {
|
|
@@ -1428,15 +1714,20 @@ function checkDocAnswers(
|
|
|
1428
1714
|
*/
|
|
1429
1715
|
function checkDump(dump: Json, key: Key): asserts dump is JsonObject {
|
|
1430
1716
|
checkDumpShape(dump);
|
|
1431
|
-
|
|
1717
|
+
// A page without answerers knows one set of names; each answerer has their own.
|
|
1718
|
+
const plain = knownNames(key, {});
|
|
1432
1719
|
for (const kind of COLLECTIONS) {
|
|
1433
1720
|
const collection = dump[kind];
|
|
1434
|
-
for (const [
|
|
1721
|
+
for (const [docId, doc] of sortedEntries(
|
|
1435
1722
|
isObject(collection) ? collection : {},
|
|
1436
1723
|
)) {
|
|
1437
|
-
const where = `${kind}/${
|
|
1438
|
-
const
|
|
1439
|
-
|
|
1724
|
+
const where = `${kind}/${docId}`;
|
|
1725
|
+
const named = answererOf(where, docId, key);
|
|
1726
|
+
const names = (named.who === undefined ? plain : knownNames(key, named))[
|
|
1727
|
+
kind
|
|
1728
|
+
];
|
|
1729
|
+
const questions = checkDocName(where, named, kind, names);
|
|
1730
|
+
checkDocFields(where, doc, kind, names.fields);
|
|
1440
1731
|
checkDocAnswers(where, doc, questions);
|
|
1441
1732
|
}
|
|
1442
1733
|
}
|
|
@@ -1524,8 +1815,8 @@ function answersOf(doc: JsonObject, ctx: Ctx) {
|
|
|
1524
1815
|
);
|
|
1525
1816
|
return { given, invalid };
|
|
1526
1817
|
}
|
|
1527
|
-
/** One
|
|
1528
|
-
function
|
|
1818
|
+
/** One stored answer: its answers, what is invalid, the reasons, and whether it is done. */
|
|
1819
|
+
function answerOf(doc: JsonObject, ctx: Ctx) {
|
|
1529
1820
|
const { given, invalid } = answersOf(doc, ctx);
|
|
1530
1821
|
const reasons = Object.fromEntries(
|
|
1531
1822
|
Object.entries(textsOf(doc["reasons"])).filter(([, r]) => r.trim()),
|
|
@@ -1537,9 +1828,6 @@ function labelOf(nid: string, meta: KeyEntry, doc: JsonObject, ctx: Ctx) {
|
|
|
1537
1828
|
)
|
|
1538
1829
|
.map(([q]) => q);
|
|
1539
1830
|
return {
|
|
1540
|
-
id: nid,
|
|
1541
|
-
source_id: meta.source_id,
|
|
1542
|
-
group: meta.group,
|
|
1543
1831
|
answers: given,
|
|
1544
1832
|
invalid_answers: invalid,
|
|
1545
1833
|
reasons,
|
|
@@ -1548,19 +1836,32 @@ function labelOf(nid: string, meta: KeyEntry, doc: JsonObject, ctx: Ctx) {
|
|
|
1548
1836
|
Object.values(given).every((v) => v !== null) && !unexplained.length,
|
|
1549
1837
|
source: textOf(doc["source"]),
|
|
1550
1838
|
saved_at: doc["savedAt"] ?? null,
|
|
1839
|
+
// Only a page built with `min_seconds` stores how long the item was on screen.
|
|
1840
|
+
...R.pick({ seconds: doc["seconds"] ?? null }, secondsOf(ctx.key)),
|
|
1551
1841
|
};
|
|
1552
1842
|
}
|
|
1553
|
-
type
|
|
1843
|
+
type Answer = ReturnType<typeof answerOf>;
|
|
1844
|
+
/** The `seconds` field, on a page built with `min_seconds`; none on any other. */
|
|
1845
|
+
const secondsOf = (key: Key): readonly "seconds"[] =>
|
|
1846
|
+
key.min_seconds === undefined ? [] : ["seconds"];
|
|
1847
|
+
/** One item of the labels file of a page without answerers. */
|
|
1848
|
+
const labelOf = (nid: string, meta: KeyEntry, doc: JsonObject, ctx: Ctx) => ({
|
|
1849
|
+
id: nid,
|
|
1850
|
+
source_id: meta.source_id,
|
|
1851
|
+
group: meta.group,
|
|
1852
|
+
...answerOf(doc, ctx),
|
|
1853
|
+
});
|
|
1554
1854
|
|
|
1555
1855
|
/** Same rule as for item answers: a familiarity outside the options is marked, not counted as rated. */
|
|
1556
1856
|
function groupsOf(
|
|
1557
1857
|
groupIds: readonly string[],
|
|
1558
1858
|
groups: Json | undefined,
|
|
1559
1859
|
allowed: Allowed,
|
|
1860
|
+
prefix = "",
|
|
1560
1861
|
) {
|
|
1561
1862
|
return Object.fromEntries(
|
|
1562
1863
|
groupIds.map((g) => {
|
|
1563
|
-
const doc = docOf(groups, g);
|
|
1864
|
+
const doc = docOf(groups, `${prefix}${g}`);
|
|
1564
1865
|
const fam = textOf(doc["familiarity"]);
|
|
1565
1866
|
const bad = fam !== null && !allowed.familiarity.includes(fam);
|
|
1566
1867
|
return [
|
|
@@ -1575,7 +1876,10 @@ function groupsOf(
|
|
|
1575
1876
|
}),
|
|
1576
1877
|
);
|
|
1577
1878
|
}
|
|
1578
|
-
function reportLabels(
|
|
1879
|
+
function reportLabels(
|
|
1880
|
+
items: readonly (Answer & { readonly id: string })[],
|
|
1881
|
+
allowed: Allowed,
|
|
1882
|
+
): void {
|
|
1579
1883
|
for (const i of items) {
|
|
1580
1884
|
for (const [q, v] of Object.entries(i.invalid_answers)) {
|
|
1581
1885
|
say(
|
|
@@ -1605,9 +1909,14 @@ function reportGroups(
|
|
|
1605
1909
|
}
|
|
1606
1910
|
}
|
|
1607
1911
|
/** Each practice item of the key's round: what was answered first, what was expected, whether they match. */
|
|
1608
|
-
function scorePractice(
|
|
1912
|
+
function scorePractice(
|
|
1913
|
+
dump: JsonObject,
|
|
1914
|
+
round: string,
|
|
1915
|
+
all: Expected,
|
|
1916
|
+
prefix: string,
|
|
1917
|
+
) {
|
|
1609
1918
|
return Object.entries(all).map(([pid, expected]) => {
|
|
1610
|
-
const doc = docOf(dump["practice"],
|
|
1919
|
+
const doc = docOf(dump["practice"], `${prefix}r${round}-${pid}`);
|
|
1611
1920
|
const answers = isObject(doc["answers"]) ? doc["answers"] : {};
|
|
1612
1921
|
const matched = Object.entries(expected).every(
|
|
1613
1922
|
([q, v]) => answers[q] === v,
|
|
@@ -1621,9 +1930,14 @@ function scorePractice(dump: JsonObject, round: string, all: Expected) {
|
|
|
1621
1930
|
};
|
|
1622
1931
|
});
|
|
1623
1932
|
}
|
|
1624
|
-
/** One file per round: a second round exported the same day keeps the first. */
|
|
1625
|
-
const practiceFile = (
|
|
1626
|
-
|
|
1933
|
+
/** One file per round (and answerer): a second round exported the same day keeps the first. */
|
|
1934
|
+
const practiceFile = (
|
|
1935
|
+
study: string,
|
|
1936
|
+
round: number,
|
|
1937
|
+
day: string,
|
|
1938
|
+
who: string | undefined,
|
|
1939
|
+
): string =>
|
|
1940
|
+
`${study}-practice-r${String(round)}${who === undefined ? "" : `-${who}`}-${day}.json`;
|
|
1627
1941
|
/**
|
|
1628
1942
|
* Every practice round the store holds answers for, each scored against its own expected answers
|
|
1629
1943
|
* into its own file; never into the labels. The key's own round is reported even unanswered.
|
|
@@ -1636,79 +1950,169 @@ function exportPractice(
|
|
|
1636
1950
|
): void {
|
|
1637
1951
|
const p = key.practice;
|
|
1638
1952
|
if (p === undefined) return;
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
.forEach(([round, expected]) => {
|
|
1953
|
+
const rounds = R.sortBy(R.entries(roundsIn(p)), ([r]) => Number(r));
|
|
1954
|
+
(key.answerers ?? [undefined]).forEach((who) => {
|
|
1955
|
+
rounds.forEach(([round, expected]) => {
|
|
1642
1956
|
exportRound(
|
|
1643
1957
|
key.study,
|
|
1644
1958
|
Number(round),
|
|
1645
|
-
scorePractice(
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1959
|
+
scorePractice(
|
|
1960
|
+
dump,
|
|
1961
|
+
round,
|
|
1962
|
+
expected,
|
|
1963
|
+
who === undefined ? "" : `${who}${SEP}`,
|
|
1964
|
+
),
|
|
1965
|
+
{ target, day, current: Number(round) === p.round, who },
|
|
1651
1966
|
);
|
|
1652
1967
|
});
|
|
1968
|
+
});
|
|
1969
|
+
}
|
|
1970
|
+
/** Where a round's practice goes, and whose it is: no one's on a page without answerers. */
|
|
1971
|
+
interface RoundAt {
|
|
1972
|
+
readonly target: string;
|
|
1973
|
+
readonly day: string;
|
|
1974
|
+
readonly current: boolean;
|
|
1975
|
+
readonly who: string | undefined;
|
|
1653
1976
|
}
|
|
1654
1977
|
/** One round's scored practice to `<study>-practice-r<round>-<date>.json`, and one line about it. */
|
|
1655
1978
|
function exportRound(
|
|
1656
1979
|
study: string,
|
|
1657
1980
|
round: number,
|
|
1658
1981
|
items: ReturnType<typeof scorePractice>,
|
|
1659
|
-
at:
|
|
1660
|
-
readonly target: string;
|
|
1661
|
-
readonly day: string;
|
|
1662
|
-
readonly current: boolean;
|
|
1663
|
-
},
|
|
1982
|
+
at: RoundAt,
|
|
1664
1983
|
): void {
|
|
1984
|
+
const named = `practice round ${String(round)}${at.who === undefined ? "" : ` (${at.who})`}`;
|
|
1665
1985
|
if (!items.some((i) => Object.keys(i.answers).length)) {
|
|
1666
|
-
if (at.current) say(
|
|
1986
|
+
if (at.current) say(`${named}: not answered`);
|
|
1667
1987
|
return;
|
|
1668
1988
|
}
|
|
1669
1989
|
const missed = items.filter((i) => !i.matched).map((i) => i.id);
|
|
1670
1990
|
const matched = items.length - missed.length;
|
|
1671
|
-
const path = join(at.target, practiceFile(study, round, at.day));
|
|
1991
|
+
const path = join(at.target, practiceFile(study, round, at.day, at.who));
|
|
1672
1992
|
writeJson(path, {
|
|
1673
1993
|
study,
|
|
1674
1994
|
round,
|
|
1995
|
+
...R.pickBy({ answerer: at.who }, R.isDefined),
|
|
1675
1996
|
exported: at.day,
|
|
1676
1997
|
matched,
|
|
1677
1998
|
total: items.length,
|
|
1678
1999
|
items,
|
|
1679
2000
|
});
|
|
1680
2001
|
say(
|
|
1681
|
-
|
|
2002
|
+
`${named}: ${String(matched)} of ${String(items.length)} matched${missed.length ? `; missed ${missed.join(", ")}` : ""} -> ${path}`,
|
|
1682
2003
|
);
|
|
1683
2004
|
}
|
|
2005
|
+
/** The counts of one set of answers and groups. */
|
|
2006
|
+
const tally = (
|
|
2007
|
+
items: readonly Answer[],
|
|
2008
|
+
groups: ReturnType<typeof groupsOf>,
|
|
2009
|
+
) => ({
|
|
2010
|
+
groups,
|
|
2011
|
+
complete_items: items.filter((i) => i.complete).length,
|
|
2012
|
+
invalid_items: items.filter((i) => Object.keys(i.invalid_answers).length)
|
|
2013
|
+
.length,
|
|
2014
|
+
invalid_groups: Object.values(groups).filter(
|
|
2015
|
+
(g) => g.invalid_familiarity !== null,
|
|
2016
|
+
).length,
|
|
2017
|
+
total_items: items.length,
|
|
2018
|
+
});
|
|
2019
|
+
type Tally = ReturnType<typeof tally>;
|
|
2020
|
+
/** The familiarity of the groups of `onPage`, or none on a page that asks none. */
|
|
2021
|
+
const groupsFor = (
|
|
2022
|
+
onPage: readonly (readonly [string, KeyEntry])[],
|
|
2023
|
+
dump: JsonObject,
|
|
2024
|
+
ctx: Ctx,
|
|
2025
|
+
prefix = "",
|
|
2026
|
+
) =>
|
|
2027
|
+
ctx.allowed.familiarity.length
|
|
2028
|
+
? groupsOf(
|
|
2029
|
+
R.unique(onPage.map(([, m]) => m.group)),
|
|
2030
|
+
dump["groups"],
|
|
2031
|
+
ctx.allowed,
|
|
2032
|
+
prefix,
|
|
2033
|
+
)
|
|
2034
|
+
: {};
|
|
1684
2035
|
/** The labels file: every item on the page with its answers, the groups, and the counts. */
|
|
1685
2036
|
function labelsOf(dump: JsonObject, ctx: Ctx, day: string) {
|
|
1686
2037
|
const onPage = Object.entries(ctx.key.ids).filter(([, m]) => m.on_page);
|
|
1687
2038
|
const items = onPage.map(([nid, meta]) =>
|
|
1688
2039
|
labelOf(nid, meta, docOf(dump["answers"], nid), ctx),
|
|
1689
2040
|
);
|
|
1690
|
-
const groups = ctx.allowed.familiarity.length
|
|
1691
|
-
? groupsOf(
|
|
1692
|
-
[...new Set(onPage.map(([, m]) => m.group))],
|
|
1693
|
-
dump["groups"],
|
|
1694
|
-
ctx.allowed,
|
|
1695
|
-
)
|
|
1696
|
-
: {};
|
|
1697
2041
|
return {
|
|
1698
2042
|
study: ctx.key.study,
|
|
1699
2043
|
exported: day,
|
|
1700
2044
|
questions: ctx.key.questions,
|
|
1701
2045
|
items,
|
|
1702
|
-
|
|
1703
|
-
complete_items: items.filter((i) => i.complete).length,
|
|
1704
|
-
invalid_items: items.filter((i) => Object.keys(i.invalid_answers).length)
|
|
1705
|
-
.length,
|
|
1706
|
-
invalid_groups: Object.values(groups).filter(
|
|
1707
|
-
(g) => g.invalid_familiarity !== null,
|
|
1708
|
-
).length,
|
|
1709
|
-
total_items: items.length,
|
|
2046
|
+
...tally(items, groupsFor(onPage, dump, ctx)),
|
|
1710
2047
|
};
|
|
1711
2048
|
}
|
|
2049
|
+
/**
|
|
2050
|
+
* The labels file of a page with answerers: each item with every answerer it is shown to and their
|
|
2051
|
+
* answers apart (`by_answerer`), and each answerer's groups and counts (`by_answerer` at the top).
|
|
2052
|
+
*/
|
|
2053
|
+
function answererLabelsOf(
|
|
2054
|
+
dump: JsonObject,
|
|
2055
|
+
ctx: Ctx,
|
|
2056
|
+
day: string,
|
|
2057
|
+
all: readonly string[],
|
|
2058
|
+
) {
|
|
2059
|
+
const onPage = Object.entries(ctx.key.ids).filter(([, m]) => m.on_page);
|
|
2060
|
+
const docFor = (who: string, nid: string) =>
|
|
2061
|
+
answerOf(docOf(dump["answers"], `${who}${SEP}${nid}`), ctx);
|
|
2062
|
+
const items = onPage.map(([nid, meta]) => ({
|
|
2063
|
+
id: nid,
|
|
2064
|
+
source_id: meta.source_id,
|
|
2065
|
+
group: meta.group,
|
|
2066
|
+
answerers: shownTo(all, meta),
|
|
2067
|
+
by_answerer: R.fromKeys(shownTo(all, meta), (who) => docFor(who, nid)),
|
|
2068
|
+
}));
|
|
2069
|
+
return {
|
|
2070
|
+
study: ctx.key.study,
|
|
2071
|
+
exported: day,
|
|
2072
|
+
questions: ctx.key.questions,
|
|
2073
|
+
answerers: all,
|
|
2074
|
+
items,
|
|
2075
|
+
by_answerer: R.fromKeys(all, (who) =>
|
|
2076
|
+
tallyOf({ who, all }, onPage, dump, ctx),
|
|
2077
|
+
),
|
|
2078
|
+
};
|
|
2079
|
+
}
|
|
2080
|
+
/** One answerer's groups and counts, over the items shown to them. */
|
|
2081
|
+
function tallyOf(
|
|
2082
|
+
{ who, all }: { readonly who: string; readonly all: readonly string[] },
|
|
2083
|
+
onPage: readonly (readonly [string, KeyEntry])[],
|
|
2084
|
+
dump: JsonObject,
|
|
2085
|
+
ctx: Ctx,
|
|
2086
|
+
): Tally {
|
|
2087
|
+
const mine = onPage.filter(([, m]) => shownTo(all, m).includes(who));
|
|
2088
|
+
return tally(
|
|
2089
|
+
mine.map(([nid]) =>
|
|
2090
|
+
answerOf(docOf(dump["answers"], `${who}${SEP}${nid}`), ctx),
|
|
2091
|
+
),
|
|
2092
|
+
groupsFor(mine, dump, ctx, `${who}${SEP}`),
|
|
2093
|
+
);
|
|
2094
|
+
}
|
|
2095
|
+
/** Each answerer's items and groups, named `<answerer>~<id>` in the report, as in the store. */
|
|
2096
|
+
function reportAnswerers(
|
|
2097
|
+
out: ReturnType<typeof answererLabelsOf>,
|
|
2098
|
+
allowed: Allowed,
|
|
2099
|
+
): void {
|
|
2100
|
+
R.forEachObj(out.by_answerer, (t, who) => {
|
|
2101
|
+
reportLabels(
|
|
2102
|
+
out.items.flatMap((i) => {
|
|
2103
|
+
const a = i.by_answerer[who];
|
|
2104
|
+
return a === undefined ? [] : [{ id: `${who}${SEP}${i.id}`, ...a }];
|
|
2105
|
+
}),
|
|
2106
|
+
allowed,
|
|
2107
|
+
);
|
|
2108
|
+
reportGroups(
|
|
2109
|
+
R.mapKeys(t.groups, (g) => `${who}${SEP}${g}`),
|
|
2110
|
+
allowed,
|
|
2111
|
+
);
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
const completeLine = (t: Tally): string =>
|
|
2115
|
+
`${String(t.complete_items)} of ${String(t.total_items)} items complete`;
|
|
1712
2116
|
export function exportLabels(
|
|
1713
2117
|
dumpPath: string,
|
|
1714
2118
|
keyPath: string,
|
|
@@ -1722,14 +2126,34 @@ export function exportLabels(
|
|
|
1722
2126
|
assertAllowed(allowed, key, keyPath);
|
|
1723
2127
|
checkDump(dump, key);
|
|
1724
2128
|
checkNoFamiliarity(dump, allowed);
|
|
1725
|
-
const
|
|
2129
|
+
const ctx = { key, allowed };
|
|
2130
|
+
const all = key.answerers;
|
|
2131
|
+
const out =
|
|
2132
|
+
all === undefined
|
|
2133
|
+
? labelsOf(dump, ctx, day)
|
|
2134
|
+
: answererLabelsOf(dump, ctx, day, all);
|
|
1726
2135
|
mkdirSync(outDir, { recursive: true });
|
|
1727
2136
|
const path = join(outDir, `${key.study}-labels-${day}.json`);
|
|
1728
2137
|
writeJson(path, out);
|
|
1729
2138
|
exportPractice(dump, key, outDir, day);
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
2139
|
+
reportExport(path, out, allowed);
|
|
2140
|
+
}
|
|
2141
|
+
/** What the export wrote, and every item and group it did not count. */
|
|
2142
|
+
function reportExport(
|
|
2143
|
+
path: string,
|
|
2144
|
+
out: ReturnType<typeof labelsOf> | ReturnType<typeof answererLabelsOf>,
|
|
2145
|
+
allowed: Allowed,
|
|
2146
|
+
): void {
|
|
2147
|
+
if ("by_answerer" in out) {
|
|
2148
|
+
say(
|
|
2149
|
+
`wrote ${path}: ${R.entries(out.by_answerer)
|
|
2150
|
+
.map(([who, t]) => `${who}: ${completeLine(t)}`)
|
|
2151
|
+
.join("; ")}`,
|
|
2152
|
+
);
|
|
2153
|
+
reportAnswerers(out, allowed);
|
|
2154
|
+
} else {
|
|
2155
|
+
say(`wrote ${path}: ${completeLine(out)}`);
|
|
2156
|
+
reportLabels(out.items, allowed);
|
|
2157
|
+
reportGroups(out.groups, allowed);
|
|
2158
|
+
}
|
|
1735
2159
|
}
|