champollion 0.3.3 → 0.4.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.
Files changed (142) hide show
  1. package/README.md +52 -37
  2. package/bin/cli.js +53 -5
  3. package/index.js +63 -2
  4. package/lib/api-key.js +17 -4
  5. package/lib/autofix.js +83 -36
  6. package/lib/bridge/method_bridge.py +15 -3
  7. package/lib/cards/reader.js +51 -3
  8. package/lib/cards/remote.js +15 -0
  9. package/lib/cards/search-names.js +178 -0
  10. package/lib/command-help.js +289 -88
  11. package/lib/commands/audit.js +10 -3
  12. package/lib/commands/card.js +583 -226
  13. package/lib/commands/doctor.js +54 -18
  14. package/lib/commands/help.js +37 -32
  15. package/lib/commands/init.js +1689 -87
  16. package/lib/commands/integrity.js +127 -40
  17. package/lib/commands/leaderboard.js +187 -67
  18. package/lib/commands/models.js +9 -2
  19. package/lib/commands/provenance.js +7 -2
  20. package/lib/commands/recommend.js +43 -14
  21. package/lib/commands/register-corpus.js +649 -130
  22. package/lib/commands/seal-corpus.js +1 -1
  23. package/lib/commands/status.js +564 -27
  24. package/lib/commands/submit.js +17 -12
  25. package/lib/commands/sync.js +31 -7
  26. package/lib/commands/tm.js +16 -10
  27. package/lib/commands/verify.js +27 -3
  28. package/lib/commands/wrap.js +63 -5
  29. package/lib/commands/xliff.js +135 -64
  30. package/lib/commercial-eligibility.js +1 -1
  31. package/lib/config.js +196 -14
  32. package/lib/content-estimate.js +96 -0
  33. package/lib/content-refusals.js +270 -0
  34. package/lib/content-review.js +372 -0
  35. package/lib/content-sync.js +1127 -344
  36. package/lib/content.js +94 -7
  37. package/lib/corpus-registration.mjs +197 -38
  38. package/lib/cost-label.js +29 -0
  39. package/lib/cost-report.js +726 -78
  40. package/lib/diff.js +38 -4
  41. package/lib/docusaurus-sync.js +965 -253
  42. package/lib/edit-distance.js +31 -0
  43. package/lib/fallback.js +964 -0
  44. package/lib/file-scope.js +106 -0
  45. package/lib/flatten.js +80 -3
  46. package/lib/flutter-locales.js +124 -0
  47. package/lib/format.js +266 -12
  48. package/lib/hash.js +146 -21
  49. package/lib/icu-structure.js +929 -0
  50. package/lib/integrity.js +223 -75
  51. package/lib/language-pair.js +157 -0
  52. package/lib/lint.js +78 -16
  53. package/lib/local-only-marks.js +106 -0
  54. package/lib/locale-layout.js +1103 -0
  55. package/lib/locale-state.js +571 -0
  56. package/lib/methods/anthropic.js +5 -0
  57. package/lib/methods/apertium.js +6 -3
  58. package/lib/methods/api.js +138 -25
  59. package/lib/methods/base.js +17 -0
  60. package/lib/methods/coaching-data.js +153 -0
  61. package/lib/methods/content-separator.js +43 -0
  62. package/lib/methods/deepl.js +1 -1
  63. package/lib/methods/direct-llm.js +252 -103
  64. package/lib/methods/external.js +146 -63
  65. package/lib/methods/gemini.js +1 -0
  66. package/lib/methods/google-translate.js +1 -0
  67. package/lib/methods/http-utils.js +41 -0
  68. package/lib/methods/libretranslate.js +7 -2
  69. package/lib/methods/llm-coached.js +68 -128
  70. package/lib/methods/llm.js +80 -31
  71. package/lib/methods/local.js +93 -10
  72. package/lib/methods/microsoft-translator.js +1 -2
  73. package/lib/methods/openai.js +4 -2
  74. package/lib/methods/openrouter-client.js +20 -19
  75. package/lib/methods/openrouter-pricing.js +150 -13
  76. package/lib/methods/prompt-methods.js +20 -0
  77. package/lib/methods/provider-pricing.js +42 -1
  78. package/lib/methods/request-capture.js +104 -0
  79. package/lib/methods/tilde.js +1 -1
  80. package/lib/methods/translated.js +1 -2
  81. package/lib/missing-key.js +93 -0
  82. package/lib/models.js +11 -0
  83. package/lib/name-rules.js +32 -0
  84. package/lib/named-keys.js +172 -0
  85. package/lib/no-translate.js +4 -3
  86. package/lib/output.js +160 -19
  87. package/lib/pairs.js +586 -30
  88. package/lib/placeholders.js +394 -0
  89. package/lib/plugins.js +8 -0
  90. package/lib/plural-gap-redo.js +109 -0
  91. package/lib/plurals.js +323 -0
  92. package/lib/po.js +1187 -0
  93. package/lib/public-catalogue.js +74 -0
  94. package/lib/recommend.js +527 -32
  95. package/lib/redo.js +95 -0
  96. package/lib/refusal-category.js +44 -0
  97. package/lib/registers.js +255 -11
  98. package/lib/repair-script.js +20 -13
  99. package/lib/scripts.js +193 -106
  100. package/lib/seal.mjs +6 -5
  101. package/lib/sealed-qualifier.mjs +2 -2
  102. package/lib/segment.js +2 -1
  103. package/lib/seo.js +19 -9
  104. package/lib/serve.js +43 -6
  105. package/lib/shared-output-seed.js +164 -0
  106. package/lib/source-contexts.js +39 -0
  107. package/lib/submit.mjs +57 -5
  108. package/lib/sync.js +2923 -474
  109. package/lib/terminology.js +13 -4
  110. package/lib/tm-evict.js +179 -0
  111. package/lib/tm-seed.js +5 -2
  112. package/lib/tm.js +818 -36
  113. package/lib/translate-pair.js +639 -34
  114. package/lib/translate.js +78 -5
  115. package/lib/types.js +22 -3
  116. package/lib/validate.js +880 -17
  117. package/lib/verify.js +1296 -104
  118. package/lib/watch.js +32 -13
  119. package/lib/xliff.js +44 -3
  120. package/package.json +3 -2
  121. package/shared/CORPORA-CARDS.md +2 -0
  122. package/shared/DATA-SOVEREIGNTY.md +19 -20
  123. package/shared/LANGUAGE-CARD-FIELDS.md +1 -1
  124. package/shared/cards-fallback.json +1 -1
  125. package/shared/catalogue/card-config.json +1 -1
  126. package/shared/curated-orthography-conventions.json +26 -8
  127. package/shared/docent/faq.en.json +14 -16
  128. package/shared/docent/system-prompt.md +17 -19
  129. package/shared/explainers/tc-features.json +15 -15
  130. package/shared/gettext-plural-forms.json +45 -0
  131. package/shared/human-services.json +1 -1
  132. package/shared/method-registry.json +2 -0
  133. package/shared/metric-registry.json +96 -18
  134. package/shared/schemas/champollion-plugin.schema.json +4 -0
  135. package/shared/schemas/corpora-card.schema.json +20 -10
  136. package/shared/schemas/human-services.schema.json +2 -2
  137. package/shared/schemas/language-card.schema.json +1 -1
  138. package/shared/schemas/method-card.schema.json +1 -1
  139. package/shared/schemas/method-index-record.schema.json +67 -0
  140. package/shared/schemas/method-registry.schema.json +4 -0
  141. package/shared/schemas/metric-registry.schema.json +55 -1
  142. package/shared/docent/corpus.json +0 -11333
