dsh-think-translate 1.2.19 → 1.2.21

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.
@@ -1,54 +1,54 @@
1
- import { randomUUID } from 'node:crypto'
2
-
3
- const OWN_REF = /^DSH_XLATE_[A-F0-9]{32}$/
4
-
5
- /** Store manually entered keys with DSH; configuration contains references only. */
6
- export async function prepareCredentials(config, credentials) {
7
- const next = JSON.parse(JSON.stringify(config))
8
- const created = []
9
- try {
10
- for (const [id, p] of Object.entries(next.providers || {})) {
11
- delete p.hasApiKey
12
- if (p.source === 'dsh' || p.apiKeyEnv) {
13
- delete p.apiKey
14
- delete p.apiKeyRef
15
- continue
16
- }
17
- if (typeof p.apiKey === 'string' && p.apiKey) {
18
- // Ollama's compatibility placeholder is not a credential.
19
- if (!(id === 'openai' && p.apiKey === 'ollama-local' && /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(?=[:/]|$)/i.test(p.baseURL || ''))) {
20
- if (!credentials?.set) throw new Error('DSH credential storage is unavailable; use an existing credential reference instead')
21
- const ref = 'DSH_XLATE_' + randomUUID().replaceAll('-', '').toUpperCase()
22
- await credentials.set(ref, p.apiKey)
23
- created.push(ref)
24
- p.apiKeyRef = ref
25
- }
26
- }
27
- delete p.apiKey
28
- }
29
- return { config: next, created }
30
- } catch (_error) {
31
- await discardCredentials(created, credentials)
32
- // Storage/provider errors can contain secrets. Never forward their text.
33
- throw new Error('Unable to save API key in DSH credential storage; configuration was not changed')
34
- }
35
- }
36
-
37
- /** Remove only references created and owned by this plugin. */
38
- export async function discardCredentials(refs, credentials) {
39
- if (!credentials?.unset) return
40
- await Promise.allSettled(refs.filter(ref => OWN_REF.test(ref)).map(ref => credentials.unset(ref)))
41
- }
42
-
43
- /** Apply explicit key replacement/deletion while preserving omitted, masked fields. */
44
- export function applyCredentialEdits(next, patch) {
45
- const fields = patch?.providers?._full?._full === true ? patch.providers._full : patch?.providers
46
- for (const [id, p] of Object.entries(fields || {})) {
47
- if (!p || typeof p !== 'object' || !next.providers?.[id] || next.providers[id].source === 'dsh') continue
48
- if (Object.hasOwn(p, 'apiKey') || (typeof p.apiKeyEnv === 'string' && p.apiKeyEnv)) delete next.providers[id].apiKeyRef
49
- }
50
- }
51
-
52
- export function ownedCredentialRefs(config) {
53
- return Object.values(config?.providers || {}).map(p => p.apiKeyRef).filter(ref => typeof ref === 'string' && OWN_REF.test(ref))
54
- }
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ const OWN_REF = /^DSH_XLATE_[A-F0-9]{32}$/
4
+
5
+ /** Store manually entered keys with DSH; configuration contains references only. */
6
+ export async function prepareCredentials(config, credentials) {
7
+ const next = JSON.parse(JSON.stringify(config))
8
+ const created = []
9
+ try {
10
+ for (const [id, p] of Object.entries(next.providers || {})) {
11
+ delete p.hasApiKey
12
+ if (p.source === 'dsh' || p.apiKeyEnv) {
13
+ delete p.apiKey
14
+ delete p.apiKeyRef
15
+ continue
16
+ }
17
+ if (typeof p.apiKey === 'string' && p.apiKey) {
18
+ // Ollama's compatibility placeholder is not a credential.
19
+ if (!(id === 'openai' && p.apiKey === 'ollama-local' && /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(?=[:/]|$)/i.test(p.baseURL || ''))) {
20
+ if (!credentials?.set) throw new Error('DSH credential storage is unavailable; use an existing credential reference instead')
21
+ const ref = 'DSH_XLATE_' + randomUUID().replaceAll('-', '').toUpperCase()
22
+ await credentials.set(ref, p.apiKey)
23
+ created.push(ref)
24
+ p.apiKeyRef = ref
25
+ }
26
+ }
27
+ delete p.apiKey
28
+ }
29
+ return { config: next, created }
30
+ } catch (_error) {
31
+ await discardCredentials(created, credentials)
32
+ // Storage/provider errors can contain secrets. Never forward their text.
33
+ throw new Error('Unable to save API key in DSH credential storage; configuration was not changed')
34
+ }
35
+ }
36
+
37
+ /** Remove only references created and owned by this plugin. */
38
+ export async function discardCredentials(refs, credentials) {
39
+ if (!credentials?.unset) return
40
+ await Promise.allSettled(refs.filter(ref => OWN_REF.test(ref)).map(ref => credentials.unset(ref)))
41
+ }
42
+
43
+ /** Apply explicit key replacement/deletion while preserving omitted, masked fields. */
44
+ export function applyCredentialEdits(next, patch) {
45
+ const fields = patch?.providers?._full?._full === true ? patch.providers._full : patch?.providers
46
+ for (const [id, p] of Object.entries(fields || {})) {
47
+ if (!p || typeof p !== 'object' || !next.providers?.[id] || next.providers[id].source === 'dsh') continue
48
+ if (Object.hasOwn(p, 'apiKey') || (typeof p.apiKeyEnv === 'string' && p.apiKeyEnv)) delete next.providers[id].apiKeyRef
49
+ }
50
+ }
51
+
52
+ export function ownedCredentialRefs(config) {
53
+ return Object.values(config?.providers || {}).map(p => p.apiKeyRef).filter(ref => typeof ref === 'string' && OWN_REF.test(ref))
54
+ }
@@ -0,0 +1,54 @@
1
+ // Curated English technical terms, not an automatic word-by-word dictionary.
2
+ // Increment the version whenever a source or translation changes (cache contract).
3
+ export const BUILTIN_GLOSSARY_VERSION = 'technical-v1'
4
+ const languages = ['zh-CN', 'ja', 'ko', 'es', 'fr', 'de', 'ru', 'pt']
5
+ const rows = [
6
+ ['AI agent', 'AI 智能体', 'AI エージェント', 'AI 에이전트', 'agente de IA', 'agent IA', 'KI-Agent', 'ИИ-агент', 'agente de IA'],
7
+ ['agent', '智能体', 'エージェント', '에이전트', 'agente', 'agent', 'Agent', 'агент', 'agente'],
8
+ ['reasoning', '推理', '推論', '추론', 'razonamiento', 'raisonnement', 'Schlussfolgern', 'рассуждение', 'raciocínio'],
9
+ ['large language model', '大语言模型', '大規模言語モデル', '대규모 언어 모델', 'modelo de lenguaje de gran tamaño', 'grand modèle de langage', 'großes Sprachmodell', 'большая языковая модель', 'modelo de linguagem de grande porte'],
10
+ ['context window', '上下文窗口', 'コンテキストウィンドウ', '컨텍스트 창', 'ventana de contexto', 'fenêtre de contexte', 'Kontextfenster', 'контекстное окно', 'janela de contexto'],
11
+ ['chain of thought', '思维链', '思考の連鎖', '사고 과정', 'cadena de pensamiento', 'chaîne de pensée', 'Gedankenkette', 'цепочка рассуждений', 'cadeia de pensamento'],
12
+ ['prompt injection', '提示注入', 'プロンプトインジェクション', '프롬프트 주입', 'inyección de instrucciones', 'injection de prompt', 'Prompt-Injection', 'инъекция промпта', 'injeção de prompt'],
13
+ ['function calling', '函数调用', '関数呼び出し', '함수 호출', 'llamada a funciones', 'appel de fonctions', 'Funktionsaufruf', 'вызов функций', 'chamada de funções'],
14
+ ['tool calling', '工具调用', 'ツール呼び出し', '도구 호출', 'llamada a herramientas', 'appel d’outils', 'Werkzeugaufruf', 'вызов инструментов', 'chamada de ferramentas'],
15
+ ['fine-tuning', '微调', 'ファインチューニング', '미세 조정', 'ajuste fino', 'ajustement fin', 'Feinabstimmung', 'дообучение', 'ajuste fino'],
16
+ ['retrieval-augmented generation', '检索增强生成', '検索拡張生成', '검색 증강 생성', 'generación aumentada por recuperación', 'génération augmentée par récupération', 'abrufgestützte Generierung', 'генерация с дополнением поиском', 'geração aumentada por recuperação'],
17
+ ['embedding', '嵌入向量', '埋め込み', '임베딩', 'representación vectorial', 'plongement vectoriel', 'Einbettung', 'векторное представление', 'representação vetorial'],
18
+ ['inference', '推理', '推論', '추론', 'inferencia', 'inférence', 'Inferenz', 'инференс', 'inferência'],
19
+ ['provider', '提供方', 'プロバイダー', '제공자', 'proveedor', 'fournisseur', 'Anbieter', 'провайдер', 'provedor'],
20
+ ['rate limit', '速率限制', 'レート制限', '요청 속도 제한', 'límite de solicitudes', 'limite de requêtes', 'Ratenbegrenzung', 'ограничение частоты запросов', 'limite de requisições'],
21
+ ['API key', 'API 密钥', 'API キー', 'API 키', 'clave de API', 'clé API', 'API-Schlüssel', 'ключ API', 'chave de API'],
22
+ ['access token', '访问令牌', 'アクセストークン', '액세스 토큰', 'token de acceso', 'jeton d’accès', 'Zugriffstoken', 'токен доступа', 'token de acesso'],
23
+ ['environment variable', '环境变量', '環境変数', '환경 변수', 'variable de entorno', 'variable d’environnement', 'Umgebungsvariable', 'переменная окружения', 'variável de ambiente'],
24
+ ['working directory', '工作目录', '作業ディレクトリ', '작업 디렉터리', 'directorio de trabajo', 'répertoire de travail', 'Arbeitsverzeichnis', 'рабочий каталог', 'diretório de trabalho'],
25
+ ['command line', '命令行', 'コマンドライン', '명령줄', 'línea de comandos', 'ligne de commande', 'Befehlszeile', 'командная строка', 'linha de comando'],
26
+ ['pull request', '拉取请求', 'プルリクエスト', '풀 리퀘스트', 'solicitud de incorporación', 'demande de fusion', 'Pull Request', 'запрос на слияние', 'solicitação de pull'],
27
+ ['merge conflict', '合并冲突', 'マージ競合', '병합 충돌', 'conflicto de fusión', 'conflit de fusion', 'Zusammenführungskonflikt', 'конфликт слияния', 'conflito de mesclagem'],
28
+ ['version control', '版本控制', 'バージョン管理', '버전 관리', 'control de versiones', 'gestion de versions', 'Versionskontrolle', 'контроль версий', 'controle de versão'],
29
+ ['source code', '源代码', 'ソースコード', '소스 코드', 'código fuente', 'code source', 'Quellcode', 'исходный код', 'código-fonte'],
30
+ ['dependency injection', '依赖注入', '依存性注入', '의존성 주입', 'inyección de dependencias', 'injection de dépendances', 'Abhängigkeitsinjektion', 'внедрение зависимостей', 'injeção de dependências'],
31
+ ['unit test', '单元测试', '単体テスト', '단위 테스트', 'prueba unitaria', 'test unitaire', 'Modultest', 'модульный тест', 'teste unitário'],
32
+ ['integration test', '集成测试', '結合テスト', '통합 테스트', 'prueba de integración', 'test d’intégration', 'Integrationstest', 'интеграционный тест', 'teste de integração'],
33
+ ['regression test', '回归测试', '回帰テスト', '회귀 테스트', 'prueba de regresión', 'test de régression', 'Regressionstest', 'регрессионный тест', 'teste de regressão'],
34
+ ['stack trace', '堆栈跟踪', 'スタックトレース', '스택 추적', 'traza de pila', 'trace de pile', 'Stapelrückverfolgung', 'трассировка стека', 'rastreamento de pilha'],
35
+ ['middleware', '中间件', 'ミドルウェア', '미들웨어', 'middleware', 'intergiciel', 'Middleware', 'промежуточное ПО', 'middleware'],
36
+ ['callback', '回调', 'コールバック', '콜백', 'función de retorno', 'fonction de rappel', 'Rückruffunktion', 'обратный вызов', 'função de retorno'],
37
+ ['deadlock', '死锁', 'デッドロック', '교착 상태', 'interbloqueo', 'interblocage', 'Verklemmung', 'взаимная блокировка', 'impasse'],
38
+ ['race condition', '竞态条件', '競合状態', '경쟁 상태', 'condición de carrera', 'condition de concurrence', 'Wettlaufsituation', 'состояние гонки', 'condição de corrida'],
39
+ ['memory leak', '内存泄漏', 'メモリリーク', '메모리 누수', 'fuga de memoria', 'fuite de mémoire', 'Speicherleck', 'утечка памяти', 'vazamento de memória'],
40
+ ['garbage collection', '垃圾回收', 'ガベージコレクション', '가비지 컬렉션', 'recolección de basura', 'ramasse-miettes', 'Speicherbereinigung', 'сборка мусора', 'coleta de lixo'],
41
+ ['cache invalidation', '缓存失效', 'キャッシュの無効化', '캐시 무효화', 'invalidación de caché', 'invalidation du cache', 'Cache-Invalidierung', 'инвалидация кэша', 'invalidação de cache'],
42
+ ['load balancing', '负载均衡', '負荷分散', '부하 분산', 'equilibrio de carga', 'répartition de charge', 'Lastverteilung', 'балансировка нагрузки', 'balanceamento de carga'],
43
+ ['reverse proxy', '反向代理', 'リバースプロキシ', '리버스 프록시', 'proxy inverso', 'proxy inverse', 'Reverse Proxy', 'обратный прокси', 'proxy reverso'],
44
+ ['webhook', 'Webhook', 'Webhook', '웹훅', 'webhook', 'webhook', 'Webhook', 'вебхук', 'webhook'],
45
+ ['streaming response', '流式响应', 'ストリーミング応答', '스트리밍 응답', 'respuesta en flujo', 'réponse en continu', 'Streaming-Antwort', 'потоковый ответ', 'resposta em fluxo'],
46
+ ['batch processing', '批处理', 'バッチ処理', '일괄 처리', 'procesamiento por lotes', 'traitement par lots', 'Stapelverarbeitung', 'пакетная обработка', 'processamento em lote'],
47
+ ['fallback', '失败回退', 'フォールバック', '폴백', 'alternativa de respaldo', 'solution de repli', 'Ausweichlösung', 'резервный вариант', 'alternativa de contingência'],
48
+ ]
49
+ const preserved = ['DeepSeek', 'Ollama', 'OpenAI', 'Anthropic', 'GitHub', 'Git', 'Node.js', 'JavaScript', 'TypeScript', 'Python', 'React', 'JSON', 'Markdown', 'SDK', 'API', 'CLI', 'LLM', 'RAG']
50
+ const entries = Object.fromEntries(['en', ...languages].map(language => [language, Object.freeze([
51
+ ...rows.map(row => Object.freeze({ source: row[0], target: language === 'en' ? row[0] : row[languages.indexOf(language) + 1] })),
52
+ ...preserved.map(source => Object.freeze({ source, target: source })),
53
+ ])]))
54
+ export const BUILTIN_GLOSSARY = Object.freeze({ version: BUILTIN_GLOSSARY_VERSION, entries: Object.freeze(entries) })
package/lib/glossary.js CHANGED
@@ -1,138 +1,151 @@
1
- export const GLOSSARY_LANGUAGES = ['zh-CN', 'en', 'ja', 'ko', 'es', 'fr', 'de', 'ru', 'pt']
2
- export const GLOSSARY_LIMIT = 128
3
-
4
- function glossaryError(code, line = 1) {
5
- return Object.assign(new Error('Invalid glossary configuration: ' + code + ' at line ' + line), { code: 'GLOSSARY_' + code, line })
6
- }
7
-
8
- export function validateGlossaryEntries(entries) {
9
- if (!Array.isArray(entries)) throw glossaryError('FORMAT')
10
- if (entries.length > GLOSSARY_LIMIT) throw glossaryError('LIMIT', GLOSSARY_LIMIT + 1)
11
- const seen = new Set()
12
- return entries.map((entry, index) => {
13
- if (!entry || typeof entry.source !== 'string' || typeof entry.target !== 'string') throw glossaryError('FORMAT', index + 1)
14
- const source = entry.source.trim(), target = entry.target.trim()
15
- if (!source || !target || source.includes('=>') || /[\x00-\x1f\x7f]/.test(source + target)) throw glossaryError('FORMAT', index + 1)
16
- if (source.length > 128 || target.length > 256) throw glossaryError('LIMIT', index + 1)
17
- const key = source.toLowerCase()
18
- if (seen.has(key)) throw glossaryError('DUPLICATE', index + 1)
19
- seen.add(key)
20
- return { source, target }
21
- })
22
- }
23
-
24
- /** One literal source => target pair per nonblank line; user text is never a regex. */
25
- export function parseGlossaryText(text) {
26
- const entries = [], lines = []
27
- String(text).split(/\r?\n/).forEach((line, index) => {
28
- if (!line.trim()) return
29
- const separator = line.indexOf('=>')
30
- if (separator < 0) throw glossaryError('FORMAT', index + 1)
31
- entries.push({ source: line.slice(0, separator), target: line.slice(separator + 2) })
32
- lines.push(index + 1)
33
- })
34
- try { return validateGlossaryEntries(entries) }
35
- catch (error) { if (lines[error.line - 1]) error.line = lines[error.line - 1]; throw error }
36
- }
37
-
38
- export function serializeGlossary(entries) {
39
- return (entries || []).map(entry => entry.source + ' => ' + entry.target).join('\n')
40
- }
41
-
42
- /** Validate only fields supplied by the configuration editor; keep other languages. */
43
- export function normalizeGlossaryPatch(value) {
44
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw glossaryError('FORMAT')
45
- const result = {}
46
- if (value.enabled !== undefined) {
47
- if (typeof value.enabled !== 'boolean') throw glossaryError('FORMAT')
48
- result.enabled = value.enabled
49
- }
50
- if (value.entries !== undefined) {
51
- if (!value.entries || typeof value.entries !== 'object' || Array.isArray(value.entries)) throw glossaryError('FORMAT')
52
- result.entries = {}
53
- for (const [language, entries] of Object.entries(value.entries)) {
54
- if (!GLOSSARY_LANGUAGES.includes(language)) throw glossaryError('LANGUAGE')
55
- result.entries[language] = validateGlossaryEntries(entries)
56
- }
57
- }
58
- return result
59
- }
60
-
61
- // Provider translation. Call only for prose already separated from protected code.
62
- export function glossaryEntriesForTarget(glossary, target) {
63
- if (!glossary || glossary.enabled === false) return []
64
- const tag = String(target || '').replaceAll('_', '-').toLowerCase()
65
- const traditional = !/(?:^|-)hans(?:-|$)/.test(tag) && /(?:^|-)(?:hant|tw|hk|mo)(?:-|$)/.test(tag)
66
- const language = /^zh(?:-|$)/.test(tag)
67
- ? traditional ? null : 'zh-CN'
68
- : GLOSSARY_LANGUAGES.find(language => language.toLowerCase() === tag.split('-')[0])
69
- return language ? validateGlossaryEntries(glossary.entries?.[language] || []) : []
70
- }
71
-
72
- function escapePattern(text) { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') }
73
-
74
- function glossaryMatches(text, entries) {
75
- if (!entries.length) return []
76
- const sorted = entries.slice().sort((a, b) => b.source.length - a.source.length)
77
- const pattern = new RegExp(sorted.map(entry => '(' + escapePattern(entry.source) + ')').join('|'), 'giu')
78
- const matches = []
79
- let match
80
- while ((match = pattern.exec(text)) !== null) {
81
- const entry = sorted[match.slice(1).findIndex(value => value !== undefined)]
82
- const end = match.index + match[0].length
83
- if ((/[A-Za-z0-9_]/.test(entry.source[0]) && /[A-Za-z0-9_]/.test(text[match.index - 1] || ''))
84
- || (/[A-Za-z0-9_]/.test(entry.source.at(-1)) && /[A-Za-z0-9_]/.test(text[end] || ''))) continue
85
- matches.push({ start: match.index, end, target: entry.target })
86
- }
87
- return matches
88
- }
89
-
90
- /** Keep a matched multiword term intact at long-input chunk boundaries. */
91
- export function splitGlossaryText(text, entries, fallback) {
92
- if (!entries.length || text.length <= 1200) return fallback(text)
93
- const matches = glossaryMatches(text, entries), chunks = []
94
- let start = 0
95
- while (start < text.length) {
96
- let end = Math.min(start + 1200, text.length)
97
- if (end < text.length) {
98
- const paragraph = text.lastIndexOf('\n\n', end - 2)
99
- const space = text.lastIndexOf(' ', end - 1)
100
- if (paragraph > start) end = paragraph + 2
101
- else if (space > start + 600) end = space + 1
102
- if (/[\uD800-\uDBFF]/.test(text[end - 1]) && /[\uDC00-\uDFFF]/.test(text[end] || '')) end--
103
- const crossing = matches.find(match => match.start < end && match.end > end)
104
- if (crossing) end = crossing.start
105
- }
106
- chunks.push(text.slice(start, end))
107
- start = end
108
- }
109
- return chunks
110
- }
111
-
112
- /** Restore terms only when every opaque token survives once; otherwise fail open via the provider chain. */
113
- export async function translateWithGlossary(text, entries, translate) {
114
- if (!entries.length) return { text: await translate(text), targets: [] }
115
- const matches = glossaryMatches(text, entries)
116
- let prefix = 'ZXQG'
117
- while (text.toLowerCase().includes(prefix.toLowerCase()) || entries.some(entry => entry.target.toLowerCase().includes(prefix.toLowerCase()))) prefix += 'Q'
118
- const tokens = []
119
- let cursor = 0, masked = ''
120
- for (const match of matches) {
121
- const token = prefix + tokens.length + 'QXZ'
122
- const before = text.slice(cursor, match.start)
123
- masked += before + token
124
- tokens.push({ token, target: match.target })
125
- cursor = match.end
126
- }
127
- if (!tokens.length) return { text: await translate(text), targets: [] }
128
- masked += text.slice(cursor)
129
- let translated = await translate(masked)
130
- if (typeof translated !== 'string') throw new Error('Translation provider returned invalid glossary text')
131
- for (const { token, target } of tokens) {
132
- const exactToken = new RegExp(escapePattern(token), 'gi')
133
- if ((translated.match(exactToken) || []).length !== 1) throw new Error('Translation provider changed a glossary token')
134
- translated = translated.replace(exactToken, () => target)
135
- }
136
- if (translated.toLowerCase().includes(prefix.toLowerCase())) throw new Error('Translation provider returned an unknown glossary token')
137
- return { text: translated, targets: [...new Set(tokens.map(token => token.target))] }
138
- }
1
+ import { BUILTIN_GLOSSARY } from './glossary-presets.js'
2
+
3
+ export const GLOSSARY_LANGUAGES = ['zh-CN', 'en', 'ja', 'ko', 'es', 'fr', 'de', 'ru', 'pt']
4
+ export const GLOSSARY_LIMIT = 128
5
+
6
+ function glossaryError(code, line = 1) {
7
+ return Object.assign(new Error('Invalid glossary configuration: ' + code + ' at line ' + line), { code: 'GLOSSARY_' + code, line })
8
+ }
9
+
10
+ export function validateGlossaryEntries(entries) {
11
+ if (!Array.isArray(entries)) throw glossaryError('FORMAT')
12
+ if (entries.length > GLOSSARY_LIMIT) throw glossaryError('LIMIT', GLOSSARY_LIMIT + 1)
13
+ const seen = new Set()
14
+ return entries.map((entry, index) => {
15
+ if (!entry || typeof entry.source !== 'string' || typeof entry.target !== 'string') throw glossaryError('FORMAT', index + 1)
16
+ const source = entry.source.trim(), target = entry.target.trim()
17
+ if (!source || !target || source.includes('=>') || /[\x00-\x1f\x7f]/.test(source + target)) throw glossaryError('FORMAT', index + 1)
18
+ if (source.length > 128 || target.length > 256) throw glossaryError('LIMIT', index + 1)
19
+ const key = source.toLowerCase()
20
+ if (seen.has(key)) throw glossaryError('DUPLICATE', index + 1)
21
+ seen.add(key)
22
+ return { source, target }
23
+ })
24
+ }
25
+
26
+ /** One literal source => target pair per nonblank line; user text is never a regex. */
27
+ export function parseGlossaryText(text) {
28
+ const entries = [], lines = []
29
+ String(text).split(/\r?\n/).forEach((line, index) => {
30
+ if (!line.trim()) return
31
+ const separator = line.indexOf('=>')
32
+ if (separator < 0) throw glossaryError('FORMAT', index + 1)
33
+ entries.push({ source: line.slice(0, separator), target: line.slice(separator + 2) })
34
+ lines.push(index + 1)
35
+ })
36
+ try { return validateGlossaryEntries(entries) }
37
+ catch (error) { if (lines[error.line - 1]) error.line = lines[error.line - 1]; throw error }
38
+ }
39
+
40
+ export function serializeGlossary(entries) {
41
+ return (entries || []).map(entry => entry.source + ' => ' + entry.target).join('\n')
42
+ }
43
+
44
+ /** Validate only fields supplied by the configuration editor; keep other languages. */
45
+ export function normalizeGlossaryPatch(value) {
46
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw glossaryError('FORMAT')
47
+ const result = {}
48
+ if (value.enabled !== undefined) {
49
+ if (typeof value.enabled !== 'boolean') throw glossaryError('FORMAT')
50
+ result.enabled = value.enabled
51
+ }
52
+ if (value.builtin !== undefined) {
53
+ if (typeof value.builtin !== 'boolean') throw glossaryError('FORMAT')
54
+ result.builtin = value.builtin
55
+ }
56
+ if (value.entries !== undefined) {
57
+ if (!value.entries || typeof value.entries !== 'object' || Array.isArray(value.entries)) throw glossaryError('FORMAT')
58
+ result.entries = {}
59
+ for (const [language, entries] of Object.entries(value.entries)) {
60
+ if (!GLOSSARY_LANGUAGES.includes(language)) throw glossaryError('LANGUAGE')
61
+ result.entries[language] = validateGlossaryEntries(entries)
62
+ }
63
+ }
64
+ return result
65
+ }
66
+
67
+ // Provider translation. Call only for prose already separated from protected code.
68
+ export function glossaryEntriesForTarget(glossary, target) {
69
+ if (!glossary) return []
70
+ const tag = String(target || '').replaceAll('_', '-').toLowerCase()
71
+ const traditional = !/(?:^|-)hans(?:-|$)/.test(tag) && /(?:^|-)(?:hant|tw|hk|mo)(?:-|$)/.test(tag)
72
+ const language = /^zh(?:-|$)/.test(tag)
73
+ ? traditional ? null : 'zh-CN'
74
+ : GLOSSARY_LANGUAGES.find(language => language.toLowerCase() === tag.split('-')[0])
75
+ if (!language) return []
76
+ const custom = glossary.enabled === false ? [] : validateGlossaryEntries(glossary.entries?.[language] || [])
77
+ const builtin = glossary.builtin === true ? BUILTIN_GLOSSARY.entries[language] : []
78
+ // The custom limit is independent of the built-in set. Case-insensitive exact
79
+ // source overrides win without copying built-ins into user configuration.
80
+ const merged = new Map(builtin.map(entry => [entry.source.toLowerCase(), entry]))
81
+ custom.forEach(entry => merged.set(entry.source.toLowerCase(), entry))
82
+ return [...merged.values()]
83
+ }
84
+
85
+ function escapePattern(text) { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') }
86
+
87
+ function glossaryMatches(text, entries) {
88
+ if (!entries.length) return []
89
+ const sorted = entries.slice().sort((a, b) => b.source.length - a.source.length)
90
+ const pattern = new RegExp(sorted.map(entry => '(' + escapePattern(entry.source) + ')').join('|'), 'giu')
91
+ const matches = []
92
+ let match
93
+ while ((match = pattern.exec(text)) !== null) {
94
+ const entry = sorted[match.slice(1).findIndex(value => value !== undefined)]
95
+ const end = match.index + match[0].length
96
+ if ((/[A-Za-z0-9_]/.test(entry.source[0]) && /[A-Za-z0-9_]/.test(text[match.index - 1] || ''))
97
+ || (/[A-Za-z0-9_]/.test(entry.source.at(-1)) && /[A-Za-z0-9_]/.test(text[end] || ''))) continue
98
+ matches.push({ start: match.index, end, target: entry.target })
99
+ }
100
+ return matches
101
+ }
102
+
103
+ /** Keep a matched multiword term intact at long-input chunk boundaries. */
104
+ export function splitGlossaryText(text, entries, fallback) {
105
+ if (!entries.length || text.length <= 1200) return fallback(text)
106
+ const matches = glossaryMatches(text, entries), chunks = []
107
+ let start = 0
108
+ while (start < text.length) {
109
+ let end = Math.min(start + 1200, text.length)
110
+ if (end < text.length) {
111
+ const paragraph = text.lastIndexOf('\n\n', end - 2)
112
+ const space = text.lastIndexOf(' ', end - 1)
113
+ if (paragraph > start) end = paragraph + 2
114
+ else if (space > start + 600) end = space + 1
115
+ if (/[\uD800-\uDBFF]/.test(text[end - 1]) && /[\uDC00-\uDFFF]/.test(text[end] || '')) end--
116
+ const crossing = matches.find(match => match.start < end && match.end > end)
117
+ if (crossing) end = crossing.start
118
+ }
119
+ chunks.push(text.slice(start, end))
120
+ start = end
121
+ }
122
+ return chunks
123
+ }
124
+
125
+ /** Restore terms only when every opaque token survives once; otherwise fail open via the provider chain. */
126
+ export async function translateWithGlossary(text, entries, translate) {
127
+ if (!entries.length) return { text: await translate(text), targets: [] }
128
+ const matches = glossaryMatches(text, entries)
129
+ let prefix = 'ZXQG'
130
+ while (text.toLowerCase().includes(prefix.toLowerCase()) || entries.some(entry => entry.target.toLowerCase().includes(prefix.toLowerCase()))) prefix += 'Q'
131
+ const tokens = []
132
+ let cursor = 0, masked = ''
133
+ for (const match of matches) {
134
+ const token = prefix + tokens.length + 'QXZ'
135
+ const before = text.slice(cursor, match.start)
136
+ masked += before + token
137
+ tokens.push({ token, target: match.target })
138
+ cursor = match.end
139
+ }
140
+ if (!tokens.length) return { text: await translate(text), targets: [] }
141
+ masked += text.slice(cursor)
142
+ let translated = await translate(masked)
143
+ if (typeof translated !== 'string') throw new Error('Translation provider returned invalid glossary text')
144
+ for (const { token, target } of tokens) {
145
+ const exactToken = new RegExp(escapePattern(token), 'gi')
146
+ if ((translated.match(exactToken) || []).length !== 1) throw new Error('Translation provider changed a glossary token')
147
+ translated = translated.replace(exactToken, () => target)
148
+ }
149
+ if (translated.toLowerCase().includes(prefix.toLowerCase())) throw new Error('Translation provider returned an unknown glossary token')
150
+ return { text: translated, targets: [...new Set(tokens.map(token => token.target))] }
151
+ }