paperlint 14.1.0 → 14.3.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 { describeRule, } from "./rule-descriptions.js";
12
13
  import { applyPreset } from "./label-page-presets.js";
@@ -97,6 +98,9 @@ const readJson = (path) => {
97
98
  // prettier-ignore
98
99
  const PAGE_FIELDS = ["group", "title", "body", "code", "context", "file"];
99
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";
100
104
  // The linter rules an item names ("read as ruff `TID253`"): the page shows each with the linter's
101
105
  // own description, looked up at build time (rule-descriptions.ts), never typed into items.json.
102
106
  const RULES_FIELD = "rules";
@@ -107,6 +111,9 @@ const UNIT_FIELD = "unit";
107
111
  const READINGS_FIELD = "readings";
108
112
  // Content blocks (#380): generic, on any kind. Every label and sentence a block shows comes from
109
113
  // items.json (or a preset, label-page-presets.ts); the page knows only the block's shape.
114
+ // `quote`, `terms` and `answer_notes` (#392): the words a card asks about, shown as a quote with
115
+ // its source; tap-to-see notes on terms in that quote and in the excerpt's annotation; and a sub-line
116
+ // under an answer that says what the answer means on this card.
110
117
  const BLOCK_FIELDS = [
111
118
  "situation",
112
119
  "excerpt",
@@ -114,6 +121,9 @@ const BLOCK_FIELDS = [
114
121
  "more",
115
122
  "table",
116
123
  "repeat",
124
+ "quote",
125
+ "terms",
126
+ "answer_notes",
117
127
  ];
118
128
  const ALLOWED_FIELDS = [
119
129
  ...PAGE_FIELDS,
@@ -121,6 +131,7 @@ const ALLOWED_FIELDS = [
121
131
  UNIT_FIELD,
122
132
  READINGS_FIELD,
123
133
  ...BLOCK_FIELDS,
134
+ ANSWERERS_FIELD,
124
135
  ...PRIVATE_FIELDS,
125
136
  ];
126
137
  const FAMILIARITY = [
@@ -147,6 +158,11 @@ const RESERVED_NAMES = [
147
158
  "__lookupSetter__",
148
159
  ];
149
160
  const NAME_PATTERN = `(?!(?:${RESERVED_NAMES.join("|")})$)[A-Za-z0-9_-]+`;
161
+ /**
162
+ * With `answerers`, a document's id is `<answerer>~<id>`: `~` is outside NAME_PATTERN, so no
163
+ * answerer's name, item id, group or practice id can hold it, and the id splits at its first `~`.
164
+ */
165
+ const SEP = "~";
150
166
  const SOURCES = ["page", "chat"];
151
167
  const SOURCES_SHOWN = `(${SOURCES.map(quoted).join(", ")})`;
152
168
  const string = (required = true, pattern, why) => pattern === undefined
@@ -173,9 +189,9 @@ const array = (items, required = true, min = 0) => ({
173
189
  min,
174
190
  items,
175
191
  });
176
- const mapping = (values, keyPattern, min = 0) => ({
192
+ const mapping = (values, keyPattern, min = 0, required = true) => ({
177
193
  kind: "map",
178
- required: true,
194
+ required,
179
195
  min,
180
196
  key_pattern: keyPattern,
181
197
  values,
@@ -206,14 +222,42 @@ const CARD_FIELDS = {
206
222
  }, true, false),
207
223
  [READINGS_FIELD]: obj({ a: string(), b: string() }, true, false),
208
224
  file: string(false),
209
- situation: optional({ summary: string(), question: string() }),
225
+ situation: optional({ summary: string(false), question: string() }),
210
226
  excerpt: optional({
211
227
  file: string(false),
228
+ // the file's language, after its path: "path · Python"
229
+ lang: string(false),
212
230
  url: LINK,
213
- lines: array(obj({ n: integer(), text: text(), mark: boolean() }), true, 1),
231
+ // the header of an extra gutter column; each line's `gutter` is its value there ("depth")
232
+ gutter_header: string(false),
233
+ lines: array(obj({
234
+ n: integer(),
235
+ text: text(),
236
+ mark: boolean(),
237
+ gutter: string(false),
238
+ // a line shown only to place the code (an enclosing block's opener): drawn faint
239
+ context: boolean(false),
240
+ }), true, 1),
241
+ // one short fact, drawn in a box right under the marked line, pointing at it
242
+ annotation: string(false),
214
243
  caption: string(false),
215
244
  mark_label: string(false),
216
245
  }),
246
+ quote: optional({ text: string(), source: string(false) }),
247
+ terms: array(obj({
248
+ term: string(),
249
+ note: array(obj({
250
+ title: string(false),
251
+ says: string(false),
252
+ quote: string(false),
253
+ code: string(false),
254
+ example: string(false),
255
+ url: string(true, "https?://\\S+", "an http(s) link"),
256
+ url_label: string(),
257
+ }), true, 1),
258
+ }), false, 1),
259
+ // question id → option value → the sub-line under that answer's button on this card
260
+ answer_notes: mapping(mapping(string(), NAME_PATTERN, 1), NAME_PATTERN, 1, false),
217
261
  references: optional({
218
262
  summary: string(),
219
263
  entries: array(obj({
@@ -272,9 +316,10 @@ const itemSchema = (kind) => obj({
272
316
  source_id: string(),
273
317
  ...Object.fromEntries(PAGE_FIELDS.filter((f) => f !== "file").map((f) => [
274
318
  f,
275
- string(f === "group" || f === "body"),
319
+ string(f === "group"),
276
320
  ])),
277
321
  ...cardFields(kind),
322
+ [ANSWERERS_FIELD]: array(string(true, NAME_PATTERN), false, 1),
278
323
  }, false);
279
324
  // `icon` (an emoji) tells the buttons apart at a glance. No coloured side stripe on a button: it
280
325
  // reads as decoration, not meaning (the build-label-page look test refuses one).
@@ -289,7 +334,7 @@ const OPTION_SCHEMA = obj({
289
334
  const practiceSchema = (kind) => obj({
290
335
  ...Object.fromEntries(PAGE_FIELDS.filter((f) => f !== "group" && f !== "file").map((f) => [
291
336
  f,
292
- string(f === "body"),
337
+ string(false),
293
338
  ])),
294
339
  ...cardFields(kind),
295
340
  expected: mapping(string(), NAME_PATTERN, 1),
@@ -310,6 +355,10 @@ const configSchema = (kind) => obj({
310
355
  seed: integer(),
311
356
  groups: mapping(string(), NAME_PATTERN, 1), // ids become store document ids
312
357
  labeller_knows: array(string(true, NAME_PATTERN), false),
358
+ // Two or more people answer the same page, each under their own name (doc ids `<name>~<id>`).
359
+ answerers: array(string(true, NAME_PATTERN), false, 2),
360
+ // An item's answer saves only once the item has been on screen this many seconds.
361
+ min_seconds: integer(false),
313
362
  // true: a "how well do you know {group}" question per group, for a study that filters on it
314
363
  // (rule 5). Left out, none is asked: an approval page's author has never seen the repositories.
315
364
  familiarity: boolean(false),
@@ -325,6 +374,9 @@ const configSchema = (kind) => obj({
325
374
  line_part: string(false),
326
375
  repeat: string(false),
327
376
  practice_example: string(false),
377
+ hidden: string(false),
378
+ hidden_one: string(false),
379
+ wait: string(false),
328
380
  }),
329
381
  items: array(itemSchema(kind), true, 1),
330
382
  });
@@ -495,8 +547,35 @@ function checkPractice(cfg) {
495
547
  fail(`${where}.${missing}: every question needs its expected answer`);
496
548
  });
497
549
  }
550
+ /** The first entry of `names` that repeats an earlier one, refused by its path. */
551
+ function checkDistinct(names, where) {
552
+ const at = names.findIndex((x, i) => names.indexOf(x) !== i);
553
+ if (at !== -1)
554
+ fail(`${where}[${String(at)}]: ${repr(names[at])} is repeated`);
555
+ }
556
+ /** Each answerer once; an item's own list a subset of the page's; a dwell of a second or more. */
557
+ function checkAnswerers(cfg) {
558
+ if ((cfg.min_seconds ?? 1) < 1)
559
+ fail(`min_seconds: ${repr(cfg.min_seconds)} — must be 1 or more`);
560
+ const all = cfg.answerers;
561
+ if (all !== undefined)
562
+ checkDistinct(all, "answerers");
563
+ cfg.items.forEach((item, n) => {
564
+ const where = `items[${String(n)}].answerers`;
565
+ const own = item.answerers;
566
+ if (own === undefined)
567
+ return;
568
+ if (all === undefined)
569
+ return fail(`${where}: the page has no \`answerers\` — add the page's list, or take this out`);
570
+ const k = own.findIndex((who) => !R.isIncludedIn(who, all));
571
+ if (k !== -1)
572
+ fail(`${where}[${String(k)}]: ${repr(own[k])} is not in \`answerers\` (${all.join(", ")})`);
573
+ checkDistinct(own, where);
574
+ });
575
+ }
498
576
  function checkConfig(cfg) {
499
577
  checkGroups(cfg);
578
+ checkAnswerers(cfg);
500
579
  if (!asksFamiliarity(cfg) && cfg.familiarity_question !== undefined) {
501
580
  fail(`familiarity_question: ${repr(cfg.familiarity_question)} is never asked, because \`familiarity\` is not true — remove it, or set "familiarity": true`);
502
581
  }
@@ -558,7 +637,7 @@ const SITUATION_TEXTS = ["summary", "question"];
558
637
  const lineOf = (card) => card.excerpt?.lines
559
638
  .filter((l) => l.mark)
560
639
  .map((l) => ({ n: l.n, text: l.text.trim() }))[0];
561
- const usesLine = (card) => SITUATION_TEXTS.some((f) => card.situation?.[f].includes(LINE_TOKEN));
640
+ const usesLine = (card) => SITUATION_TEXTS.some((f) => card.situation?.[f]?.includes(LINE_TOKEN));
562
641
  /**
563
642
  * What the block schema cannot say: an excerpt marks a line, `{line}` has a marked line to stand for,
564
643
  * and a repeat sits inside its count.
@@ -567,7 +646,7 @@ function checkBlocks(cfg) {
567
646
  cardsOf(cfg).forEach(([at, card]) => {
568
647
  if (card.excerpt !== undefined && !card.excerpt.lines.some((l) => l.mark))
569
648
  fail(`${at}.excerpt.lines: no line is marked — mark the line the card is about (\`mark: true\`)`);
570
- const token = SITUATION_TEXTS.find((f) => card.situation?.[f].includes(LINE_TOKEN));
649
+ const token = SITUATION_TEXTS.find((f) => card.situation?.[f]?.includes(LINE_TOKEN));
571
650
  if (token !== undefined && card.excerpt === undefined)
572
651
  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}\``);
573
652
  const r = card.repeat;
@@ -575,6 +654,61 @@ function checkBlocks(cfg) {
575
654
  fail(`${at}.repeat.index: ${String(r.index)} is not between 1 and ${String(r.of)}`);
576
655
  });
577
656
  }
657
+ /**
658
+ * The texts a term may open a note from, the words asked about first: the quote, its source, then
659
+ * the excerpt's annotation. The page looks a term up in the same texts, split the same way at
660
+ * backticks, so a term the build finds is a term the page draws.
661
+ */
662
+ const termTexts = (card) => [card.quote?.text, card.quote?.source, card.excerpt?.annotation].filter(R.isDefined);
663
+ const findsTerm = (card, term) => termTexts(card).some((t) => t.split("`").some((part) => part.includes(term)));
664
+ /**
665
+ * What the schema cannot say about the #392 blocks: a card says something (`body` or `quote`); each
666
+ * term occurs in a text that can carry it, and each note entry shows a quote or a snippet; an answer
667
+ * note names a question of the page and one of its options.
668
+ */
669
+ function checkCardText(cfg) {
670
+ const options = new Map(cfg.questions.map((q) => [q.id, q.options.map((o) => o.value)]));
671
+ cardsOf(cfg).forEach(([at, card]) => {
672
+ if (card.body === undefined && card.quote === undefined)
673
+ fail(`${at}.body: is required and missing — a card shows its \`body\`, or its words as a \`quote\``);
674
+ checkTerms(at, card);
675
+ checkAnswerNotes(at, card, options);
676
+ });
677
+ }
678
+ /**
679
+ * One term's text inside another's on the same card (or the same text twice): the page draws one
680
+ * button for the words, so the other term's note would never open.
681
+ */
682
+ function checkTermsDoNotOverlap(at, card) {
683
+ const texts = (card.terms ?? []).map((t) => t.term);
684
+ const named = (k) => `${at}.terms[${String(k)}]`;
685
+ texts.forEach((inner, k) => {
686
+ const j = texts.findIndex((outer, m) => m !== k && outer.includes(inner) && (outer !== inner || m > k));
687
+ if (j !== -1)
688
+ fail(`${named(k)}.term: ${repr(inner)} is inside ${named(j)}.term ${repr(texts[j])} — the page draws one of two overlapping terms, so the other note never opens`);
689
+ });
690
+ }
691
+ function checkTerms(at, card) {
692
+ checkTermsDoNotOverlap(at, card);
693
+ (card.terms ?? []).forEach((t, k) => {
694
+ const where = `${at}.terms[${String(k)}]`;
695
+ if (!findsTerm(card, t.term))
696
+ fail(`${where}.term: ${repr(t.term)} is not in the card's annotation, quote or quote source — a note opens from words the card shows`);
697
+ const bare = t.note.findIndex((e) => e.quote === undefined && e.code === undefined);
698
+ if (bare !== -1)
699
+ fail(`${where}.note[${String(bare)}]: has neither \`quote\` nor \`code\` — a note shows its source's own words`);
700
+ });
701
+ }
702
+ function checkAnswerNotes(at, card, options) {
703
+ Object.entries(card.answer_notes ?? {}).forEach(([qid, notes]) => {
704
+ const values = options.get(qid);
705
+ if (values === undefined)
706
+ return fail(`${at}.answer_notes.${qid}: not a question of this page (${[...options.keys()].join(", ")})`);
707
+ const stray = Object.keys(notes).find((v) => !values.includes(v));
708
+ if (stray !== undefined)
709
+ fail(`${at}.answer_notes.${qid}.${stray}: not one of ${values.join(", ")}`);
710
+ });
711
+ }
578
712
  /** Every card of the config, items then practice, by path. */
579
713
  const cardsOf = (cfg) => [
580
714
  ...cfg.items.map((i, n) => [`items[${String(n)}]`, i]),
@@ -671,7 +805,7 @@ const situationShown = (item) => item.situation === undefined
671
805
  ? {}
672
806
  : {
673
807
  situation: {
674
- summary: item.situation.summary,
808
+ ...R.pickBy({ summary: item.situation.summary }, R.isDefined),
675
809
  question: item.situation.question,
676
810
  ...(usesLine(item) ? { line: lineOf(item) } : {}),
677
811
  },
@@ -745,7 +879,12 @@ const DEFAULT_LABELS = {
745
879
  line_part: "{line} · part {part} of {of}",
746
880
  repeat: "Card {index} of {of}",
747
881
  practice_example: "",
882
+ hidden: "lines {from}\u2013{to}",
883
+ hidden_one: "line {from}",
884
+ wait: "\u23f3 You can answer in {s} s",
748
885
  };
886
+ /** The answerers and the dwell, only when items.json sets them: a page without them is as it was. */
887
+ const twoKeys = (cfg) => R.pickBy({ answerers: cfg.answerers, min_seconds: cfg.min_seconds }, R.isDefined);
749
888
  /** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
750
889
  function payloadOf(cfg, page, described) {
751
890
  const used = Object.keys(cfg.groups).filter((g) => page.items.some((i) => i.group === g));
@@ -759,18 +898,22 @@ function payloadOf(cfg, page, described) {
759
898
  : "",
760
899
  familiarity: asksFamiliarity(cfg) ? FAMILIARITY : [],
761
900
  allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
901
+ ...twoKeys(cfg),
762
902
  questions: cfg.questions,
763
903
  practice: {
764
904
  round: practiceRound(cfg),
765
905
  items: practiceShown(cfg, described),
766
906
  },
767
- items: page.items.map((i) => ({
768
- id: page.idOf(i),
769
- ...shownFields(i),
770
- ...rulesShown(i, described),
771
- })),
907
+ items: page.items.map((i) => itemShown(i, page.idOf(i), described)),
772
908
  };
773
909
  }
910
+ /** One item as its card shows it, and whom it is shown to when not every answerer. */
911
+ const itemShown = (i, id, described) => ({
912
+ id,
913
+ ...shownFields(i),
914
+ ...rulesShown(i, described),
915
+ ...R.pickBy({ answerers: i.answerers }, R.isDefined),
916
+ });
774
917
  /** Every id the key keeps (on the page now, or kept from an earlier build), with its source and group. */
775
918
  function idsOf(cfg, page) {
776
919
  const shown = new Set(page.items.map(page.idOf));
@@ -781,15 +924,14 @@ function idsOf(cfg, page) {
781
924
  ]),
782
925
  ...cfg.items.map((i) => [i.source_id, i.group]),
783
926
  ]);
927
+ const bySource = R.indexBy(cfg.items, (i) => i.source_id);
784
928
  const byId = [...page.ids].sort(([, a], [, b]) => byText(a, b));
785
- return Object.fromEntries(byId.map(([sid, nid]) => [
786
- nid,
787
- {
788
- source_id: sid,
789
- group: String(groupOf.get(sid)),
790
- on_page: shown.has(nid),
791
- },
792
- ]));
929
+ return R.pullObject(byId, ([, nid]) => nid, ([sid, nid]) => ({
930
+ source_id: sid,
931
+ group: String(groupOf.get(sid)),
932
+ on_page: shown.has(nid),
933
+ ...R.pickBy({ answerers: bySource[sid]?.answerers }, R.isDefined),
934
+ }));
793
935
  }
794
936
  /** Every round of a key's practice; a key from before `rounds` has only its own round. */
795
937
  const roundsIn = (p) => p.rounds ?? { [String(p.round)]: p.expected };
@@ -806,6 +948,7 @@ function keyOf(cfg, page, earlier) {
806
948
  return {
807
949
  study: cfg.study,
808
950
  seed: cfg.seed,
951
+ ...twoKeys(cfg),
809
952
  questions: cfg.questions.map((q) => q.id),
810
953
  // What counts as an answer: the same lists the page was given (`allowedValues`). `export`
811
954
  // checks every stored answer and familiarity against them, so the build's options decide.
@@ -838,6 +981,7 @@ export function prepareBuild(configPath, outDir) {
838
981
  checkItemFields(cfg);
839
982
  checkUnits(cfg);
840
983
  checkBlocks(cfg);
984
+ checkCardText(cfg);
841
985
  checkNoSourceShown(cfg);
842
986
  const described = describeRules(cfg, dirname(configPath));
843
987
  mkdirSync(outDir, { recursive: true });
@@ -855,6 +999,10 @@ function assertKey(_value) {
855
999
  // key.json is this script's own output; what an older build left out is checked where it is read.
856
1000
  }
857
1001
  const ANSWER_DOC_FIELDS = ["answers", "reasons", "savedAt", "source"];
1002
+ /** A page built with `min_seconds` also stores how long the item was on screen before its first save. */
1003
+ const answerDocFields = (key) => key.min_seconds === undefined
1004
+ ? ANSWER_DOC_FIELDS
1005
+ : ["answers", "reasons", "savedAt", "seconds", "source"];
858
1006
  const GROUP_DOC_FIELDS = ["familiarity", "savedAt", "source"];
859
1007
  const DOC_FIELD_TYPES = [
860
1008
  ["answers", isObject, "an object {question: value}"],
@@ -869,6 +1017,11 @@ const DOC_FIELD_TYPES = [
869
1017
  (v) => v === null || typeof v === "string",
870
1018
  "a timestamp string or null",
871
1019
  ],
1020
+ [
1021
+ "seconds",
1022
+ (v) => v === null || (typeof v === "number" && Number.isInteger(v) && v >= 0),
1023
+ "a whole number of seconds or null",
1024
+ ],
872
1025
  ];
873
1026
  const COLLECTIONS = ["answers", "groups", "practice"];
874
1027
  const COLLECTION_NAMES = COLLECTIONS;
@@ -888,15 +1041,18 @@ const practiceDocs = (key) => {
888
1041
  // Each round's own questions: a later round may have dropped or renamed one.
889
1042
  return Object.entries(roundsIn(p)).flatMap(([r, expected]) => Object.entries(expected).map(([pid, answers]) => [`r${r}-${pid}`, Object.keys(answers)]));
890
1043
  };
891
- function knownNames(key) {
892
- const entries = Object.entries(key.ids);
1044
+ /** Who an item is shown to: its own list, else every answerer of the page. */
1045
+ const shownTo = (all, meta) => meta.answerers ?? all;
1046
+ /** The names of a page without answerers, or those of one answerer's documents. */
1047
+ function knownNames(key, whose) {
1048
+ const entries = Object.entries(key.ids).filter(([, m]) => whose.who === undefined || shownTo(whose.all, m).includes(whose.who));
893
1049
  const shown = entries.filter(([, m]) => m.on_page);
894
1050
  const practice = practiceDocs(key);
895
1051
  return {
896
1052
  answers: {
897
1053
  known: entries.map(([nid]) => [nid, key.questions]),
898
1054
  onPage: shown.map(([nid]) => nid),
899
- fields: ANSWER_DOC_FIELDS,
1055
+ fields: answerDocFields(key),
900
1056
  },
901
1057
  groups: {
902
1058
  known: entries.map(([, m]) => [m.group, key.questions]),
@@ -906,7 +1062,7 @@ function knownNames(key) {
906
1062
  practice: {
907
1063
  known: practice,
908
1064
  onPage: practice.map(([n]) => n),
909
- fields: ANSWER_DOC_FIELDS,
1065
+ fields: answerDocFields(key),
910
1066
  },
911
1067
  };
912
1068
  }
@@ -925,10 +1081,28 @@ function checkDumpShape(dump) {
925
1081
  if (stray !== undefined)
926
1082
  fail(`${stray}: the store has no such collection (expected ${COLLECTIONS.join(", ")})`);
927
1083
  }
928
- function checkDocName(where, name, kind, names) {
1084
+ /**
1085
+ * A document's answerer and its name without it. A page without answerers has none; with them, an
1086
+ * id without `~`, or before it a name the page does not have, is refused.
1087
+ */
1088
+ function answererOf(where, docId, key) {
1089
+ const all = key.answerers;
1090
+ if (all === undefined)
1091
+ return { name: docId };
1092
+ const at = docId.indexOf(SEP);
1093
+ if (at === -1)
1094
+ return fail(`${where}: this page has answerers (${all.join(", ")}), so a document's id is <answerer>${SEP}<id>`);
1095
+ const who = docId.slice(0, at);
1096
+ if (!all.includes(who))
1097
+ fail(`${where}: ${repr(who)} is not an answerer of this page (${all.join(", ")})`);
1098
+ return { who, all, name: docId.slice(at + 1) };
1099
+ }
1100
+ function checkDocName(where, { who, name }, kind, names) {
929
1101
  const hit = names.known.find(([n]) => n === name);
930
1102
  if (hit === undefined) {
931
- return fail(`${where}: the key does not know this ${NOUN[kind]} (${[...new Set(names.known.map(([n]) => n))].sort().join(", ")})`);
1103
+ return fail(`${where}: the key does not know this ${NOUN[kind]}${who === undefined ? "" : ` for ${who}`} (${R.unique(names.known.map(([n]) => n))
1104
+ .sort()
1105
+ .join(", ")})`);
932
1106
  }
933
1107
  if (!names.onPage.includes(name)) {
934
1108
  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`);
@@ -983,13 +1157,16 @@ function checkDocAnswers(where, doc, questions) {
983
1157
  */
984
1158
  function checkDump(dump, key) {
985
1159
  checkDumpShape(dump);
986
- const names = knownNames(key);
1160
+ // A page without answerers knows one set of names; each answerer has their own.
1161
+ const plain = knownNames(key, {});
987
1162
  for (const kind of COLLECTIONS) {
988
1163
  const collection = dump[kind];
989
- for (const [name, doc] of sortedEntries(isObject(collection) ? collection : {})) {
990
- const where = `${kind}/${name}`;
991
- const questions = checkDocName(where, name, kind, names[kind]);
992
- checkDocFields(where, doc, kind, names[kind].fields);
1164
+ for (const [docId, doc] of sortedEntries(isObject(collection) ? collection : {})) {
1165
+ const where = `${kind}/${docId}`;
1166
+ const named = answererOf(where, docId, key);
1167
+ const names = (named.who === undefined ? plain : knownNames(key, named))[kind];
1168
+ const questions = checkDocName(where, named, kind, names);
1169
+ checkDocFields(where, doc, kind, names.fields);
993
1170
  checkDocAnswers(where, doc, questions);
994
1171
  }
995
1172
  }
@@ -1042,8 +1219,8 @@ function answersOf(doc, ctx) {
1042
1219
  const given = Object.fromEntries(raw.map(([q, v]) => [q, has(invalid, q) ? null : v]));
1043
1220
  return { given, invalid };
1044
1221
  }
1045
- /** One item of the labels file: its answers, what is invalid, the reasons, and whether it is done. */
1046
- function labelOf(nid, meta, doc, ctx) {
1222
+ /** One stored answer: its answers, what is invalid, the reasons, and whether it is done. */
1223
+ function answerOf(doc, ctx) {
1047
1224
  const { given, invalid } = answersOf(doc, ctx);
1048
1225
  const reasons = Object.fromEntries(Object.entries(textsOf(doc["reasons"])).filter(([, r]) => r.trim()));
1049
1226
  const needsWhy = ctx.allowed.needs_why ?? {};
@@ -1051,9 +1228,6 @@ function labelOf(nid, meta, doc, ctx) {
1051
1228
  .filter(([q, v]) => v !== null && listed(needsWhy, q, v) && !has(reasons, q))
1052
1229
  .map(([q]) => q);
1053
1230
  return {
1054
- id: nid,
1055
- source_id: meta.source_id,
1056
- group: meta.group,
1057
1231
  answers: given,
1058
1232
  invalid_answers: invalid,
1059
1233
  reasons,
@@ -1061,12 +1235,23 @@ function labelOf(nid, meta, doc, ctx) {
1061
1235
  complete: Object.values(given).every((v) => v !== null) && !unexplained.length,
1062
1236
  source: textOf(doc["source"]),
1063
1237
  saved_at: doc["savedAt"] ?? null,
1238
+ // Only a page built with `min_seconds` stores how long the item was on screen.
1239
+ ...R.pick({ seconds: doc["seconds"] ?? null }, secondsOf(ctx.key)),
1064
1240
  };
1065
1241
  }
1242
+ /** The `seconds` field, on a page built with `min_seconds`; none on any other. */
1243
+ const secondsOf = (key) => key.min_seconds === undefined ? [] : ["seconds"];
1244
+ /** One item of the labels file of a page without answerers. */
1245
+ const labelOf = (nid, meta, doc, ctx) => ({
1246
+ id: nid,
1247
+ source_id: meta.source_id,
1248
+ group: meta.group,
1249
+ ...answerOf(doc, ctx),
1250
+ });
1066
1251
  /** Same rule as for item answers: a familiarity outside the options is marked, not counted as rated. */
1067
- function groupsOf(groupIds, groups, allowed) {
1252
+ function groupsOf(groupIds, groups, allowed, prefix = "") {
1068
1253
  return Object.fromEntries(groupIds.map((g) => {
1069
- const doc = docOf(groups, g);
1254
+ const doc = docOf(groups, `${prefix}${g}`);
1070
1255
  const fam = textOf(doc["familiarity"]);
1071
1256
  const bad = fam !== null && !allowed.familiarity.includes(fam);
1072
1257
  return [
@@ -1104,9 +1289,9 @@ function reportGroups(groups, allowed) {
1104
1289
  }
1105
1290
  }
1106
1291
  /** Each practice item of the key's round: what was answered first, what was expected, whether they match. */
1107
- function scorePractice(dump, round, all) {
1292
+ function scorePractice(dump, round, all, prefix) {
1108
1293
  return Object.entries(all).map(([pid, expected]) => {
1109
- const doc = docOf(dump["practice"], `r${round}-${pid}`);
1294
+ const doc = docOf(dump["practice"], `${prefix}r${round}-${pid}`);
1110
1295
  const answers = isObject(doc["answers"]) ? doc["answers"] : {};
1111
1296
  const matched = Object.entries(expected).every(([q, v]) => answers[q] === v);
1112
1297
  return {
@@ -1118,8 +1303,8 @@ function scorePractice(dump, round, all) {
1118
1303
  };
1119
1304
  });
1120
1305
  }
1121
- /** One file per round: a second round exported the same day keeps the first. */
1122
- const practiceFile = (study, round, day) => `${study}-practice-r${String(round)}-${day}.json`;
1306
+ /** One file per round (and answerer): a second round exported the same day keeps the first. */
1307
+ const practiceFile = (study, round, day, who) => `${study}-practice-r${String(round)}${who === undefined ? "" : `-${who}`}-${day}.json`;
1123
1308
  /**
1124
1309
  * Every practice round the store holds answers for, each scored against its own expected answers
1125
1310
  * into its own file; never into the labels. The key's own round is reported even unanswered.
@@ -1128,56 +1313,99 @@ function exportPractice(dump, key, target, day) {
1128
1313
  const p = key.practice;
1129
1314
  if (p === undefined)
1130
1315
  return;
1131
- Object.entries(roundsIn(p))
1132
- .sort(([a], [b]) => Number(a) - Number(b))
1133
- .forEach(([round, expected]) => {
1134
- exportRound(key.study, Number(round), scorePractice(dump, round, expected), {
1135
- target,
1136
- day,
1137
- current: Number(round) === p.round,
1316
+ const rounds = R.sortBy(R.entries(roundsIn(p)), ([r]) => Number(r));
1317
+ (key.answerers ?? [undefined]).forEach((who) => {
1318
+ rounds.forEach(([round, expected]) => {
1319
+ exportRound(key.study, Number(round), scorePractice(dump, round, expected, who === undefined ? "" : `${who}${SEP}`), { target, day, current: Number(round) === p.round, who });
1138
1320
  });
1139
1321
  });
1140
1322
  }
1141
1323
  /** One round's scored practice to `<study>-practice-r<round>-<date>.json`, and one line about it. */
1142
1324
  function exportRound(study, round, items, at) {
1325
+ const named = `practice round ${String(round)}${at.who === undefined ? "" : ` (${at.who})`}`;
1143
1326
  if (!items.some((i) => Object.keys(i.answers).length)) {
1144
1327
  if (at.current)
1145
- say(`practice round ${String(round)}: not answered`);
1328
+ say(`${named}: not answered`);
1146
1329
  return;
1147
1330
  }
1148
1331
  const missed = items.filter((i) => !i.matched).map((i) => i.id);
1149
1332
  const matched = items.length - missed.length;
1150
- const path = join(at.target, practiceFile(study, round, at.day));
1333
+ const path = join(at.target, practiceFile(study, round, at.day, at.who));
1151
1334
  writeJson(path, {
1152
1335
  study,
1153
1336
  round,
1337
+ ...R.pickBy({ answerer: at.who }, R.isDefined),
1154
1338
  exported: at.day,
1155
1339
  matched,
1156
1340
  total: items.length,
1157
1341
  items,
1158
1342
  });
1159
- say(`practice round ${String(round)}: ${String(matched)} of ${String(items.length)} matched${missed.length ? `; missed ${missed.join(", ")}` : ""} -> ${path}`);
1343
+ say(`${named}: ${String(matched)} of ${String(items.length)} matched${missed.length ? `; missed ${missed.join(", ")}` : ""} -> ${path}`);
1160
1344
  }
1345
+ /** The counts of one set of answers and groups. */
1346
+ const tally = (items, groups) => ({
1347
+ groups,
1348
+ complete_items: items.filter((i) => i.complete).length,
1349
+ invalid_items: items.filter((i) => Object.keys(i.invalid_answers).length)
1350
+ .length,
1351
+ invalid_groups: Object.values(groups).filter((g) => g.invalid_familiarity !== null).length,
1352
+ total_items: items.length,
1353
+ });
1354
+ /** The familiarity of the groups of `onPage`, or none on a page that asks none. */
1355
+ const groupsFor = (onPage, dump, ctx, prefix = "") => ctx.allowed.familiarity.length
1356
+ ? groupsOf(R.unique(onPage.map(([, m]) => m.group)), dump["groups"], ctx.allowed, prefix)
1357
+ : {};
1161
1358
  /** The labels file: every item on the page with its answers, the groups, and the counts. */
1162
1359
  function labelsOf(dump, ctx, day) {
1163
1360
  const onPage = Object.entries(ctx.key.ids).filter(([, m]) => m.on_page);
1164
1361
  const items = onPage.map(([nid, meta]) => labelOf(nid, meta, docOf(dump["answers"], nid), ctx));
1165
- const groups = ctx.allowed.familiarity.length
1166
- ? groupsOf([...new Set(onPage.map(([, m]) => m.group))], dump["groups"], ctx.allowed)
1167
- : {};
1168
1362
  return {
1169
1363
  study: ctx.key.study,
1170
1364
  exported: day,
1171
1365
  questions: ctx.key.questions,
1172
1366
  items,
1173
- groups,
1174
- complete_items: items.filter((i) => i.complete).length,
1175
- invalid_items: items.filter((i) => Object.keys(i.invalid_answers).length)
1176
- .length,
1177
- invalid_groups: Object.values(groups).filter((g) => g.invalid_familiarity !== null).length,
1178
- total_items: items.length,
1367
+ ...tally(items, groupsFor(onPage, dump, ctx)),
1179
1368
  };
1180
1369
  }
1370
+ /**
1371
+ * The labels file of a page with answerers: each item with every answerer it is shown to and their
1372
+ * answers apart (`by_answerer`), and each answerer's groups and counts (`by_answerer` at the top).
1373
+ */
1374
+ function answererLabelsOf(dump, ctx, day, all) {
1375
+ const onPage = Object.entries(ctx.key.ids).filter(([, m]) => m.on_page);
1376
+ const docFor = (who, nid) => answerOf(docOf(dump["answers"], `${who}${SEP}${nid}`), ctx);
1377
+ const items = onPage.map(([nid, meta]) => ({
1378
+ id: nid,
1379
+ source_id: meta.source_id,
1380
+ group: meta.group,
1381
+ answerers: shownTo(all, meta),
1382
+ by_answerer: R.fromKeys(shownTo(all, meta), (who) => docFor(who, nid)),
1383
+ }));
1384
+ return {
1385
+ study: ctx.key.study,
1386
+ exported: day,
1387
+ questions: ctx.key.questions,
1388
+ answerers: all,
1389
+ items,
1390
+ by_answerer: R.fromKeys(all, (who) => tallyOf({ who, all }, onPage, dump, ctx)),
1391
+ };
1392
+ }
1393
+ /** One answerer's groups and counts, over the items shown to them. */
1394
+ function tallyOf({ who, all }, onPage, dump, ctx) {
1395
+ const mine = onPage.filter(([, m]) => shownTo(all, m).includes(who));
1396
+ return tally(mine.map(([nid]) => answerOf(docOf(dump["answers"], `${who}${SEP}${nid}`), ctx)), groupsFor(mine, dump, ctx, `${who}${SEP}`));
1397
+ }
1398
+ /** Each answerer's items and groups, named `<answerer>~<id>` in the report, as in the store. */
1399
+ function reportAnswerers(out, allowed) {
1400
+ R.forEachObj(out.by_answerer, (t, who) => {
1401
+ reportLabels(out.items.flatMap((i) => {
1402
+ const a = i.by_answerer[who];
1403
+ return a === undefined ? [] : [{ id: `${who}${SEP}${i.id}`, ...a }];
1404
+ }), allowed);
1405
+ reportGroups(R.mapKeys(t.groups, (g) => `${who}${SEP}${g}`), allowed);
1406
+ });
1407
+ }
1408
+ const completeLine = (t) => `${String(t.complete_items)} of ${String(t.total_items)} items complete`;
1181
1409
  export function exportLabels(dumpPath, keyPath, outDir, day) {
1182
1410
  const dump = readJson(dumpPath);
1183
1411
  const key = readJson(keyPath);
@@ -1186,12 +1414,28 @@ export function exportLabels(dumpPath, keyPath, outDir, day) {
1186
1414
  assertAllowed(allowed, key, keyPath);
1187
1415
  checkDump(dump, key);
1188
1416
  checkNoFamiliarity(dump, allowed);
1189
- const out = labelsOf(dump, { key, allowed }, day);
1417
+ const ctx = { key, allowed };
1418
+ const all = key.answerers;
1419
+ const out = all === undefined
1420
+ ? labelsOf(dump, ctx, day)
1421
+ : answererLabelsOf(dump, ctx, day, all);
1190
1422
  mkdirSync(outDir, { recursive: true });
1191
1423
  const path = join(outDir, `${key.study}-labels-${day}.json`);
1192
1424
  writeJson(path, out);
1193
1425
  exportPractice(dump, key, outDir, day);
1194
- say(`wrote ${path}: ${String(out.complete_items)} of ${String(out.total_items)} items complete`);
1195
- reportLabels(out.items, allowed);
1196
- reportGroups(out.groups, allowed);
1426
+ reportExport(path, out, allowed);
1427
+ }
1428
+ /** What the export wrote, and every item and group it did not count. */
1429
+ function reportExport(path, out, allowed) {
1430
+ if ("by_answerer" in out) {
1431
+ say(`wrote ${path}: ${R.entries(out.by_answerer)
1432
+ .map(([who, t]) => `${who}: ${completeLine(t)}`)
1433
+ .join("; ")}`);
1434
+ reportAnswerers(out, allowed);
1435
+ }
1436
+ else {
1437
+ say(`wrote ${path}: ${completeLine(out)}`);
1438
+ reportLabels(out.items, allowed);
1439
+ reportGroups(out.groups, allowed);
1440
+ }
1197
1441
  }