paperlint 14.1.0 → 14.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,7 @@
7
7
  import { createHash } from "node:crypto";
8
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
+ import * as R from "remeda";
10
11
  import { z } from "zod";
11
12
  import { 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";
@@ -121,6 +125,7 @@ const ALLOWED_FIELDS = [
121
125
  UNIT_FIELD,
122
126
  READINGS_FIELD,
123
127
  ...BLOCK_FIELDS,
128
+ ANSWERERS_FIELD,
124
129
  ...PRIVATE_FIELDS,
125
130
  ];
126
131
  const FAMILIARITY = [
@@ -147,6 +152,11 @@ const RESERVED_NAMES = [
147
152
  "__lookupSetter__",
148
153
  ];
149
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 = "~";
150
160
  const SOURCES = ["page", "chat"];
151
161
  const SOURCES_SHOWN = `(${SOURCES.map(quoted).join(", ")})`;
152
162
  const string = (required = true, pattern, why) => pattern === undefined
@@ -275,6 +285,7 @@ const itemSchema = (kind) => obj({
275
285
  string(f === "group" || f === "body"),
276
286
  ])),
277
287
  ...cardFields(kind),
288
+ [ANSWERERS_FIELD]: array(string(true, NAME_PATTERN), false, 1),
278
289
  }, false);
279
290
  // `icon` (an emoji) tells the buttons apart at a glance. No coloured side stripe on a button: it
280
291
  // reads as decoration, not meaning (the build-label-page look test refuses one).
@@ -310,6 +321,10 @@ const configSchema = (kind) => obj({
310
321
  seed: integer(),
311
322
  groups: mapping(string(), NAME_PATTERN, 1), // ids become store document ids
312
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),
313
328
  // true: a "how well do you know {group}" question per group, for a study that filters on it
314
329
  // (rule 5). Left out, none is asked: an approval page's author has never seen the repositories.
315
330
  familiarity: boolean(false),
@@ -495,8 +510,35 @@ function checkPractice(cfg) {
495
510
  fail(`${where}.${missing}: every question needs its expected answer`);
496
511
  });
497
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
+ }
498
539
  function checkConfig(cfg) {
499
540
  checkGroups(cfg);
541
+ checkAnswerers(cfg);
500
542
  if (!asksFamiliarity(cfg) && cfg.familiarity_question !== undefined) {
501
543
  fail(`familiarity_question: ${repr(cfg.familiarity_question)} is never asked, because \`familiarity\` is not true — remove it, or set "familiarity": true`);
502
544
  }
@@ -746,6 +788,8 @@ const DEFAULT_LABELS = {
746
788
  repeat: "Card {index} of {of}",
747
789
  practice_example: "",
748
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);
749
793
  /** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
750
794
  function payloadOf(cfg, page, described) {
751
795
  const used = Object.keys(cfg.groups).filter((g) => page.items.some((i) => i.group === g));
@@ -759,18 +803,22 @@ function payloadOf(cfg, page, described) {
759
803
  : "",
760
804
  familiarity: asksFamiliarity(cfg) ? FAMILIARITY : [],
761
805
  allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
806
+ ...twoKeys(cfg),
762
807
  questions: cfg.questions,
763
808
  practice: {
764
809
  round: practiceRound(cfg),
765
810
  items: practiceShown(cfg, described),
766
811
  },
767
- items: page.items.map((i) => ({
768
- id: page.idOf(i),
769
- ...shownFields(i),
770
- ...rulesShown(i, described),
771
- })),
812
+ items: page.items.map((i) => itemShown(i, page.idOf(i), described)),
772
813
  };
773
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
+ });
774
822
  /** Every id the key keeps (on the page now, or kept from an earlier build), with its source and group. */
775
823
  function idsOf(cfg, page) {
776
824
  const shown = new Set(page.items.map(page.idOf));
@@ -781,15 +829,14 @@ function idsOf(cfg, page) {
781
829
  ]),
782
830
  ...cfg.items.map((i) => [i.source_id, i.group]),
783
831
  ]);
832
+ const bySource = R.indexBy(cfg.items, (i) => i.source_id);
784
833
  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
- ]));
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
+ }));
793
840
  }
794
841
  /** Every round of a key's practice; a key from before `rounds` has only its own round. */
795
842
  const roundsIn = (p) => p.rounds ?? { [String(p.round)]: p.expected };
