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.
@@ -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 & { readonly source_id: string; readonly group: string };
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 Object.fromEntries(
1149
- byId.map(([sid, nid]) => [
1150
- nid,
1151
- {
1152
- source_id: sid,
1153
- group: String(groupOf.get(sid)),
1154
- on_page: shown.has(nid),
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
- function knownNames(key: Key): Readonly<Record<Collection, Names>> {
1301
- const entries = Object.entries(key.ids);
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: ANSWER_DOC_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: ANSWER_DOC_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]} (${[...new Set(names.known.map(([n]) => n))].sort().join(", ")})`,
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
- const names = knownNames(key);
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 [name, doc] of sortedEntries(
1553
+ for (const [docId, doc] of sortedEntries(
1435
1554
  isObject(collection) ? collection : {},
1436
1555
  )) {
1437
- const where = `${kind}/${name}`;
1438
- const questions = checkDocName(where, name, kind, names[kind]);
1439
- checkDocFields(where, doc, kind, names[kind].fields);
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 item of the labels file: its answers, what is invalid, the reasons, and whether it is done. */
1528
- function labelOf(nid: string, meta: KeyEntry, doc: JsonObject, ctx: Ctx) {
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 Label = ReturnType<typeof labelOf>;
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(items: readonly Label[], allowed: Allowed): void {
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(dump: JsonObject, round: string, all: Expected) {
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"], `r${round}-${pid}`);
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 = (study: string, round: number, day: string): string =>
1626
- `${study}-practice-r${String(round)}-${day}.json`;
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
- Object.entries(roundsIn(p))
1640
- .sort(([a], [b]) => Number(a) - Number(b))
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(dump, round, expected),
1646
- {
1647
- target,
1648
- day,
1649
- current: Number(round) === p.round,
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(`practice round ${String(round)}: not answered`);
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
- `practice round ${String(round)}: ${String(matched)} of ${String(items.length)} matched${missed.length ? `; missed ${missed.join(", ")}` : ""} -> ${path}`,
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
- groups,
1703
- complete_items: items.filter((i) => i.complete).length,
1704
- invalid_items: items.filter((i) => Object.keys(i.invalid_answers).length)
1705
- .length,
1706
- invalid_groups: Object.values(groups).filter(
1707
- (g) => g.invalid_familiarity !== null,
1708
- ).length,
1709
- total_items: items.length,
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 out = labelsOf(dump, { key, allowed }, day);
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
- say(
1731
- `wrote ${path}: ${String(out.complete_items)} of ${String(out.total_items)} items complete`,
1732
- );
1733
- reportLabels(out.items, allowed);
1734
- reportGroups(out.groups, allowed);
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
  }