champollion 0.3.4 → 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 (132) hide show
  1. package/README.md +41 -26
  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 +34 -0
  8. package/lib/cards/remote.js +15 -0
  9. package/lib/cards/search-names.js +178 -0
  10. package/lib/command-help.js +286 -85
  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 +632 -125
  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 +15 -9
  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 +194 -35
  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 +6 -1
  100. package/lib/seal.mjs +4 -3
  101. package/lib/sealed-qualifier.mjs +1 -1
  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 +1 -1
  121. package/shared/CORPORA-CARDS.md +2 -0
  122. package/shared/cards-fallback.json +1 -1
  123. package/shared/curated-orthography-conventions.json +26 -8
  124. package/shared/gettext-plural-forms.json +45 -0
  125. package/shared/method-registry.json +2 -0
  126. package/shared/metric-registry.json +96 -18
  127. package/shared/schemas/champollion-plugin.schema.json +4 -0
  128. package/shared/schemas/corpora-card.schema.json +8 -2
  129. package/shared/schemas/method-index-record.schema.json +67 -0
  130. package/shared/schemas/method-registry.schema.json +4 -0
  131. package/shared/schemas/metric-registry.schema.json +55 -1
  132. package/shared/docent/corpus.json +0 -11739
package/lib/serve.js CHANGED
@@ -20,8 +20,10 @@
20
20
  *
21
21
  * NO FORKED TRANSLATION LOGIC:
22
22
  * Requests run through the SAME pipeline `sync` uses — resolveRuntime for
