paperlint 14.1.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 +43 -30
- package/package.json +1 -1
- package/skills/build-label-page/SKILL.md +15 -1
- package/skills/build-label-page/SKILL.md.spec.ts +14 -0
- package/skills/build-label-page/references/closed-choice-generator.md +42 -0
- package/skills/build-label-page/scripts/label-page-answerers.test.ts +381 -0
- package/skills/build-label-page/scripts/label-page-browser.test.ts +309 -0
- package/skills/build-label-page/scripts/label-page-core.js +206 -58
- package/skills/build-label-page/scripts/label-page-core.test.ts +24 -9
- package/skills/build-label-page/scripts/label-page-core.ts +333 -77
- package/skills/build-label-page/scripts/label-page.js +5 -0
- package/skills/build-label-page/scripts/label-page.ts +5 -0
- package/skills/build-label-page/scripts/page-template.html +205 -18
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
|
+
import * as R from "remeda";
|
|
10
11
|
import { z } from "zod";
|
|
11
12
|
import {
|
|
12
13
|
describeRule,
|
|
@@ -106,6 +107,9 @@ const readJson = (path: string): Json => {
|
|
|
106
107
|
// prettier-ignore
|
|
107
108
|
const PAGE_FIELDS = ["group", "title", "body", "code", "context", "file"] as const;
|
|
108
109
|
const PRIVATE_FIELDS = ["source_id"] as const;
|
|
110
|
+
// Who of the page's `answerers` an item is shown to: tells the page whom to show it, nothing about
|
|
111
|
+
// the answer or the source.
|
|
112
|
+
const ANSWERERS_FIELD = "answerers";
|
|
109
113
|
// The linter rules an item names ("read as ruff `TID253`"): the page shows each with the linter's
|
|
110
114
|
// own description, looked up at build time (rule-descriptions.ts), never typed into items.json.
|
|
111
115
|
const RULES_FIELD = "rules";
|
|
@@ -130,6 +134,7 @@ const ALLOWED_FIELDS: readonly string[] = [
|
|
|
130
134
|
UNIT_FIELD,
|
|
131
135
|
READINGS_FIELD,
|
|
132
136
|
...BLOCK_FIELDS,
|
|
137
|
+
ANSWERERS_FIELD,
|
|
133
138
|
...PRIVATE_FIELDS,
|
|
134
139
|
];
|
|
135
140
|
|
|
@@ -157,6 +162,11 @@ const RESERVED_NAMES = [
|
|
|
157
162
|
"__lookupSetter__",
|
|
158
163
|
];
|
|
159
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 = "~";
|
|
160
170
|
const SOURCES = ["page", "chat"];
|
|
161
171
|
const SOURCES_SHOWN = `(${SOURCES.map(quoted).join(", ")})`;
|
|
162
172
|
|
|
@@ -356,6 +366,7 @@ const itemSchema = (kind: Kind): SchemaNode =>
|
|
|
356
366
|
]),
|
|
357
367
|
),
|
|
358
368
|
...cardFields(kind),
|
|
369
|
+
[ANSWERERS_FIELD]: array(string(true, NAME_PATTERN), false, 1),
|
|
359
370
|
},
|
|
360
371
|
false, // an unlisted item field is the leak guard's refusal, with its own message
|
|
361
372
|
);
|
|
@@ -397,6 +408,10 @@ const configSchema = (kind: Kind): SchemaNode =>
|
|
|
397
408
|
seed: integer(),
|
|
398
409
|
groups: mapping(string(), NAME_PATTERN, 1), // ids become store document ids
|
|
399
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),
|
|
400
415
|
// true: a "how well do you know {group}" question per group, for a study that filters on it
|
|
401
416
|
// (rule 5). Left out, none is asked: an approval page's author has never seen the repositories.
|
|
402
417
|
familiarity: boolean(false),
|
|
@@ -614,7 +629,11 @@ type Card = {
|
|
|
614
629
|
readonly unit?: Unit;
|
|
615
630
|
readonly readings?: { readonly a: string; readonly b: string };
|
|
616
631
|
};
|
|
617
|
-
type Item = Card & {
|
|
632
|
+
type Item = Card & {
|
|
633
|
+
readonly source_id: string;
|
|
634
|
+
readonly group: string;
|
|
635
|
+
readonly answerers?: readonly string[];
|
|
636
|
+
};
|
|
618
637
|
type Practice = Card & {
|
|
619
638
|
readonly expected: Readonly<Record<string, string>>;
|
|
620
639
|
readonly why: string;
|
|
@@ -627,6 +646,8 @@ interface Config {
|
|
|
627
646
|
readonly seed: number;
|
|
628
647
|
readonly groups: Readonly<Record<string, string>>;
|
|
629
648
|
readonly labeller_knows?: readonly string[];
|
|
649
|
+
readonly answerers?: readonly string[];
|
|
650
|
+
readonly min_seconds?: number;
|
|
630
651
|
readonly familiarity?: boolean;
|
|
631
652
|
readonly familiarity_question?: string;
|
|
632
653
|
readonly questions: readonly Question[];
|
|
@@ -744,8 +765,37 @@ function checkPractice(cfg: Config): void {
|
|
|
744
765
|
fail(`${where}.${missing}: every question needs its expected answer`);
|
|
745
766
|
});
|
|
746
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
|
+
}
|
|
747
796
|
function checkConfig(cfg: Config): void {
|
|
748
797
|
checkGroups(cfg);
|
|
798
|
+
checkAnswerers(cfg);
|
|
749
799
|
if (!asksFamiliarity(cfg) && cfg.familiarity_question !== undefined) {
|
|
750
800
|
fail(
|
|
751
801
|
`familiarity_question: ${repr(cfg.familiarity_question)} is never asked, because \`familiarity\` is not true — remove it, or set "familiarity": true`,
|
|
@@ -948,6 +998,8 @@ interface KeyEntry {
|
|
|
948
998
|
readonly source_id: string;
|
|
949
999
|
readonly group: string;
|
|
950
1000
|
readonly on_page: boolean;
|
|
1001
|
+
/** Who the item is shown to, when not every answerer of the page. */
|
|
1002
|
+
readonly answerers?: readonly string[];
|
|
951
1003
|
}
|
|
952
1004
|
interface OldKey {
|
|
953
1005
|
readonly seed: number;
|
|
@@ -1102,6 +1154,12 @@ const DEFAULT_LABELS: Readonly<Record<string, string>> = {
|
|
|
1102
1154
|
repeat: "Card {index} of {of}",
|
|
1103
1155
|
practice_example: "",
|
|
1104
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
|
+
);
|
|
1105
1163
|
/** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
|
|
1106
1164
|
function payloadOf(
|
|
1107
1165
|
cfg: Config,
|
|
@@ -1121,18 +1179,22 @@ function payloadOf(
|
|
|
1121
1179
|
: "",
|
|
1122
1180
|
familiarity: asksFamiliarity(cfg) ? FAMILIARITY : [],
|
|
1123
1181
|
allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
|
|
1182
|
+
...twoKeys(cfg),
|
|
1124
1183
|
questions: cfg.questions,
|
|
1125
1184
|
practice: {
|
|
1126
1185
|
round: practiceRound(cfg),
|
|
1127
1186
|
items: practiceShown(cfg, described),
|
|
1128
1187
|
},
|
|
1129
|
-
items: page.items.map((i) => (
|
|
1130
|
-
id: page.idOf(i),
|
|
1131
|
-
...shownFields(i),
|
|
1132
|
-
...rulesShown(i, described),
|
|
1133
|
-
})),
|
|
1188
|
+
items: page.items.map((i) => itemShown(i, page.idOf(i), described)),
|
|
1134
1189
|
};
|
|
1135
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
|
+
});
|
|
1136
1198
|
export type Payload = ReturnType<typeof payloadOf>;
|
|
1137
1199
|
/** Every id the key keeps (on the page now, or kept from an earlier build), with its source and group. */
|
|
1138
1200
|
function idsOf(cfg: Config, page: ReturnType<typeof itemsOnPage>): JsonObject {
|
|
@@ -1144,16 +1206,17 @@ function idsOf(cfg: Config, page: ReturnType<typeof itemsOnPage>): JsonObject {
|
|
|
1144
1206
|
]),
|
|
1145
1207
|
...cfg.items.map((i): [string, string] => [i.source_id, i.group]),
|
|
1146
1208
|
]);
|
|
1209
|
+
const bySource = R.indexBy(cfg.items, (i) => i.source_id);
|
|
1147
1210
|
const byId = [...page.ids].sort(([, a], [, b]) => byText(a, b));
|
|
1148
|
-
return
|
|
1149
|
-
byId
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
},
|
|
1156
|
-
|
|
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
|
+
}),
|
|
1157
1220
|
);
|
|
1158
1221
|
}
|
|
1159
1222
|
/** Round → its practice items' expected answers. */
|
|
@@ -1178,6 +1241,7 @@ function keyOf(
|
|
|
1178
1241
|
return {
|
|
1179
1242
|
study: cfg.study,
|
|
1180
1243
|
seed: cfg.seed,
|
|
1244
|
+
...twoKeys(cfg),
|
|
1181
1245
|
questions: cfg.questions.map((q) => q.id),
|
|
1182
1246
|
// What counts as an answer: the same lists the page was given (`allowedValues`). `export`
|
|
1183
1247
|
// checks every stored answer and familiarity against them, so the build's options decide.
|
|
@@ -1229,6 +1293,8 @@ export function prepareBuild(configPath: string, outDir: string) {
|
|
|
1229
1293
|
interface Key {
|
|
1230
1294
|
readonly study: string;
|
|
1231
1295
|
readonly questions: readonly string[];
|
|
1296
|
+
readonly answerers?: readonly string[];
|
|
1297
|
+
readonly min_seconds?: number;
|
|
1232
1298
|
readonly allowed?: Json;
|
|
1233
1299
|
readonly practice?: {
|
|
1234
1300
|
readonly round: number;
|
|
@@ -1246,6 +1312,11 @@ function assertKey(_value: Json): asserts _value is Json & Key {
|
|
|
1246
1312
|
}
|
|
1247
1313
|
|
|
1248
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"];
|
|
1249
1320
|
const GROUP_DOC_FIELDS = ["familiarity", "savedAt", "source"];
|
|
1250
1321
|
const DOC_FIELD_TYPES: readonly (readonly [
|
|
1251
1322
|
string,
|
|
@@ -1264,6 +1335,12 @@ const DOC_FIELD_TYPES: readonly (readonly [
|
|
|
1264
1335
|
(v) => v === null || typeof v === "string",
|
|
1265
1336
|
"a timestamp string or null",
|
|
1266
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
|
+
],
|
|
1267
1344
|
];
|
|
1268
1345
|
const COLLECTIONS = ["answers", "groups", "practice"] as const;
|
|
1269
1346
|
type Collection = (typeof COLLECTIONS)[number];
|
|
@@ -1297,15 +1374,29 @@ interface Names {
|
|
|
1297
1374
|
readonly onPage: readonly string[];
|
|
1298
1375
|
readonly fields: readonly string[];
|
|
1299
1376
|
}
|
|
1300
|
-
|
|
1301
|
-
|
|
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
|
+
);
|
|
1302
1393
|
const shown = entries.filter(([, m]) => m.on_page);
|
|
1303
1394
|
const practice = practiceDocs(key);
|
|
1304
1395
|
return {
|
|
1305
1396
|
answers: {
|
|
1306
1397
|
known: entries.map(([nid]) => [nid, key.questions] as const),
|
|
1307
1398
|
onPage: shown.map(([nid]) => nid),
|
|
1308
|
-
fields:
|
|
1399
|
+
fields: answerDocFields(key),
|
|
1309
1400
|
},
|
|
1310
1401
|
groups: {
|
|
1311
1402
|
known: entries.map(([, m]) => [m.group, key.questions] as const),
|
|
@@ -1315,7 +1406,7 @@ function knownNames(key: Key): Readonly<Record<Collection, Names>> {
|
|
|
1315
1406
|
practice: {
|
|
1316
1407
|
known: practice,
|
|
1317
1408
|
onPage: practice.map(([n]) => n),
|
|
1318
|
-
fields:
|
|
1409
|
+
fields: answerDocFields(key),
|
|
1319
1410
|
},
|
|
1320
1411
|
};
|
|
1321
1412
|
}
|
|
@@ -1340,16 +1431,43 @@ function checkDumpShape(dump: Json): asserts dump is JsonObject {
|
|
|
1340
1431
|
`${stray}: the store has no such collection (expected ${COLLECTIONS.join(", ")})`,
|
|
1341
1432
|
);
|
|
1342
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
|
+
}
|
|
1343
1457
|
function checkDocName(
|
|
1344
1458
|
where: string,
|
|
1345
|
-
name: string,
|
|
1459
|
+
{ who, name }: Whose & { readonly name: string },
|
|
1346
1460
|
kind: Collection,
|
|
1347
1461
|
names: Names,
|
|
1348
1462
|
): readonly string[] {
|
|
1349
1463
|
const hit = names.known.find(([n]) => n === name);
|
|
1350
1464
|
if (hit === undefined) {
|
|
1351
1465
|
return fail(
|
|
1352
|
-
`${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(", ")})`,
|
|
1353
1471
|
);
|
|
1354
1472
|
}
|
|
1355
1473
|
if (!names.onPage.includes(name)) {
|
|
@@ -1428,15 +1546,20 @@ function checkDocAnswers(
|
|
|
1428
1546
|
*/
|
|
1429
1547
|
function checkDump(dump: Json, key: Key): asserts dump is JsonObject {
|
|
1430
1548
|
checkDumpShape(dump);
|
|
1431
|
-
|
|
1549
|
+
// A page without answerers knows one set of names; each answerer has their own.
|
|
1550
|
+
const plain = knownNames(key, {});
|
|
1432
1551
|
for (const kind of COLLECTIONS) {
|
|
1433
1552
|
const collection = dump[kind];
|
|
1434
|
-
for (const [
|
|
1553
|
+
for (const [docId, doc] of sortedEntries(
|
|
1435
1554
|
isObject(collection) ? collection : {},
|
|
1436
1555
|
)) {
|
|
1437
|
-
const where = `${kind}/${
|
|
1438
|
-
const
|
|
1439
|
-
|
|
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);
|
|
1440
1563
|
checkDocAnswers(where, doc, questions);
|
|
1441
1564
|
}
|
|
1442
1565
|
}
|
|
@@ -1524,8 +1647,8 @@ function answersOf(doc: JsonObject, ctx: Ctx) {
|
|
|
1524
1647
|
);
|
|
1525
1648
|
return { given, invalid };
|
|
1526
1649
|
}
|
|
1527
|
-
/** One
|
|
1528
|
-
function
|
|
1650
|
+
/** One stored answer: its answers, what is invalid, the reasons, and whether it is done. */
|
|
1651
|
+
function answerOf(doc: JsonObject, ctx: Ctx) {
|
|
1529
1652
|
const { given, invalid } = answersOf(doc, ctx);
|
|
1530
1653
|
const reasons = Object.fromEntries(
|
|
1531
1654
|
Object.entries(textsOf(doc["reasons"])).filter(([, r]) => r.trim()),
|
|
@@ -1537,9 +1660,6 @@ function labelOf(nid: string, meta: KeyEntry, doc: JsonObject, ctx: Ctx) {
|
|
|
1537
1660
|
)
|
|
1538
1661
|
.map(([q]) => q);
|
|
1539
1662
|
return {
|
|
1540
|
-
id: nid,
|
|
1541
|
-
source_id: meta.source_id,
|
|
1542
|
-
group: meta.group,
|
|
1543
1663
|
answers: given,
|
|
1544
1664
|
invalid_answers: invalid,
|
|
1545
1665
|
reasons,
|
|
@@ -1548,19 +1668,32 @@ function labelOf(nid: string, meta: KeyEntry, doc: JsonObject, ctx: Ctx) {
|
|
|
1548
1668
|
Object.values(given).every((v) => v !== null) && !unexplained.length,
|
|
1549
1669
|
source: textOf(doc["source"]),
|
|
1550
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)),
|
|
1551
1673
|
};
|
|
1552
1674
|
}
|
|
1553
|
-
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
|
+
});
|
|
1554
1686
|
|
|
1555
1687
|
/** Same rule as for item answers: a familiarity outside the options is marked, not counted as rated. */
|
|
1556
1688
|
function groupsOf(
|
|
1557
1689
|
groupIds: readonly string[],
|
|
1558
1690
|
groups: Json | undefined,
|
|
1559
1691
|
allowed: Allowed,
|
|
1692
|
+
prefix = "",
|
|
1560
1693
|
) {
|
|
1561
1694
|
return Object.fromEntries(
|
|
1562
1695
|
groupIds.map((g) => {
|
|
1563
|
-
const doc = docOf(groups, g);
|
|
1696
|
+
const doc = docOf(groups, `${prefix}${g}`);
|
|
1564
1697
|
const fam = textOf(doc["familiarity"]);
|
|
1565
1698
|
const bad = fam !== null && !allowed.familiarity.includes(fam);
|
|
1566
1699
|
return [
|
|
@@ -1575,7 +1708,10 @@ function groupsOf(
|
|
|
1575
1708
|
}),
|
|
1576
1709
|
);
|
|
1577
1710
|
}
|
|
1578
|
-
function reportLabels(
|
|
1711
|
+
function reportLabels(
|
|
1712
|
+
items: readonly (Answer & { readonly id: string })[],
|
|
1713
|
+
allowed: Allowed,
|
|
1714
|
+
): void {
|
|
1579
1715
|
for (const i of items) {
|
|
1580
1716
|
for (const [q, v] of Object.entries(i.invalid_answers)) {
|
|
1581
1717
|
say(
|
|
@@ -1605,9 +1741,14 @@ function reportGroups(
|
|
|
1605
1741
|
}
|
|
1606
1742
|
}
|
|
1607
1743
|
/** Each practice item of the key's round: what was answered first, what was expected, whether they match. */
|
|
1608
|
-
function scorePractice(
|
|
1744
|
+
function scorePractice(
|
|
1745
|
+
dump: JsonObject,
|
|
1746
|
+
round: string,
|
|
1747
|
+
all: Expected,
|
|
1748
|
+
prefix: string,
|
|
1749
|
+
) {
|
|
1609
1750
|
return Object.entries(all).map(([pid, expected]) => {
|
|
1610
|
-
const doc = docOf(dump["practice"],
|
|
1751
|
+
const doc = docOf(dump["practice"], `${prefix}r${round}-${pid}`);
|
|
1611
1752
|
const answers = isObject(doc["answers"]) ? doc["answers"] : {};
|
|
1612
1753
|
const matched = Object.entries(expected).every(
|
|
1613
1754
|
([q, v]) => answers[q] === v,
|
|
@@ -1621,9 +1762,14 @@ function scorePractice(dump: JsonObject, round: string, all: Expected) {
|
|
|
1621
1762
|
};
|
|
1622
1763
|
});
|
|
1623
1764
|
}
|
|
1624
|
-
/** One file per round: a second round exported the same day keeps the first. */
|
|
1625
|
-
const practiceFile = (
|
|
1626
|
-
|
|
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`;
|
|
1627
1773
|
/**
|
|
1628
1774
|
* Every practice round the store holds answers for, each scored against its own expected answers
|
|
1629
1775
|
* into its own file; never into the labels. The key's own round is reported even unanswered.
|
|
@@ -1636,79 +1782,169 @@ function exportPractice(
|
|
|
1636
1782
|
): void {
|
|
1637
1783
|
const p = key.practice;
|
|
1638
1784
|
if (p === undefined) return;
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
.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]) => {
|
|
1642
1788
|
exportRound(
|
|
1643
1789
|
key.study,
|
|
1644
1790
|
Number(round),
|
|
1645
|
-
scorePractice(
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1791
|
+
scorePractice(
|
|
1792
|
+
dump,
|
|
1793
|
+
round,
|
|
1794
|
+
expected,
|
|
1795
|
+
who === undefined ? "" : `${who}${SEP}`,
|
|
1796
|
+
),
|
|
1797
|
+
{ target, day, current: Number(round) === p.round, who },
|
|
1651
1798
|
);
|
|
1652
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;
|
|
1653
1808
|
}
|
|
1654
1809
|
/** One round's scored practice to `<study>-practice-r<round>-<date>.json`, and one line about it. */
|
|
1655
1810
|
function exportRound(
|
|
1656
1811
|
study: string,
|
|
1657
1812
|
round: number,
|
|
1658
1813
|
items: ReturnType<typeof scorePractice>,
|
|
1659
|
-
at:
|
|
1660
|
-
readonly target: string;
|
|
1661
|
-
readonly day: string;
|
|
1662
|
-
readonly current: boolean;
|
|
1663
|
-
},
|
|
1814
|
+
at: RoundAt,
|
|
1664
1815
|
): void {
|
|
1816
|
+
const named = `practice round ${String(round)}${at.who === undefined ? "" : ` (${at.who})`}`;
|
|
1665
1817
|
if (!items.some((i) => Object.keys(i.answers).length)) {
|
|
1666
|
-
if (at.current) say(
|
|
1818
|
+
if (at.current) say(`${named}: not answered`);
|
|
1667
1819
|
return;
|
|
1668
1820
|
}
|
|
1669
1821
|
const missed = items.filter((i) => !i.matched).map((i) => i.id);
|
|
1670
1822
|
const matched = items.length - missed.length;
|
|
1671
|
-
const path = join(at.target, practiceFile(study, round, at.day));
|
|
1823
|
+
const path = join(at.target, practiceFile(study, round, at.day, at.who));
|
|
1672
1824
|
writeJson(path, {
|
|
1673
1825
|
study,
|
|
1674
1826
|
round,
|
|
1827
|
+
...R.pickBy({ answerer: at.who }, R.isDefined),
|
|
1675
1828
|
exported: at.day,
|
|
1676
1829
|
matched,
|
|
1677
1830
|
total: items.length,
|
|
1678
1831
|
items,
|
|
1679
1832
|
});
|
|
1680
1833
|
say(
|
|
1681
|
-
|
|
1834
|
+
`${named}: ${String(matched)} of ${String(items.length)} matched${missed.length ? `; missed ${missed.join(", ")}` : ""} -> ${path}`,
|
|
1682
1835
|
);
|
|
1683
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
|
+
: {};
|
|
1684
1867
|
/** The labels file: every item on the page with its answers, the groups, and the counts. */
|
|
1685
1868
|
function labelsOf(dump: JsonObject, ctx: Ctx, day: string) {
|
|
1686
1869
|
const onPage = Object.entries(ctx.key.ids).filter(([, m]) => m.on_page);
|
|
1687
1870
|
const items = onPage.map(([nid, meta]) =>
|
|
1688
1871
|
labelOf(nid, meta, docOf(dump["answers"], nid), ctx),
|
|
1689
1872
|
);
|
|
1690
|
-
const groups = ctx.allowed.familiarity.length
|
|
1691
|
-
? groupsOf(
|
|
1692
|
-
[...new Set(onPage.map(([, m]) => m.group))],
|
|
1693
|
-
dump["groups"],
|
|
1694
|
-
ctx.allowed,
|
|
1695
|
-
)
|
|
1696
|
-
: {};
|
|
1697
1873
|
return {
|
|
1698
1874
|
study: ctx.key.study,
|
|
1699
1875
|
exported: day,
|
|
1700
1876
|
questions: ctx.key.questions,
|
|
1701
1877
|
items,
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
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
|
+
),
|
|
1710
1910
|
};
|
|
1711
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`;
|
|
1712
1948
|
export function exportLabels(
|
|
1713
1949
|
dumpPath: string,
|
|
1714
1950
|
keyPath: string,
|
|
@@ -1722,14 +1958,34 @@ export function exportLabels(
|
|
|
1722
1958
|
assertAllowed(allowed, key, keyPath);
|
|
1723
1959
|
checkDump(dump, key);
|
|
1724
1960
|
checkNoFamiliarity(dump, allowed);
|
|
1725
|
-
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);
|
|
1726
1967
|
mkdirSync(outDir, { recursive: true });
|
|
1727
1968
|
const path = join(outDir, `${key.study}-labels-${day}.json`);
|
|
1728
1969
|
writeJson(path, out);
|
|
1729
1970
|
exportPractice(dump, key, outDir, day);
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
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
|
+
}
|
|
1735
1991
|
}
|