package/lib/autofix.js CHANGED
@@ -12,6 +12,14 @@
12
12
  * 5. One-undo — `wrap --undo` restores from backup
13
13
  * 6. Diff output — prints git-style diff of every change
14
14
  *
15
+ * NOT FOR FLUTTER OR GETTEXT. wrap writes dotted keys for t('…') calls.
16
+ * An ARB key must be a Dart identifier, read as
17
+ * AppLocalizations.of(context)!.homeTitle; a gettext key IS the source
18
+ * text, marked with _("…") and collected by the project's own extractor.
19
+ * Writing "general.welcome" into either produces a catalog the app cannot
20
+ * use, so wrap refuses those formats before any component is rewritten
21
+ * (wrapUnsupportedReason, checked by the wrap command up front).
22
+ *
15
23
  * KEY GENERATION:
16
24
  * "Welcome to my portfolio" → t("general.welcome_to_my_portfolio")
17
25
  * "Get in Touch" → t("general.get_in_touch")
@@ -24,6 +32,7 @@ import fs from 'node:fs';
24
32
  import path from 'node:path';
25
33
  import { execSync } from 'node:child_process';
26
34
  import { isNonTranslatableString } from './string-classify.js';
35
+ import { readLocaleFile, writeLocaleFile, detectYAMLStyle } from './format.js';
27
36
 
