@goodandready/dsh-russian-lang 0.3.0 → 0.3.3

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 (46) hide show
  1. package/README.md +20 -5
  2. package/README.ru.md +20 -5
  3. package/bin/dsh-i18n.mjs +247 -0
  4. package/lib/client.js +2932 -2360
  5. package/lib/index.js +89 -15
  6. package/lib/locales/core.json +1 -1
  7. package/lib/locales/plugins/03-dshmarket.json +1 -1
  8. package/lib/locales/plugins/17-dsh-context.json +1 -1
  9. package/lib/locales/plugins/26-config-manager.json +1 -1
  10. package/lib/locales/plugins/29-config-manager-recovery.json +1 -1
  11. package/lib/locales/plugins/32-vision-bridge.json +1 -1
  12. package/lib/locales/plugins/33-messenger-gateway.json +1 -1
  13. package/lib/locales/plugins/34-key-rotation.json +1 -1
  14. package/lib/locales/plugins/35-gitea.json +1 -1
  15. package/lib/locales/plugins/36-cost-meter.json +1 -1
  16. package/lib/locales/plugins/37-time-machine.json +1 -1
  17. package/lib/locales/plugins/40-clinebot.json +1 -1
  18. package/lib/locales/plugins/41-lanmode.json +1 -1
  19. package/lib/locales/plugins/42-plugin-console.json +1 -1
  20. package/lib/locales/plugins/48-task-tracker.json +1 -1
  21. package/lib/locales/plugins/49-dsml-guard.json +1 -1
  22. package/lib/locales/plugins/54-workshop-market.json +1 -1
  23. package/lib/locales/plugins/56-cron.json +1 -1
  24. package/lib/locales/plugins/58-grok-xsearch.json +1 -1
  25. package/lib/locales/plugins/59-goal.json +1 -1
  26. package/lib/locales/plugins/60-model-sync.json +1 -1
  27. package/lib/locales/plugins/61-issue-reporter.json +1 -1
  28. package/lib/locales/plugins/62-model-search.json +1 -1
  29. package/lib/locales/plugins/63-agent-orchestrator.json +1 -1
  30. package/lib/locales/plugins/65-subscriptions.json +1 -1
  31. package/lib/locales/plugins/67-voice.json +1 -1
  32. package/lib/locales/plugins/68-image-gen.json +1 -1
  33. package/lib/locales/plugins/70-hypotheses.json +1 -1
  34. package/lib/locales/plugins/72-server-monitor.json +1 -1
  35. package/lib/locales/plugins/73-agentrouter.json +1 -1
  36. package/lib/locales/plugins/77-github-ops.json +1 -0
  37. package/lib/locales/plugins/78-web-gateway.json +1 -0
  38. package/lib/locales/plugins/79-approval-gate.json +1 -0
  39. package/lib/locales/plugins/80-ip-pool.json +1 -0
  40. package/lib/locales/plugins/81-ego-browser.json +1 -0
  41. package/lib/locales/plugins/82-effort-slider.json +1 -0
  42. package/lib/locales/zh-ru.json +1 -1
  43. package/lib/locales.js +50 -12
  44. package/lib/pure.js +306 -14
  45. package/lib/translator.js +55 -0
  46. package/package.json +7 -2
package/lib/locales.js CHANGED
@@ -7,6 +7,29 @@ const CORE_FILE = join(__dirname, "locales", "core.json")
7
7
  const PLUGINS_DIR = join(__dirname, "locales", "plugins")
8
8
  const ZH_RU_FILE = join(__dirname, "locales", "zh-ru.json")
9
9
 
