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.
- package/README.md +52 -37
- package/bin/cli.js +53 -5
- package/index.js +63 -2
- package/lib/api-key.js +17 -4
- package/lib/autofix.js +83 -36
- package/lib/bridge/method_bridge.py +15 -3
- package/lib/cards/reader.js +51 -3
- package/lib/cards/remote.js +15 -0
- package/lib/cards/search-names.js +178 -0
- package/lib/command-help.js +289 -88
- package/lib/commands/audit.js +10 -3
- package/lib/commands/card.js +583 -226
- package/lib/commands/doctor.js +54 -18
- package/lib/commands/help.js +37 -32
- package/lib/commands/init.js +1689 -87
- package/lib/commands/integrity.js +127 -40
- package/lib/commands/leaderboard.js +187 -67
- package/lib/commands/models.js +9 -2
- package/lib/commands/provenance.js +7 -2
- package/lib/commands/recommend.js +43 -14
- package/lib/commands/register-corpus.js +649 -130
- package/lib/commands/seal-corpus.js +1 -1
- package/lib/commands/status.js +564 -27
- package/lib/commands/submit.js +17 -12
- package/lib/commands/sync.js +31 -7
- package/lib/commands/tm.js +16 -10
- package/lib/commands/verify.js +27 -3
- package/lib/commands/wrap.js +63 -5
- package/lib/commands/xliff.js +135 -64
- package/lib/commercial-eligibility.js +1 -1
- package/lib/config.js +196 -14
- package/lib/content-estimate.js +96 -0
- package/lib/content-refusals.js +270 -0
- package/lib/content-review.js +372 -0
- package/lib/content-sync.js +1127 -344
- package/lib/content.js +94 -7
- package/lib/corpus-registration.mjs +197 -38
- package/lib/cost-label.js +29 -0
- package/lib/cost-report.js +726 -78
- package/lib/diff.js +38 -4
- package/lib/docusaurus-sync.js +965 -253
- package/lib/edit-distance.js +31 -0
- package/lib/fallback.js +964 -0
- package/lib/file-scope.js +106 -0
- package/lib/flatten.js +80 -3
- package/lib/flutter-locales.js +124 -0
- package/lib/format.js +266 -12
- package/lib/hash.js +146 -21
- package/lib/icu-structure.js +929 -0
- package/lib/integrity.js +223 -75
- package/lib/language-pair.js +157 -0
- package/lib/lint.js +78 -16
- package/lib/local-only-marks.js +106 -0
- package/lib/locale-layout.js +1103 -0
- package/lib/locale-state.js +571 -0
- package/lib/methods/anthropic.js +5 -0
- package/lib/methods/apertium.js +6 -3
- package/lib/methods/api.js +138 -25
- package/lib/methods/base.js +17 -0
- package/lib/methods/coaching-data.js +153 -0
- package/lib/methods/content-separator.js +43 -0
- package/lib/methods/deepl.js +1 -1
- package/lib/methods/direct-llm.js +252 -103
- package/lib/methods/external.js +146 -63
- package/lib/methods/gemini.js +1 -0
- package/lib/methods/google-translate.js +1 -0
- package/lib/methods/http-utils.js +41 -0
- package/lib/methods/libretranslate.js +7 -2
- package/lib/methods/llm-coached.js +68 -128
- package/lib/methods/llm.js +80 -31
- package/lib/methods/local.js +93 -10
- package/lib/methods/microsoft-translator.js +1 -2
- package/lib/methods/openai.js +4 -2
- package/lib/methods/openrouter-client.js +20 -19
- package/lib/methods/openrouter-pricing.js +150 -13
- package/lib/methods/prompt-methods.js +20 -0
- package/lib/methods/provider-pricing.js +42 -1
- package/lib/methods/request-capture.js +104 -0
- package/lib/methods/tilde.js +1 -1
- package/lib/methods/translated.js +1 -2
- package/lib/missing-key.js +93 -0
- package/lib/models.js +11 -0
- package/lib/name-rules.js +32 -0
- package/lib/named-keys.js +172 -0
- package/lib/no-translate.js +4 -3
- package/lib/output.js +160 -19
- package/lib/pairs.js +586 -30
- package/lib/placeholders.js +394 -0
- package/lib/plugins.js +8 -0
- package/lib/plural-gap-redo.js +109 -0
- package/lib/plurals.js +323 -0
- package/lib/po.js +1187 -0
- package/lib/public-catalogue.js +74 -0
- package/lib/recommend.js +527 -32
- package/lib/redo.js +95 -0
- package/lib/refusal-category.js +44 -0
- package/lib/registers.js +255 -11
- package/lib/repair-script.js +20 -13
- package/lib/scripts.js +193 -106
- package/lib/seal.mjs +6 -5
- package/lib/sealed-qualifier.mjs +2 -2
- package/lib/segment.js +2 -1
- package/lib/seo.js +19 -9
- package/lib/serve.js +43 -6
- package/lib/shared-output-seed.js +164 -0
- package/lib/source-contexts.js +39 -0
- package/lib/submit.mjs +57 -5
- package/lib/sync.js +2923 -474
- package/lib/terminology.js +13 -4
- package/lib/tm-evict.js +179 -0
- package/lib/tm-seed.js +5 -2
- package/lib/tm.js +818 -36
- package/lib/translate-pair.js +639 -34
- package/lib/translate.js +78 -5
- package/lib/types.js +22 -3
- package/lib/validate.js +880 -17
- package/lib/verify.js +1296 -104
- package/lib/watch.js +32 -13
- package/lib/xliff.js +44 -3
- package/package.json +3 -2
- package/shared/CORPORA-CARDS.md +2 -0
- package/shared/DATA-SOVEREIGNTY.md +19 -20
- package/shared/LANGUAGE-CARD-FIELDS.md +1 -1
- package/shared/cards-fallback.json +1 -1
- package/shared/catalogue/card-config.json +1 -1
- package/shared/curated-orthography-conventions.json +26 -8
- package/shared/docent/faq.en.json +14 -16
- package/shared/docent/system-prompt.md +17 -19
- package/shared/explainers/tc-features.json +15 -15
- package/shared/gettext-plural-forms.json +45 -0
- package/shared/human-services.json +1 -1
- package/shared/method-registry.json +2 -0
- package/shared/metric-registry.json +96 -18
- package/shared/schemas/champollion-plugin.schema.json +4 -0
- package/shared/schemas/corpora-card.schema.json +20 -10
- package/shared/schemas/human-services.schema.json +2 -2
- package/shared/schemas/language-card.schema.json +1 -1
- package/shared/schemas/method-card.schema.json +1 -1
- package/shared/schemas/method-index-record.schema.json +67 -0
- package/shared/schemas/method-registry.schema.json +4 -0
- package/shared/schemas/metric-registry.schema.json +55 -1
- 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
|
-
*
|
|
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 {
|
|
349
|
-
* @
|
|
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
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
const
|
|
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 =
|
|
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]] =
|
|
418
|
+
current[parts[parts.length - 1]] = `${prefix}${fix.text}`;
|
|
392
419
|
}
|
|
393
|
-
const
|
|
394
|
-
|
|
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
|
-
|
|
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)
|
package/lib/cards/reader.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/lib/cards/remote.js
CHANGED
|
@@ -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
|
+
}
|