23
- * config/plugin/preflight resolution and translateAndValidate for the
24
- * TM partition → API call → deterministic quality gate → TM store sequence.
23
+ * config/plugin/preflight resolution and translateWithFallback for the
24
+ * TM partition → API call → deterministic quality gate → TM store sequence
25
+ * (plus the pair's fallback method, when one is configured, for what the
26
+ * pair's own method could not translate safely).
25
27
  * TM hits are served from cache at $0; gate failures come back as
26
28
  * structured per-key errors, never as silently degraded output.
27
29
  *
@@ -49,7 +51,8 @@ import { createRequire } from 'node:module';
49
51
  import { resolveConfig } from './config.js';
50
52
  import { resolveRuntime } from './sync.js';
51
53
  import { loadTM, saveTM, isTMDirty, partitionByTM, tmMethodKey } from './tm.js';
52
- import { translateAndValidate } from './translate-pair.js';
54
+ import { translateWithFallback } from './translate-pair.js';
55
+ import { createFallbackBudget, fallbackSummary } from './fallback.js';
53
56
  import { estimateCost, QUALITY_TIERS } from './pairs.js';
54
57
  import { getMethod } from './translate.js';
55
58
  import { isUnsafeKey } from './security.js';
@@ -502,7 +505,7 @@ async function handleTranslate(ctx, body, res) {
502
505
  const { misses } = partitionByTM(runtime.tm, payload, keyNames, targetLocale, tmKey);
503
506
  let estimate = null;
504
507
  if (misses.length > 0) {
505
- estimate = await estimateCost(misses.length, pairConfig);
508
+ estimate = await estimateCost(misses.length, pairConfig, { cwd: runtime.cwd });
506
509
  const estimated = estimate?.estimatedCost ?? null;
507
510
 
508
511
  if (maxCostPerRequest !== null || maxSessionCost !== null) {
@@ -525,26 +528,47 @@ async function handleTranslate(ctx, body, res) {
525
528
  }
526
529
  }
527
530
 
531
+ // ── The pair's fallback method, under the same caps ────────────────
532
+ // What the pair's own method cannot translate safely goes to its
533
+ // fallback (if configured). Under a cap, that batch is priced before it
534
+ // runs and must fit in what the primary's estimate left of the tighter of
535
+ // the per-request cap and the remaining session ceiling (lib/fallback.js).
536
+ const capsLeft = [
537
+ maxCostPerRequest,
538
+ maxSessionCost !== null ? maxSessionCost - session.spendUsd : null,
539
+ ].filter(v => v !== null);
540
+ const primaryEstimate = misses.length > 0 ? (estimate?.estimatedCost ?? 0) : 0;
541
+ const fallbackBudget = createFallbackBudget({
542
+ maxCost: capsLeft.length > 0 ? Math.min(...capsLeft) : null,
543
+ committed: primaryEstimate,
544
+ cwd: runtime.cwd,
545
+ });
546
+
528
547
  // ── The real pipeline: TM partition → API → quality gate → TM store ──
529
548
  let result;
530
549
  try {
531
- result = await translateAndValidate(keyNames, payload, pairConfig, pairKey, {
550
+ result = await translateWithFallback(keyNames, payload, pairConfig, pairKey, {
532
551
  apiKey: runtime.apiKey,
552
+ cwd: runtime.cwd,
533
553
  tm: runtime.tm,
534
554
  targetCode: targetLocale,
535
555
  onProgress: null,
556
+ budget: fallbackBudget,
536
557
  });
537
558
  } catch (err) {
538
559
  output.error(`[serve] ${pairKey}: pipeline error — ${err.message}`);
539
560
  return sendError(res, 500, 'internal_error', 'Translation pipeline failed. See server logs.');
540
561
  }
541
- if (result.apiCalled) output.progress(' done\n');
562
+ if (result.apiCalled) output.progressDone(`[serve] ${pairKey}`, 'done');
542
563
 
543
564
  // Session ledger: count the estimate for any request that actually hit
544
565
  // the upstream API (conservative — estimates deliberately err high).
545
566
  if (result.apiCalled && estimate?.estimatedCost != null) {
546
567
  session.spendUsd += estimate.estimatedCost;
547
568
  }
569
+ // Fallback batches approved under a cap carry their estimate too.
570
+ const fallbackSpend = fallbackBudget.committed - primaryEstimate;
571
+ if (fallbackSpend > 0) session.spendUsd += fallbackSpend;
548
572
 
549
573
  // Persist TM mutations (stores AND evictions) after every request so a
550
574
  // crash never loses paid, gate-validated translations. Atomic write.
@@ -616,6 +640,17 @@ async function handleTranslate(ctx, body, res) {
616
640
  costUsd = estimate.estimatedCost;
617
641
  costBasis = 'estimate';
618
642
  }
643
+ // A fallback that ran adds its own spend: priced when a cap made us price
644
+ // it, otherwise unknown — never silently left out of a known figure.
645
+ if (result.fallback?.attempted > 0) {
646
+ if (fallbackBudget.maxCost !== null && costUsd !== null) {
647
+ costUsd += fallbackSpend;
648
+ costBasis = 'estimate';
649
+ } else {
650
+ costUsd = null;
651
+ costBasis = 'unknown';
652
+ }
653
+ }
619
654
 
620
655
  const meta = {
621
656
  served_by: `champollion-serve/${CLI_VERSION}`,
@@ -629,6 +664,8 @@ async function handleTranslate(ctx, body, res) {
629
664
  translated: translatedCount,
630
665
  failed: errorCount,
631
666
  tm_hits: result.tmHitCount,
667
+ // The pair's fallback method and what it did (pairs with a fallback).
668
+ ...(result.fallback && { fallback: fallbackSummary(result.fallback) }),
632
669
  // Script conversion status for this pair: which script was written and
633
670
  // how many values stayed in the working script (unmapped letters).
634
671
  script_conversion: {
@@ -0,0 +1,164 @@
1
+ /**
2
+ * shared-output-seed.js — what a locale's shared-output index starts a sync
3
+ * with: the translations already on disk that this run leaves alone.
4
+ *
5
+ * WHY: `verify` checks one locale's whole state for a model repeating one
6
+ * memorized sentence (lib/verify.js sharedOutputErrors): every key value of
7
+ * every file AND every Markdown page, counted together. Sync's gate
8
+ * (lib/validate.js SharedOutputIndex) saw less — only what this run accepted,
9
+ * plus, per key-value file, what that file kept, added when the file was
10
+ * reached. So a new answer repeating a sentence that sat in an untouched
11
+ * newsletter page, in a key-value file with nothing to translate (it returns
12
+ * before it seeds), or in a namespace file processed later, was written —
13
+ * and `verify` then flagged it (Round 10, school persona). Seeding the index
14
+ * with what the run leaves alone, before anything is checked, gives the gate
15
+ * the same scope `verify` reads.
16
+ *
17
+ * "Left alone" is what the run will not re-translate:
18
+ * - a key value Champollion recorded writing for the CURRENT source text
19
+ * (the lock's written record matches the source and the value), or one
20
+ * with no record whose source did not change — never an '[EN] '
21
+ * placeholder, a pending key, a key named for a redo, or any key under a
22
+ * bulk `--redo all`;
23
+ * - a Markdown page whose content lock says it was made from the current
24
+ * source, when no --redo files:/content, --force-content or --retranslate
25
+ * scope reaches it.
26
+ * Values the run replaces are left out, so their old text never counts
27
+ * against the new one. Read-only.
28
+ */
29
+
30
+ import fs from 'node:fs';
31
+ import path from 'node:path';
32
+ import crypto from 'node:crypto';
33
+ import { expectedForTarget, readLocaleFlat, lockKey, discoverLocaleLayout, loadSourceUnits } from './locale-layout.js';
34
+ import { originKey } from './plurals.js';
35
+ import { decodeWritten, shortSourceHash, valueHash, LockState } from './locale-state.js';
36
+ import { SharedOutputIndex, sharedOutputItems } from './validate.js';
37
+ import { readLock } from './hash.js';
38
+ import { discoverContentFiles, getTargetContentPath } from './content.js';
39
+ import { contentItemsFor } from './verify.js';
40
+ import { readContentManifest } from './content-sync.js';
41
+
42
+ /**
43
+ * Items (lib/validate.js sharedOutputItems shape) for the translations of
44
+ * `code` on disk that this run leaves alone.
45
+ *
46
+ * @param {object} p
47
+ * @param {object} p.config - Resolved config (inputLocale, contentDir, format, fallbackPrefix, …)
48
+ * @param {object} p.layout - Locale layout (lib/locale-layout.js)
49
+ * @param {Array<object>} p.units - Source units (loadSourceUnits)
50
+ * @param {string} p.code - Target locale
51
+ * @param {{ written: object, pending: object }} p.localeState - LockState.peek(code)
52
+ * @param {string[]} [p.namedKeys] - Keys named for a redo (source key space, namespaced)
53
+ * @param {boolean} [p.bulk] - `--redo all`: every key is re-translated
54
+ * @param {object|null} [p.fileScope] - lib/file-scope.js (--redo files: / --files / --retranslate)
55
+ * @param {boolean} [p.forceContent] - --force-content / --redo content
56
+ * @param {string} p.cwd - Project directory
57
+ * @returns {Array<{ key: string, source: string, value: string }>}
58
+ */
59
+ export function itemsLeftAlone({
60
+ config, layout, units, code, localeState, namedKeys = [], bulk = false, fileScope = null, forceContent = false, cwd,
61
+ }) {
62
+ const items = [];
63
+ const fallbackPrefix = config.fallbackPrefix || '[EN] ';
64
+ const named = new Set(namedKeys);
65
+ const written = localeState?.written || {};
66
+ const pending = localeState?.pending || {};
67
+
68
+ if (!bulk) {
69
+ for (const unit of units || []) {
70
+ const file = layout.fileFor(code, unit.ns);
71
+ if (!fs.existsSync(file.path)) continue;
72
+ let target;
73
+ try { target = readLocaleFlat(file) || {}; } catch { continue; }
74
+ const { flat: expected, expansion } = expectedForTarget(unit, config.inputLocale, code);
75
+ const changed = new Set(unit.changedKeys || []);
76
+ for (const [k, v] of Object.entries(target)) {
77
+ const src = expected[k];
78
+ if (typeof v !== 'string' || typeof src !== 'string' || v.startsWith(fallbackPrefix)) continue;
79
+ const lk = lockKey(layout, unit.ns, k);
80
+ const origin = originKey(k, expansion);
81
+ if (pending[lk] !== undefined || named.has(lk) || named.has(lockKey(layout, unit.ns, origin))) continue;
82
+ const record = decodeWritten(written[lk]);
83
+ if (record) {
84
+ if (record.source !== shortSourceHash(src) || record.value !== valueHash(v)) continue;
85
+ } else if (changed.has(origin)) {
86
+ continue;
87
+ }
88
+ items.push(...sharedOutputItems(lk, src, v));
89
+ }
90
+ }
91
+ }
92
+
93
+ if (config.contentDir && config.format !== 'docusaurus') {
94
+ const contentDir = path.resolve(cwd, config.contentDir);
95
+ const manifest = readContentManifest(cwd);
96
+ let sources = [];
97
+ try { sources = fs.existsSync(contentDir) ? discoverContentFiles(contentDir, config.inputLocale) : []; } catch { sources = []; }
98
+ const leftAlone = new Set();
99
+ for (const sourcePath of sources) {
100
+ const relPath = path.relative(contentDir, sourcePath);
101
+ if (!fs.existsSync(getTargetContentPath(sourcePath, code, config.inputLocale))) continue;
102
+ if (fileScope && !fileScope.includes(relPath)) { leftAlone.add(sourcePath); continue; }
103
+ if (forceContent || (fileScope && fileScope.retranslates(relPath))) continue;
104
+ let hash;
105
+ try { hash = crypto.createHash('sha256').update(fs.readFileSync(sourcePath, 'utf-8'), 'utf-8').digest('hex'); } catch { continue; }
106
+ if (manifest[`${relPath}:${code}`] === hash) leftAlone.add(sourcePath);
107
+ }
108
+ if (leftAlone.size > 0) items.push(...contentItemsFor(config, cwd, code, { only: (p) => leftAlone.has(p) }));
109
+ }
110
+ return items;
111
+ }
112
+
113
+ /**
114
+ * The shared-output index a sync of `code` starts with — ONE construction for
115
+ * sync's gate and for anything else that must refuse what sync refuses (the
116
+ * MCP translate tool with a project_dir): the sentences an earlier sync caught
117
+ * a model repeating (the cache's `_meta.memorized`, refused from their first
118
+ * source on), plus every translation of the locale on disk that the run
119
+ * leaves alone (itemsLeftAlone). Round 10, school persona: the translate tool
120
+ * returned the sentence the project already knew to be memorized, because
121
+ * its index held only that call's answers.
122
+ *
123
+ * @param {object} p
124
+ * @param {object} p.config - Resolved config
125
+ * @param {string} p.cwd - Project directory
126
+ * @param {string} p.code - Target locale
127
+ * @param {object|null} [p.tm] - The project's cache (lib/tm.js loadTM)
128
+ * @param {object} [p.layout] - Locale layout (discovered when absent)
129
+ * @param {Array<object>} [p.units] - Source units (loaded when absent)
130
+ * @param {object} [p.localeState] - LockState.peek(code) (read from the lock when absent)
131
+ * @param {string[]} [p.namedKeys] - Keys named for a redo this run
132
+ * @param {boolean} [p.bulk] - `--redo all`
133
+ * @param {object|null} [p.fileScope] - --redo files: / --files / --retranslate
134
+ * @param {boolean} [p.forceContent] - --force-content / --redo content
135
+ * @returns {SharedOutputIndex}
136
+ */
137
+ export function projectSharedOutputIndex({
138
+ config, cwd, code, tm = null, layout = null, units = null, localeState = null,
139
+ namedKeys = [], bulk = false, fileScope = null, forceContent = false,
140
+ }) {
141
+ const index = new SharedOutputIndex({ protectedTerms: config.protectedTerms || [] });
142
+ index.markMemorized(tm?._meta?.memorized?.[code] || []);
143
+ let lay = layout;
144
+ let us = units;
145
+ let state = localeState;
146
+ // A project whose key-value files cannot be read (a Docusaurus site, a
147
+ // missing locales folder) still has its pages and its memorized sentences.
148
+ if (!lay || !us) {
149
+ try {
150
+ lay = discoverLocaleLayout(config, { cwd });
151
+ us = loadSourceUnits(lay);
152
+ } catch {
153
+ lay = null;
154
+ us = [];
155
+ }
156
+ }
157
+ if (!state) {
158
+ try { state = new LockState(readLock(cwd).locales).peek(code); } catch { state = { written: {}, pending: {} }; }
159
+ }
160
+ index.add(itemsLeftAlone({
161
+ config, layout: lay, units: lay ? us : [], code, localeState: state, namedKeys, bulk, fileScope, forceContent, cwd,
162
+ }));
163
+ return index;
164
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * source-contexts.js — which gettext contexts (msgctxt) a project's source
3
+ * gives each text.
4
+ *
5
+ * Sync keys a gettext entry with a context as `msgctxt\u0004msgid`, and caches
6
+ * its translation under the context folded into the text (lib/tm-evict.js
7
+ * tmSourceText): "Cancel" the button and "Cancel" the verb are two entries.
8
+ * A caller that translates a bare text in a project (the MCP translate tool
9
+ * with project_dir) needs to know when that text exists there ONLY with a
10
+ * context: a context-free cache entry for it is never read by sync, and only
11
+ * shadows the context-keyed ones (Round 13, Django persona).
12
+ */
13
+
14
+ import { discoverLocaleLayout, loadSourceUnits } from './locale-layout.js';
15
+ import { CONTEXT_SEPARATOR } from './tm-evict.js';
16
+
17
+ /**
18
+ * Every source text of the project, with whether it has an entry without a
19
+ * context and the contexts it has.
20
+ *
21
+ * @param {object} config - A resolved config (lib/config.js resolveConfig)
22
+ * @param {{ cwd: string }} options
23
+ * @returns {Map<string, { plain: boolean, contexts: string[] }>} source text → its entries
24
+ */
25
+ export function sourceTextContexts(config, { cwd }) {
26
+ const layout = discoverLocaleLayout(config, { cwd });
27
+ const out = new Map();
28
+ for (const unit of loadSourceUnits(layout)) {
29
+ for (const [key, value] of Object.entries(unit.flat)) {
30
+ if (typeof value !== 'string') continue;
31
+ if (!out.has(value)) out.set(value, { plain: false, contexts: [] });
32
+ const entry = out.get(value);
33
+ const at = key.indexOf(CONTEXT_SEPARATOR);
34
+ if (at < 0) entry.plain = true;
35
+ else if (!entry.contexts.includes(key.slice(0, at))) entry.contexts.push(key.slice(0, at));
36
+ }
37
+ }
38
+ return out;
39
+ }
package/lib/submit.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * submit — declarative model behind `champollion submit`.
2
+ * submit — declarative model behind `champollion network submit`.
3
3
  *
4
4
  * The CLI front door to Champollion's REVIEW-GATED community submission path.
5
5
  * It gathers the fields a submission needs and hands the contributor a
@@ -22,6 +22,8 @@
22
22
  * @see .github/ISSUE_TEMPLATE/
23
23
  */
24
24
 
25
+ import { parseLanguagePair, formatLanguagePair } from './language-pair.js';
26
+
25
27
  /**
26
28
  * The compliance attestation every submission must carry. A single source of
27
29
  * truth so the CLI, the issue forms, and the docs all say the SAME thing.
@@ -30,6 +32,14 @@ export const ATTESTATION_TEXT =
30
32
  'I confirm this is publicly listable, contains NO corpus content or personal data, ' +
31
33
  "and respects the source's license and any community/sovereignty restrictions.";
32
34
 
35
+ /**
36
+ * The label of every "language pair(s)" field. Three types asked for pairs in
37
+ * two spellings (eng-crk in one, source→target in two); the reader below
38
+ * takes any spelling lib/language-pair.js accepts and the submission carries
39
+ * each pair as source>target, the form the leaderboard writes.
40
+ */
41
+ export const PAIRS_LABEL = 'Language pair(s), ISO 639-3, one per line, source>target (e.g. eng>crk; eng-crk works too)';
42
+
33
43
  /** Repo the issue forms live in. Overridable, but this is the real default. */
34
44
  export const DEFAULT_REPO_BASE = 'https://github.com/gamedaysuits/Champollion';
35
45
 
@@ -37,6 +47,7 @@ export const DEFAULT_REPO_BASE = 'https://github.com/gamedaysuits/Champollion';
37
47
  // input — single-line free text
38
48
  // text — multi-line free text (textarea)
39
49
  // choice — one-of a fixed list (`choices`)
50
+ // (a text field with `pairs: true` holds language pairs, one per line — readPairLines)
40
51
  // bool — a required confirmation (attestation / consent); never sent in the
41
52
  // pre-fill URL (GitHub can't pre-check a checkbox), only recorded
42
53
  // locally and enforced by validation.
@@ -57,7 +68,7 @@ export const SUBMISSION_TYPES = [
57
68
  blurb: 'An evaluation corpus or benchmark — metadata + a fetch-from-source pointer only, never the corpus content.',
58
69
  fields: [
59
70
  { id: 'dataset-name', label: 'Dataset name', kind: 'input', required: true, primary: true },
60
- { id: 'pairs', label: 'Language pair(s) (ISO 639-3, e.g. eng-crk; one per line)', kind: 'text', required: true },
71
+ { id: 'pairs', label: PAIRS_LABEL, kind: 'text', pairs: true, required: true },
61
72
  { id: 'license', label: 'License', kind: 'choice', required: true,
62
73
  choices: ['CC-BY-4.0', 'CC-BY-SA-4.0', 'CC0-1.0', 'CC-BY-NC-4.0', 'Proprietary / all rights reserved', 'Other / unsure'] },
63
74
  { id: 'license-url', label: 'License URL', kind: 'input' },
@@ -135,7 +146,7 @@ export const SUBMISSION_TYPES = [
135
146
  { id: 'display-name', label: 'Provider display name (consented public name)', kind: 'input', required: true, primary: true },
136
147
  { id: 'provider-type', label: 'Provider type', kind: 'choice', required: true,
137
148
  choices: ['community_org', 'agency', 'individual'] },
138
- { id: 'pairs', label: 'Language pair(s) (ISO 639-3 source→target, one per line)', kind: 'text', required: true },
149
+ { id: 'pairs', label: PAIRS_LABEL, kind: 'text', pairs: true, required: true },
139
150
  { id: 'variety', label: 'Variety / script (e.g. SRO, Latn)', kind: 'input' },
140
151
  { id: 'dispatch-channel', label: 'Dispatch channel (the address itself is PII — omit it)', kind: 'choice',
141
152
  choices: ['email', 'webhook', 'api'] },
@@ -158,7 +169,7 @@ export const SUBMISSION_TYPES = [
158
169
  blurb: 'A published result from another system/paper — cited, never re-hosted or re-ranked as our own measurement.',
159
170
  fields: [
160
171
  { id: 'system-name', label: 'System / method name', kind: 'input', required: true, primary: true },
161
- { id: 'pairs', label: 'Language pair(s) (ISO 639-3 source→target, one per line)', kind: 'text', required: true },
172
+ { id: 'pairs', label: PAIRS_LABEL, kind: 'text', pairs: true, required: true },
162
173
  { id: 'dataset', label: 'Dataset / benchmark', kind: 'input', required: true },
163
174
  { id: 'metric', label: 'Metric', kind: 'choice', required: true,
164
175
  choices: ['chrF++', 'chrF', 'BLEU', 'COMET', 'TER', 'Other'] },
@@ -252,7 +263,7 @@ export function repoBaseFromRepository(repoUrl) {
252
263
  */
253
264
  export function validateSubmission(type, values = {}) {
254
265
  const errors = [];
255
- if (!type) return { ok: false, errors: ['unknown submission type — run `champollion submit --list`'] };
266
+ if (!type) return { ok: false, errors: ['unknown submission type — run `champollion network submit --list`'] };
256
267
  for (const f of type.fields) {
257
268
  const v = values[f.id];
258
269
  if (f.kind === 'bool') {
@@ -266,10 +277,51 @@ export function validateSubmission(type, values = {}) {
266
277
  const empty = v == null || String(v).trim() === '';
267
278
  if (empty) errors.push(`missing required field: ${f.id} (${f.label})`);
268
279
  }
280
+ if (f.pairs && v != null && String(v).trim() !== '') {
281
+ for (const e of readPairLines(v).errors) errors.push(`${f.id}: ${e}`);
282
+ }
269
283
  }
270
284
  return { ok: errors.length === 0, errors };
271
285
  }
272
286
 
287
+ /**
288
+ * Read a "language pair(s)" field: one pair per line, each in any spelling
289
+ * lib/language-pair.js accepts. Blank lines are skipped.
290
+ *
291
+ * @param {*} value
292
+ * @returns {{pairs: string[], errors: string[]}} pairs written source>target
293
+ */
294
+ export function readPairLines(value) {
295
+ const pairs = [];
296
+ const errors = [];
297
+ String(value).split(/\r?\n/).forEach((line, i) => {
298
+ if (!line.trim()) return;
299
+ const p = parseLanguagePair(line, { label: `line ${i + 1}:` });
300
+ if (p.ok) pairs.push(formatLanguagePair(p));
301
+ else errors.push(p.error);
302
+ });
303
+ return { pairs, errors };
304
+ }
305
+
306
+ /**
307
+ * The values with every pair field written the one way (source>target, one
308
+ * per line), for the issue URL and the local record. A field that does not
309
+ * read cleanly is left as typed — validateSubmission reports it.
310
+ *
311
+ * @param {object} type one of SUBMISSION_TYPES
312
+ * @param {Record<string,*>} values
313
+ * @returns {Record<string,*>} a new object; `values` is not changed
314
+ */
315
+ export function normalizeSubmissionValues(type, values = {}) {
316
+ const out = { ...values };
317
+ for (const f of type.fields) {
318
+ if (!f.pairs || out[f.id] == null || String(out[f.id]).trim() === '') continue;
319
+ const r = readPairLines(out[f.id]);
320
+ if (r.errors.length === 0) out[f.id] = r.pairs.join('\n');
321
+ }
322
+ return out;
323
+ }
324
+
273
325
  /** The field whose value seeds the issue title (the `primary` one). */
274
326
  function primaryField(type) {
275
327
  return type.fields.find((f) => f.primary) || null;