@@ -806,6 +853,7 @@ function keyOf(cfg, page, earlier) {
806
853
  return {
807
854
  study: cfg.study,
808
855
  seed: cfg.seed,
856
+ ...twoKeys(cfg),
809
857
  questions: cfg.questions.map((q) => q.id),
810
858
  // What counts as an answer: the same lists the page was given (`allowedValues`). `export`
811
859
  // checks every stored answer and familiarity against them, so the build's options decide.
@@ -855,6 +903,10 @@ function assertKey(_value) {
855
903
  // key.json is this script's own output; what an older build left out is checked where it is read.
856
904
  }
857
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"];
858
910
  const GROUP_DOC_FIELDS = ["familiarity", "savedAt", "source"];
859
911
  const DOC_FIELD_TYPES = [
860
912
  ["answers", isObject, "an object {question: value}"],
@@ -869,6 +921,11 @@ const DOC_FIELD_TYPES = [
869
921
  (v) => v === null || typeof v === "string",
870
922
  "a timestamp string or null",
871
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
+ ],
872
929
  ];
873
930
  const COLLECTIONS = ["answers", "groups", "practice"];
874
931
  const COLLECTION_NAMES = COLLECTIONS;
@@ -888,15 +945,18 @@ const practiceDocs = (key) => {
888
945
  // Each round's own questions: a later round may have dropped or renamed one.
889
946
  return Object.entries(roundsIn(p)).flatMap(([r, expected]) => Object.entries(expected).map(([pid, answers]) => [`r${r}-${pid}`, Object.keys(answers)]));
890
947
  };
891
- function knownNames(key) {
892
- 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));
893
953
  const shown = entries.filter(([, m]) => m.on_page);
894
954
  const practice = practiceDocs(key);
895
955
  return {
896
956
  answers: {
897
957
  known: entries.map(([nid]) => [nid, key.questions]),
898
958
  onPage: shown.map(([nid]) => nid),
899
- fields: ANSWER_DOC_FIELDS,
959
+ fields: answerDocFields(key),
900
960
  },
901
961
  groups: {
902
962
  known: entries.map(([, m]) => [m.group, key.questions]),
@@ -906,7 +966,7 @@ function knownNames(key) {
906
966
  practice: {
907
967
  known: practice,
908
968
  onPage: practice.map(([n]) => n),
909
- fields: ANSWER_DOC_FIELDS,
969
+ fields: answerDocFields(key),
910
970
  },
911
971
  };
912
972
  }
@@ -925,10 +985,28 @@ function checkDumpShape(dump) {
925
985
  if (stray !== undefined)
926
986
  fail(`${stray}: the store has no such collection (expected ${COLLECTIONS.join(", ")})`);
927
987
  }
928
- 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) {
929
1005
  const hit = names.known.find(([n]) => n === name);
930
1006
  if (hit === undefined) {
931
- 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(", ")})`);
932
1010
  }
933
1011
  if (!names.onPage.includes(name)) {
934
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`);
@@ -983,13 +1061,16 @@ function checkDocAnswers(where, doc, questions) {
983
1061
  */
984
1062
  function checkDump(dump, key) {
985
1063
  checkDumpShape(dump);
986
- const names = knownNames(key);
1064
+ // A page without answerers knows one set of names; each answerer has their own.
1065
+ const plain = knownNames(key, {});
987
1066
  for (const kind of COLLECTIONS) {
988
1067
  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);
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);
993
1074
  checkDocAnswers(where, doc, questions);
994
1075
  }
995
1076
  }
@@ -1042,8 +1123,8 @@ function answersOf(doc, ctx) {
1042
1123
  const given = Object.fromEntries(raw.map(([q, v]) => [q, has(invalid, q) ? null : v]));
1043
1124
  return { given, invalid };
1044
1125
  }
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) {
1126
+ /** One stored answer: its answers, what is invalid, the reasons, and whether it is done. */
1127
+ function answerOf(doc, ctx) {
1047
1128
  const { given, invalid } = answersOf(doc, ctx);
1048
1129
  const reasons = Object.fromEntries(Object.entries(textsOf(doc["reasons"])).filter(([, r]) => r.trim()));
1049
1130
  const needsWhy = ctx.allowed.needs_why ?? {};
@@ -1051,9 +1132,6 @@ function labelOf(nid, meta, doc, ctx) {
1051
1132
  .filter(([q, v]) => v !== null && listed(needsWhy, q, v) && !has(reasons, q))
1052
1133
  .map(([q]) => q);
1053
1134
  return {
1054
- id: nid,
1055
- source_id: meta.source_id,
1056
- group: meta.group,
1057
1135
  answers: given,
1058
1136
  invalid_answers: invalid,
1059
1137
  reasons,
@@ -1061,12 +1139,23 @@ function labelOf(nid, meta, doc, ctx) {
1061
1139
  complete: Object.values(given).every((v) => v !== null) && !unexplained.length,
1062
1140
  source: textOf(doc["source"]),
1063
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)),
1064
1144
  };
1065
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
+ });
1066
1155
  /** Same rule as for item answers: a familiarity outside the options is marked, not counted as rated. */