28
37
  // -----------------------------------------------------------------
29
38
  // Key generation
@@ -343,57 +352,94 @@ function isAmbiguous(text, lineContext) {
343
352
  }
344
353
 
345
354
  /**
346
- * Add generated keys to locale files.
355
+ * Why `wrap` cannot store extracted keys in this locale file, or null when
356
+ * it can. Checked by the wrap command BEFORE any component is rewritten
357
+ * (a rewritten t('…') call with nowhere to store its key shows the raw key
358
+ * name in the app), and again by addKeysToLocales.
347
359
  *
348
- * @param {object[]} fixes - Array of { key, text } from processFile
349
- * @param {string} localesDir - Path to locale files directory
350
- * @param {string} sourceLocale - Source locale code (e.g., 'en')
351
- * @param {string[]} targetLocales - Target locale codes
360
+ * @param {{ format: string, rel?: string, path?: string }} file
361
+ * @returns {string|null}
352
362
  */
353
- function addKeysToLocales(fixes, localesDir, sourceLocale, targetLocales) {
354
- if (fixes.length === 0) return;
355
-
356
- // Build key-value map from fixes
357
- const newKeys = {};
358
- for (const fix of fixes) {
359
- // Expand dot-notation key into nested object
360
- const parts = fix.key.split('.');
361
- let current = newKeys;
362
- for (let i = 0; i < parts.length - 1; i++) {
363
- if (!current[parts[i]]) current[parts[i]] = {};
364
- current = current[parts[i]];
365
- }
366
- current[parts[parts.length - 1]] = fix.text;
363
+ function wrapUnsupportedReason(file) {
364
+ const name = file.rel || (file.path ? path.basename(file.path) : 'the locale file');
365
+ if (file.format === 'arb') {
366
+ return `wrap does not apply to Flutter ARB files (${name}): it writes dotted keys for t('…') calls, `
367
+ + 'but an ARB key must be a Dart identifier, read as AppLocalizations.of(context)!.myKey. '
368
+ + `Add messages to ${name} yourself — champollion sync translates them.`;
367
369
  }
370
+ if (file.format === 'po') {
371
+ return `wrap does not apply to gettext catalogs (${name}): in gettext the key IS the source text. `
372
+ + 'Mark strings with _("…") / gettext("…") (Django: {% translate %}), collect them with your '
373
+ + 'extractor (makemessages, pybabel extract, xgettext), and champollion sync translates the catalog.';
374
+ }
375
+ return null;
376
+ }
368
377
 
369
- // Update source locale file
370
- const sourcePath = path.join(localesDir, `${sourceLocale}.json`);
371
- if (fs.existsSync(sourcePath)) {
372
- const existing = JSON.parse(fs.readFileSync(sourcePath, 'utf-8'));
373
- const merged = deepMerge(existing, newKeys);
374
- fs.writeFileSync(sourcePath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
378
+ /**
379
+ * Add generated keys to locale files.
380
+ *
381
+ * The files come from the project's locale layout (lib/locale-layout.js),
382
+ * resolved by the caller BEFORE any source file is rewritten: one file for
383
+ * a flat project, the chosen namespace's file for a folder-per-locale one.
384
+ * The format is the file's own — a Hugo project's en.toml gains TOML keys
385
+ * (this used to look only for `<locale>.json`, so TOML/YAML projects lost
386
+ * every extracted key without a word).
387
+ *
388
+ * Source gets the extracted text; each EXISTING target file gets the same
389
+ * keys with an "[EN] " placeholder, which the next sync translates. Keys
390
+ * already present are never overwritten.
391
+ *
392
+ * @param {object[]} fixes - Array of { key, text } from processFile
393
+ * @param {{ path: string, format: string }} sourceFile - Source locale file
394
+ * @param {Array<{ path: string, format: string }>} targetFiles - Target locale files
395
+ * @returns {{ source: boolean, targets: number }} What was written
396
+ */
397
+ function addKeysToLocales(fixes, sourceFile, targetFiles = []) {
398
+ const written = { source: false, targets: 0 };
399
+ if (fixes.length === 0) return written;
400
+ // Defense in depth: the wrap command refuses these formats up front.
401
+ for (const file of [sourceFile, ...targetFiles]) {
402
+ const reason = wrapUnsupportedReason(file);
403
+ if (reason) throw new Error(reason);
375
404
  }
376
405
 
377
- // Add placeholder entries to target locale files
378
- for (const locale of targetLocales) {
379
- const targetPath = path.join(localesDir, `${locale}.json`);
380
- if (fs.existsSync(targetPath)) {
381
- const existing = JSON.parse(fs.readFileSync(targetPath, 'utf-8'));
382
- // Use [EN] prefix for target locales as untranslated markers
383
- const placeholders = {};
406
+ const addTo = (file, prefix) => {
407
+ if (!fs.existsSync(file.path)) return false;
408
+ if (file.format === 'json') {
409
+ // Build the nested object of new keys from dot-notation keys.
410
+ const newKeys = {};
384
411
  for (const fix of fixes) {
385
412
  const parts = fix.key.split('.');
386
- let current = placeholders;
413
+ let current = newKeys;
387
414
  for (let i = 0; i < parts.length - 1; i++) {
388
415
  if (!current[parts[i]]) current[parts[i]] = {};
389
416
  current = current[parts[i]];
390
417
  }
391
- current[parts[parts.length - 1]] = `[EN] ${fix.text}`;
418
+ current[parts[parts.length - 1]] = `${prefix}${fix.text}`;
392
419
  }
393
- const merged = deepMerge(existing, placeholders);
394
- fs.writeFileSync(targetPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
420
+ const existing = JSON.parse(fs.readFileSync(file.path, 'utf-8'));
421
+ const merged = deepMerge(existing, newKeys);
422
+ fs.writeFileSync(file.path, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
423
+ return true;
424
+ }
425
+ // TOML / YAML: flat maps through the same reader/writer sync uses.
426
+ const flat = readLocaleFile(file.path, file.format);
427
+ for (const fix of fixes) {
428
+ if (!(fix.key in flat)) flat[fix.key] = `${prefix}${fix.text}`;
395
429
  }
430
+ const yamlStyle = file.format === 'yaml'
431
+ ? detectYAMLStyle(fs.readFileSync(file.path, 'utf-8'))
432
+ : null;
433
+ writeLocaleFile(file.path, flat, file.format, flat, yamlStyle);
434
+ return true;
435
+ };
436
+
437
+ written.source = addTo(sourceFile, '');
438
+ // Use [EN] prefix for target locales as untranslated markers
439
+ for (const target of targetFiles) {
440
+ if (addTo(target, '[EN] ')) written.targets++;
396
441
  }
442
+ return written;
397
443
  }
398
444
 
399
445
  /**
@@ -428,5 +474,6 @@ export {
428
474
  shouldFixText,
429
475
  isAmbiguous,
430
476
  addKeysToLocales,
477
+ wrapUnsupportedReason,
431
478
  deepMerge,
432
479
  };
@@ -27,7 +27,7 @@ the method modules themselves, which bring their own dependencies).
27
27
  Why self-contained:
28
28
  The bridge inlines the method_loader logic (~60 lines) so the user
29
29
  does NOT need to pip install the Arena. Only the method module
30
- (e.g., pip install crk-translate) is required.
30
+ (e.g., python3 -m pip install crk-translate) is required.
31
31
 
32
32
  Maintained alongside the Arena's method_loader.py — if the manifest
33
33
  format changes there, it must be updated here. Both live in the same
@@ -58,6 +58,13 @@ logger = logging.getLogger("champollion.bridge")
58
58
 
59
59
  _REQUIRED_MANIFEST_FIELDS = {"name", "entry_point"}
60
60
 
61
+ # Temperature a plugin receives when the CLI config sets none. It MUST equal
62
+ # the harness RunConfig default (arena/mt_eval_harness/config.py
63
+ # DEFAULT_TEMPERATURE) — a plugin validated in the Arena must see the same
64
+ # config here. cli/test/external-method.test.js checks the two against each
65
+ # other. (This was 0.3: a CLI-only default the plugin never saw in the Arena.)
66
+ HARNESS_DEFAULT_TEMPERATURE = 0.0
67
+
61
68
 
62
69
  class MethodLoadError(Exception):
63
70
  """Raised when a method plugin cannot be loaded."""
@@ -145,7 +152,7 @@ def _load_method(method_dir: Path) -> tuple:
145
152
  raise MethodLoadError(
146
153
  f"Failed to load {module_file}: {type(e).__name__}: {e}\n"
147
154
  f" Check that the method module's dependencies are installed.\n"
148
- f" Try: pip install -e {method_dir.parent}"
155
+ f" Try: {sys.executable} -m pip install -e '{method_dir.parent}'"
149
156
  ) from e
150
157
 
151
158
  if not hasattr(module, class_name):
@@ -280,7 +287,12 @@ async def _handle_translate(method, request: dict) -> dict:
280
287
  cfg.model_id = config_overrides.get("model") # CrkPipelineMethod reads this
281
288
  cfg.source_lang = request.get("source_locale", "en")
282
289
  cfg.target_lang = request.get("target_locale", "")
283
- cfg.temperature = config_overrides.get("temperature", 0.3)
290
+ # The CLI sends null (or omits the key) when the config sets no
291
+ # temperature; either way the plugin gets the harness default.
292
+ temperature = config_overrides.get("temperature")
293
+ cfg.temperature = (
294
+ HARNESS_DEFAULT_TEMPERATURE if temperature is None else float(temperature)
295
+ )
284
296
 
285
297
  # Call the method's translate — same interface the Arena uses
286
298
  results = await method.translate(entries, cfg)
@@ -122,6 +122,40 @@ export function isDisputed(value) {
122
122
  || value.agreement === AGREEMENT.INCOMMENSURABLE);
123
123
  }
124
124
 
125
+ /**
126
+ * Every attribution envelope on a card, with the dotted path it sits at.
127
+ *
128
+ * A display layer that lists the envelopes it knows by name goes stale the
129
+ * day the atlas starts attributing another field — and the new field's
130
+ * disagreement then never reaches a reader. Walking the card finds them all,
131
+ * so a surface can show every disputed field, including ones it was not
132
+ * written for. `_`-prefixed keys are bookkeeping (provenance, build stamps),
133
+ * not facts, and are skipped; an envelope's own values are not walked into.
134
+ *
135
+ * @param {object} card a raw or normalized card
136
+ * @returns {{path: string, value: object}[]}
137
+ */
138
+ export function attributedFields(card) {
139
+ const found = [];
140
+ const walk = (v, p) => {
141
+ if (!v || typeof v !== 'object') return;
142
+ if (isAttributed(v)) {
143
+ found.push({ path: p, value: v });
144
+ return;
145
+ }
146
+ if (Array.isArray(v)) {
147
+ v.forEach((x, i) => walk(x, `${p}[${i}]`));
148
+ return;
149
+ }
150
+ for (const [k, x] of Object.entries(v)) {
151
+ if (k.startsWith('_')) continue;
152
+ walk(x, p ? `${p}.${k}` : k);
153
+ }
154
+ };
155
+ walk(card, '');
156
+ return found;
157
+ }
158
+
125
159
  /**
126
160
  * Read one card. Returns null when the language has no card — which is a real
127
161
  * answer: a language with no asserted value gets no card, rather than an empty
@@ -386,10 +420,24 @@ export function normalizeCard(card) {
386
420
  if (scales) {
387
421
  const claims = attributions(out.endangerment);
388
422
  for (const source of scales.authorityOrder) {
389
- const hit = claims.find((c) => String(c.source ?? '').startsWith(source));
423
+ // One source can hold several assessments (ELCat keeps one per
424
+ // record, each with its own certainty). The projector stores them
425
+ // alphabetically, which says nothing about confidence — reading the
426
+ // first one showed Plains Cree as "endangered" from a 0.2-certainty,
427
+ // BC-only record over ELCat's 0.8-certainty "threatened". Take the
428
+ // assessment the source is most certain of; ties keep stored order.
429
+ let hit = null;
430
+ let tier = null;
431
+ let best = -Infinity;
432
+ for (const c of claims) {
433
+ if (!String(c.source ?? '').startsWith(source)) continue;
434
+ const t = scales.scales[source]?.map?.[String(c.value).trim()];
435
+ if (!t) continue;
436
+ const m = /Certainty:\s*([0-9.]+)/.exec(String(c.note ?? ''));
437
+ const certainty = m ? Number(m[1]) : -1;
438
+ if (certainty > best) { best = certainty; hit = c; tier = t; }
439
+ }
390
440
  if (!hit) continue;
391
- const tier = scales.scales[source]?.map?.[String(hit.value).trim()];
392
- if (!tier) continue;
393
441
  out.vitality = {
394
442
  unescoStatus: tier,
395
443
  assessedBy: hit.source,
@@ -262,6 +262,15 @@ export function deriveRegistersFromFormality(formality) {
262
262
  * be worse than omitting them.
263
263
  */
264
264
  const DETAIL_PASSTHROUGH = [
265
+ // Attributed envelope, same shape as the card's ({agreement, values:
266
+ // [{value, source, note}]}); the reader derives vitality from it by source.
267
+ // It was published but not passed through, so every long-tail card lost
268
+ // its endangerment (MCP get_language on abp, 2026-10-03).
269
+ 'endangerment',
270
+ // Dictionaries / grammars / wordlists — "what exists for this language".
271
+ // Published by build-trading-card-data.mjs from 2026-10-03 on; absent on
272
+ // rows uploaded before that, in which case the field simply stays unset.
273
+ 'lexicalResources',
265
274
  'classification',
266
275
  'vitality',
267
276
  'speakerEstimates',
@@ -290,6 +299,10 @@ const DETAIL_PASSTHROUGH = [
290
299
  'macrolanguage',
291
300
  'members',
292
301
  'taxonomyNotes',
302
+ // Per-field citations (build-trading-card-data.mjs from 2026-10-04 on).
303
+ // Absent on rows uploaded before that: readers then report the source as
304
+ // not carried rather than inventing one.
305
+ '_fieldSources',
293
306
  ];
294
307
 
295
308
  /**
@@ -352,7 +365,9 @@ export function buildCardFromRemote(indexRow, detailRow, { aliases } = {}) {
352
365
  if (derived.defaultKey && !card.formality.default) {
353
366
  card.formality = { ...card.formality, default: derived.defaultKey };
354
367
  }
368
+ // Merged, never replaced: the detail blob's own citations came first.
355
369
  card._fieldSources = {
370
+ ...(card._fieldSources || {}),
356
371
  registers: `derived-from-formality (${card.formality.source || 'unknown'})`,
357
372
  };
358
373
  }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * search-names.js — the names a language is FOUND by, for the bundled manifest.
3
+ *
4
+ * An npm install ships the full card for ~1,150 core languages only; every
5
+ * other language is a manifest entry in shared/cards-fallback.json. That entry
6
+ * used to carry the displayed name and code aliases and nothing else, so an
7
+ * install could not find Innu (moe) by "Montagnais" — the alternate name
8
+ * ISO 639-3 records — nor 1,543 languages by their endonyms nor 411 by the
9
+ * name another registry gives them (Round 11). The repo corpus found them all.
10
+ *
11
+ * This module turns a card's recorded names into the manifest's compact
12
+ * search-name list, and back:
13
+ *
14
+ * - which names: every value of the `name` envelope (each registry's name),
15
+ * every value of the `endonym` envelope, and `alternateNames` (ISO 639-3's
16
+ * list) — read through the card adapter's `attributions()`, each with the
17
+ * source that records it;
18
+ * - deduplicated the way search compares names (`foldForSearch`, the same
19
+ * fold as the MCP server's normalizeForMatch): a name that folds to the
20
+ * displayed name or a code alias is dropped (search already finds it), and
21
+ * names that fold alike are kept once — the first spelling, cited to the
22
+ * sources that record exactly that spelling (never to a source that wrote
23
+ * it differently);
24
+ * - each name's sources kept compactly: `[text, ref, ref, …]`, where a ref
25
+ * indexes the bundle's `nameRefs` table of `[field, source]` pairs
26
+ * (three sources × three fields today — a source id is written once, not
27
+ * once per name).
28
+ *
29
+ * A search name is for FINDING a language. Anything a result shows from one
30
+ * carries its field and source (decodeSearchNames returns both), so the
31
+ * result can say how it matched and cite it.
32
+ */
33
+
34
+ import { attributions, isAttributed } from './reader.js';
35
+
36
+ /** Card fields whose values are names a language is known by, in card order. */
37
+ export const SEARCH_NAME_FIELDS = Object.freeze(['name', 'endonym', 'alternateNames']);
38
+
39
+ /**
40
+ * Fold a string the way language search compares names: Unicode-decompose,
41
+ * drop combining marks, lowercase, collapse every run of non-letters/digits
42
+ * to one space. "Èdè Yorùbá" → "ede yoruba"; "Ta'Izzi-Adeni" → "ta izzi adeni".
43
+ * Must stay identical to normalizeForMatch in mcp-server/src/tools/languages.js
44
+ * (a test there holds the two together).
45
+ *
46
+ * @param {unknown} s
47
+ * @returns {string}
48
+ */
49
+ export function foldForSearch(s) {
50
+ return String(s ?? '')
51
+ .normalize('NFD')
52
+ .replace(/\p{M}+/gu, '')
53
+ .toLowerCase()
54
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
55
+ .trim();
56
+ }
57
+
58
+ /** A bare lowercase 2–3 letter string is a code, not a name ("Ata" is a name). */
59
+ const CODE_LIKE = /^[a-z]{2,3}$/;
60
+
61
+ /** The sources a card stamps on a flat field (`_fieldSources`). */
62
+ function stampedSources(card, field) {
63
+ const v = card?._fieldSources?.[field];
64
+ return (Array.isArray(v) ? v : [v]).filter((s) => typeof s === 'string' && s);
65
+ }
66
+
67
+ /**
68
+ * Every recorded name on a RAW card (before normalizeCard flattens `name`),
69
+ * each with the field and source that record it.
70
+ *
71
+ * @param {object} raw a card as stored (attribution envelopes intact)
72
+ * @returns {Array<{text: string, field: string, source: string|null}>}
73
+ */
74
+ export function searchNameClaims(raw) {
75
+ const out = [];
76
+ for (const field of SEARCH_NAME_FIELDS) {
77
+ const value = raw?.[field];
78
+ if (value === null || value === undefined) continue;
79
+ // An envelope names each value's source; a flat value (alternateNames is
80
+ // a plain list) carries the sources the card stamps on the field.
81
+ const stamped = isAttributed(value) ? null : stampedSources(raw, field);
82
+ for (const claim of attributions(value)) {
83
+ const values = Array.isArray(claim?.value) ? claim.value : [claim?.value];
84
+ const sources = stamped ?? [claim?.source];
85
+ for (const v of values) {
86
+ if (typeof v !== 'string' || !v.trim()) continue;
87
+ const text = v.trim();
88
+ if (field === 'alternateNames' && CODE_LIKE.test(text)) continue;
89
+ for (const s of sources.length ? sources : [null]) {
90
+ out.push({ text, field, source: typeof s === 'string' && s ? s : null });
91
+ }
92
+ }
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /**
99
+ * The `nameRefs` table for a set of claims: every `[field, source]` pair that
100
+ * occurs, sorted (so the bundle is deterministic), and the lookup that turns
101
+ * a pair into its index.
102
+ *
103
+ * @param {Iterable<{field: string, source: string|null}>} claims
104
+ * @returns {{ table: Array<[string, string|null]>, ref: (field: string, source: string|null) => number }}
105
+ */
106
+ export function buildRefTable(claims) {
107
+ const keys = new Set();
108
+ for (const { field, source } of claims) keys.add(JSON.stringify([field, source ?? null]));
109
+ const sorted = [...keys].sort();
110
+ const index = new Map(sorted.map((k, i) => [k, i]));
111
+ return {
112
+ table: sorted.map((k) => JSON.parse(k)),
113
+ ref: (field, source) => {
114
+ const i = index.get(JSON.stringify([field, source ?? null]));
115
+ if (i === undefined) throw new Error(`no name ref for [${field}, ${source}] — build the table from the same claims`);
116
+ return i;
117
+ },
118
+ };
119
+ }
120
+
121
+ /**
122
+ * The compact search-name list for one manifest entry.
123
+ *
124
+ * @param {Array<{text: string, field: string, source: string|null}>} claims
125
+ * from searchNameClaims()
126
+ * @param {{ exclude?: string[], ref: (field: string, source: string|null) => number }} opts
127
+ * exclude: names search already reaches (the displayed name, code aliases)
128
+ * @returns {Array<Array<string|number>>} [[text, ref, …], …] — empty when
129
+ * the card records no name search would not already find
130
+ */
131
+ export function encodeSearchNames(claims, { exclude = [], ref }) {
132
+ const reached = new Set(exclude.map(foldForSearch).filter(Boolean));
133
+ const byFold = new Map();
134
+ for (const { text, field, source } of claims) {
135
+ const key = foldForSearch(text);
136
+ if (!key || reached.has(key)) continue;
137
+ let entry = byFold.get(key);
138
+ if (!entry) {
139
+ entry = { text, refs: [] };
140
+ byFold.set(key, entry);
141
+ }
142
+ // Cited only to the sources that record THIS spelling: a source that wrote
143
+ // "kweyol" is not quoted as writing "Kwéyòl". The other spelling folds the
144
+ // same, so search loses nothing by keeping one.
145
+ if (text !== entry.text) continue;
146
+ const r = ref(field, source);
147
+ if (!entry.refs.includes(r)) entry.refs.push(r);
148
+ }
149
+ return [...byFold.values()].map((e) => [e.text, ...e.refs]);
150
+ }
151
+
152
+ /**
153
+ * Read a manifest entry's search names back, each with its field and sources.
154
+ *
155
+ * @param {object} entry a manifest entry ({ n, a, d, s })
156
+ * @param {Array<[string, string|null]>} refs the bundle's `nameRefs`
157
+ * @returns {Array<{text: string, fields: Array<{field: string, sources: string[]}>}>}
158
+ * fields in first-cited order; an unknown ref is skipped, never guessed
159
+ */
160
+ export function decodeSearchNames(entry, refs) {
161
+ const out = [];
162
+ for (const tuple of Array.isArray(entry?.s) ? entry.s : []) {
163
+ if (!Array.isArray(tuple) || typeof tuple[0] !== 'string') continue;
164
+ const fields = [];
165
+ for (const r of tuple.slice(1)) {
166
+ const pair = Array.isArray(refs) ? refs[r] : undefined;
167
+ if (!Array.isArray(pair) || typeof pair[0] !== 'string') continue;
168
+ let f = fields.find((x) => x.field === pair[0]);
169
+ if (!f) {
170
+ f = { field: pair[0], sources: [] };
171
+ fields.push(f);
172
+ }
173
+ if (typeof pair[1] === 'string' && pair[1] && !f.sources.includes(pair[1])) f.sources.push(pair[1]);
174
+ }
175
+ if (fields.length) out.push({ text: tuple[0], fields });
176
+ }
177
+ return out;
178
+ }