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