1067
- function groupsOf(groupIds, groups, allowed) {
1156
+ function groupsOf(groupIds, groups, allowed, prefix = "") {
1068
1157
  return Object.fromEntries(groupIds.map((g) => {
1069
- const doc = docOf(groups, g);
1158
+ const doc = docOf(groups, `${prefix}${g}`);
1070
1159
  const fam = textOf(doc["familiarity"]);
1071
1160
  const bad = fam !== null && !allowed.familiarity.includes(fam);
1072
1161
  return [
@@ -1104,9 +1193,9 @@ function reportGroups(groups, allowed) {
1104
1193
  }
1105
1194
  }
1106
1195
  /** Each practice item of the key's round: what was answered first, what was expected, whether they match. */
1107
- function scorePractice(dump, round, all) {
1196
+ function scorePractice(dump, round, all, prefix) {
1108
1197
  return Object.entries(all).map(([pid, expected]) => {
1109
- const doc = docOf(dump["practice"], `r${round}-${pid}`);
1198
+ const doc = docOf(dump["practice"], `${prefix}r${round}-${pid}`);
1110
1199
  const answers = isObject(doc["answers"]) ? doc["answers"] : {};
1111
1200
  const matched = Object.entries(expected).every(([q, v]) => answers[q] === v);
1112
1201
  return {
@@ -1118,8 +1207,8 @@ function scorePractice(dump, round, all) {
1118
1207
  };
1119
1208
  });
1120
1209
  }
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`;
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`;
1123
1212
  /**
1124
1213
  * Every practice round the store holds answers for, each scored against its own expected answers
1125
1214
  * into its own file; never into the labels. The key's own round is reported even unanswered.
@@ -1128,56 +1217,99 @@ function exportPractice(dump, key, target, day) {
1128
1217
  const p = key.practice;
1129
1218
  if (p === undefined)
1130
1219
  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,
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 });
1138
1224
  });
1139
1225
  });
1140
1226
  }
1141
1227
  /** One round's scored practice to `<study>-practice-r<round>-<date>.json`, and one line about it. */
1142
1228
  function exportRound(study, round, items, at) {
1229
+ const named = `practice round ${String(round)}${at.who === undefined ? "" : ` (${at.who})`}`;
1143
1230
  if (!items.some((i) => Object.keys(i.answers).length)) {
1144
1231
  if (at.current)
1145
- say(`practice round ${String(round)}: not answered`);
1232
+ say(`${named}: not answered`);
1146
1233
  return;
1147
1234
  }
1148
1235
  const missed = items.filter((i) => !i.matched).map((i) => i.id);
1149
1236
  const matched = items.length - missed.length;
1150
- const path = join(at.target, practiceFile(study, round, at.day));
1237
+ const path = join(at.target, practiceFile(study, round, at.day, at.who));
1151
1238
  writeJson(path, {
1152
1239
  study,
1153
1240
  round,
1241
+ ...R.pickBy({ answerer: at.who }, R.isDefined),
1154
1242
  exported: at.day,
1155
1243
  matched,
1156
1244
  total: items.length,
1157
1245
  items,
1158
1246
  });
1159
- 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}`);
1160
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
+ : {};
1161
1262
  /** The labels file: every item on the page with its answers, the groups, and the counts. */
1162
1263
  function labelsOf(dump, ctx, day) {
1163
1264
  const onPage = Object.entries(ctx.key.ids).filter(([, m]) => m.on_page);
1164
1265
  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
1266
  return {
1169
1267
  study: ctx.key.study,
1170
1268
  exported: day,
1171
1269
  questions: ctx.key.questions,
1172
1270
  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,
1271
+ ...tally(items, groupsFor(onPage, dump, ctx)),
1179
1272
  };
1180
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`;
1181
1313
  export function exportLabels(dumpPath, keyPath, outDir, day) {
1182
1314
  const dump = readJson(dumpPath);
1183
1315
  const key = readJson(keyPath);
@@ -1186,12 +1318,28 @@ export function exportLabels(dumpPath, keyPath, outDir, day) {
1186
1318
  assertAllowed(allowed, key, keyPath);
1187
1319
  checkDump(dump, key);
1188
1320
  checkNoFamiliarity(dump, allowed);
1189
- 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);
1190
1326
  mkdirSync(outDir, { recursive: true });
1191
1327
  const path = join(outDir, `${key.study}-labels-${day}.json`);
1192
1328
  writeJson(path, out);
1193
1329
  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);
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
+ }
1197
1345
  }
@@ -6,6 +6,7 @@
6
6
  import assert from "node:assert/strict";
7
7
  import { readFileSync, writeFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
+ import * as R from "remeda";
9
10
  import { test } from "vitest";
10
11
  import { z } from "zod";
11
12
  import {
@@ -133,6 +134,8 @@ const FULL: DocObject = (() => {
133
134
  QUESTIONS[1],
134
135
  ],
135
136
  labeller_knows: ["python", "go", "rust"],
137
+ answerers: ["author", "second"],
138
+ min_seconds: 30,
136
139
  familiarity: true,
137
140
  familiarity_question: "How well do you know {group}?",
138
141
  labels: {
@@ -144,7 +147,9 @@ const FULL: DocObject = (() => {
144
147
  practice_example: "P",
145
148
  },
146
149
  practice: PRACTICE.map((p, n) => (n === 0 ? { ...p, ...CARD } : p)),
147
- items: ITEMS.map((i, n) => (n === 0 ? { ...i, ...CARD } : i)),
150
+ items: ITEMS.map((i, n) =>
151
+ n === 0 ? { ...i, ...CARD, answerers: ["author"] } : i,
152
+ ),
148
153
  }),
149
154
  );
150
155
  assert.ok(isObject(doc));
@@ -275,6 +280,21 @@ function badCases({ node, member }: Leaf): readonly BadCase[] {
275
280
  return [...wrongType, ...extra];
276
281
  }
277
282
 
283
+ /** `doc` with its first item's own answerers taken out. */
284
+ function withoutItemAnswerers(doc: DocObject): DocObject {
285
+ const out = put(doc, ["items", 0, "answerers"], undefined);
286
+ assert.ok(isObject(out));
287
+ return out;
288
+ }
289
+ /**
290
+ * A field whose absence takes another with it: familiarity_question asks nothing unless familiarity
291
+ * is on, and an item's answerers name the page's.
292
+ */
293
+ const RELAX: Partial<Record<string, (doc: DocObject) => DocObject>> = {
294
+ familiarity: (doc) => R.omit(doc, ["familiarity_question"]),
295
+ answerers: withoutItemAnswerers,
296
+ };
297
+
278
298
  /** Spoil `leaf` each way in `badCases`; every one must stop the build and name the path. */
279
299
  function expectRefused(leaf: Leaf): void {
280
300
  badCases(leaf).forEach(([label, value], i) => {
@@ -290,16 +310,9 @@ function expectRefused(leaf: Leaf): void {
290
310
  });
291
311
  if (!leaf.node.required && !leaf.member) {
292
312
  const absent = spoil(leaf.path, undefined);
293
- // familiarity_question asks nothing unless familiarity is on: the two leave together.
294
313
  const r = tryBuild(
295
314
  `schema-${leaf.path.replace(/\W/g, "_")}-absent`,
296
- leaf.path === "familiarity"
297
- ? Object.fromEntries(
298
- Object.entries(absent).filter(
299
- ([k]) => k !== "familiarity_question",
300
- ),
301
- )
302
- : absent,
315
+ (RELAX[leaf.path] ?? R.identity())(absent),
303
316
  );
304
317
  assert.equal(r.status, 0, `${leaf.path} is optional: ${r.out}`);
305
318
  }
@@ -313,6 +326,8 @@ const DOCUMENTED_FIELDS = [
313
326
  "seed",
314
327
  "groups",
315
328
  "labeller_knows",
329
+ "answerers",
330
+ "min_seconds",
316
331
  "familiarity",
317
332
  "familiarity_question",
318
333
  "questions",