paperlint 14.0.0 → 14.2.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/dist/skills/build-label-page/scripts/label-page-core.d.ts +105 -25
- package/dist/skills/build-label-page/scripts/label-page-presets.d.ts +10 -0
- package/package.json +1 -1
- package/skills/build-label-page/SKILL.md +20 -2
- package/skills/build-label-page/SKILL.md.spec.ts +19 -1
- package/skills/build-label-page/references/closed-choice-generator.md +171 -5
- package/skills/build-label-page/scripts/fixtures/blocks-items.json +214 -0
- package/skills/build-label-page/scripts/fixtures/linter-config-witness-items.json +448 -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 +335 -0
- package/skills/build-label-page/scripts/label-page-browser.test.ts +637 -2
- package/skills/build-label-page/scripts/label-page-core.js +360 -70
- package/skills/build-label-page/scripts/label-page-core.test.ts +78 -11
- package/skills/build-label-page/scripts/label-page-core.ts +585 -107
- package/skills/build-label-page/scripts/label-page-html.test.ts +22 -0
- package/skills/build-label-page/scripts/label-page-presets.js +222 -0
- package/skills/build-label-page/scripts/label-page-presets.test.ts +175 -0
- package/skills/build-label-page/scripts/label-page-presets.ts +257 -0
- package/skills/build-label-page/scripts/label-page.js +13 -2
- package/skills/build-label-page/scripts/label-page.ts +13 -2
- package/skills/build-label-page/scripts/page-template.html +438 -44
|
@@ -7,12 +7,14 @@
|
|
|
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,
|
|
13
14
|
type Described as RuleText,
|
|
14
15
|
type RuleRef,
|
|
15
16
|
} from "./rule-descriptions.ts";
|
|
17
|
+
import { applyPreset } from "./label-page-presets.ts";
|
|
16
18
|
|
|
17
19
|
// ── refusals and output ───────────────────────────────────────────────────────────────────────
|
|
18
20
|
export class Refusal extends Error {
|
|
@@ -83,6 +85,11 @@ const writeJson = (path: string, value: unknown): void => {
|
|
|
83
85
|
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
84
86
|
};
|
|
85
87
|
const JSON_VALUE = z.json();
|
|
88
|
+
/** The config with its named preset applied (label-page-presets.ts), refused by path. */
|
|
89
|
+
const withPreset = (raw: Json): Json => {
|
|
90
|
+
const r = applyPreset(raw);
|
|
91
|
+
return r.ok ? JSON_VALUE.parse(r.value) : fail(r.message);
|
|
92
|
+
};
|
|
86
93
|
const readJson = (path: string): Json => {
|
|
87
94
|
try {
|
|
88
95
|
return JSON_VALUE.parse(JSON.parse(readFileSync(path, "utf8")));
|
|
@@ -100,6 +107,9 @@ const readJson = (path: string): Json => {
|
|
|
100
107
|
// prettier-ignore
|
|
101
108
|
const PAGE_FIELDS = ["group", "title", "body", "code", "context", "file"] as const;
|
|
102
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";
|
|
103
113
|
// The linter rules an item names ("read as ruff `TID253`"): the page shows each with the linter's
|
|
104
114
|
// own description, looked up at build time (rule-descriptions.ts), never typed into items.json.
|
|
105
115
|
const RULES_FIELD = "rules";
|
|
@@ -108,11 +118,23 @@ const RULES_FIELD = "rules";
|
|
|
108
118
|
const UNIT_FIELD = "unit";
|
|
109
119
|
// Two readings of one requirement, shown as rows A and B (#374).
|
|
110
120
|
const READINGS_FIELD = "readings";
|
|
121
|
+
// Content blocks (#380): generic, on any kind. Every label and sentence a block shows comes from
|
|
122
|
+
// items.json (or a preset, label-page-presets.ts); the page knows only the block's shape.
|
|
123
|
+
const BLOCK_FIELDS = [
|
|
124
|
+
"situation",
|
|
125
|
+
"excerpt",
|
|
126
|
+
"references",
|
|
127
|
+
"more",
|
|
128
|
+
"table",
|
|
129
|
+
"repeat",
|
|
130
|
+
] as const;
|
|
111
131
|
const ALLOWED_FIELDS: readonly string[] = [
|
|
112
132
|
...PAGE_FIELDS,
|
|
113
133
|
RULES_FIELD,
|
|
114
134
|
UNIT_FIELD,
|
|
115
135
|
READINGS_FIELD,
|
|
136
|
+
...BLOCK_FIELDS,
|
|
137
|
+
ANSWERERS_FIELD,
|
|
116
138
|
...PRIVATE_FIELDS,
|
|
117
139
|
];
|
|
118
140
|
|
|
@@ -140,6 +162,11 @@ const RESERVED_NAMES = [
|
|
|
140
162
|
"__lookupSetter__",
|
|
141
163
|
];
|
|
142
164
|
const NAME_PATTERN = `(?!(?:${RESERVED_NAMES.join("|")})$)[A-Za-z0-9_-]+`;
|
|
165
|
+
/**
|
|
166
|
+
* With `answerers`, a document's id is `<answerer>~<id>`: `~` is outside NAME_PATTERN, so no
|
|
167
|
+
* answerer's name, item id, group or practice id can hold it, and the id splits at its first `~`.
|
|
168
|
+
*/
|
|
169
|
+
const SEP = "~";
|
|
143
170
|
const SOURCES = ["page", "chat"];
|
|
144
171
|
const SOURCES_SHOWN = `(${SOURCES.map(quoted).join(", ")})`;
|
|
145
172
|
|
|
@@ -152,6 +179,7 @@ const SOURCES_SHOWN = `(${SOURCES.map(quoted).join(", ")})`;
|
|
|
152
179
|
// refusal, what a value must be (a pattern) or why a field is required (a page kind needs it).
|
|
153
180
|
type SchemaNode = (
|
|
154
181
|
| { kind: "string"; required: boolean; pattern?: string }
|
|
182
|
+
| { kind: "text"; required: boolean }
|
|
155
183
|
| { kind: "integer"; required: boolean }
|
|
156
184
|
| { kind: "boolean"; required: boolean }
|
|
157
185
|
| { kind: "array"; required: boolean; min: number; items: SchemaNode }
|
|
@@ -179,6 +207,8 @@ const string = (required = true, pattern?: string, why?: string): SchemaNode =>
|
|
|
179
207
|
pattern,
|
|
180
208
|
...(why === undefined ? {} : { why }),
|
|
181
209
|
};
|
|
210
|
+
/** A string that may be empty: a blank line of code. */
|
|
211
|
+
const text = (): SchemaNode => ({ kind: "text", required: true });
|
|
182
212
|
const integer = (required = true): SchemaNode => ({
|
|
183
213
|
kind: "integer",
|
|
184
214
|
required,
|
|
@@ -212,6 +242,14 @@ const obj = (
|
|
|
212
242
|
|
|
213
243
|
// What an item and a practice item both may carry beyond text: a practice card looks like the real
|
|
214
244
|
// cards it rehearses. Each is optional here; a page's `kind` makes some of them required (KINDS).
|
|
245
|
+
const LINK = string(false, "https?://\\S+", "an http(s) link");
|
|
246
|
+
const optional = (fields: Readonly<Record<string, SchemaNode>>): SchemaNode =>
|
|
247
|
+
obj(fields, true, false);
|
|
248
|
+
const TEXT_FIELD = obj({
|
|
249
|
+
label: string(false),
|
|
250
|
+
text: string(),
|
|
251
|
+
style: string(false, "code|note", "code or note"),
|
|
252
|
+
});
|
|
215
253
|
const CARD_FIELDS: Readonly<Record<string, SchemaNode>> = {
|
|
216
254
|
[RULES_FIELD]: array(
|
|
217
255
|
obj({
|
|
@@ -235,6 +273,51 @@ const CARD_FIELDS: Readonly<Record<string, SchemaNode>> = {
|
|
|
235
273
|
),
|
|
236
274
|
[READINGS_FIELD]: obj({ a: string(), b: string() }, true, false),
|
|
237
275
|
file: string(false),
|
|
276
|
+
situation: optional({ summary: string(), question: string() }),
|
|
277
|
+
excerpt: optional({
|
|
278
|
+
file: string(false),
|
|
279
|
+
url: LINK,
|
|
280
|
+
lines: array(obj({ n: integer(), text: text(), mark: boolean() }), true, 1),
|
|
281
|
+
caption: string(false),
|
|
282
|
+
mark_label: string(false),
|
|
283
|
+
}),
|
|
284
|
+
references: optional({
|
|
285
|
+
summary: string(),
|
|
286
|
+
entries: array(
|
|
287
|
+
obj({
|
|
288
|
+
label: string(false),
|
|
289
|
+
title: string(),
|
|
290
|
+
fields: array(TEXT_FIELD, true, 1),
|
|
291
|
+
url: LINK,
|
|
292
|
+
url_label: string(false),
|
|
293
|
+
}),
|
|
294
|
+
true,
|
|
295
|
+
1,
|
|
296
|
+
),
|
|
297
|
+
}),
|
|
298
|
+
more: optional({
|
|
299
|
+
summary: string(),
|
|
300
|
+
items: array(string(), true, 1),
|
|
301
|
+
rest: string(false),
|
|
302
|
+
}),
|
|
303
|
+
table: optional({
|
|
304
|
+
summary: string(),
|
|
305
|
+
note: string(false),
|
|
306
|
+
rows: array(
|
|
307
|
+
obj({
|
|
308
|
+
label: string(),
|
|
309
|
+
value: string(),
|
|
310
|
+
plain: string(false),
|
|
311
|
+
mark: optional({
|
|
312
|
+
tone: string(true, "yes|no", "yes or no"),
|
|
313
|
+
text: string(),
|
|
314
|
+
}),
|
|
315
|
+
}),
|
|
316
|
+
true,
|
|
317
|
+
1,
|
|
318
|
+
),
|
|
319
|
+
}),
|
|
320
|
+
repeat: optional({ group: string(), index: integer(), of: integer() }),
|
|
238
321
|
};
|
|
239
322
|
/**
|
|
240
323
|
* THE PAGE KINDS, and the card fields each requires on every item and practice card (#373). A fix to
|
|
@@ -283,6 +366,7 @@ const itemSchema = (kind: Kind): SchemaNode =>
|
|
|
283
366
|
]),
|
|
284
367
|
),
|
|
285
368
|
...cardFields(kind),
|
|
369
|
+
[ANSWERERS_FIELD]: array(string(true, NAME_PATTERN), false, 1),
|
|
286
370
|
},
|
|
287
371
|
false, // an unlisted item field is the leak guard's refusal, with its own message
|
|
288
372
|
);
|
|
@@ -324,6 +408,10 @@ const configSchema = (kind: Kind): SchemaNode =>
|
|
|
324
408
|
seed: integer(),
|
|
325
409
|
groups: mapping(string(), NAME_PATTERN, 1), // ids become store document ids
|
|
326
410
|
labeller_knows: array(string(true, NAME_PATTERN), false),
|
|
411
|
+
// Two or more people answer the same page, each under their own name (doc ids `<name>~<id>`).
|
|
412
|
+
answerers: array(string(true, NAME_PATTERN), false, 2),
|
|
413
|
+
// An item's answer saves only once the item has been on screen this many seconds.
|
|
414
|
+
min_seconds: integer(false),
|
|
327
415
|
// true: a "how well do you know {group}" question per group, for a study that filters on it
|
|
328
416
|
// (rule 5). Left out, none is asked: an approval page's author has never seen the repositories.
|
|
329
417
|
familiarity: boolean(false),
|
|
@@ -331,6 +419,15 @@ const configSchema = (kind: Kind): SchemaNode =>
|
|
|
331
419
|
questions: array(QUESTION_SCHEMA, true, 1),
|
|
332
420
|
practice: array(practiceSchema(kind), true, MIN_PRACTICE),
|
|
333
421
|
practice_round: integer(false),
|
|
422
|
+
// Page-level words the blocks use (references/closed-choice-generator.md §«Content blocks»).
|
|
423
|
+
labels: optional({
|
|
424
|
+
place: string(false),
|
|
425
|
+
body: string(false),
|
|
426
|
+
line: string(false),
|
|
427
|
+
line_part: string(false),
|
|
428
|
+
repeat: string(false),
|
|
429
|
+
practice_example: string(false),
|
|
430
|
+
}),
|
|
334
431
|
items: array(itemSchema(kind), true, 1),
|
|
335
432
|
});
|
|
336
433
|
/** The declared schema (`schema` prints it): every field, those a kind requires left optional. */
|
|
@@ -412,6 +509,20 @@ function checkBoolean(value: Json, where: string): void {
|
|
|
412
509
|
if (typeof value !== "boolean")
|
|
413
510
|
fail(`${where}: must be true or false, got ${repr(value)}`);
|
|
414
511
|
}
|
|
512
|
+
/** A string that may be empty (a blank line of code), or an integer. */
|
|
513
|
+
function checkScalar(
|
|
514
|
+
value: Json,
|
|
515
|
+
kind: "text" | "integer",
|
|
516
|
+
where: string,
|
|
517
|
+
): void {
|
|
518
|
+
if (kind === "text" && typeof value !== "string")
|
|
519
|
+
fail(`${where}: must be a string, got ${repr(value)}`);
|
|
520
|
+
if (
|
|
521
|
+
kind === "integer" &&
|
|
522
|
+
(typeof value !== "number" || !Number.isInteger(value))
|
|
523
|
+
)
|
|
524
|
+
fail(`${where}: must be an integer, got ${repr(value)}`);
|
|
525
|
+
}
|
|
415
526
|
/** Refuse the first place `value` departs from `node`; the message starts with the field's path. */
|
|
416
527
|
function checkValue(value: Json, node: SchemaNode, path: string): void {
|
|
417
528
|
const where = path || "items.json";
|
|
@@ -419,10 +530,9 @@ function checkValue(value: Json, node: SchemaNode, path: string): void {
|
|
|
419
530
|
case "string":
|
|
420
531
|
checkString(value, node, where);
|
|
421
532
|
break;
|
|
533
|
+
case "text":
|
|
422
534
|
case "integer":
|
|
423
|
-
|
|
424
|
-
fail(`${where}: must be an integer, got ${repr(value)}`);
|
|
425
|
-
}
|
|
535
|
+
checkScalar(value, node.kind, where);
|
|
426
536
|
break;
|
|
427
537
|
case "boolean":
|
|
428
538
|
checkBoolean(value, where);
|
|
@@ -459,16 +569,71 @@ interface Unit {
|
|
|
459
569
|
readonly part: number;
|
|
460
570
|
readonly of: number;
|
|
461
571
|
}
|
|
572
|
+
interface TextField {
|
|
573
|
+
readonly label?: string;
|
|
574
|
+
readonly text: string;
|
|
575
|
+
readonly style?: string;
|
|
576
|
+
}
|
|
577
|
+
interface CodeLine {
|
|
578
|
+
readonly n: number;
|
|
579
|
+
readonly text: string;
|
|
580
|
+
readonly mark: boolean;
|
|
581
|
+
}
|
|
582
|
+
/** The content blocks (#380), each optional on any kind. */
|
|
583
|
+
interface Blocks {
|
|
584
|
+
readonly situation?: { readonly summary: string; readonly question: string };
|
|
585
|
+
readonly excerpt?: {
|
|
586
|
+
readonly file?: string;
|
|
587
|
+
readonly url?: string;
|
|
588
|
+
readonly lines: readonly CodeLine[];
|
|
589
|
+
readonly caption?: string;
|
|
590
|
+
readonly mark_label?: string;
|
|
591
|
+
};
|
|
592
|
+
readonly references?: {
|
|
593
|
+
readonly summary: string;
|
|
594
|
+
readonly entries: readonly {
|
|
595
|
+
readonly label?: string;
|
|
596
|
+
readonly title: string;
|
|
597
|
+
readonly fields: readonly TextField[];
|
|
598
|
+
readonly url?: string;
|
|
599
|
+
readonly url_label?: string;
|
|
600
|
+
}[];
|
|
601
|
+
};
|
|
602
|
+
readonly more?: {
|
|
603
|
+
readonly summary: string;
|
|
604
|
+
readonly items: readonly string[];
|
|
605
|
+
readonly rest?: string;
|
|
606
|
+
};
|
|
607
|
+
readonly table?: {
|
|
608
|
+
readonly summary: string;
|
|
609
|
+
readonly note?: string;
|
|
610
|
+
readonly rows: readonly {
|
|
611
|
+
readonly label: string;
|
|
612
|
+
readonly value: string;
|
|
613
|
+
readonly plain?: string;
|
|
614
|
+
readonly mark?: { readonly tone: string; readonly text: string };
|
|
615
|
+
}[];
|
|
616
|
+
};
|
|
617
|
+
readonly repeat?: {
|
|
618
|
+
readonly group: string;
|
|
619
|
+
readonly index: number;
|
|
620
|
+
readonly of: number;
|
|
621
|
+
};
|
|
622
|
+
}
|
|
462
623
|
/** What a card shows, on an item and on a practice item alike. */
|
|
463
624
|
type Card = {
|
|
464
625
|
readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
|
|
465
|
-
} & {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
626
|
+
} & Blocks & {
|
|
627
|
+
readonly body: string;
|
|
628
|
+
readonly rules?: readonly RuleRef[];
|
|
629
|
+
readonly unit?: Unit;
|
|
630
|
+
readonly readings?: { readonly a: string; readonly b: string };
|
|
631
|
+
};
|
|
632
|
+
type Item = Card & {
|
|
633
|
+
readonly source_id: string;
|
|
634
|
+
readonly group: string;
|
|
635
|
+
readonly answerers?: readonly string[];
|
|
470
636
|
};
|
|
471
|
-
type Item = Card & { readonly source_id: string; readonly group: string };
|
|
472
637
|
type Practice = Card & {
|
|
473
638
|
readonly expected: Readonly<Record<string, string>>;
|
|
474
639
|
readonly why: string;
|
|
@@ -481,11 +646,14 @@ interface Config {
|
|
|
481
646
|
readonly seed: number;
|
|
482
647
|
readonly groups: Readonly<Record<string, string>>;
|
|
483
648
|
readonly labeller_knows?: readonly string[];
|
|
649
|
+
readonly answerers?: readonly string[];
|
|
650
|
+
readonly min_seconds?: number;
|
|
484
651
|
readonly familiarity?: boolean;
|
|
485
652
|
readonly familiarity_question?: string;
|
|
486
653
|
readonly questions: readonly Question[];
|
|
487
654
|
readonly practice: readonly Practice[];
|
|
488
655
|
readonly practice_round?: number;
|
|
656
|
+
readonly labels?: Readonly<Record<string, string>>;
|
|
489
657
|
readonly items: readonly Item[];
|
|
490
658
|
}
|
|
491
659
|
/** The config is typed by passing CONFIG_SCHEMA: `checkValue` refuses (throws) on any departure. */
|
|
@@ -597,8 +765,37 @@ function checkPractice(cfg: Config): void {
|
|
|
597
765
|
fail(`${where}.${missing}: every question needs its expected answer`);
|
|
598
766
|
});
|
|
599
767
|
}
|
|
768
|
+
/** The first entry of `names` that repeats an earlier one, refused by its path. */
|
|
769
|
+
function checkDistinct(names: readonly string[], where: string): void {
|
|
770
|
+
const at = names.findIndex((x, i) => names.indexOf(x) !== i);
|
|
771
|
+
if (at !== -1)
|
|
772
|
+
fail(`${where}[${String(at)}]: ${repr(names[at])} is repeated`);
|
|
773
|
+
}
|
|
774
|
+
/** Each answerer once; an item's own list a subset of the page's; a dwell of a second or more. */
|
|
775
|
+
function checkAnswerers(cfg: Config): void {
|
|
776
|
+
if ((cfg.min_seconds ?? 1) < 1)
|
|
777
|
+
fail(`min_seconds: ${repr(cfg.min_seconds)} — must be 1 or more`);
|
|
778
|
+
const all = cfg.answerers;
|
|
779
|
+
if (all !== undefined) checkDistinct(all, "answerers");
|
|
780
|
+
cfg.items.forEach((item, n) => {
|
|
781
|
+
const where = `items[${String(n)}].answerers`;
|
|
782
|
+
const own = item.answerers;
|
|
783
|
+
if (own === undefined) return;
|
|
784
|
+
if (all === undefined)
|
|
785
|
+
return fail(
|
|
786
|
+
`${where}: the page has no \`answerers\` — add the page's list, or take this out`,
|
|
787
|
+
);
|
|
788
|
+
const k = own.findIndex((who) => !R.isIncludedIn(who, all));
|
|
789
|
+
if (k !== -1)
|
|
790
|
+
fail(
|
|
791
|
+
`${where}[${String(k)}]: ${repr(own[k])} is not in \`answerers\` (${all.join(", ")})`,
|
|
792
|
+
);
|
|
793
|
+
checkDistinct(own, where);
|
|
794
|
+
});
|
|
795
|
+
}
|
|
600
796
|
function checkConfig(cfg: Config): void {
|
|
601
797
|
checkGroups(cfg);
|
|
798
|
+
checkAnswerers(cfg);
|
|
602
799
|
if (!asksFamiliarity(cfg) && cfg.familiarity_question !== undefined) {
|
|
603
800
|
fail(
|
|
604
801
|
`familiarity_question: ${repr(cfg.familiarity_question)} is never asked, because \`familiarity\` is not true — remove it, or set "familiarity": true`,
|
|
@@ -688,6 +885,43 @@ function checkUnits(cfg: Config): void {
|
|
|
688
885
|
});
|
|
689
886
|
}
|
|
690
887
|
}
|
|
888
|
+
/**
|
|
889
|
+
* A situation's sentence or question may name the excerpt's marked line as `{line}`: the page writes
|
|
890
|
+
* «line N: » and the line's code there, so a question sits next to the code it is about.
|
|
891
|
+
*/
|
|
892
|
+
const LINE_TOKEN = "{line}";
|
|
893
|
+
const SITUATION_TEXTS = ["summary", "question"] as const;
|
|
894
|
+
/** The line `{line}` stands for: the first marked line, its number and its code without the indent. */
|
|
895
|
+
const lineOf = (card: Card) =>
|
|
896
|
+
card.excerpt?.lines
|
|
897
|
+
.filter((l) => l.mark)
|
|
898
|
+
.map((l) => ({ n: l.n, text: l.text.trim() }))[0];
|
|
899
|
+
const usesLine = (card: Card): boolean =>
|
|
900
|
+
SITUATION_TEXTS.some((f) => card.situation?.[f].includes(LINE_TOKEN));
|
|
901
|
+
/**
|
|
902
|
+
* What the block schema cannot say: an excerpt marks a line, `{line}` has a marked line to stand for,
|
|
903
|
+
* and a repeat sits inside its count.
|
|
904
|
+
*/
|
|
905
|
+
function checkBlocks(cfg: Config): void {
|
|
906
|
+
cardsOf(cfg).forEach(([at, card]) => {
|
|
907
|
+
if (card.excerpt !== undefined && !card.excerpt.lines.some((l) => l.mark))
|
|
908
|
+
fail(
|
|
909
|
+
`${at}.excerpt.lines: no line is marked — mark the line the card is about (\`mark: true\`)`,
|
|
910
|
+
);
|
|
911
|
+
const token = SITUATION_TEXTS.find((f) =>
|
|
912
|
+
card.situation?.[f].includes(LINE_TOKEN),
|
|
913
|
+
);
|
|
914
|
+
if (token !== undefined && card.excerpt === undefined)
|
|
915
|
+
fail(
|
|
916
|
+
`${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}\``,
|
|
917
|
+
);
|
|
918
|
+
const r = card.repeat;
|
|
919
|
+
if (r !== undefined && (r.index < 1 || r.index > r.of))
|
|
920
|
+
fail(
|
|
921
|
+
`${at}.repeat.index: ${String(r.index)} is not between 1 and ${String(r.of)}`,
|
|
922
|
+
);
|
|
923
|
+
});
|
|
924
|
+
}
|
|
691
925
|
/** Every card of the config, items then practice, by path. */
|
|
692
926
|
const cardsOf = (cfg: Config) => [
|
|
693
927
|
...cfg.items.map((i, n) => [`items[${String(n)}]`, i] as const),
|
|
@@ -715,12 +949,24 @@ function checkItemFields(cfg: Config): void {
|
|
|
715
949
|
);
|
|
716
950
|
});
|
|
717
951
|
}
|
|
718
|
-
/** Every
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
952
|
+
/** Every string inside a value: each is tested on its own, as the page renders it, never a serialisation. */
|
|
953
|
+
const stringsIn = (v: unknown): readonly string[] =>
|
|
954
|
+
typeof v === "string"
|
|
955
|
+
? [v]
|
|
956
|
+
: Array.isArray(v)
|
|
957
|
+
? v.flatMap(stringsIn)
|
|
958
|
+
: typeof v === "object" && v !== null
|
|
959
|
+
? Object.values(v).flatMap(stringsIn)
|
|
960
|
+
: [];
|
|
961
|
+
/**
|
|
962
|
+
* Every text of an item the labeller sees, by field: each string the page shows (the fields as
|
|
963
|
+
* `shownFields` passes them, so a repeat without its group), and every rule it names.
|
|
964
|
+
*/
|
|
965
|
+
const visibleTexts = (item: Item): readonly (readonly [string, string])[] =>
|
|
966
|
+
Object.entries({
|
|
967
|
+
...shownFields(item),
|
|
968
|
+
[RULES_FIELD]: item.rules ?? [],
|
|
969
|
+
}).flatMap(([f, v]) => stringsIn(v).map((t) => [f, t] as const));
|
|
724
970
|
const escapeRegExp = (s: string): string =>
|
|
725
971
|
s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
|
|
726
972
|
/**
|
|
@@ -752,6 +998,8 @@ interface KeyEntry {
|
|
|
752
998
|
readonly source_id: string;
|
|
753
999
|
readonly group: string;
|
|
754
1000
|
readonly on_page: boolean;
|
|
1001
|
+
/** Who the item is shown to, when not every answerer of the page. */
|
|
1002
|
+
readonly answerers?: readonly string[];
|
|
755
1003
|
}
|
|
756
1004
|
interface OldKey {
|
|
757
1005
|
readonly seed: number;
|
|
@@ -804,13 +1052,18 @@ function assignIds(
|
|
|
804
1052
|
}
|
|
805
1053
|
|
|
806
1054
|
// ── a build, short of the page ──────────────────────────────────────────────────────────────────
|
|
807
|
-
/** A
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
1055
|
+
/** A card's situation as the page shows it, with the line `{line}` stands for. */
|
|
1056
|
+
const situationShown = (item: Card) =>
|
|
1057
|
+
item.situation === undefined
|
|
1058
|
+
? {}
|
|
1059
|
+
: {
|
|
1060
|
+
situation: {
|
|
1061
|
+
summary: item.situation.summary,
|
|
1062
|
+
question: item.situation.question,
|
|
1063
|
+
...(usesLine(item) ? { line: lineOf(item) } : {}),
|
|
1064
|
+
},
|
|
1065
|
+
};
|
|
1066
|
+
const shownFields = (item: Card): Readonly<Record<string, unknown>> => ({
|
|
814
1067
|
...Object.fromEntries(
|
|
815
1068
|
PAGE_FIELDS.flatMap((f) => {
|
|
816
1069
|
const v = item[f];
|
|
@@ -828,6 +1081,15 @@ const shownFields = (
|
|
|
828
1081
|
},
|
|
829
1082
|
}),
|
|
830
1083
|
...(item.readings === undefined ? {} : { [READINGS_FIELD]: item.readings }),
|
|
1084
|
+
...Object.fromEntries(
|
|
1085
|
+
BLOCK_FIELDS.filter((f) => f !== "repeat" && f !== "situation").flatMap(
|
|
1086
|
+
(f) => (item[f] === undefined ? [] : [[f, item[f]]]),
|
|
1087
|
+
),
|
|
1088
|
+
),
|
|
1089
|
+
...situationShown(item),
|
|
1090
|
+
...(item.repeat === undefined
|
|
1091
|
+
? {}
|
|
1092
|
+
: { repeat: { index: item.repeat.index, of: item.repeat.of } }),
|
|
831
1093
|
});
|
|
832
1094
|
/** Rule → the linter's description (and a ruff rule's family), for every rule a card names. */
|
|
833
1095
|
type Described = ReadonlyMap<string, RuleText>;
|
|
@@ -836,14 +1098,16 @@ const ruleKey = (r: RuleRef): string => `${r.linter} ${r.id}`;
|
|
|
836
1098
|
function describeRules(cfg: Config, fromDir: string): Described {
|
|
837
1099
|
return new Map(
|
|
838
1100
|
cardsOf(cfg).flatMap(([at, card]) =>
|
|
839
|
-
(card.rules ?? []).
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1101
|
+
(card.rules ?? []).flatMap(
|
|
1102
|
+
(r, k): readonly (readonly [string, RuleText])[] => {
|
|
1103
|
+
const text = describeRule(r, fromDir);
|
|
1104
|
+
return text === undefined
|
|
1105
|
+
? fail(
|
|
1106
|
+
`${at}.rules[${String(k)}]: ${r.linter} ${repr(r.id)} has no description from the linter (${r.linter === "ruff" ? "`ruff rule` does not know it, or ruff is not on PATH" : "not a rule of the ESLint, or of a plugin, installed where items.json is"}) — check the id`,
|
|
1107
|
+
)
|
|
1108
|
+
: [[ruleKey(r), text]];
|
|
1109
|
+
},
|
|
1110
|
+
),
|
|
847
1111
|
),
|
|
848
1112
|
);
|
|
849
1113
|
}
|
|
@@ -882,6 +1146,20 @@ const practiceShown = (cfg: Config, described: Described) =>
|
|
|
882
1146
|
...shownFields(p),
|
|
883
1147
|
...rulesShown(p, described),
|
|
884
1148
|
}));
|
|
1149
|
+
/** The page's own words where `labels` leaves one out (references/closed-choice-generator.md). */
|
|
1150
|
+
const DEFAULT_LABELS: Readonly<Record<string, string>> = {
|
|
1151
|
+
place: "{id} · {n} of {total}",
|
|
1152
|
+
line: "Line",
|
|
1153
|
+
line_part: "{line} · part {part} of {of}",
|
|
1154
|
+
repeat: "Card {index} of {of}",
|
|
1155
|
+
practice_example: "",
|
|
1156
|
+
};
|
|
1157
|
+
/** The answerers and the dwell, only when items.json sets them: a page without them is as it was. */
|
|
1158
|
+
const twoKeys = (cfg: Config) =>
|
|
1159
|
+
R.pickBy(
|
|
1160
|
+
{ answerers: cfg.answerers, min_seconds: cfg.min_seconds },
|
|
1161
|
+
R.isDefined,
|
|
1162
|
+
);
|
|
885
1163
|
/** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
|
|
886
1164
|
function payloadOf(
|
|
887
1165
|
cfg: Config,
|
|
@@ -894,24 +1172,29 @@ function payloadOf(
|
|
|
894
1172
|
return {
|
|
895
1173
|
title: cfg.title,
|
|
896
1174
|
intro: cfg.intro ?? "",
|
|
1175
|
+
labels: { ...DEFAULT_LABELS, ...cfg.labels },
|
|
897
1176
|
groups: used.map((g) => ({ id: g, label: String(cfg.groups[g]) })),
|
|
898
1177
|
familiarity_question: asksFamiliarity(cfg)
|
|
899
1178
|
? (cfg.familiarity_question ?? "How well do you know {group}?")
|
|
900
1179
|
: "",
|
|
901
1180
|
familiarity: asksFamiliarity(cfg) ? FAMILIARITY : [],
|
|
902
1181
|
allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
|
|
1182
|
+
...twoKeys(cfg),
|
|
903
1183
|
questions: cfg.questions,
|
|
904
1184
|
practice: {
|
|
905
1185
|
round: practiceRound(cfg),
|
|
906
1186
|
items: practiceShown(cfg, described),
|
|
907
1187
|
},
|
|
908
|
-
items: page.items.map((i) => (
|
|
909
|
-
id: page.idOf(i),
|
|
910
|
-
...shownFields(i),
|
|
911
|
-
...rulesShown(i, described),
|
|
912
|
-
})),
|
|
1188
|
+
items: page.items.map((i) => itemShown(i, page.idOf(i), described)),
|
|
913
1189
|
};
|
|
914
1190
|
}
|
|
1191
|
+
/** One item as its card shows it, and whom it is shown to when not every answerer. */
|
|
1192
|
+
const itemShown = (i: Item, id: string, described: Described) => ({
|
|
1193
|
+
id,
|
|
1194
|
+
...shownFields(i),
|
|
1195
|
+
...rulesShown(i, described),
|
|
1196
|
+
...R.pickBy({ answerers: i.answerers }, R.isDefined),
|
|
1197
|
+
});
|
|
915
1198
|
export type Payload = ReturnType<typeof payloadOf>;
|
|
916
1199
|
/** Every id the key keeps (on the page now, or kept from an earlier build), with its source and group. */
|
|
917
1200
|
function idsOf(cfg: Config, page: ReturnType<typeof itemsOnPage>): JsonObject {
|
|
@@ -923,16 +1206,17 @@ function idsOf(cfg: Config, page: ReturnType<typeof itemsOnPage>): JsonObject {
|
|
|
923
1206
|
]),
|
|
924
1207
|
...cfg.items.map((i): [string, string] => [i.source_id, i.group]),
|
|
925
1208
|
]);
|
|
1209
|
+
const bySource = R.indexBy(cfg.items, (i) => i.source_id);
|
|
926
1210
|
const byId = [...page.ids].sort(([, a], [, b]) => byText(a, b));
|
|
927
|
-
return
|
|
928
|
-
byId
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
},
|
|
935
|
-
|
|
1211
|
+
return R.pullObject(
|
|
1212
|
+
byId,
|
|
1213
|
+
([, nid]) => nid,
|
|
1214
|
+
([sid, nid]) => ({
|
|
1215
|
+
source_id: sid,
|
|
1216
|
+
group: String(groupOf.get(sid)),
|
|
1217
|
+
on_page: shown.has(nid),
|
|
1218
|
+
...R.pickBy({ answerers: bySource[sid]?.answerers }, R.isDefined),
|
|
1219
|
+
}),
|
|
936
1220
|
);
|
|
937
1221
|
}
|
|
938
1222
|
/** Round → its practice items' expected answers. */
|
|
@@ -957,6 +1241,7 @@ function keyOf(
|
|
|
957
1241
|
return {
|
|
958
1242
|
study: cfg.study,
|
|
959
1243
|
seed: cfg.seed,
|
|
1244
|
+
...twoKeys(cfg),
|
|
960
1245
|
questions: cfg.questions.map((q) => q.id),
|
|
961
1246
|
// What counts as an answer: the same lists the page was given (`allowedValues`). `export`
|
|
962
1247
|
// checks every stored answer and familiarity against them, so the build's options decide.
|
|
@@ -983,13 +1268,14 @@ function keyOf(
|
|
|
983
1268
|
* returns what the page shows. A page written by hand can take its blindness and its key from here.
|
|
984
1269
|
*/
|
|
985
1270
|
export function prepareBuild(configPath: string, outDir: string) {
|
|
986
|
-
const cfg = readJson(configPath);
|
|
1271
|
+
const cfg = withPreset(readJson(configPath));
|
|
987
1272
|
assertConfig(cfg);
|
|
988
1273
|
// Once its `kind` is known to be one, the fields that kind needs on every card.
|
|
989
1274
|
checkValue(cfg, configSchema(cfg.kind), "");
|
|
990
1275
|
checkConfig(cfg);
|
|
991
1276
|
checkItemFields(cfg);
|
|
992
1277
|
checkUnits(cfg);
|
|
1278
|
+
checkBlocks(cfg);
|
|
993
1279
|
checkNoSourceShown(cfg);
|
|
994
1280
|
const described = describeRules(cfg, dirname(configPath));
|
|
995
1281
|
mkdirSync(outDir, { recursive: true });
|
|
@@ -1007,6 +1293,8 @@ export function prepareBuild(configPath: string, outDir: string) {
|
|
|
1007
1293
|
interface Key {
|
|
1008
1294
|
readonly study: string;
|
|
1009
1295
|
readonly questions: readonly string[];
|
|
1296
|
+
readonly answerers?: readonly string[];
|
|
1297
|
+
readonly min_seconds?: number;
|
|
1010
1298
|
readonly allowed?: Json;
|
|
1011
1299
|
readonly practice?: {
|
|
1012
1300
|
readonly round: number;
|
|
@@ -1024,6 +1312,11 @@ function assertKey(_value: Json): asserts _value is Json & Key {
|
|
|
1024
1312
|
}
|
|
1025
1313
|
|
|
1026
1314
|
const ANSWER_DOC_FIELDS = ["answers", "reasons", "savedAt", "source"];
|
|
1315
|
+
/** A page built with `min_seconds` also stores how long the item was on screen before its first save. */
|
|
1316
|
+
const answerDocFields = (key: Key): readonly string[] =>
|
|
1317
|
+
key.min_seconds === undefined
|
|
1318
|
+
? ANSWER_DOC_FIELDS
|
|
1319
|
+
: ["answers", "reasons", "savedAt", "seconds", "source"];
|
|
1027
1320
|
const GROUP_DOC_FIELDS = ["familiarity", "savedAt", "source"];
|
|
1028
1321
|
const DOC_FIELD_TYPES: readonly (readonly [
|
|
1029
1322
|
string,
|
|
@@ -1042,6 +1335,12 @@ const DOC_FIELD_TYPES: readonly (readonly [
|
|
|
1042
1335
|
(v) => v === null || typeof v === "string",
|
|
1043
1336
|
"a timestamp string or null",
|
|
1044
1337
|
],
|
|
1338
|
+
[
|
|
1339
|
+
"seconds",
|
|
1340
|
+
(v) =>
|
|
1341
|
+
v === null || (typeof v === "number" && Number.isInteger(v) && v >= 0),
|
|
1342
|
+
"a whole number of seconds or null",
|
|
1343
|
+
],
|
|
1045
1344
|
];
|
|
1046
1345
|
const COLLECTIONS = ["answers", "groups", "practice"] as const;
|
|
1047
1346
|
type Collection = (typeof COLLECTIONS)[number];
|
|
@@ -1075,15 +1374,29 @@ interface Names {
|
|
|
1075
1374
|
readonly onPage: readonly string[];
|
|
1076
1375
|
readonly fields: readonly string[];
|
|
1077
1376
|
}
|
|
1078
|
-
|
|
1079
|
-
|
|
1377
|
+
/** Who an item is shown to: its own list, else every answerer of the page. */
|
|
1378
|
+
const shownTo = (all: readonly string[], meta: KeyEntry): readonly string[] =>
|
|
1379
|
+
meta.answerers ?? all;
|
|
1380
|
+
/** A document's answerer, and the page's answerers; none on a page without them. */
|
|
1381
|
+
type Whose =
|
|
1382
|
+
| { readonly who?: undefined }
|
|
1383
|
+
| { readonly who: string; readonly all: readonly string[] };
|
|
1384
|
+
/** The names of a page without answerers, or those of one answerer's documents. */
|
|
1385
|
+
function knownNames(
|
|
1386
|
+
key: Key,
|
|
1387
|
+
whose: Whose,
|
|
1388
|
+
): Readonly<Record<Collection, Names>> {
|
|
1389
|
+
const entries = Object.entries(key.ids).filter(
|
|
1390
|
+
([, m]) =>
|
|
1391
|
+
whose.who === undefined || shownTo(whose.all, m).includes(whose.who),
|
|
1392
|
+
);
|
|
1080
1393
|
const shown = entries.filter(([, m]) => m.on_page);
|
|
1081
1394
|
const practice = practiceDocs(key);
|
|
1082
1395
|
return {
|
|
1083
1396
|
answers: {
|
|
1084
1397
|
known: entries.map(([nid]) => [nid, key.questions] as const),
|
|
1085
1398
|
onPage: shown.map(([nid]) => nid),
|
|
1086
|
-
fields:
|
|
1399
|
+
fields: answerDocFields(key),
|
|
1087
1400
|
},
|
|
1088
1401
|
groups: {
|
|
1089
1402
|
known: entries.map(([, m]) => [m.group, key.questions] as const),
|
|
@@ -1093,7 +1406,7 @@ function knownNames(key: Key): Readonly<Record<Collection, Names>> {
|
|
|
1093
1406
|
practice: {
|
|
1094
1407
|
known: practice,
|
|
1095
1408
|
onPage: practice.map(([n]) => n),
|
|
1096
|
-
fields:
|
|
1409
|
+
fields: answerDocFields(key),
|
|
1097
1410
|
},
|
|
1098
1411
|
};
|
|
1099
1412
|
}
|
|
@@ -1118,16 +1431,43 @@ function checkDumpShape(dump: Json): asserts dump is JsonObject {
|
|
|
1118
1431
|
`${stray}: the store has no such collection (expected ${COLLECTIONS.join(", ")})`,
|
|
1119
1432
|
);
|
|
1120
1433
|
}
|
|
1434
|
+
/**
|
|
1435
|
+
* A document's answerer and its name without it. A page without answerers has none; with them, an
|
|
1436
|
+
* id without `~`, or before it a name the page does not have, is refused.
|
|
1437
|
+
*/
|
|
1438
|
+
function answererOf(
|
|
1439
|
+
where: string,
|
|
1440
|
+
docId: string,
|
|
1441
|
+
key: Key,
|
|
1442
|
+
): Whose & { readonly name: string } {
|
|
1443
|
+
const all = key.answerers;
|
|
1444
|
+
if (all === undefined) return { name: docId };
|
|
1445
|
+
const at = docId.indexOf(SEP);
|
|
1446
|
+
if (at === -1)
|
|
1447
|
+
return fail(
|
|
1448
|
+
`${where}: this page has answerers (${all.join(", ")}), so a document's id is <answerer>${SEP}<id>`,
|
|
1449
|
+
);
|
|
1450
|
+
const who = docId.slice(0, at);
|
|
1451
|
+
if (!all.includes(who))
|
|
1452
|
+
fail(
|
|
1453
|
+
`${where}: ${repr(who)} is not an answerer of this page (${all.join(", ")})`,
|
|
1454
|
+
);
|
|
1455
|
+
return { who, all, name: docId.slice(at + 1) };
|
|
1456
|
+
}
|
|
1121
1457
|
function checkDocName(
|
|
1122
1458
|
where: string,
|
|
1123
|
-
name: string,
|
|
1459
|
+
{ who, name }: Whose & { readonly name: string },
|
|
1124
1460
|
kind: Collection,
|
|
1125
1461
|
names: Names,
|
|
1126
1462
|
): readonly string[] {
|
|
1127
1463
|
const hit = names.known.find(([n]) => n === name);
|
|
1128
1464
|
if (hit === undefined) {
|
|
1129
1465
|
return fail(
|
|
1130
|
-
`${where}: the key does not know this ${NOUN[kind]}
|
|
1466
|
+
`${where}: the key does not know this ${NOUN[kind]}${who === undefined ? "" : ` for ${who}`} (${R.unique(
|
|
1467
|
+
names.known.map(([n]) => n),
|
|
1468
|
+
)
|
|
1469
|
+
.sort()
|
|
1470
|
+
.join(", ")})`,
|
|
1131
1471
|
);
|
|
1132
1472
|
}
|
|
1133
1473
|
if (!names.onPage.includes(name)) {
|
|
@@ -1206,15 +1546,20 @@ function checkDocAnswers(
|
|
|
1206
1546
|
*/
|
|
1207
1547
|
function checkDump(dump: Json, key: Key): asserts dump is JsonObject {
|
|
1208
1548
|
checkDumpShape(dump);
|
|
1209
|
-
|
|
1549
|
+
// A page without answerers knows one set of names; each answerer has their own.
|
|
1550
|
+
const plain = knownNames(key, {});
|
|
1210
1551
|
for (const kind of COLLECTIONS) {
|
|
1211
1552
|
const collection = dump[kind];
|
|
1212
|
-
for (const [
|
|
1553
|
+
for (const [docId, doc] of sortedEntries(
|
|
1213
1554
|
isObject(collection) ? collection : {},
|
|
1214
1555
|
)) {
|
|
1215
|
-
const where = `${kind}/${
|
|
1216
|
-
const
|
|
1217
|
-
|
|
1556
|
+
const where = `${kind}/${docId}`;
|
|
1557
|
+
const named = answererOf(where, docId, key);
|
|
1558
|
+
const names = (named.who === undefined ? plain : knownNames(key, named))[
|
|
1559
|
+
kind
|
|
1560
|
+
];
|
|
1561
|
+
const questions = checkDocName(where, named, kind, names);
|
|
1562
|
+
checkDocFields(where, doc, kind, names.fields);
|
|
1218
1563
|
checkDocAnswers(where, doc, questions);
|
|
1219
1564
|
}
|
|
1220
1565
|
}
|
|
@@ -1302,8 +1647,8 @@ function answersOf(doc: JsonObject, ctx: Ctx) {
|
|
|
1302
1647
|
);
|
|
1303
1648
|
return { given, invalid };
|
|
1304
1649
|
}
|
|
1305
|
-
/** One
|
|
1306
|
-
function
|
|
1650
|
+
/** One stored answer: its answers, what is invalid, the reasons, and whether it is done. */
|
|
1651
|
+
function answerOf(doc: JsonObject, ctx: Ctx) {
|
|
1307
1652
|
const { given, invalid } = answersOf(doc, ctx);
|
|
1308
1653
|
const reasons = Object.fromEntries(
|
|
1309
1654
|
Object.entries(textsOf(doc["reasons"])).filter(([, r]) => r.trim()),
|
|
@@ -1315,9 +1660,6 @@ function labelOf(nid: string, meta: KeyEntry, doc: JsonObject, ctx: Ctx) {
|
|
|
1315
1660
|
)
|
|
1316
1661
|
.map(([q]) => q);
|
|
1317
1662
|
return {
|
|
1318
|
-
id: nid,
|
|
1319
|
-
source_id: meta.source_id,
|
|
1320
|
-
group: meta.group,
|
|
1321
1663
|
answers: given,
|
|
1322
1664
|
invalid_answers: invalid,
|
|
1323
1665
|
reasons,
|
|
@@ -1326,19 +1668,32 @@ function labelOf(nid: string, meta: KeyEntry, doc: JsonObject, ctx: Ctx) {
|
|
|
1326
1668
|
Object.values(given).every((v) => v !== null) && !unexplained.length,
|
|
1327
1669
|
source: textOf(doc["source"]),
|
|
1328
1670
|
saved_at: doc["savedAt"] ?? null,
|
|
1671
|
+
// Only a page built with `min_seconds` stores how long the item was on screen.
|
|
1672
|
+
...R.pick({ seconds: doc["seconds"] ?? null }, secondsOf(ctx.key)),
|
|
1329
1673
|
};
|
|
1330
1674
|
}
|
|
1331
|
-
type
|
|
1675
|
+
type Answer = ReturnType<typeof answerOf>;
|
|
1676
|
+
/** The `seconds` field, on a page built with `min_seconds`; none on any other. */
|
|
1677
|
+
const secondsOf = (key: Key): readonly "seconds"[] =>
|
|
1678
|
+
key.min_seconds === undefined ? [] : ["seconds"];
|
|
1679
|
+
/** One item of the labels file of a page without answerers. */
|
|
1680
|
+
const labelOf = (nid: string, meta: KeyEntry, doc: JsonObject, ctx: Ctx) => ({
|
|
1681
|
+
id: nid,
|
|
1682
|
+
source_id: meta.source_id,
|
|
1683
|
+
group: meta.group,
|
|
1684
|
+
...answerOf(doc, ctx),
|
|
1685
|
+
});
|
|
1332
1686
|
|
|
1333
1687
|
/** Same rule as for item answers: a familiarity outside the options is marked, not counted as rated. */
|
|
1334
1688
|
function groupsOf(
|
|
1335
1689
|
groupIds: readonly string[],
|
|
1336
1690
|
groups: Json | undefined,
|
|
1337
1691
|
allowed: Allowed,
|
|
1692
|
+
prefix = "",
|
|
1338
1693
|
) {
|
|
1339
1694
|
return Object.fromEntries(
|
|
1340
1695
|
groupIds.map((g) => {
|
|
1341
|
-
const doc = docOf(groups, g);
|
|
1696
|
+
const doc = docOf(groups, `${prefix}${g}`);
|
|
1342
1697
|
const fam = textOf(doc["familiarity"]);
|
|
1343
1698
|
const bad = fam !== null && !allowed.familiarity.includes(fam);
|
|
1344
1699
|
return [
|
|
@@ -1353,7 +1708,10 @@ function groupsOf(
|
|
|
1353
1708
|
}),
|
|
1354
1709
|
);
|
|
1355
1710
|
}
|
|
1356
|
-
function reportLabels(
|
|
1711
|
+
function reportLabels(
|
|
1712
|
+
items: readonly (Answer & { readonly id: string })[],
|
|
1713
|
+
allowed: Allowed,
|
|
1714
|
+
): void {
|
|
1357
1715
|
for (const i of items) {
|
|
1358
1716
|
for (const [q, v] of Object.entries(i.invalid_answers)) {
|
|
1359
1717
|
say(
|
|
@@ -1383,9 +1741,14 @@ function reportGroups(
|
|
|
1383
1741
|
}
|
|
1384
1742
|
}
|
|
1385
1743
|
/** Each practice item of the key's round: what was answered first, what was expected, whether they match. */
|
|
1386
|
-
function scorePractice(
|
|
1744
|
+
function scorePractice(
|
|
1745
|
+
dump: JsonObject,
|
|
1746
|
+
round: string,
|
|
1747
|
+
all: Expected,
|
|
1748
|
+
prefix: string,
|
|
1749
|
+
) {
|
|
1387
1750
|
return Object.entries(all).map(([pid, expected]) => {
|
|
1388
|
-
const doc = docOf(dump["practice"],
|
|
1751
|
+
const doc = docOf(dump["practice"], `${prefix}r${round}-${pid}`);
|
|
1389
1752
|
const answers = isObject(doc["answers"]) ? doc["answers"] : {};
|
|
1390
1753
|
const matched = Object.entries(expected).every(
|
|
1391
1754
|
([q, v]) => answers[q] === v,
|
|
@@ -1399,9 +1762,14 @@ function scorePractice(dump: JsonObject, round: string, all: Expected) {
|
|
|
1399
1762
|
};
|
|
1400
1763
|
});
|
|
1401
1764
|
}
|
|
1402
|
-
/** One file per round: a second round exported the same day keeps the first. */
|
|
1403
|
-
const practiceFile = (
|
|
1404
|
-
|
|
1765
|
+
/** One file per round (and answerer): a second round exported the same day keeps the first. */
|
|
1766
|
+
const practiceFile = (
|
|
1767
|
+
study: string,
|
|
1768
|
+
round: number,
|
|
1769
|
+
day: string,
|
|
1770
|
+
who: string | undefined,
|
|
1771
|
+
): string =>
|
|
1772
|
+
`${study}-practice-r${String(round)}${who === undefined ? "" : `-${who}`}-${day}.json`;
|
|
1405
1773
|
/**
|
|
1406
1774
|
* Every practice round the store holds answers for, each scored against its own expected answers
|
|
1407
1775
|
* into its own file; never into the labels. The key's own round is reported even unanswered.
|
|
@@ -1414,79 +1782,169 @@ function exportPractice(
|
|
|
1414
1782
|
): void {
|
|
1415
1783
|
const p = key.practice;
|
|
1416
1784
|
if (p === undefined) return;
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
.forEach(([round, expected]) => {
|
|
1785
|
+
const rounds = R.sortBy(R.entries(roundsIn(p)), ([r]) => Number(r));
|
|
1786
|
+
(key.answerers ?? [undefined]).forEach((who) => {
|
|
1787
|
+
rounds.forEach(([round, expected]) => {
|
|
1420
1788
|
exportRound(
|
|
1421
1789
|
key.study,
|
|
1422
1790
|
Number(round),
|
|
1423
|
-
scorePractice(
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1791
|
+
scorePractice(
|
|
1792
|
+
dump,
|
|
1793
|
+
round,
|
|
1794
|
+
expected,
|
|
1795
|
+
who === undefined ? "" : `${who}${SEP}`,
|
|
1796
|
+
),
|
|
1797
|
+
{ target, day, current: Number(round) === p.round, who },
|
|
1429
1798
|
);
|
|
1430
1799
|
});
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1802
|
+
/** Where a round's practice goes, and whose it is: no one's on a page without answerers. */
|
|
1803
|
+
interface RoundAt {
|
|
1804
|
+
readonly target: string;
|
|
1805
|
+
readonly day: string;
|
|
1806
|
+
readonly current: boolean;
|
|
1807
|
+
readonly who: string | undefined;
|
|
1431
1808
|
}
|
|
1432
1809
|
/** One round's scored practice to `<study>-practice-r<round>-<date>.json`, and one line about it. */
|
|
1433
1810
|
function exportRound(
|
|
1434
1811
|
study: string,
|
|
1435
1812
|
round: number,
|
|
1436
1813
|
items: ReturnType<typeof scorePractice>,
|
|
1437
|
-
at:
|
|
1438
|
-
readonly target: string;
|
|
1439
|
-
readonly day: string;
|
|
1440
|
-
readonly current: boolean;
|
|
1441
|
-
},
|
|
1814
|
+
at: RoundAt,
|
|
1442
1815
|
): void {
|
|
1816
|
+
const named = `practice round ${String(round)}${at.who === undefined ? "" : ` (${at.who})`}`;
|
|
1443
1817
|
if (!items.some((i) => Object.keys(i.answers).length)) {
|
|
1444
|
-
if (at.current) say(
|
|
1818
|
+
if (at.current) say(`${named}: not answered`);
|
|
1445
1819
|
return;
|
|
1446
1820
|
}
|
|
1447
1821
|
const missed = items.filter((i) => !i.matched).map((i) => i.id);
|
|
1448
1822
|
const matched = items.length - missed.length;
|
|
1449
|
-
const path = join(at.target, practiceFile(study, round, at.day));
|
|
1823
|
+
const path = join(at.target, practiceFile(study, round, at.day, at.who));
|
|
1450
1824
|
writeJson(path, {
|
|
1451
1825
|
study,
|
|
1452
1826
|
round,
|
|
1827
|
+
...R.pickBy({ answerer: at.who }, R.isDefined),
|
|
1453
1828
|
exported: at.day,
|
|
1454
1829
|
matched,
|
|
1455
1830
|
total: items.length,
|
|
1456
1831
|
items,
|
|
1457
1832
|
});
|
|
1458
1833
|
say(
|
|
1459
|
-
|
|
1834
|
+
`${named}: ${String(matched)} of ${String(items.length)} matched${missed.length ? `; missed ${missed.join(", ")}` : ""} -> ${path}`,
|
|
1460
1835
|
);
|
|
1461
1836
|
}
|
|
1837
|
+
/** The counts of one set of answers and groups. */
|
|
1838
|
+
const tally = (
|
|
1839
|
+
items: readonly Answer[],
|
|
1840
|
+
groups: ReturnType<typeof groupsOf>,
|
|
1841
|
+
) => ({
|
|
1842
|
+
groups,
|
|
1843
|
+
complete_items: items.filter((i) => i.complete).length,
|
|
1844
|
+
invalid_items: items.filter((i) => Object.keys(i.invalid_answers).length)
|
|
1845
|
+
.length,
|
|
1846
|
+
invalid_groups: Object.values(groups).filter(
|
|
1847
|
+
(g) => g.invalid_familiarity !== null,
|
|
1848
|
+
).length,
|
|
1849
|
+
total_items: items.length,
|
|
1850
|
+
});
|
|
1851
|
+
type Tally = ReturnType<typeof tally>;
|
|
1852
|
+
/** The familiarity of the groups of `onPage`, or none on a page that asks none. */
|
|
1853
|
+
const groupsFor = (
|
|
1854
|
+
onPage: readonly (readonly [string, KeyEntry])[],
|
|
1855
|
+
dump: JsonObject,
|
|
1856
|
+
ctx: Ctx,
|
|
1857
|
+
prefix = "",
|
|
1858
|
+
) =>
|
|
1859
|
+
ctx.allowed.familiarity.length
|
|
1860
|
+
? groupsOf(
|
|
1861
|
+
R.unique(onPage.map(([, m]) => m.group)),
|
|
1862
|
+
dump["groups"],
|
|
1863
|
+
ctx.allowed,
|
|
1864
|
+
prefix,
|
|
1865
|
+
)
|
|
1866
|
+
: {};
|
|
1462
1867
|
/** The labels file: every item on the page with its answers, the groups, and the counts. */
|
|
1463
1868
|
function labelsOf(dump: JsonObject, ctx: Ctx, day: string) {
|
|
1464
1869
|
const onPage = Object.entries(ctx.key.ids).filter(([, m]) => m.on_page);
|
|
1465
1870
|
const items = onPage.map(([nid, meta]) =>
|
|
1466
1871
|
labelOf(nid, meta, docOf(dump["answers"], nid), ctx),
|
|
1467
1872
|
);
|
|
1468
|
-
const groups = ctx.allowed.familiarity.length
|
|
1469
|
-
? groupsOf(
|
|
1470
|
-
[...new Set(onPage.map(([, m]) => m.group))],
|
|
1471
|
-
dump["groups"],
|
|
1472
|
-
ctx.allowed,
|
|
1473
|
-
)
|
|
1474
|
-
: {};
|
|
1475
1873
|
return {
|
|
1476
1874
|
study: ctx.key.study,
|
|
1477
1875
|
exported: day,
|
|
1478
1876
|
questions: ctx.key.questions,
|
|
1479
1877
|
items,
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1878
|
+
...tally(items, groupsFor(onPage, dump, ctx)),
|
|
1879
|
+
};
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* The labels file of a page with answerers: each item with every answerer it is shown to and their
|
|
1883
|
+
* answers apart (`by_answerer`), and each answerer's groups and counts (`by_answerer` at the top).
|
|
1884
|
+
*/
|
|
1885
|
+
function answererLabelsOf(
|
|
1886
|
+
dump: JsonObject,
|
|
1887
|
+
ctx: Ctx,
|
|
1888
|
+
day: string,
|
|
1889
|
+
all: readonly string[],
|
|
1890
|
+
) {
|
|
1891
|
+
const onPage = Object.entries(ctx.key.ids).filter(([, m]) => m.on_page);
|
|
1892
|
+
const docFor = (who: string, nid: string) =>
|
|
1893
|
+
answerOf(docOf(dump["answers"], `${who}${SEP}${nid}`), ctx);
|
|
1894
|
+
const items = onPage.map(([nid, meta]) => ({
|
|
1895
|
+
id: nid,
|
|
1896
|
+
source_id: meta.source_id,
|
|
1897
|
+
group: meta.group,
|
|
1898
|
+
answerers: shownTo(all, meta),
|
|
1899
|
+
by_answerer: R.fromKeys(shownTo(all, meta), (who) => docFor(who, nid)),
|
|
1900
|
+
}));
|
|
1901
|
+
return {
|
|
1902
|
+
study: ctx.key.study,
|
|
1903
|
+
exported: day,
|
|
1904
|
+
questions: ctx.key.questions,
|
|
1905
|
+
answerers: all,
|
|
1906
|
+
items,
|
|
1907
|
+
by_answerer: R.fromKeys(all, (who) =>
|
|
1908
|
+
tallyOf({ who, all }, onPage, dump, ctx),
|
|
1909
|
+
),
|
|
1488
1910
|
};
|
|
1489
1911
|
}
|
|
1912
|
+
/** One answerer's groups and counts, over the items shown to them. */
|
|
1913
|
+
function tallyOf(
|
|
1914
|
+
{ who, all }: { readonly who: string; readonly all: readonly string[] },
|
|
1915
|
+
onPage: readonly (readonly [string, KeyEntry])[],
|
|
1916
|
+
dump: JsonObject,
|
|
1917
|
+
ctx: Ctx,
|
|
1918
|
+
): Tally {
|
|
1919
|
+
const mine = onPage.filter(([, m]) => shownTo(all, m).includes(who));
|
|
1920
|
+
return tally(
|
|
1921
|
+
mine.map(([nid]) =>
|
|
1922
|
+
answerOf(docOf(dump["answers"], `${who}${SEP}${nid}`), ctx),
|
|
1923
|
+
),
|
|
1924
|
+
groupsFor(mine, dump, ctx, `${who}${SEP}`),
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
/** Each answerer's items and groups, named `<answerer>~<id>` in the report, as in the store. */
|
|
1928
|
+
function reportAnswerers(
|
|
1929
|
+
out: ReturnType<typeof answererLabelsOf>,
|
|
1930
|
+
allowed: Allowed,
|
|
1931
|
+
): void {
|
|
1932
|
+
R.forEachObj(out.by_answerer, (t, who) => {
|
|
1933
|
+
reportLabels(
|
|
1934
|
+
out.items.flatMap((i) => {
|
|
1935
|
+
const a = i.by_answerer[who];
|
|
1936
|
+
return a === undefined ? [] : [{ id: `${who}${SEP}${i.id}`, ...a }];
|
|
1937
|
+
}),
|
|
1938
|
+
allowed,
|
|
1939
|
+
);
|
|
1940
|
+
reportGroups(
|
|
1941
|
+
R.mapKeys(t.groups, (g) => `${who}${SEP}${g}`),
|
|
1942
|
+
allowed,
|
|
1943
|
+
);
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
const completeLine = (t: Tally): string =>
|
|
1947
|
+
`${String(t.complete_items)} of ${String(t.total_items)} items complete`;
|
|
1490
1948
|
export function exportLabels(
|
|
1491
1949
|
dumpPath: string,
|
|
1492
1950
|
keyPath: string,
|
|
@@ -1500,14 +1958,34 @@ export function exportLabels(
|
|
|
1500
1958
|
assertAllowed(allowed, key, keyPath);
|
|
1501
1959
|
checkDump(dump, key);
|
|
1502
1960
|
checkNoFamiliarity(dump, allowed);
|
|
1503
|
-
const
|
|
1961
|
+
const ctx = { key, allowed };
|
|
1962
|
+
const all = key.answerers;
|
|
1963
|
+
const out =
|
|
1964
|
+
all === undefined
|
|
1965
|
+
? labelsOf(dump, ctx, day)
|
|
1966
|
+
: answererLabelsOf(dump, ctx, day, all);
|
|
1504
1967
|
mkdirSync(outDir, { recursive: true });
|
|
1505
1968
|
const path = join(outDir, `${key.study}-labels-${day}.json`);
|
|
1506
1969
|
writeJson(path, out);
|
|
1507
1970
|
exportPractice(dump, key, outDir, day);
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1971
|
+
reportExport(path, out, allowed);
|
|
1972
|
+
}
|
|
1973
|
+
/** What the export wrote, and every item and group it did not count. */
|
|
1974
|
+
function reportExport(
|
|
1975
|
+
path: string,
|
|
1976
|
+
out: ReturnType<typeof labelsOf> | ReturnType<typeof answererLabelsOf>,
|
|
1977
|
+
allowed: Allowed,
|
|
1978
|
+
): void {
|
|
1979
|
+
if ("by_answerer" in out) {
|
|
1980
|
+
say(
|
|
1981
|
+
`wrote ${path}: ${R.entries(out.by_answerer)
|
|
1982
|
+
.map(([who, t]) => `${who}: ${completeLine(t)}`)
|
|
1983
|
+
.join("; ")}`,
|
|
1984
|
+
);
|
|
1985
|
+
reportAnswerers(out, allowed);
|
|
1986
|
+
} else {
|
|
1987
|
+
say(`wrote ${path}: ${completeLine(out)}`);
|
|
1988
|
+
reportLabels(out.items, allowed);
|
|
1989
|
+
reportGroups(out.groups, allowed);
|
|
1990
|
+
}
|
|
1513
1991
|
}
|