10
+ let _pluginCache = null
11
+ let _pluginIndex = null
12
+
13
+ function ensurePluginCache() {
14
+ if (_pluginCache && _pluginIndex) return
15
+ _pluginCache = {}
16
+ _pluginIndex = new Map()
17
+ if (existsSync(PLUGINS_DIR)) {
18
+ for (const file of readdirSync(PLUGINS_DIR).sort()) {
19
+ if (!file.endsWith(".json")) continue
20
+ try {
21
+ const data = JSON.parse(readFileSync(join(PLUGINS_DIR, file), "utf8"))
22
+ for (const [ns, entries] of Object.entries(data)) {
23
+ _pluginCache[ns] = Object.assign(_pluginCache[ns] || {}, entries)
24
+ _pluginIndex.set(ns.toLowerCase(), ns)
25
+ const baseName = ns.replace(/^@[^/]+\//, "")
26
+ _pluginIndex.set(baseName.toLowerCase(), ns)
27
+ }
28
+ } catch (_) { /* bestEffort */ void _; }
29
+ }
30
+ }
31
+ }
32
+
10
33
  export function getCoreDictionaries() {
11
34
  if (existsSync(CORE_FILE)) {
12
35
  try {
@@ -17,19 +40,34 @@ export function getCoreDictionaries() {
17
40
  }
18
41
 
19
42
  export function getPluginDictionaries() {
20
- const merged = {}
21
- if (existsSync(PLUGINS_DIR)) {
22
- for (const file of readdirSync(PLUGINS_DIR).sort()) {
23
- if (!file.endsWith(".json")) continue
24
- try {
25
- const data = JSON.parse(readFileSync(join(PLUGINS_DIR, file), "utf8"))
26
- for (const [ns, entries] of Object.entries(data)) {
27
- merged[ns] = Object.assign(merged[ns] || {}, entries)
28
- }
29
- } catch (_) { /* bestEffort */ void _; }
43
+ ensurePluginCache()
44
+ return Object.assign(
45
+ {},
46
+ _pluginCache
47
+ )
48
+ }
49
+
50
+ export function getPluginDictionariesByNames(names) {
51
+ ensurePluginCache()
52
+ if (!names || (Array.isArray(names) && names.length === 0)) {
53
+ return Object.assign(
54
+ {},
55
+ _pluginCache
56
+ )
57
+ }
58
+ const nameList = Array.isArray(names) ? names : String(names).split(",")
59
+ const result = {}
60
+ for (const rawName of nameList) {
61
+ const trimmed = String(rawName).trim()
62
+ if (!trimmed) continue
63
+ const targetNs = _pluginIndex.get(trimmed.toLowerCase())
64
+ if (targetNs && _pluginCache[targetNs]) {
65
+ result[targetNs] = _pluginCache[targetNs]
66
+ } else if (_pluginCache[trimmed]) {
67
+ result[trimmed] = _pluginCache[trimmed]
30
68
  }
31
69
  }
32
- return merged
70
+ return result
33
71
  }
34
72
 
35
73
  export function getAllDictionaries() {
@@ -41,7 +79,7 @@ export function getAllDictionaries() {
41
79
  export function getZhRuMap() {
42
80
  if (existsSync(ZH_RU_FILE)) {
43
81
  try {
44
- return JSON.parse(readFileSync(ZH_RU_FILE, "utf8"))
82
+ return JSON.parse(readFileSync(ZG_RU_FILE, "utf8"))
45
83
  } catch (_) { /* bestEffort */ void _; }
46
84
  }
47
85
  return {}
package/lib/pure.js CHANGED
@@ -17,7 +17,50 @@
17
17
  // ---------------------------------------------------------------- форматы
18
18
 
19
19
  const numberFormat = new Intl.NumberFormat('ru-RU')
20
+ const compactNumberFormat = new Intl.NumberFormat('ru-RU', { notation: 'compact', compactDisplay: 'short' })
20
21
  const relativeTimeFormat = new Intl.RelativeTimeFormat('ru-RU', { numeric: 'auto' })
22
+
23
+ const datePresetFormats = {
24
+ short: new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' }),
25
+ shortDateTime: new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }),
26
+ medium: new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }),
27
+ long: new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }),
28
+ time: new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
29
+ }
30
+
31
+ export const formatDate = (val, style = 'short') => {
32
+ if (val === null || val === undefined || val === '') return ''
33
+ const d = val instanceof Date ? val : new Date(typeof val === 'number' && val < 1e12 ? val * 1000 : val)
34
+ if (isNaN(d.getTime())) return String(val)
35
+ if (typeof style === 'string' && datePresetFormats[style]) {
36
+ return datePresetFormats[style].format(d)
37
+ }
38
+ if (typeof style === 'object' && style !== null) {
39
+ try {
40
+ return new Intl.DateTimeFormat('ru-RU', style).format(d)
41
+ } catch (_) {
42
+ return datePresetFormats.short.format(d)
43
+ }
44
+ }
45
+ return datePresetFormats.short.format(d)
46
+ }
47
+
48
+ export const formatCompactNumber = (val) => {
49
+ if (val === null || val === undefined || val === '') return ''
50
+ const n = typeof val === 'number' ? val : Number(val)
51
+ return isNaN(n) ? String(val) : compactNumberFormat.format(n)
52
+ }
53
+
54
+ export const formatTokens = (count, compact = false) => {
55
+ if (count === null || count === undefined || count === '') return ''
56
+ const n = typeof count === 'number' ? count : Number(count)
57
+ if (isNaN(n)) return String(count)
58
+ const tokenWord = plural(n, ['токен', 'токена', 'токенов'])
59
+ if (compact && n >= 1000) {
60
+ return `${compactNumberFormat.format(n)} токенов`
61
+ }
62
+ return `${numberFormat.format(n)} ${tokenWord}`
63
+ }
21
64
  const currencyFormats = new Map()
22
65
 
23
66
  const getCurrencyFormat = (cur) => {
@@ -119,24 +162,64 @@ export const inflect = (phrase, cName) => {
119
162
 
120
163
  // ------------------------------------------------- подстановка и плюрализация
121
164
 
165
+ const pluralRules = new Intl.PluralRules('ru-RU')
166
+
167
+ /** Русская форма числительного: one | few | many | other. */
168
+ export const pluralForm = (n) => pluralRules.select(n)
169
+
170
+ /**
171
+ * Универсальная русская плюрализация 1, 2-4, 5+
172
+ * @param {number|string} count - Число элементов
173
+ * @param {string[]|string} forms - Массив [one, few, many] или строка первого варианта
174
+ * @param {boolean} [withCount=false] - Включать ли число в результат ("5 задач" vs "задач")
175
+ */
176
+ export function plural(count, forms, withCount = false) {
177
+ const n = typeof count === 'number' ? count : (Number(count) || 0)
178
+ let one = '', few = '', many = ''
179
+ if (Array.isArray(forms)) {
180
+ one = forms[0] || ''
181
+ few = forms[1] || one
182
+ many = forms[2] || few || one
183
+ } else if (typeof forms === 'string') {
184
+ one = forms
185
+ few = arguments[2] || one
186
+ many = arguments[3] || few || one
187
+ withCount = arguments[4] || false
188
+ } else {
189
+ return ''
190
+ }
191
+ const form = pluralRules.select(n)
192
+ let word = many
193
+ if (form === 'one') word = one
194
+ else if (form === 'few') word = few
195
+ else if (form === 'many') word = many
196
+
197
+ return withCount ? `${formatNumber(n)} ${word}` : word
198
+ }
199
+
122
200
  export const fill = (template, params) => {
123
201
  if (!params || typeof params !== 'object') return String(template)
124
- return String(template).replace(/\{(\w+)(?::(\w+))?\}/g, (match, name, spec) => {
202
+ return String(template).replace(/\{(\w+)(?::([^}]+))?\}/g, (match, name, spec) => {
125
203
  if (!(name in params)) return match
126
204
  const val = params[name]
205
+ if (!spec) return String(val)
127
206
  if (spec === 'number') return formatNumber(val)
207
+ if (spec === 'compact') return formatCompactNumber(val)
208
+ if (spec === 'tokens') return formatTokens(val)
209
+ if (spec === 'tokens:compact') return formatTokens(val, true)
128
210
  if (spec === 'reltime') return formatRelativeTime(val)
129
211
  if (spec === 'currency') return formatCurrency(val, params.currency || 'RUB')
212
+ if (spec === 'date') return formatDate(val)
213
+ if (spec.startsWith('date:')) return formatDate(val, spec.slice(5))
214
+ if (spec.startsWith('plural:')) {
215
+ const parts = spec.slice(7).split(',')
216
+ return plural(val, parts)
217
+ }
130
218
  if (INFLECT_CASES.has(spec)) return inflect(String(val), spec)
131
219
  return String(val)
132
220
  })
133
221
  }
134
222
 
135
- const pluralRules = new Intl.PluralRules('ru-RU')
136
-
137
- /** Русская форма числительного: one | few | many | other. */
138
- export const pluralForm = (n) => pluralRules.select(n)
139
-
140
223
  // ------------------------------------------------------ поисковая морфология
141
224
 
142
225
  export const stemRussian = (word) => {
@@ -197,7 +280,13 @@ export const ERROR_MAP = {
197
280
  500: { title: 'Внутренняя ошибка сервера', message: 'На стороне сервера произошел сбой', hint: 'Попробуйте повторить запрос позже или проверьте серверные логи.' },
198
281
  502: { title: 'Ошибочный шлюз (Bad Gateway)', message: 'Промежуточный прокси не получил корректный ответ', hint: 'Проверьте работу нижележащей службы или upstream-сервера.' },
199
282
  503: { title: 'Служба временно недоступна', message: 'Сервер перегружен или находится на обслуживании', hint: 'Попробуйте повторить операцию через некоторое время.' },
200
- 504: { title: 'Шлюз не отвечает (Gateway Timeout)', message: 'Превышено время ожидания ответа от upstream-сервера', hint: 'Попробуйте повторить запрос позже.' }
283
+ 504: { title: 'Шлюз не отвечает (Gateway Timeout)', message: 'Превышено время ожидания ответа от upstream-сервера', hint: 'Попробуйте повторить запрос позже.' },
284
+ FETCH_FAILED: { title: 'Сетевой запрос не удался', message: 'Не удалось связаться с сервером (Failed to fetch)', hint: 'Проверьте сетевое подключение и доступность адреса.' },
285
+ CORS_ERROR: { title: 'Ошибка CORS', message: 'Запрос заблокирован политикой безопасности браузера', hint: 'Проверьте заголовки разрешённых источников на сервере.' },
286
+ WS_CLOSED: { title: 'Разрыв WebSocket', message: 'Связь с сервером по WebSocket прервана', hint: 'Проверьте работу сервера и сетевой статус.' },
287
+ QUOTA_EXCEEDED: { title: 'Исчерпана квота API', message: 'Превышен лимит запросов или баланс провайдера', hint: 'Проверьте баланс API-ключа в кабинете провайдера.' },
288
+ CONTEXT_OVERFLOW: { title: 'Превышен контекст', message: 'Объём диалога превышает размер контекстного окна', hint: 'Очистите или сожмите историю сообщений сессии.' },
289
+ MODEL_NOT_FOUND: { title: 'Модель не найдена', message: 'Указанная модель отсутствует или недоступна', hint: 'Проверьте идентификатор модели в настройках.' }
201
290
  }
202
291
 
203
292
  const fromMap = (key, rawMsg) => ({
@@ -213,9 +302,25 @@ export const humanizeError = (err) => {
213
302
  if (lookupKey && ERROR_MAP[lookupKey]) return fromMap(lookupKey, rawMsg)
214
303
  if (/rate limit|too many requests/i.test(rawMsg)) return fromMap(429, rawMsg)
215
304
  if (/unauthorized|invalid token|invalid api key/i.test(rawMsg)) return fromMap(401, rawMsg)
305
+ if (/failed to fetch|network\s*error|load\s*failed|net::err_/i.test(rawMsg)) return fromMap('FETCH_FAILED', rawMsg)
306
+ if (/cors|cross-origin/i.test(rawMsg)) return fromMap('CORS_ERROR', rawMsg)
307
+ if (/websocket.*(?:closed|failed)/i.test(rawMsg)) return fromMap('WS_CLOSED', rawMsg)
308
+ if (/insufficient_quota|quota\s*exceeded/i.test(rawMsg)) return fromMap('QUOTA_EXCEEDED', rawMsg)
309
+ if (/context.*(?:exceeded|overflow|length)/i.test(rawMsg)) return fromMap('CONTEXT_OVERFLOW', rawMsg)
310
+ if (/model.*not.*found|model.*does not exist/i.test(rawMsg)) return fromMap('MODEL_NOT_FOUND', rawMsg)
216
311
  return { code: 'UNKNOWN', title: 'Ошибка операции', message: rawMsg, hint: 'Проверьте параметры операции и логи.', raw: rawMsg }
217
312
  }
218
313
 
314
+ export const formatErrorToast = (err) => {
315
+ const h = humanizeError(err)
316
+ if (!h) return null
317
+ return {
318
+ type: 'error',
319
+ title: h.title,
320
+ description: h.message + (h.hint ? ' ' + h.hint : '')
321
+ }
322
+ }
323
+
219
324
  // -------------------------------------------------------- отчёт об ошибке
220
325
 
221
326
  /** Версию передаёт вызывающий: в бандл её подставляет build.py из package.json,
@@ -293,7 +398,10 @@ export const yoCase = (src, repl) => {
293
398
  /** pairs — [[«еще», «ещё»], ...] из build.py (ручные + корпусные, за вычетом
294
399
  * омографов). Регистр разбирает yoCase, поэтому пара нужна одна на слово. */
295
400
  export const makeTypoYo = (pairs) => {
296
- const compiled = pairs.map((p) => [new RegExp(YO_EDGE_L + p[0] + YO_EDGE_R, 'gi'), p[1]])
401
+ const list = typeof pairs === 'string'
402
+ ? pairs.split('|').filter(Boolean).map((s) => s.split(':'))
403
+ : (pairs || [])
404
+ const compiled = list.map((p) => [new RegExp(YO_EDGE_L + p[0] + YO_EDGE_R, 'gi'), p[1]])
297
405
  return (text) => {
298
406
  for (const [re, repl] of compiled) text = text.replace(re, (match) => yoCase(match, repl))
299
407
  return text
@@ -486,20 +594,38 @@ export const SYSTEM_PROMPT_PRESETS = {
486
594
  technical_expert: {
487
595
  id: 'technical_expert',
488
596
  label: 'Технический эксперт',
489
- desc: 'Строгая русская инженерная терминология, чистый код и русские комментарии',
490
- text: 'Отвечай пользователю на русском языке. Используй точную русскую инженерную терминологию, пиши чистый, идиоматичный код с русскими комментариями и следуй лучшим практикам разработки. Если пользователь пишет на другом языке, отвечай на его языке.'
597
+ desc: 'Точная терминология, чистый код и русские комментарии',
598
+ text: 'Отвечай пользователю на русском языке. Используй точную инженерную терминологию, пиши чистый код и русские комментарии.'
491
599
  },
492
600
  tech_writer: {
493
601
  id: 'tech_writer',
494
602
  label: 'Технический писатель',
495
- desc: 'Структурированные тексты, Markdown, ГОСТ/RFC и выверенная типографика',
496
- text: 'Отвечай пользователю на русском языке. Оформляй документацию и пояснения в структурированном виде (Markdown, списки, таблицы), строго соблюдай правила русской типографики («кавычки-ёлочки», длинное тире, буква «ё») и требования к техническим текстам.'
603
+ desc: 'Структурированные тексты, Markdown и выверенная типографика',
604
+ text: 'Отвечай пользователю на русском языке. Оформляй документацию в Markdown, строго соблюдай правила русской типографики («ёлочки», тире, «ё»).'
497
605
  },
498
606
  concise: {
499
607
  id: 'concise',
500
608
  label: 'Лаконичный режим',
501
- desc: 'Краткие ёмкие ответы без лишней вводной воды и повторов',
502
- text: 'Отвечай пользователю на русском языке максимально кратко и ёмко. Без лишних вводных слов, повторов и пространных рассуждений. Сразу переходи к сути, коду или прямому решению задачи.'
609
+ desc: 'Краткие ёмкие ответы без вводных слов и воды',
610
+ text: 'Отвечай пользователю на русском языке максимально кратко и ёмко. Без вводных слов и воды, сразу код или решение.'
611
+ },
612
+ code_reviewer: {
613
+ id: 'code_reviewer',
614
+ label: 'Код-ревьюер',
615
+ desc: 'Поиск багов, безопасность и архитектурный аудит',
616
+ text: 'Анализируй код и отвечай на русском языке. Ищи уязвимости, граничные случаи и нарушения контрактов, предлагая исправления.'
617
+ },
618
+ architect: {
619
+ id: 'architect',
620
+ label: 'Системный архитектор',
621
+ desc: 'Контракты API/CLI, модульность и надёжность',
622
+ text: 'Отвечай на русском языке как архитектор. Проектируй модульные системы, чёткие контракты API/CLI и надёжные решения без лишней сложности.'
623
+ },
624
+ tutor: {
625
+ id: 'tutor',
626
+ label: 'Наставник (ментор)',
627
+ desc: 'Понятные объяснения сложных концепций и пошаговый разбор',
628
+ text: 'Отвечай на русском языке как наставник. Объясняй сложные темы простыми словами, наглядными аналогиями и пошаговыми примерами.'
503
629
  }
504
630
  }
505
631
 
@@ -548,3 +674,169 @@ export const exportSessionToMarkdown = (session, options = {}) => {
548
674
  md += `*Сгенерировано с помощью [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) и @goodandready/dsh-russian-lang*\n`
549
675
  return md
550
676
  }
677
+
678
+ // -------------------------------------------------- инспектор переводов и оверрайды (#298, #330)
679
+
680
+ export const findTranslationKey = (text, dicts = {}, overrides = {}, zhRu = {}, domEn = {}, options = {}) => {
681
+ if (!text || typeof text !== 'string') return null
682
+ const trimmed = text.trim()
683
+ if (!trimmed) return null
684
+
685
+ // 1. Оверрайды пользователя (наивысший приоритет)
686
+ if (overrides && typeof overrides === 'object') {
687
+ if (overrides[trimmed] !== undefined) {
688
+ return { key: trimmed, value: overrides[trimmed], source: 'override' }
689
+ }
690
+ for (const [k, v] of Object.entries(overrides)) {
691
+ if (v === text || (typeof v === 'string' && v.trim() === trimmed)) {
692
+ return { key: k, value: v, source: 'override' }
693
+ }
694
+ }
695
+ }
696
+
697
+ // 2. Словари неймспейсов
698
+ if (dicts && typeof dicts === 'object') {
699
+ for (const [ns, map] of Object.entries(dicts)) {
700
+ if (!map || typeof map !== 'object') continue
701
+ if (map[trimmed] !== undefined) {
702
+ return { ns, key: trimmed, value: map[trimmed], source: 'dictionary' }
703
+ }
704
+ for (const [k, v] of Object.entries(map)) {
705
+ if (v === text || (typeof v === 'string' && v.trim() === trimmed)) {
706
+ return { ns, key: k, value: v, source: 'dictionary' }
707
+ }
708
+ }
709
+ }
710
+ }
711
+
712
+ // 3. Таблица китайских соответствий ZH_RU
713
+ if (zhRu && typeof zhRu === 'object') {
714
+ for (const [zh, ru] of Object.entries(zhRu)) {
715
+ if (ru === text || (typeof ru === 'string' && ru.trim() === trimmed)) {
716
+ return { zh, key: zh, value: ru, source: 'dom_zh' }
717
+ }
718
+ }
719
+ if (zhRu[trimmed] !== undefined) {
720
+ return { zh: trimmed, key: trimmed, value: zhRu[trimmed], source: 'dom_zh_original' }
721
+ }
722
+ }
723
+
724
+ // 4. Таблица английских соответствий DOM_EN_TEXT
725
+ if (domEn && typeof domEn === 'object') {
726
+ for (const [en, ru] of Object.entries(domEn)) {
727
+ if (ru === text || (typeof ru === 'string' && ru.trim() === trimmed)) {
728
+ return { en, key: en, value: ru, source: 'dom_en' }
729
+ }
730
+ }
731
+ if (domEn[trimmed] !== undefined) {
732
+ return { en: trimmed, key: trimmed, value: domEn[trimmed], source: 'dom_en_original' }
733
+ }
734
+ }
735
+
736
+ // 5. Детекция непереведённых фрагментов (при явном запросе)
737
+ if (options && options.detectUntranslated) {
738
+ const hasRu = /[а-яё]/i.test(trimmed)
739
+ const hasZh = /[㐀-鿿豈-﫿]/.test(trimmed)
740
+ const hasEn = /[a-z]/i.test(trimmed)
741
+ if (!hasRu && hasZh) {
742
+ return { key: trimmed, value: trimmed, source: 'untranslated_zh' }
743
+ }
744
+ if (!hasRu && hasEn) {
745
+ return { key: trimmed, value: trimmed, source: 'untranslated_en' }
746
+ }
747
+ }
748
+
749
+ return null
750
+ }
751
+
752
+ export const generateBugReportSnippet = (info = {}) => {
753
+ const { key, ns, original, current, override, pkgVersion = '0.3.1' } = info
754
+ const lines = [
755
+ '### 🌐 Неточность перевода / Пользовательское предложение',
756
+ '- **Пакет:** `@goodandready/dsh-russian-lang@v' + pkgVersion + '`',
757
+ ns ? '- **Пространство имён:** `' + ns + '`' : null,
758
+ key ? '- **Ключ:** `' + key + '`' : null,
759
+ original ? '- **Оригинальный текст:** `' + original + '`' : null,
760
+ current ? '- **Текущий перевод:** `' + current + '`' : null,
761
+ override ? '- **Предлагаемый перевод:** `' + override + '`' : null,
762
+ ].filter(Boolean)
763
+ return lines.join('\n')
764
+ }
765
+
766
+ // ------------------------------------------ двуязычный поиск команд (#300)
767
+
768
+ export const bilingualCommandMatch = (query, command, options = {}) => {
769
+ if (!query || !command) return { matched: false, score: 0 }
770
+ const q = String(query).trim().toLowerCase()
771
+ if (!q) return { matched: false, score: 0 }
772
+
773
+ const cmdObj = typeof command === 'string' ? { title: command } : command
774
+ const candidates = [
775
+ cmdObj.title,
776
+ cmdObj.label,
777
+ cmdObj.name,
778
+ cmdObj.originalTitle,
779
+ cmdObj.enTitle,
780
+ cmdObj.id,
781
+ cmdObj.description,
782
+ cmdObj.category,
783
+ ...(Array.isArray(cmdObj.keywords) ? cmdObj.keywords : [])
784
+ ].filter((c) => typeof c === 'string' && c.trim())
785
+
786
+ if (!candidates.length) return { matched: false, score: 0 }
787
+
788
+ const queryVariants = [{ text: q, kind: 'direct' }]
789
+
790
+ const lat2cyr = translit(q, LAYOUT_LAT_TO_CYR)
791
+ if (lat2cyr !== q) queryVariants.push({ text: lat2cyr, kind: 'layout' })
792
+
793
+ const cyr2lat = translit(q, LAYOUT_CYR_TO_LAT)
794
+ if (cyr2lat !== q) queryVariants.push({ text: cyr2lat, kind: 'layout' })
795
+
796
+ const phonLat2Cyr = phoneticTranslit(q, 'lat2cyr')
797
+ if (phonLat2Cyr && phonLat2Cyr !== q && phonLat2Cyr !== lat2cyr) {
798
+ queryVariants.push({ text: phonLat2Cyr, kind: 'phonetic' })
799
+ }
800
+
801
+ const phonCyr2Lat = phoneticTranslit(q, 'cyr2lat')
802
+ if (phonCyr2Lat && phonCyr2Lat !== q && phonCyr2Lat !== cyr2lat) {
803
+ queryVariants.push({ text: phonCyr2Lat, kind: 'phonetic' })
804
+ }
805
+
806
+ let bestScore = 0
807
+ let bestVariant = null
808
+ let bestCandidate = null
809
+
810
+ for (const variant of queryVariants) {
811
+ for (const cand of candidates) {
812
+ const score = fuzzyMatchRu(variant.text, cand)
813
+ if (score > bestScore) {
814
+ bestScore = score
815
+ bestVariant = variant
816
+ bestCandidate = cand
817
+ }
818
+ }
819
+ }
820
+
821
+ const threshold = options.threshold ?? 50
822
+ return {
823
+ matched: bestScore >= threshold,
824
+ score: bestScore,
825
+ matchedText: bestCandidate,
826
+ variant: bestVariant ? bestVariant.text : q,
827
+ kind: bestVariant ? bestVariant.kind : 'none'
828
+ }
829
+ }
830
+
831
+ export const filterCommands = (query, commands = [], options = {}) => {
832
+ if (!Array.isArray(commands)) return []
833
+ if (!query || !String(query).trim()) return commands
834
+ const results = []
835
+ for (const cmd of commands) {
836
+ const res = bilingualCommandMatch(query, cmd, options)
837
+ if (res.matched) {
838
+ results.push({ item: cmd, ...res })
839
+ }
840
+ }
841
+ return results.sort((a, b) => b.score - a.score).map((r) => r.item)
842
+ }
package/lib/translator.js CHANGED
@@ -34,6 +34,45 @@ function readJsonBody(request, limit = 500_000) {
34
34
  })
35
35
  }
36
36
 
37
+ function header(request, name) {
38
+ const value = request?.headers?.[name]
39
+ return Array.isArray(value) ? value[0] : value
40
+ }
41
+
42
+ function isLoopback(value) {
43
+ const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
44
+ return (
45
+ address === 'localhost' ||
46
+ address === 'localhost.' ||
47
+ address === '::1' ||
48
+ address?.startsWith('127.') === true ||
49
+ address?.startsWith('::ffff:127.') === true
50
+ )
51
+ }
52
+
53
+ export function isTrustedTranslatorRequest(request) {
54
+ if (request?.socket?.remoteAddress && !isLoopback(request.socket.remoteAddress)) {
55
+ return false
56
+ }
57
+ const site = header(request, 'sec-fetch-site')
58
+ if (site !== undefined && site !== 'same-origin' && site !== 'none') {
59
+ return false
60
+ }
61
+ const origin = header(request, 'origin')
62
+ const host = header(request, 'host')
63
+ if (origin !== undefined) {
64
+ try {
65
+ const url = new URL(origin)
66
+ if (!isLoopback(url.hostname)) return false
67
+ if (host && url.host !== host) return false
68
+ } catch {
69
+ return false
70
+ }
71
+ }
72
+ return true
73
+ }
74
+
75
+
37
76
  export async function getTranslatorStatus(settingsCtx) {
38
77
  const currentSettings = settingsCtx?.settings?.get('russian-lang') || {}
39
78
  const engine = currentSettings.translateEngine || 'off'
@@ -214,6 +253,14 @@ export function registerTranslator(wctx, settingsCtx) {
214
253
  response.end(JSON.stringify({ error: 'Method not allowed' }))
215
254
  return
216
255
  }
256
+ if (!isTrustedTranslatorRequest(request)) {
257
+ response.writeHead(403, {
258
+ 'content-type': 'application/json; charset=utf-8',
259
+ 'cache-control': 'no-store'
260
+ })
261
+ response.end(JSON.stringify({ error: 'Rejected non-local or cross-origin translator setup request.' }))
262
+ return
263
+ }
217
264
  const result = await setupLibreTranslateContainer()
218
265
  response.writeHead(200, {
219
266
  'content-type': 'application/json; charset=utf-8',
@@ -240,6 +287,14 @@ export function registerTranslator(wctx, settingsCtx) {
240
287
  response.end(JSON.stringify({ error: 'Method not allowed' }))
241
288
  return
242
289
  }
290
+ if (!isTrustedTranslatorRequest(request)) {
291
+ response.writeHead(403, {
292
+ 'content-type': 'application/json; charset=utf-8',
293
+ 'cache-control': 'no-store'
294
+ })
295
+ response.end(JSON.stringify({ error: 'Rejected non-local or cross-origin translation request.' }))
296
+ return
297
+ }
243
298
 
244
299
  const body = await readJsonBody(request)
245
300
  const text = body.text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-russian-lang",
3
- "version": "0.3.0",
3
+ "version": "0.3.3",
4
4
  "description": "Russian localization for DeepSeek Harness (DSH v0.1.6-alpha.1+) web UI: adds Русский as native language option. 100.0% coverage across 11,295 keys (127 namespaces). Features full DSH v0.1.6-alpha.1 core support (terminal, archive, command palette, Auto review, trajectory, PTC/Creator presets) and complete GoodAndReady plugin catalog (subscriptions, tts, image-gen, voice, key-limits, notify, commandcode, plugin-console).",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -12,6 +12,7 @@
12
12
  },
13
13
  "files": [
14
14
  "lib",
15
+ "bin",
15
16
  "cordis.patch.yml",
16
17
  "README.md",
17
18
  "README.ru.md",
@@ -25,7 +26,8 @@
25
26
  "platform": "web",
26
27
  "inject": [
27
28
  "@deepseek-ai/dsh-client-locale",
28
- "@deepseek-ai/dsh-client-ui-settings"
29
+ "@deepseek-ai/dsh-client-ui-settings",
30
+ "@deepseek-ai/dsh-client-ui-slots"
29
31
  ]
30
32
  }
31
33
  },
@@ -65,5 +67,8 @@
65
67
  "homepage": "https://github.com/GooDAnDReaDY/dsh-russian-lang#readme",
66
68
  "bugs": {
67
69
  "url": "https://github.com/GooDAnDReaDY/dsh-russian-lang/issues"
70
+ },
71
+ "bin": {
72
+ "dsh-i18n": "./bin/dsh-i18n.mjs"
68
73
  }
69
74
  }