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.
- package/README.md +41 -26
- 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 +34 -0
- package/lib/cards/remote.js +15 -0
- package/lib/cards/search-names.js +178 -0
- package/lib/command-help.js +286 -85
- 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 +632 -125
- 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 +15 -9
- 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 +194 -35
- 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 +6 -1
- package/lib/seal.mjs +4 -3
- package/lib/sealed-qualifier.mjs +1 -1
- 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 +1 -1
- package/shared/CORPORA-CARDS.md +2 -0
- package/shared/cards-fallback.json +1 -1
- package/shared/curated-orthography-conventions.json +26 -8
- package/shared/gettext-plural-forms.json +45 -0
- 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 +8 -2
- 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 -11739
|
@@ -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
|
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
|
+
}
|