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,8 +7,10 @@
|
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
|
+
import * as R from "remeda";
|
|
10
11
|
import { z } from "zod";
|
|
11
12
|
import { describeRule, } from "./rule-descriptions.js";
|
|
13
|
+
import { applyPreset } from "./label-page-presets.js";
|
|
12
14
|
// ── refusals and output ───────────────────────────────────────────────────────────────────────
|
|
13
15
|
export class Refusal extends Error {
|
|
14
16
|
exitCode;
|
|
@@ -74,6 +76,11 @@ const writeJson = (path, value) => {
|
|
|
74
76
|
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
75
77
|
};
|
|
76
78
|
const JSON_VALUE = z.json();
|
|
79
|
+
/** The config with its named preset applied (label-page-presets.ts), refused by path. */
|
|
80
|
+
const withPreset = (raw) => {
|
|
81
|
+
const r = applyPreset(raw);
|
|
82
|
+
return r.ok ? JSON_VALUE.parse(r.value) : fail(r.message);
|
|
83
|
+
};
|
|
77
84
|
const readJson = (path) => {
|
|
78
85
|
try {
|
|
79
86
|
return JSON_VALUE.parse(JSON.parse(readFileSync(path, "utf8")));
|
|
@@ -91,6 +98,9 @@ const readJson = (path) => {
|
|
|
91
98
|
// prettier-ignore
|
|
92
99
|
const PAGE_FIELDS = ["group", "title", "body", "code", "context", "file"];
|
|
93
100
|
const PRIVATE_FIELDS = ["source_id"];
|
|
101
|
+
// Who of the page's `answerers` an item is shown to: tells the page whom to show it, nothing about
|
|
102
|
+
// the answer or the source.
|
|
103
|
+
const ANSWERERS_FIELD = "answerers";
|
|
94
104
|
// The linter rules an item names ("read as ruff `TID253`"): the page shows each with the linter's
|
|
95
105
|
// own description, looked up at build time (rule-descriptions.ts), never typed into items.json.
|
|
96
106
|
const RULES_FIELD = "rules";
|
|
@@ -99,11 +109,23 @@ const RULES_FIELD = "rules";
|
|
|
99
109
|
const UNIT_FIELD = "unit";
|
|
100
110
|
// Two readings of one requirement, shown as rows A and B (#374).
|
|
101
111
|
const READINGS_FIELD = "readings";
|
|
112
|
+
// Content blocks (#380): generic, on any kind. Every label and sentence a block shows comes from
|
|
113
|
+
// items.json (or a preset, label-page-presets.ts); the page knows only the block's shape.
|
|
114
|
+
const BLOCK_FIELDS = [
|
|
115
|
+
"situation",
|
|
116
|
+
"excerpt",
|
|
117
|
+
"references",
|
|
118
|
+
"more",
|
|
119
|
+
"table",
|
|
120
|
+
"repeat",
|
|
121
|
+
];
|
|
102
122
|
const ALLOWED_FIELDS = [
|
|
103
123
|
...PAGE_FIELDS,
|
|
104
124
|
RULES_FIELD,
|
|
105
125
|
UNIT_FIELD,
|
|
106
126
|
READINGS_FIELD,
|
|
127
|
+
...BLOCK_FIELDS,
|
|
128
|
+
ANSWERERS_FIELD,
|
|
107
129
|
...PRIVATE_FIELDS,
|
|
108
130
|
];
|
|
109
131
|
const FAMILIARITY = [
|
|
@@ -130,6 +152,11 @@ const RESERVED_NAMES = [
|
|
|
130
152
|
"__lookupSetter__",
|
|
131
153
|
];
|
|
132
154
|
const NAME_PATTERN = `(?!(?:${RESERVED_NAMES.join("|")})$)[A-Za-z0-9_-]+`;
|
|
155
|
+
/**
|
|
156
|
+
* With `answerers`, a document's id is `<answerer>~<id>`: `~` is outside NAME_PATTERN, so no
|
|
157
|
+
* answerer's name, item id, group or practice id can hold it, and the id splits at its first `~`.
|
|
158
|
+
*/
|
|
159
|
+
const SEP = "~";
|
|
133
160
|
const SOURCES = ["page", "chat"];
|
|
134
161
|
const SOURCES_SHOWN = `(${SOURCES.map(quoted).join(", ")})`;
|
|
135
162
|
const string = (required = true, pattern, why) => pattern === undefined
|
|
@@ -140,6 +167,8 @@ const string = (required = true, pattern, why) => pattern === undefined
|
|
|
140
167
|
pattern,
|
|
141
168
|
...(why === undefined ? {} : { why }),
|
|
142
169
|
};
|
|
170
|
+
/** A string that may be empty: a blank line of code. */
|
|
171
|
+
const text = () => ({ kind: "text", required: true });
|
|
143
172
|
const integer = (required = true) => ({
|
|
144
173
|
kind: "integer",
|
|
145
174
|
required,
|
|
@@ -164,6 +193,13 @@ const mapping = (values, keyPattern, min = 0) => ({
|
|
|
164
193
|
const obj = (fields, closed = true, required = true) => ({ kind: "object", required, closed, fields });
|
|
165
194
|
// What an item and a practice item both may carry beyond text: a practice card looks like the real
|
|
166
195
|
// cards it rehearses. Each is optional here; a page's `kind` makes some of them required (KINDS).
|
|
196
|
+
const LINK = string(false, "https?://\\S+", "an http(s) link");
|
|
197
|
+
const optional = (fields) => obj(fields, true, false);
|
|
198
|
+
const TEXT_FIELD = obj({
|
|
199
|
+
label: string(false),
|
|
200
|
+
text: string(),
|
|
201
|
+
style: string(false, "code|note", "code or note"),
|
|
202
|
+
});
|
|
167
203
|
const CARD_FIELDS = {
|
|
168
204
|
[RULES_FIELD]: array(obj({
|
|
169
205
|
linter: string(true, "eslint|ruff", "eslint or ruff"),
|
|
@@ -180,6 +216,43 @@ const CARD_FIELDS = {
|
|
|
180
216
|
}, true, false),
|
|
181
217
|
[READINGS_FIELD]: obj({ a: string(), b: string() }, true, false),
|
|
182
218
|
file: string(false),
|
|
219
|
+
situation: optional({ summary: string(), question: string() }),
|
|
220
|
+
excerpt: optional({
|
|
221
|
+
file: string(false),
|
|
222
|
+
url: LINK,
|
|
223
|
+
lines: array(obj({ n: integer(), text: text(), mark: boolean() }), true, 1),
|
|
224
|
+
caption: string(false),
|
|
225
|
+
mark_label: string(false),
|
|
226
|
+
}),
|
|
227
|
+
references: optional({
|
|
228
|
+
summary: string(),
|
|
229
|
+
entries: array(obj({
|
|
230
|
+
label: string(false),
|
|
231
|
+
title: string(),
|
|
232
|
+
fields: array(TEXT_FIELD, true, 1),
|
|
233
|
+
url: LINK,
|
|
234
|
+
url_label: string(false),
|
|
235
|
+
}), true, 1),
|
|
236
|
+
}),
|
|
237
|
+
more: optional({
|
|
238
|
+
summary: string(),
|
|
239
|
+
items: array(string(), true, 1),
|
|
240
|
+
rest: string(false),
|
|
241
|
+
}),
|
|
242
|
+
table: optional({
|
|
243
|
+
summary: string(),
|
|
244
|
+
note: string(false),
|
|
245
|
+
rows: array(obj({
|
|
246
|
+
label: string(),
|
|
247
|
+
value: string(),
|
|
248
|
+
plain: string(false),
|
|
249
|
+
mark: optional({
|
|
250
|
+
tone: string(true, "yes|no", "yes or no"),
|
|
251
|
+
text: string(),
|
|
252
|
+
}),
|
|
253
|
+
}), true, 1),
|
|
254
|
+
}),
|
|
255
|
+
repeat: optional({ group: string(), index: integer(), of: integer() }),
|
|
183
256
|
};
|
|
184
257
|
const KINDS = {
|
|
185
258
|
// keep or drop a drawn requirement: the line it came from, marked, and the rule read for it
|
|
@@ -212,6 +285,7 @@ const itemSchema = (kind) => obj({
|
|
|
212
285
|
string(f === "group" || f === "body"),
|
|
213
286
|
])),
|
|
214
287
|
...cardFields(kind),
|
|
288
|
+
[ANSWERERS_FIELD]: array(string(true, NAME_PATTERN), false, 1),
|
|
215
289
|
}, false);
|
|
216
290
|
// `icon` (an emoji) tells the buttons apart at a glance. No coloured side stripe on a button: it
|
|
217
291
|
// reads as decoration, not meaning (the build-label-page look test refuses one).
|
|
@@ -247,6 +321,10 @@ const configSchema = (kind) => obj({
|
|
|
247
321
|
seed: integer(),
|
|
248
322
|
groups: mapping(string(), NAME_PATTERN, 1), // ids become store document ids
|
|
249
323
|
labeller_knows: array(string(true, NAME_PATTERN), false),
|
|
324
|
+
// Two or more people answer the same page, each under their own name (doc ids `<name>~<id>`).
|
|
325
|
+
answerers: array(string(true, NAME_PATTERN), false, 2),
|
|
326
|
+
// An item's answer saves only once the item has been on screen this many seconds.
|
|
327
|
+
min_seconds: integer(false),
|
|
250
328
|
// true: a "how well do you know {group}" question per group, for a study that filters on it
|
|
251
329
|
// (rule 5). Left out, none is asked: an approval page's author has never seen the repositories.
|
|
252
330
|
familiarity: boolean(false),
|
|
@@ -254,6 +332,15 @@ const configSchema = (kind) => obj({
|
|
|
254
332
|
questions: array(QUESTION_SCHEMA, true, 1),
|
|
255
333
|
practice: array(practiceSchema(kind), true, MIN_PRACTICE),
|
|
256
334
|
practice_round: integer(false),
|
|
335
|
+
// Page-level words the blocks use (references/closed-choice-generator.md §«Content blocks»).
|
|
336
|
+
labels: optional({
|
|
337
|
+
place: string(false),
|
|
338
|
+
body: string(false),
|
|
339
|
+
line: string(false),
|
|
340
|
+
line_part: string(false),
|
|
341
|
+
repeat: string(false),
|
|
342
|
+
practice_example: string(false),
|
|
343
|
+
}),
|
|
257
344
|
items: array(itemSchema(kind), true, 1),
|
|
258
345
|
});
|
|
259
346
|
/** The declared schema (`schema` prints it): every field, those a kind requires left optional. */
|
|
@@ -310,6 +397,14 @@ function checkBoolean(value, where) {
|
|
|
310
397
|
if (typeof value !== "boolean")
|
|
311
398
|
fail(`${where}: must be true or false, got ${repr(value)}`);
|
|
312
399
|
}
|
|
400
|
+
/** A string that may be empty (a blank line of code), or an integer. */
|
|
401
|
+
function checkScalar(value, kind, where) {
|
|
402
|
+
if (kind === "text" && typeof value !== "string")
|
|
403
|
+
fail(`${where}: must be a string, got ${repr(value)}`);
|
|
404
|
+
if (kind === "integer" &&
|
|
405
|
+
(typeof value !== "number" || !Number.isInteger(value)))
|
|
406
|
+
fail(`${where}: must be an integer, got ${repr(value)}`);
|
|
407
|
+
}
|
|
313
408
|
/** Refuse the first place `value` departs from `node`; the message starts with the field's path. */
|
|
314
409
|
function checkValue(value, node, path) {
|
|
315
410
|
const where = path || "items.json";
|
|
@@ -317,10 +412,9 @@ function checkValue(value, node, path) {
|
|
|
317
412
|
case "string":
|
|
318
413
|
checkString(value, node, where);
|
|
319
414
|
break;
|
|
415
|
+
case "text":
|
|
320
416
|
case "integer":
|
|
321
|
-
|
|
322
|
-
fail(`${where}: must be an integer, got ${repr(value)}`);
|
|
323
|
-
}
|
|
417
|
+
checkScalar(value, node.kind, where);
|
|
324
418
|
break;
|
|
325
419
|
case "boolean":
|
|
326
420
|
checkBoolean(value, where);
|
|
@@ -416,8 +510,35 @@ function checkPractice(cfg) {
|
|
|
416
510
|
fail(`${where}.${missing}: every question needs its expected answer`);
|
|
417
511
|
});
|
|
418
512
|
}
|
|
513
|
+
/** The first entry of `names` that repeats an earlier one, refused by its path. */
|
|
514
|
+
function checkDistinct(names, where) {
|
|
515
|
+
const at = names.findIndex((x, i) => names.indexOf(x) !== i);
|
|
516
|
+
if (at !== -1)
|
|
517
|
+
fail(`${where}[${String(at)}]: ${repr(names[at])} is repeated`);
|
|
518
|
+
}
|
|
519
|
+
/** Each answerer once; an item's own list a subset of the page's; a dwell of a second or more. */
|
|
520
|
+
function checkAnswerers(cfg) {
|
|
521
|
+
if ((cfg.min_seconds ?? 1) < 1)
|
|
522
|
+
fail(`min_seconds: ${repr(cfg.min_seconds)} — must be 1 or more`);
|
|
523
|
+
const all = cfg.answerers;
|
|
524
|
+
if (all !== undefined)
|
|
525
|
+
checkDistinct(all, "answerers");
|
|
526
|
+
cfg.items.forEach((item, n) => {
|
|
527
|
+
const where = `items[${String(n)}].answerers`;
|
|
528
|
+
const own = item.answerers;
|
|
529
|
+
if (own === undefined)
|
|
530
|
+
return;
|
|
531
|
+
if (all === undefined)
|
|
532
|
+
return fail(`${where}: the page has no \`answerers\` — add the page's list, or take this out`);
|
|
533
|
+
const k = own.findIndex((who) => !R.isIncludedIn(who, all));
|
|
534
|
+
if (k !== -1)
|
|
535
|
+
fail(`${where}[${String(k)}]: ${repr(own[k])} is not in \`answerers\` (${all.join(", ")})`);
|
|
536
|
+
checkDistinct(own, where);
|
|
537
|
+
});
|
|
538
|
+
}
|
|
419
539
|
function checkConfig(cfg) {
|
|
420
540
|
checkGroups(cfg);
|
|
541
|
+
checkAnswerers(cfg);
|
|
421
542
|
if (!asksFamiliarity(cfg) && cfg.familiarity_question !== undefined) {
|
|
422
543
|
fail(`familiarity_question: ${repr(cfg.familiarity_question)} is never asked, because \`familiarity\` is not true — remove it, or set "familiarity": true`);
|
|
423
544
|
}
|
|
@@ -469,6 +590,33 @@ function checkUnits(cfg) {
|
|
|
469
590
|
});
|
|
470
591
|
}
|
|
471
592
|
}
|
|
593
|
+
/**
|
|
594
|
+
* A situation's sentence or question may name the excerpt's marked line as `{line}`: the page writes
|
|
595
|
+
* «line N: » and the line's code there, so a question sits next to the code it is about.
|
|
596
|
+
*/
|
|
597
|
+
const LINE_TOKEN = "{line}";
|
|
598
|
+
const SITUATION_TEXTS = ["summary", "question"];
|
|
599
|
+
/** The line `{line}` stands for: the first marked line, its number and its code without the indent. */
|
|
600
|
+
const lineOf = (card) => card.excerpt?.lines
|
|
601
|
+
.filter((l) => l.mark)
|
|
602
|
+
.map((l) => ({ n: l.n, text: l.text.trim() }))[0];
|
|
603
|
+
const usesLine = (card) => SITUATION_TEXTS.some((f) => card.situation?.[f].includes(LINE_TOKEN));
|
|
604
|
+
/**
|
|
605
|
+
* What the block schema cannot say: an excerpt marks a line, `{line}` has a marked line to stand for,
|
|
606
|
+
* and a repeat sits inside its count.
|
|
607
|
+
*/
|
|
608
|
+
function checkBlocks(cfg) {
|
|
609
|
+
cardsOf(cfg).forEach(([at, card]) => {
|
|
610
|
+
if (card.excerpt !== undefined && !card.excerpt.lines.some((l) => l.mark))
|
|
611
|
+
fail(`${at}.excerpt.lines: no line is marked — mark the line the card is about (\`mark: true\`)`);
|
|
612
|
+
const token = SITUATION_TEXTS.find((f) => card.situation?.[f].includes(LINE_TOKEN));
|
|
613
|
+
if (token !== undefined && card.excerpt === undefined)
|
|
614
|
+
fail(`${at}.situation.${token}: \`${LINE_TOKEN}\` names the excerpt's marked line, and the card has no \`excerpt\` — add it, or write the ${token} without \`${LINE_TOKEN}\``);
|
|
615
|
+
const r = card.repeat;
|
|
616
|
+
if (r !== undefined && (r.index < 1 || r.index > r.of))
|
|
617
|
+
fail(`${at}.repeat.index: ${String(r.index)} is not between 1 and ${String(r.of)}`);
|
|
618
|
+
});
|
|
619
|
+
}
|
|
472
620
|
/** Every card of the config, items then practice, by path. */
|
|
473
621
|
const cardsOf = (cfg) => [
|
|
474
622
|
...cfg.items.map((i, n) => [`items[${String(n)}]`, i]),
|
|
@@ -490,12 +638,22 @@ function checkItemFields(cfg) {
|
|
|
490
638
|
fail(`items[${String(n)}].group: ${repr(item.group)} is not in \`groups\``);
|
|
491
639
|
});
|
|
492
640
|
}
|
|
493
|
-
/** Every
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
641
|
+
/** Every string inside a value: each is tested on its own, as the page renders it, never a serialisation. */
|
|
642
|
+
const stringsIn = (v) => typeof v === "string"
|
|
643
|
+
? [v]
|
|
644
|
+
: Array.isArray(v)
|
|
645
|
+
? v.flatMap(stringsIn)
|
|
646
|
+
: typeof v === "object" && v !== null
|
|
647
|
+
? Object.values(v).flatMap(stringsIn)
|
|
648
|
+
: [];
|
|
649
|
+
/**
|
|
650
|
+
* Every text of an item the labeller sees, by field: each string the page shows (the fields as
|
|
651
|
+
* `shownFields` passes them, so a repeat without its group), and every rule it names.
|
|
652
|
+
*/
|
|
653
|
+
const visibleTexts = (item) => Object.entries({
|
|
654
|
+
...shownFields(item),
|
|
655
|
+
[RULES_FIELD]: item.rules ?? [],
|
|
656
|
+
}).flatMap(([f, v]) => stringsIn(v).map((t) => [f, t]));
|
|
499
657
|
const escapeRegExp = (s) => s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
|
|
500
658
|
/**
|
|
501
659
|
* A source id typed into a visible field gives the source away as well as a field would. Short ids
|
|
@@ -549,6 +707,17 @@ function assignIds(items, seed, keyPath) {
|
|
|
549
707
|
]);
|
|
550
708
|
return { ids, entries };
|
|
551
709
|
}
|
|
710
|
+
// ── a build, short of the page ──────────────────────────────────────────────────────────────────
|
|
711
|
+
/** A card's situation as the page shows it, with the line `{line}` stands for. */
|
|
712
|
+
const situationShown = (item) => item.situation === undefined
|
|
713
|
+
? {}
|
|
714
|
+
: {
|
|
715
|
+
situation: {
|
|
716
|
+
summary: item.situation.summary,
|
|
717
|
+
question: item.situation.question,
|
|
718
|
+
...(usesLine(item) ? { line: lineOf(item) } : {}),
|
|
719
|
+
},
|
|
720
|
+
};
|
|
552
721
|
const shownFields = (item) => ({
|
|
553
722
|
...Object.fromEntries(PAGE_FIELDS.flatMap((f) => {
|
|
554
723
|
const v = item[f];
|
|
@@ -565,15 +734,20 @@ const shownFields = (item) => ({
|
|
|
565
734
|
},
|
|
566
735
|
}),
|
|
567
736
|
...(item.readings === undefined ? {} : { [READINGS_FIELD]: item.readings }),
|
|
737
|
+
...Object.fromEntries(BLOCK_FIELDS.filter((f) => f !== "repeat" && f !== "situation").flatMap((f) => (item[f] === undefined ? [] : [[f, item[f]]]))),
|
|
738
|
+
...situationShown(item),
|
|
739
|
+
...(item.repeat === undefined
|
|
740
|
+
? {}
|
|
741
|
+
: { repeat: { index: item.repeat.index, of: item.repeat.of } }),
|
|
568
742
|
});
|
|
569
743
|
const ruleKey = (r) => `${r.linter} ${r.id}`;
|
|
570
744
|
/** Ask the linter for every rule named; refuse, by path, a rule it cannot describe. */
|
|
571
745
|
function describeRules(cfg, fromDir) {
|
|
572
|
-
return new Map(cardsOf(cfg).flatMap(([at, card]) => (card.rules ?? []).
|
|
746
|
+
return new Map(cardsOf(cfg).flatMap(([at, card]) => (card.rules ?? []).flatMap((r, k) => {
|
|
573
747
|
const text = describeRule(r, fromDir);
|
|
574
748
|
return text === undefined
|
|
575
749
|
? fail(`${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`)
|
|
576
|
-
: [ruleKey(r), text];
|
|
750
|
+
: [[ruleKey(r), text]];
|
|
577
751
|
})));
|
|
578
752
|
}
|
|
579
753
|
const rulesShown = (card, described) => card.rules === undefined
|
|
@@ -606,30 +780,45 @@ const practiceShown = (cfg, described) => practiceOf(cfg).map((p) => ({
|
|
|
606
780
|
...shownFields(p),
|
|
607
781
|
...rulesShown(p, described),
|
|
608
782
|
}));
|
|
783
|
+
/** The page's own words where `labels` leaves one out (references/closed-choice-generator.md). */
|
|
784
|
+
const DEFAULT_LABELS = {
|
|
785
|
+
place: "{id} · {n} of {total}",
|
|
786
|
+
line: "Line",
|
|
787
|
+
line_part: "{line} · part {part} of {of}",
|
|
788
|
+
repeat: "Card {index} of {of}",
|
|
789
|
+
practice_example: "",
|
|
790
|
+
};
|
|
791
|
+
/** The answerers and the dwell, only when items.json sets them: a page without them is as it was. */
|
|
792
|
+
const twoKeys = (cfg) => R.pickBy({ answerers: cfg.answerers, min_seconds: cfg.min_seconds }, R.isDefined);
|
|
609
793
|
/** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
|
|
610
794
|
function payloadOf(cfg, page, described) {
|
|
611
795
|
const used = Object.keys(cfg.groups).filter((g) => page.items.some((i) => i.group === g));
|
|
612
796
|
return {
|
|
613
797
|
title: cfg.title,
|
|
614
798
|
intro: cfg.intro ?? "",
|
|
799
|
+
labels: { ...DEFAULT_LABELS, ...cfg.labels },
|
|
615
800
|
groups: used.map((g) => ({ id: g, label: String(cfg.groups[g]) })),
|
|
616
801
|
familiarity_question: asksFamiliarity(cfg)
|
|
617
802
|
? (cfg.familiarity_question ?? "How well do you know {group}?")
|
|
618
803
|
: "",
|
|
619
804
|
familiarity: asksFamiliarity(cfg) ? FAMILIARITY : [],
|
|
620
805
|
allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
|
|
806
|
+
...twoKeys(cfg),
|
|
621
807
|
questions: cfg.questions,
|
|
622
808
|
practice: {
|
|
623
809
|
round: practiceRound(cfg),
|
|
624
810
|
items: practiceShown(cfg, described),
|
|
625
811
|
},
|
|
626
|
-
items: page.items.map((i) => (
|
|
627
|
-
id: page.idOf(i),
|
|
628
|
-
...shownFields(i),
|
|
629
|
-
...rulesShown(i, described),
|
|
630
|
-
})),
|
|
812
|
+
items: page.items.map((i) => itemShown(i, page.idOf(i), described)),
|
|
631
813
|
};
|
|
632
814
|
}
|
|
815
|
+
/** One item as its card shows it, and whom it is shown to when not every answerer. */
|
|
816
|
+
const itemShown = (i, id, described) => ({
|
|
817
|
+
id,
|
|
818
|
+
...shownFields(i),
|
|
819
|
+
...rulesShown(i, described),
|
|
820
|
+
...R.pickBy({ answerers: i.answerers }, R.isDefined),
|
|
821
|
+
});
|
|
633
822
|
/** Every id the key keeps (on the page now, or kept from an earlier build), with its source and group. */
|
|
634
823
|
function idsOf(cfg, page) {
|
|
635
824
|
const shown = new Set(page.items.map(page.idOf));
|
|
@@ -640,15 +829,14 @@ function idsOf(cfg, page) {
|
|
|
640
829
|
]),
|
|
641
830
|
...cfg.items.map((i) => [i.source_id, i.group]),
|
|
642
831
|
]);
|
|
832
|
+
const bySource = R.indexBy(cfg.items, (i) => i.source_id);
|
|
643
833
|
const byId = [...page.ids].sort(([, a], [, b]) => byText(a, b));
|
|
644
|
-
return
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
},
|
|
651
|
-
]));
|
|
834
|
+
return R.pullObject(byId, ([, nid]) => nid, ([sid, nid]) => ({
|
|
835
|
+
source_id: sid,
|
|
836
|
+
group: String(groupOf.get(sid)),
|
|
837
|
+
on_page: shown.has(nid),
|
|
838
|
+
...R.pickBy({ answerers: bySource[sid]?.answerers }, R.isDefined),
|
|
839
|
+
}));
|
|
652
840
|
}
|
|
653
841
|
/** Every round of a key's practice; a key from before `rounds` has only its own round. */
|
|
654
842
|
const roundsIn = (p) => p.rounds ?? { [String(p.round)]: p.expected };
|
|
@@ -665,6 +853,7 @@ function keyOf(cfg, page, earlier) {
|
|
|
665
853
|
return {
|
|
666
854
|
study: cfg.study,
|
|
667
855
|
seed: cfg.seed,
|
|
856
|
+
...twoKeys(cfg),
|
|
668
857
|
questions: cfg.questions.map((q) => q.id),
|
|
669
858
|
// What counts as an answer: the same lists the page was given (`allowedValues`). `export`
|
|
670
859
|
// checks every stored answer and familiarity against them, so the build's options decide.
|
|
@@ -689,13 +878,14 @@ function keyOf(cfg, page, earlier) {
|
|
|
689
878
|
* returns what the page shows. A page written by hand can take its blindness and its key from here.
|
|
690
879
|
*/
|
|
691
880
|
export function prepareBuild(configPath, outDir) {
|
|
692
|
-
const cfg = readJson(configPath);
|
|
881
|
+
const cfg = withPreset(readJson(configPath));
|
|
693
882
|
assertConfig(cfg);
|
|
694
883
|
// Once its `kind` is known to be one, the fields that kind needs on every card.
|
|
695
884
|
checkValue(cfg, configSchema(cfg.kind), "");
|
|
696
885
|
checkConfig(cfg);
|
|
697
886
|
checkItemFields(cfg);
|
|
698
887
|
checkUnits(cfg);
|
|
888
|
+
checkBlocks(cfg);
|
|
699
889
|
checkNoSourceShown(cfg);
|
|
700
890
|
const described = describeRules(cfg, dirname(configPath));
|
|
701
891
|
mkdirSync(outDir, { recursive: true });
|
|
@@ -713,6 +903,10 @@ function assertKey(_value) {
|
|
|
713
903
|
// key.json is this script's own output; what an older build left out is checked where it is read.
|
|
714
904
|
}
|
|
715
905
|
const ANSWER_DOC_FIELDS = ["answers", "reasons", "savedAt", "source"];
|
|
906
|
+
/** A page built with `min_seconds` also stores how long the item was on screen before its first save. */
|
|
907
|
+
const answerDocFields = (key) => key.min_seconds === undefined
|
|
908
|
+
? ANSWER_DOC_FIELDS
|
|
909
|
+
: ["answers", "reasons", "savedAt", "seconds", "source"];
|
|
716
910
|
const GROUP_DOC_FIELDS = ["familiarity", "savedAt", "source"];
|
|
717
911
|
const DOC_FIELD_TYPES = [
|
|
718
912
|
["answers", isObject, "an object {question: value}"],
|
|
@@ -727,6 +921,11 @@ const DOC_FIELD_TYPES = [
|
|
|
727
921
|
(v) => v === null || typeof v === "string",
|
|
728
922
|
"a timestamp string or null",
|
|
729
923
|
],
|
|
924
|
+
[
|
|
925
|
+
"seconds",
|
|
926
|
+
(v) => v === null || (typeof v === "number" && Number.isInteger(v) && v >= 0),
|
|
927
|
+
"a whole number of seconds or null",
|
|
928
|
+
],
|
|
730
929
|
];
|
|
731
930
|
const COLLECTIONS = ["answers", "groups", "practice"];
|
|
732
931
|
const COLLECTION_NAMES = COLLECTIONS;
|
|
@@ -746,15 +945,18 @@ const practiceDocs = (key) => {
|
|
|
746
945
|
// Each round's own questions: a later round may have dropped or renamed one.
|
|
747
946
|
return Object.entries(roundsIn(p)).flatMap(([r, expected]) => Object.entries(expected).map(([pid, answers]) => [`r${r}-${pid}`, Object.keys(answers)]));
|
|
748
947
|
};
|
|
749
|
-
|
|
750
|
-
|
|
948
|
+
/** Who an item is shown to: its own list, else every answerer of the page. */
|
|
949
|
+
const shownTo = (all, meta) => meta.answerers ?? all;
|
|
950
|
+
/** The names of a page without answerers, or those of one answerer's documents. */
|
|
951
|
+
function knownNames(key, whose) {
|
|
952
|
+
const entries = Object.entries(key.ids).filter(([, m]) => whose.who === undefined || shownTo(whose.all, m).includes(whose.who));
|
|
751
953
|
const shown = entries.filter(([, m]) => m.on_page);
|
|
752
954
|
const practice = practiceDocs(key);
|
|
753
955
|
return {
|
|
754
956
|
answers: {
|
|
755
957
|
known: entries.map(([nid]) => [nid, key.questions]),
|
|
756
958
|
onPage: shown.map(([nid]) => nid),
|
|
757
|
-
fields:
|
|
959
|
+
fields: answerDocFields(key),
|
|
758
960
|
},
|
|
759
961
|
groups: {
|
|
760
962
|
known: entries.map(([, m]) => [m.group, key.questions]),
|
|
@@ -764,7 +966,7 @@ function knownNames(key) {
|
|
|
764
966
|
practice: {
|
|
765
967
|
known: practice,
|
|
766
968
|
onPage: practice.map(([n]) => n),
|
|
767
|
-
fields:
|
|
969
|
+
fields: answerDocFields(key),
|
|
768
970
|
},
|
|
769
971
|
};
|
|
770
972
|
}
|
|
@@ -783,10 +985,28 @@ function checkDumpShape(dump) {
|
|
|
783
985
|
if (stray !== undefined)
|
|
784
986
|
fail(`${stray}: the store has no such collection (expected ${COLLECTIONS.join(", ")})`);
|
|
785
987
|
}
|
|
786
|
-
|
|
988
|
+
/**
|
|
989
|
+
* A document's answerer and its name without it. A page without answerers has none; with them, an
|
|
990
|
+
* id without `~`, or before it a name the page does not have, is refused.
|
|
991
|
+
*/
|
|
992
|
+
function answererOf(where, docId, key) {
|
|
993
|
+
const all = key.answerers;
|
|
994
|
+
if (all === undefined)
|
|
995
|
+
return { name: docId };
|
|
996
|
+
const at = docId.indexOf(SEP);
|
|
997
|
+
if (at === -1)
|
|
998
|
+
return fail(`${where}: this page has answerers (${all.join(", ")}), so a document's id is <answerer>${SEP}<id>`);
|
|
999
|
+
const who = docId.slice(0, at);
|
|
1000
|
+
if (!all.includes(who))
|
|
1001
|
+
fail(`${where}: ${repr(who)} is not an answerer of this page (${all.join(", ")})`);
|
|
1002
|
+
return { who, all, name: docId.slice(at + 1) };
|
|
1003
|
+
}
|
|
1004
|
+
function checkDocName(where, { who, name }, kind, names) {
|
|
787
1005
|
const hit = names.known.find(([n]) => n === name);
|
|
788
1006
|
if (hit === undefined) {
|
|
789
|
-
return fail(`${where}: the key does not know this ${NOUN[kind]}
|
|
1007
|
+
return fail(`${where}: the key does not know this ${NOUN[kind]}${who === undefined ? "" : ` for ${who}`} (${R.unique(names.known.map(([n]) => n))
|
|
1008
|
+
.sort()
|
|
1009
|
+
.join(", ")})`);
|
|
790
1010
|
}
|
|
791
1011
|
if (!names.onPage.includes(name)) {
|
|
792
1012
|
fail(`${where}: stored, but no longer on the page (a rebuild left it out), so the export would drop it; rebuild with it on the page, or take it out of the dump on purpose`);
|
|
@@ -841,13 +1061,16 @@ function checkDocAnswers(where, doc, questions) {
|
|
|
841
1061
|
*/
|
|
842
1062
|
function checkDump(dump, key) {
|
|
843
1063
|
checkDumpShape(dump);
|
|
844
|
-
|
|
1064
|
+
// A page without answerers knows one set of names; each answerer has their own.
|
|
1065
|
+
const plain = knownNames(key, {});
|
|
845
1066
|
for (const kind of COLLECTIONS) {
|
|
846
1067
|
const collection = dump[kind];
|
|
847
|
-
for (const [
|
|
848
|
-
const where = `${kind}/${
|
|
849
|
-
const
|
|
850
|
-
|
|
1068
|
+
for (const [docId, doc] of sortedEntries(isObject(collection) ? collection : {})) {
|
|
1069
|
+
const where = `${kind}/${docId}`;
|
|
1070
|
+
const named = answererOf(where, docId, key);
|
|
1071
|
+
const names = (named.who === undefined ? plain : knownNames(key, named))[kind];
|
|
1072
|
+
const questions = checkDocName(where, named, kind, names);
|
|
1073
|
+
checkDocFields(where, doc, kind, names.fields);
|
|
851
1074
|
checkDocAnswers(where, doc, questions);
|
|
852
1075
|
}
|
|
853
1076
|
}
|
|
@@ -900,8 +1123,8 @@ function answersOf(doc, ctx) {
|
|
|
900
1123
|
const given = Object.fromEntries(raw.map(([q, v]) => [q, has(invalid, q) ? null : v]));
|
|
901
1124
|
return { given, invalid };
|
|
902
1125
|
}
|
|
903
|
-
/** One
|
|
904
|
-
function
|
|
1126
|
+
/** One stored answer: its answers, what is invalid, the reasons, and whether it is done. */
|
|
1127
|
+
function answerOf(doc, ctx) {
|
|
905
1128
|
const { given, invalid } = answersOf(doc, ctx);
|
|
906
1129
|
const reasons = Object.fromEntries(Object.entries(textsOf(doc["reasons"])).filter(([, r]) => r.trim()));
|
|
907
1130
|
const needsWhy = ctx.allowed.needs_why ?? {};
|
|
@@ -909,9 +1132,6 @@ function labelOf(nid, meta, doc, ctx) {
|
|
|
909
1132
|
.filter(([q, v]) => v !== null && listed(needsWhy, q, v) && !has(reasons, q))
|
|
910
1133
|
.map(([q]) => q);
|
|
911
1134
|
return {
|
|
912
|
-
id: nid,
|
|
913
|
-
source_id: meta.source_id,
|
|
914
|
-
group: meta.group,
|
|
915
1135
|
answers: given,
|
|
916
1136
|
invalid_answers: invalid,
|
|
917
1137
|
reasons,
|
|
@@ -919,12 +1139,23 @@ function labelOf(nid, meta, doc, ctx) {
|
|
|
919
1139
|
complete: Object.values(given).every((v) => v !== null) && !unexplained.length,
|
|
920
1140
|
source: textOf(doc["source"]),
|
|
921
1141
|
saved_at: doc["savedAt"] ?? null,
|
|
1142
|
+
// Only a page built with `min_seconds` stores how long the item was on screen.
|
|
1143
|
+
...R.pick({ seconds: doc["seconds"] ?? null }, secondsOf(ctx.key)),
|
|
922
1144
|
};
|
|
923
1145
|
}
|
|
1146
|
+
/** The `seconds` field, on a page built with `min_seconds`; none on any other. */
|
|
1147
|
+
const secondsOf = (key) => key.min_seconds === undefined ? [] : ["seconds"];
|
|
1148
|
+
/** One item of the labels file of a page without answerers. */
|
|
1149
|
+
const labelOf = (nid, meta, doc, ctx) => ({
|
|
1150
|
+
id: nid,
|
|
1151
|
+
source_id: meta.source_id,
|
|
1152
|
+
group: meta.group,
|
|
1153
|
+
...answerOf(doc, ctx),
|
|
1154
|
+
});
|
|
924
1155
|
/** Same rule as for item answers: a familiarity outside the options is marked, not counted as rated. */
|
|
925
|
-
function groupsOf(groupIds, groups, allowed) {
|
|
1156
|
+
function groupsOf(groupIds, groups, allowed, prefix = "") {
|
|
926
1157
|
return Object.fromEntries(groupIds.map((g) => {
|
|
927
|
-
const doc = docOf(groups, g);
|
|
1158
|
+
const doc = docOf(groups, `${prefix}${g}`);
|
|
928
1159
|
const fam = textOf(doc["familiarity"]);
|
|
929
1160
|
const bad = fam !== null && !allowed.familiarity.includes(fam);
|
|
930
1161
|
return [
|
|
@@ -962,9 +1193,9 @@ function reportGroups(groups, allowed) {
|
|
|
962
1193
|
}
|
|
963
1194
|
}
|
|
964
1195
|
/** Each practice item of the key's round: what was answered first, what was expected, whether they match. */
|
|
965
|
-
function scorePractice(dump, round, all) {
|
|
1196
|
+
function scorePractice(dump, round, all, prefix) {
|
|
966
1197
|
return Object.entries(all).map(([pid, expected]) => {
|
|
967
|
-
const doc = docOf(dump["practice"],
|
|
1198
|
+
const doc = docOf(dump["practice"], `${prefix}r${round}-${pid}`);
|
|
968
1199
|
const answers = isObject(doc["answers"]) ? doc["answers"] : {};
|
|
969
1200
|
const matched = Object.entries(expected).every(([q, v]) => answers[q] === v);
|
|
970
1201
|
return {
|
|
@@ -976,8 +1207,8 @@ function scorePractice(dump, round, all) {
|
|
|
976
1207
|
};
|
|
977
1208
|
});
|
|
978
1209
|
}
|
|
979
|
-
/** One file per round: a second round exported the same day keeps the first. */
|
|
980
|
-
const practiceFile = (study, round, day) => `${study}-practice-r${String(round)}-${day}.json`;
|
|
1210
|
+
/** One file per round (and answerer): a second round exported the same day keeps the first. */
|
|
1211
|
+
const practiceFile = (study, round, day, who) => `${study}-practice-r${String(round)}${who === undefined ? "" : `-${who}`}-${day}.json`;
|
|
981
1212
|
/**
|
|
982
1213
|
* Every practice round the store holds answers for, each scored against its own expected answers
|
|
983
1214
|
* into its own file; never into the labels. The key's own round is reported even unanswered.
|
|
@@ -986,56 +1217,99 @@ function exportPractice(dump, key, target, day) {
|
|
|
986
1217
|
const p = key.practice;
|
|
987
1218
|
if (p === undefined)
|
|
988
1219
|
return;
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
.forEach(([round, expected]) => {
|
|
992
|
-
|
|
993
|
-
target,
|
|
994
|
-
day,
|
|
995
|
-
current: Number(round) === p.round,
|
|
1220
|
+
const rounds = R.sortBy(R.entries(roundsIn(p)), ([r]) => Number(r));
|
|
1221
|
+
(key.answerers ?? [undefined]).forEach((who) => {
|
|
1222
|
+
rounds.forEach(([round, expected]) => {
|
|
1223
|
+
exportRound(key.study, Number(round), scorePractice(dump, round, expected, who === undefined ? "" : `${who}${SEP}`), { target, day, current: Number(round) === p.round, who });
|
|
996
1224
|
});
|
|
997
1225
|
});
|
|
998
1226
|
}
|
|
999
1227
|
/** One round's scored practice to `<study>-practice-r<round>-<date>.json`, and one line about it. */
|
|
1000
1228
|
function exportRound(study, round, items, at) {
|
|
1229
|
+
const named = `practice round ${String(round)}${at.who === undefined ? "" : ` (${at.who})`}`;
|
|
1001
1230
|
if (!items.some((i) => Object.keys(i.answers).length)) {
|
|
1002
1231
|
if (at.current)
|
|
1003
|
-
say(
|
|
1232
|
+
say(`${named}: not answered`);
|
|
1004
1233
|
return;
|
|
1005
1234
|
}
|
|
1006
1235
|
const missed = items.filter((i) => !i.matched).map((i) => i.id);
|
|
1007
1236
|
const matched = items.length - missed.length;
|
|
1008
|
-
const path = join(at.target, practiceFile(study, round, at.day));
|
|
1237
|
+
const path = join(at.target, practiceFile(study, round, at.day, at.who));
|
|
1009
1238
|
writeJson(path, {
|
|
1010
1239
|
study,
|
|
1011
1240
|
round,
|
|
1241
|
+
...R.pickBy({ answerer: at.who }, R.isDefined),
|
|
1012
1242
|
exported: at.day,
|
|
1013
1243
|
matched,
|
|
1014
1244
|
total: items.length,
|
|
1015
1245
|
items,
|
|
1016
1246
|
});
|
|
1017
|
-
say(
|
|
1247
|
+
say(`${named}: ${String(matched)} of ${String(items.length)} matched${missed.length ? `; missed ${missed.join(", ")}` : ""} -> ${path}`);
|
|
1018
1248
|
}
|
|
1249
|
+
/** The counts of one set of answers and groups. */
|
|
1250
|
+
const tally = (items, groups) => ({
|
|
1251
|
+
groups,
|
|
1252
|
+
complete_items: items.filter((i) => i.complete).length,
|
|
1253
|
+
invalid_items: items.filter((i) => Object.keys(i.invalid_answers).length)
|
|
1254
|
+
.length,
|
|
1255
|
+
invalid_groups: Object.values(groups).filter((g) => g.invalid_familiarity !== null).length,
|
|
1256
|
+
total_items: items.length,
|
|
1257
|
+
});
|
|
1258
|
+
/** The familiarity of the groups of `onPage`, or none on a page that asks none. */
|
|
1259
|
+
const groupsFor = (onPage, dump, ctx, prefix = "") => ctx.allowed.familiarity.length
|
|
1260
|
+
? groupsOf(R.unique(onPage.map(([, m]) => m.group)), dump["groups"], ctx.allowed, prefix)
|
|
1261
|
+
: {};
|
|
1019
1262
|
/** The labels file: every item on the page with its answers, the groups, and the counts. */
|
|
1020
1263
|
function labelsOf(dump, ctx, day) {
|
|
1021
1264
|
const onPage = Object.entries(ctx.key.ids).filter(([, m]) => m.on_page);
|
|
1022
1265
|
const items = onPage.map(([nid, meta]) => labelOf(nid, meta, docOf(dump["answers"], nid), ctx));
|
|
1023
|
-
const groups = ctx.allowed.familiarity.length
|
|
1024
|
-
? groupsOf([...new Set(onPage.map(([, m]) => m.group))], dump["groups"], ctx.allowed)
|
|
1025
|
-
: {};
|
|
1026
1266
|
return {
|
|
1027
1267
|
study: ctx.key.study,
|
|
1028
1268
|
exported: day,
|
|
1029
1269
|
questions: ctx.key.questions,
|
|
1030
1270
|
items,
|
|
1031
|
-
|
|
1032
|
-
complete_items: items.filter((i) => i.complete).length,
|
|
1033
|
-
invalid_items: items.filter((i) => Object.keys(i.invalid_answers).length)
|
|
1034
|
-
.length,
|
|
1035
|
-
invalid_groups: Object.values(groups).filter((g) => g.invalid_familiarity !== null).length,
|
|
1036
|
-
total_items: items.length,
|
|
1271
|
+
...tally(items, groupsFor(onPage, dump, ctx)),
|
|
1037
1272
|
};
|
|
1038
1273
|
}
|
|
1274
|
+
/**
|
|
1275
|
+
* The labels file of a page with answerers: each item with every answerer it is shown to and their
|
|
1276
|
+
* answers apart (`by_answerer`), and each answerer's groups and counts (`by_answerer` at the top).
|
|
1277
|
+
*/
|
|
1278
|
+
function answererLabelsOf(dump, ctx, day, all) {
|
|
1279
|
+
const onPage = Object.entries(ctx.key.ids).filter(([, m]) => m.on_page);
|
|
1280
|
+
const docFor = (who, nid) => answerOf(docOf(dump["answers"], `${who}${SEP}${nid}`), ctx);
|
|
1281
|
+
const items = onPage.map(([nid, meta]) => ({
|
|
1282
|
+
id: nid,
|
|
1283
|
+
source_id: meta.source_id,
|
|
1284
|
+
group: meta.group,
|
|
1285
|
+
answerers: shownTo(all, meta),
|
|
1286
|
+
by_answerer: R.fromKeys(shownTo(all, meta), (who) => docFor(who, nid)),
|
|
1287
|
+
}));
|
|
1288
|
+
return {
|
|
1289
|
+
study: ctx.key.study,
|
|
1290
|
+
exported: day,
|
|
1291
|
+
questions: ctx.key.questions,
|
|
1292
|
+
answerers: all,
|
|
1293
|
+
items,
|
|
1294
|
+
by_answerer: R.fromKeys(all, (who) => tallyOf({ who, all }, onPage, dump, ctx)),
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
/** One answerer's groups and counts, over the items shown to them. */
|
|
1298
|
+
function tallyOf({ who, all }, onPage, dump, ctx) {
|
|
1299
|
+
const mine = onPage.filter(([, m]) => shownTo(all, m).includes(who));
|
|
1300
|
+
return tally(mine.map(([nid]) => answerOf(docOf(dump["answers"], `${who}${SEP}${nid}`), ctx)), groupsFor(mine, dump, ctx, `${who}${SEP}`));
|
|
1301
|
+
}
|
|
1302
|
+
/** Each answerer's items and groups, named `<answerer>~<id>` in the report, as in the store. */
|
|
1303
|
+
function reportAnswerers(out, allowed) {
|
|
1304
|
+
R.forEachObj(out.by_answerer, (t, who) => {
|
|
1305
|
+
reportLabels(out.items.flatMap((i) => {
|
|
1306
|
+
const a = i.by_answerer[who];
|
|
1307
|
+
return a === undefined ? [] : [{ id: `${who}${SEP}${i.id}`, ...a }];
|
|
1308
|
+
}), allowed);
|
|
1309
|
+
reportGroups(R.mapKeys(t.groups, (g) => `${who}${SEP}${g}`), allowed);
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
const completeLine = (t) => `${String(t.complete_items)} of ${String(t.total_items)} items complete`;
|
|
1039
1313
|
export function exportLabels(dumpPath, keyPath, outDir, day) {
|
|
1040
1314
|
const dump = readJson(dumpPath);
|
|
1041
1315
|
const key = readJson(keyPath);
|
|
@@ -1044,12 +1318,28 @@ export function exportLabels(dumpPath, keyPath, outDir, day) {
|
|
|
1044
1318
|
assertAllowed(allowed, key, keyPath);
|
|
1045
1319
|
checkDump(dump, key);
|
|
1046
1320
|
checkNoFamiliarity(dump, allowed);
|
|
1047
|
-
const
|
|
1321
|
+
const ctx = { key, allowed };
|
|
1322
|
+
const all = key.answerers;
|
|
1323
|
+
const out = all === undefined
|
|
1324
|
+
? labelsOf(dump, ctx, day)
|
|
1325
|
+
: answererLabelsOf(dump, ctx, day, all);
|
|
1048
1326
|
mkdirSync(outDir, { recursive: true });
|
|
1049
1327
|
const path = join(outDir, `${key.study}-labels-${day}.json`);
|
|
1050
1328
|
writeJson(path, out);
|
|
1051
1329
|
exportPractice(dump, key, outDir, day);
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1330
|
+
reportExport(path, out, allowed);
|
|
1331
|
+
}
|
|
1332
|
+
/** What the export wrote, and every item and group it did not count. */
|
|
1333
|
+
function reportExport(path, out, allowed) {
|
|
1334
|
+
if ("by_answerer" in out) {
|
|
1335
|
+
say(`wrote ${path}: ${R.entries(out.by_answerer)
|
|
1336
|
+
.map(([who, t]) => `${who}: ${completeLine(t)}`)
|
|
1337
|
+
.join("; ")}`);
|
|
1338
|
+
reportAnswerers(out, allowed);
|
|
1339
|
+
}
|
|
1340
|
+
else {
|
|
1341
|
+
say(`wrote ${path}: ${completeLine(out)}`);
|
|
1342
|
+
reportLabels(out.items, allowed);
|
|
1343
|
+
reportGroups(out.groups, allowed);
|
|
1344
|
+
}
|
|
1055
1345
|
}
|