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.
@@ -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
- if (typeof value !== "number" || !Number.isInteger(value)) {
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 text of an item the labeller sees, by field: the page fields and the line of a unit. */
494
- const visibleTexts = (item) => [
495
- ...PAGE_FIELDS.map((f) => [f, item[f] ?? ""]),
496
- [UNIT_FIELD, item.unit?.text ?? ""],
497
- [READINGS_FIELD, `${item.readings?.a ?? ""} ${item.readings?.b ?? ""}`],
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 ?? []).map((r, k) => {
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 Object.fromEntries(byId.map(([sid, nid]) => [
645
- nid,
646
- {
647
- source_id: sid,
648
- group: String(groupOf.get(sid)),
649
- on_page: shown.has(nid),
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
- function knownNames(key) {
750
- const entries = Object.entries(key.ids);
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: ANSWER_DOC_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: ANSWER_DOC_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
- function checkDocName(where, name, kind, names) {
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]} (${[...new Set(names.known.map(([n]) => n))].sort().join(", ")})`);
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
- const names = knownNames(key);
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 [name, doc] of sortedEntries(isObject(collection) ? collection : {})) {
848
- const where = `${kind}/${name}`;
849
- const questions = checkDocName(where, name, kind, names[kind]);
850
- checkDocFields(where, doc, kind, names[kind].fields);
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 item of the labels file: its answers, what is invalid, the reasons, and whether it is done. */
904
- function labelOf(nid, meta, doc, ctx) {
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"], `r${round}-${pid}`);
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
- Object.entries(roundsIn(p))
990
- .sort(([a], [b]) => Number(a) - Number(b))
991
- .forEach(([round, expected]) => {
992
- exportRound(key.study, Number(round), scorePractice(dump, round, expected), {
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(`practice round ${String(round)}: not answered`);
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(`practice round ${String(round)}: ${String(matched)} of ${String(items.length)} matched${missed.length ? `; missed ${missed.join(", ")}` : ""} -> ${path}`);
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
- groups,
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 out = labelsOf(dump, { key, allowed }, day);
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
- say(`wrote ${path}: ${String(out.complete_items)} of ${String(out.total_items)} items complete`);
1053
- reportLabels(out.items, allowed);
1054
- reportGroups(out.groups, allowed);
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
  }