@goodandready/dsh-russian-lang 0.1.31 → 0.1.32
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 +22 -11
- package/lib/client.js +350 -322
- package/lib/pure.js +337 -0
- package/package.json +2 -2
package/lib/pure.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// dsh-russian-lang — чистая половина: всё, что не трогает DOM, cordis и сеть.
|
|
2
|
+
//
|
|
3
|
+
// ФАЙЛ ПРАВИТСЯ РУКАМИ. build.py читает его целиком, снимает `export ` и
|
|
4
|
+
// вставляет тело в бандл (lib/client.js) на месте маркера. Тесты импортируют
|
|
5
|
+
// этот же файл напрямую.
|
|
6
|
+
//
|
|
7
|
+
// Смысл разделения — в том, что тесты и бандл едят один исходник. Раньше
|
|
8
|
+
// 1069 строк JavaScript лежали внутри питоновского литерала в build.py,
|
|
9
|
+
// импортировать оттуда было нечего, и тесты копировали функции к себе. Копии
|
|
10
|
+
// расходились с оригиналом молча: правка регулярки в build.py тест не ронял
|
|
11
|
+
// (#133). Всё, что сюда переехало, проверяется на том коде, который уезжает
|
|
12
|
+
// пользователю.
|
|
13
|
+
//
|
|
14
|
+
// Правило для новых функций: если функция не обращается к document, ctx,
|
|
15
|
+
// runtime или сети — её место здесь, а не в шаблоне.
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------- форматы
|
|
18
|
+
|
|
19
|
+
const numberFormat = new Intl.NumberFormat('ru-RU')
|
|
20
|
+
const relativeTimeFormat = new Intl.RelativeTimeFormat('ru-RU', { numeric: 'auto' })
|
|
21
|
+
const currencyFormats = new Map()
|
|
22
|
+
|
|
23
|
+
const getCurrencyFormat = (cur) => {
|
|
24
|
+
const c = (cur || 'RUB').toUpperCase()
|
|
25
|
+
if (!currencyFormats.has(c)) {
|
|
26
|
+
try {
|
|
27
|
+
currencyFormats.set(c, new Intl.NumberFormat('ru-RU', { style: 'currency', currency: c }))
|
|
28
|
+
} catch (e) {
|
|
29
|
+
currencyFormats.set(c, numberFormat)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return currencyFormats.get(c)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const formatNumber = (val) => {
|
|
36
|
+
const n = typeof val === 'number' ? val : Number(val)
|
|
37
|
+
return isNaN(n) ? String(val) : numberFormat.format(n)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const formatCurrency = (val, cur) => {
|
|
41
|
+
const n = typeof val === 'number' ? val : Number(val)
|
|
42
|
+
if (isNaN(n)) return String(val)
|
|
43
|
+
return getCurrencyFormat(cur).format(n)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const formatRelativeTime = (val, unit) => {
|
|
47
|
+
if (typeof val === 'number' && typeof unit === 'string') {
|
|
48
|
+
return relativeTimeFormat.format(val, unit)
|
|
49
|
+
}
|
|
50
|
+
const ts = val instanceof Date ? val.getTime() : (typeof val === 'number' ? (val < 1e12 ? val * 1000 : val) : Number(val))
|
|
51
|
+
if (isNaN(ts)) return String(val)
|
|
52
|
+
const diffSec = Math.round((ts - Date.now()) / 1000)
|
|
53
|
+
const absSec = Math.abs(diffSec)
|
|
54
|
+
if (absSec < 45) return 'только что'
|
|
55
|
+
if (absSec < 3600) return relativeTimeFormat.format(Math.round(diffSec / 60), 'minute')
|
|
56
|
+
if (absSec < 86400) return relativeTimeFormat.format(Math.round(diffSec / 3600), 'hour')
|
|
57
|
+
if (absSec < 2592000) return relativeTimeFormat.format(Math.round(diffSec / 86400), 'day')
|
|
58
|
+
if (absSec < 31536000) return relativeTimeFormat.format(Math.round(diffSec / 2592000), 'month')
|
|
59
|
+
return relativeTimeFormat.format(Math.round(diffSec / 31536000), 'year')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// -------------------------------------------------------------- склонения
|
|
63
|
+
|
|
64
|
+
export const INFLECT_CUSTOM = {
|
|
65
|
+
'пользователь': { gen: 'пользователя', dat: 'пользователю', acc: 'пользователя', ins: 'пользователем', pre: 'пользователе' },
|
|
66
|
+
'агент': { gen: 'агента', dat: 'агенту', acc: 'агента', ins: 'агентом', pre: 'агенте' },
|
|
67
|
+
'субагент': { gen: 'субагента', dat: 'субагенту', acc: 'субагента', ins: 'субагентом', pre: 'субагенте' },
|
|
68
|
+
'модель': { gen: 'модели', dat: 'модели', acc: 'модель', ins: 'моделью', pre: 'модели' },
|
|
69
|
+
'промпт': { gen: 'промпта', dat: 'промпту', acc: 'промпт', ins: 'промптом', pre: 'промпте' },
|
|
70
|
+
'инструмент': { gen: 'инструмента', dat: 'инструменту', acc: 'инструмент', ins: 'инструментом', pre: 'инструменте' },
|
|
71
|
+
'сессия': { gen: 'сессии', dat: 'сессии', acc: 'сессию', ins: 'сессией', pre: 'сессии' },
|
|
72
|
+
'ветка': { gen: 'ветки', dat: 'ветке', acc: 'ветку', ins: 'веткой', pre: 'ветке' },
|
|
73
|
+
'файл': { gen: 'файла', dat: 'файлу', acc: 'файл', ins: 'файлом', pre: 'файле' },
|
|
74
|
+
'папка': { gen: 'папки', dat: 'папке', acc: 'папку', ins: 'папкой', pre: 'папке' }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const keepCase = (src, out) => (src[0] === src[0].toUpperCase() ? out[0].toUpperCase() + out.slice(1) : out)
|
|
78
|
+
|
|
79
|
+
export const inflectWord = (word, cName) => {
|
|
80
|
+
if (!word || typeof word !== 'string') return word
|
|
81
|
+
const lower = word.toLowerCase()
|
|
82
|
+
if (INFLECT_CUSTOM[lower] && INFLECT_CUSTOM[lower][cName]) {
|
|
83
|
+
return keepCase(word, INFLECT_CUSTOM[lower][cName])
|
|
84
|
+
}
|
|
85
|
+
if (/[a-zA-Z0-9_-]/.test(word) || /^[А-ЯЁ]{2,}$/.test(word)) return word
|
|
86
|
+
if (/[оеиую]$/i.test(word) && !/(ко|ло|но|то|во|ро|до|по|со|мо|го)$/i.test(word)) return word
|
|
87
|
+
|
|
88
|
+
const w = lower
|
|
89
|
+
const endings = [
|
|
90
|
+
['ия', 2, (s) => ({ gen: s + 'ии', dat: s + 'ии', acc: s + 'ию', ins: s + 'ией', pre: s + 'ии' })],
|
|
91
|
+
['а', 1, (s) => {
|
|
92
|
+
const genEnd = /[гкхжшчщ]/.test(s.slice(-1)) ? 'и' : 'ы'
|
|
93
|
+
return { gen: s + genEnd, dat: s + 'е', acc: s + 'у', ins: s + 'ой', pre: s + 'е' }
|
|
94
|
+
}],
|
|
95
|
+
['я', 1, (s) => ({ gen: s + 'и', dat: s + 'е', acc: s + 'ю', ins: s + 'ей', pre: s + 'е' })],
|
|
96
|
+
['ь', 1, (s) => ({ gen: s + 'и', dat: s + 'и', acc: s + 'ь', ins: s + 'ью', pre: s + 'и' })],
|
|
97
|
+
['й', 1, (s) => ({ gen: s + 'я', dat: s + 'ю', acc: s + 'я', ins: s + 'ем', pre: s + 'е' })]
|
|
98
|
+
]
|
|
99
|
+
for (const [suffix, cut, build] of endings) {
|
|
100
|
+
if (w.endsWith(suffix)) return keepCase(word, build(w.slice(0, -cut))[cName] || w)
|
|
101
|
+
}
|
|
102
|
+
if (/[бвгджзклмнпрстфхцчшщ]$/.test(w)) {
|
|
103
|
+
const map = { gen: w + 'а', dat: w + 'у', acc: w, ins: w + 'ом', pre: w + 'е' }
|
|
104
|
+
return keepCase(word, map[cName] || w)
|
|
105
|
+
}
|
|
106
|
+
return word
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const INFLECT_CASES = new Set(['gen', 'dat', 'acc', 'ins', 'pre'])
|
|
110
|
+
|
|
111
|
+
export const inflect = (phrase, cName) => {
|
|
112
|
+
if (!phrase || typeof phrase !== 'string') return phrase
|
|
113
|
+
if (!INFLECT_CASES.has(cName)) return phrase
|
|
114
|
+
return phrase.split(' ').map((w) => inflectWord(w, cName)).join(' ')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ------------------------------------------------- подстановка и плюрализация
|
|
118
|
+
|
|
119
|
+
export const fill = (template, params) => String(template).replace(/\{(\w+)(?::(\w+))?\}/g, (match, name, spec) => {
|
|
120
|
+
if (!(name in params)) return match
|
|
121
|
+
const val = params[name]
|
|
122
|
+
if (spec === 'number') return formatNumber(val)
|
|
123
|
+
if (spec === 'reltime') return formatRelativeTime(val)
|
|
124
|
+
if (spec === 'currency') return formatCurrency(val, params.currency || 'RUB')
|
|
125
|
+
if (INFLECT_CASES.has(spec)) return inflect(String(val), spec)
|
|
126
|
+
return String(val)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const pluralRules = new Intl.PluralRules('ru-RU')
|
|
130
|
+
|
|
131
|
+
/** Русская форма числительного: one | few | many | other. */
|
|
132
|
+
export const pluralForm = (n) => pluralRules.select(n)
|
|
133
|
+
|
|
134
|
+
// ------------------------------------------------------ поисковая морфология
|
|
135
|
+
|
|
136
|
+
export const stemRussian = (word) => {
|
|
137
|
+
if (!word || typeof word !== 'string') return ''
|
|
138
|
+
let w = word.toLowerCase().trim()
|
|
139
|
+
if (w.length < 4) return w
|
|
140
|
+
w = w.replace(/(?:вшись|вши|ившись|ивши|ывшись|ывши|ив|ыв)$/, '')
|
|
141
|
+
w = w.replace(/(?:ся|сь)$/, '')
|
|
142
|
+
w = w.replace(/(?:ее|ие|ые|ое|ими|ыми|ей|ий|ый|ой|ем|им|ым|ом|его|ого|ему|ому|их|ых|ую|юю|ая|яя|ою|ею)$/, '')
|
|
143
|
+
w = w.replace(/(?:ила|ыла|ена|ейте|уйте|ите|или|ыли|ей|уй|ил|ыл|им|ым|ен|ило|ыло|ено|ят|ует|уют|ит|ыт|ены|ить|ыть|ишь|ую|ю)$/, '')
|
|
144
|
+
w = w.replace(/(?:ами|ями|иями|ией|иям|ием|ах|ях|иях|ев|ов|ие|ье|ей|ой|ий|ям|ем|ам|ом|а|е|и|о|у|ы|ь|ю|я)$/, '')
|
|
145
|
+
return w.length >= 2 ? w : word.toLowerCase()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const EN_RU_KEYS = {
|
|
149
|
+
'q': 'й', 'w': 'ц', 'e': 'у', 'r': 'к', 't': 'е', 'y': 'н', 'u': 'г', 'i': 'ш', 'o': 'щ', 'p': 'з', '[': 'х', ']': 'ъ',
|
|
150
|
+
'a': 'ф', 's': 'ы', 'd': 'в', 'f': 'а', 'g': 'п', 'h': 'р', 'j': 'о', 'k': 'л', 'l': 'д', ';': 'ж', "'": 'э',
|
|
151
|
+
'z': 'я', 'x': 'ч', 'c': 'с', 'v': 'м', 'b': 'и', 'n': 'т', 'm': 'ь', ',': 'б', '.': 'ю'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export const translitEnToRu = (str) => str.toLowerCase().split('').map((c) => EN_RU_KEYS[c] || c).join('')
|
|
155
|
+
|
|
156
|
+
export const fuzzyMatchRu = (query, target) => {
|
|
157
|
+
if (!query || !target) return 0
|
|
158
|
+
const q = query.toLowerCase().trim()
|
|
159
|
+
const t = target.toLowerCase().trim()
|
|
160
|
+
if (t === q) return 100
|
|
161
|
+
if (t.includes(q)) return 90
|
|
162
|
+
if (t.includes(translitEnToRu(q))) return 85
|
|
163
|
+
const qStems = q.split(/\s+/).map(stemRussian).filter(Boolean)
|
|
164
|
+
const tStems = t.split(/\s+/).map(stemRussian).filter(Boolean)
|
|
165
|
+
let matched = 0
|
|
166
|
+
for (const qs of qStems) {
|
|
167
|
+
if (tStems.some((ts) => ts.startsWith(qs) || qs.startsWith(ts))) matched++
|
|
168
|
+
}
|
|
169
|
+
if (matched === qStems.length && qStems.length > 0) return 80
|
|
170
|
+
if (matched > 0) return 50
|
|
171
|
+
return 0
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ------------------------------------------------------------- ошибки
|
|
175
|
+
|
|
176
|
+
export const ERROR_MAP = {
|
|
177
|
+
ENOENT: { title: 'Файл не найден', message: 'Указанный файл или директория не существуют', hint: 'Проверьте правильность указанного пути к файлу.' },
|
|
178
|
+
EACCES: { title: 'Отказано в доступе', message: 'Недостаточно прав для чтения или записи', hint: 'Проверьте права доступа к файлу или директории (chmod/chown).' },
|
|
179
|
+
EPERM: { title: 'Операция запрещена', message: 'Недостаточно системных привилегий', hint: 'Запустите процесс с соответствующими правами.' },
|
|
180
|
+
ECONNREFUSED: { title: 'Соединение отклонено', message: 'Целевой сервер или сервис не отвечает', hint: 'Убедитесь, что локальный или удаленный сервис запущен и слушает порт.' },
|
|
181
|
+
ETIMEDOUT: { title: 'Таймаут соединения', message: 'Превышено время ожидания ответа', hint: 'Проверьте стабильность сети или увеличьте лимит ожидания.' },
|
|
182
|
+
ENOTFOUND: { title: 'Хост не найден', message: 'Не удалось разрешить сетевой адрес', hint: 'Проверьте правильность URL или настройки DNS.' },
|
|
183
|
+
EADDRINUSE: { title: 'Порт уже занят', message: 'Сетевой порт используется другим процессом', hint: 'Остановите конфликтующий процесс или выберите другой порт.' },
|
|
184
|
+
401: { title: 'Требуется авторизация', message: 'API-ключ или токен отсутствуют или недействительны', hint: 'Проверьте настройки учетных данных и актуальность токена.' },
|
|
185
|
+
403: { title: 'Доступ запрещен', message: 'Недостаточно прав для выполнения операции', hint: 'Проверьте область действия токена или права роли.' },
|
|
186
|
+
404: { title: 'Ресурс не найден', message: 'Запрошенный адрес или объект не существует', hint: 'Проверьте правильность пути или идентификатора ресурса.' },
|
|
187
|
+
429: { title: 'Превышен лимит запросов', message: 'Слишком много запросов (Rate Limit)', hint: 'Подождите несколько минут перед повторным запросом.' },
|
|
188
|
+
500: { title: 'Внутренняя ошибка сервера', message: 'На стороне сервера произошел сбой', hint: 'Попробуйте повторить запрос позже или проверьте серверные логи.' },
|
|
189
|
+
502: { title: 'Ошибочный шлюз (Bad Gateway)', message: 'Промежуточный прокси не получил корректный ответ', hint: 'Проверьте работу нижележащей службы или upstream-сервера.' },
|
|
190
|
+
503: { title: 'Служба временно недоступна', message: 'Сервер перегружен или находится на обслуживании', hint: 'Попробуйте повторить операцию через некоторое время.' }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const fromMap = (key, rawMsg) => ({
|
|
194
|
+
code: String(key), title: ERROR_MAP[key].title, message: ERROR_MAP[key].message, hint: ERROR_MAP[key].hint, raw: rawMsg
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
export const humanizeError = (err) => {
|
|
198
|
+
if (!err) return null
|
|
199
|
+
const rawMsg = typeof err === 'string' ? err : (err.message || String(err))
|
|
200
|
+
const code = err.code || (rawMsg.match(/\b(E[A-Z]{2,20})\b/) || [])[1]
|
|
201
|
+
const status = err.status || err.statusCode || (rawMsg.match(/\b([45]\d{2})\b/) || [])[1]
|
|
202
|
+
const lookupKey = code || status
|
|
203
|
+
if (lookupKey && ERROR_MAP[lookupKey]) return fromMap(lookupKey, rawMsg)
|
|
204
|
+
if (/rate limit|too many requests/i.test(rawMsg)) return fromMap(429, rawMsg)
|
|
205
|
+
if (/unauthorized|invalid token|invalid api key/i.test(rawMsg)) return fromMap(401, rawMsg)
|
|
206
|
+
return { code: 'UNKNOWN', title: 'Ошибка операции', message: rawMsg, hint: 'Проверьте параметры операции и логи.', raw: rawMsg }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// -------------------------------------------------------- отчёт об ошибке
|
|
210
|
+
|
|
211
|
+
/** Версию передаёт вызывающий: в бандл её подставляет build.py из package.json,
|
|
212
|
+
* чтобы строка не устаревала руками (в 0.1.31 здесь стояло «0.1.29»). */
|
|
213
|
+
export const makeIssueUrl = (opts = {}, version = '') => {
|
|
214
|
+
const repo = 'GooDAnDReaDY/dsh-russian-lang'
|
|
215
|
+
const title = opts.title || (opts.plugin
|
|
216
|
+
? `[Перевод] Запрос локализации для плагина ${opts.plugin}`
|
|
217
|
+
: '[Ошибка перевода] Неточный перевод фразы')
|
|
218
|
+
const bodyLines = [
|
|
219
|
+
'### Описание проблемы',
|
|
220
|
+
opts.description || (opts.plugin
|
|
221
|
+
? `Просьба добавить русскую локализацию для плагина \`${opts.plugin}\`.`
|
|
222
|
+
: 'Обнаружена неточность в переводе интерфейса.'),
|
|
223
|
+
'',
|
|
224
|
+
'### Технический контекст',
|
|
225
|
+
opts.ns ? `- **Namespace**: \`${opts.ns}\`` : null,
|
|
226
|
+
opts.key ? `- **Ключ**: \`${opts.key}\`` : null,
|
|
227
|
+
opts.en ? `- **Оригинал (EN)**: ${opts.en}` : null,
|
|
228
|
+
opts.ru ? `- **Текущий перевод (RU)**: ${opts.ru}` : null,
|
|
229
|
+
opts.plugin ? `- **Плагин**: \`${opts.plugin}\`` : null,
|
|
230
|
+
version ? `- **Версия dsh-russian-lang**: \`${version}\`` : null,
|
|
231
|
+
typeof navigator !== 'undefined' ? `- **User Agent**: \`${navigator.userAgent}\`` : null,
|
|
232
|
+
'',
|
|
233
|
+
'### Предлагаемый вариант перевода',
|
|
234
|
+
opts.proposal || '_Опишите ваш вариант перевода..._'
|
|
235
|
+
].filter(Boolean)
|
|
236
|
+
const params = new URLSearchParams()
|
|
237
|
+
params.set('title', title)
|
|
238
|
+
params.set('body', bodyLines.join('\n'))
|
|
239
|
+
return `https://github.com/${repo}/issues/new?` + params.toString()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Словари приходят параметром — в бандле это RU, в тесте фикстура. */
|
|
243
|
+
export const makePluginLocalizationStatus = (dicts) => (ns) => {
|
|
244
|
+
const none = { status: 'none', count: 0, label: 'RU отсутствует' }
|
|
245
|
+
if (!ns) return none
|
|
246
|
+
const count = Object.keys((dicts && dicts[ns]) || {}).length
|
|
247
|
+
return count > 0 ? { status: 'full', count, label: `RU: ${count} строк` } : none
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------- типографика
|
|
251
|
+
|
|
252
|
+
export const typoQuotes = (text) => text
|
|
253
|
+
.replace(/"([^"\n]{1,200})"/g, '«$1»')
|
|
254
|
+
.replace(/[“„]([^“”\n]{1,200})[”"]/g, '«$1»')
|
|
255
|
+
.replace(/[『「]([^』」\n]{1,200})[』」]/g, '«$1»')
|
|
256
|
+
|
|
257
|
+
export const typoDash = (text) => text
|
|
258
|
+
.replace(/(^|[\s(\[«])--(?=\s|$)/g, '$1—')
|
|
259
|
+
.replace(/(^|[\s(\[«])-(?=\s)/g, '$1—')
|
|
260
|
+
|
|
261
|
+
export const typoPunct = (text) => text.replace(/\s+([,.:;!?])(?=\s|$)/g, '$1')
|
|
262
|
+
|
|
263
|
+
export const TYPO_SHORT = new Set(['в', 'с', 'к', 'о', 'у', 'а', 'и', 'но', 'не', 'ни', 'на', 'по', 'до', 'из', 'за', 'от', 'об'])
|
|
264
|
+
|
|
265
|
+
export const typoNbsp = (text) => text.replace(/(^|[\s(\[«])([а-яё]{1,2})(\s+)/g, (match, lead, word) => (
|
|
266
|
+
TYPO_SHORT.has(word) ? lead + word + ' ' : match
|
|
267
|
+
))
|
|
268
|
+
|
|
269
|
+
// \b здесь не годится: он определён через \w = [A-Za-z0-9_], кириллица в \w не
|
|
270
|
+
// входит, поэтому границы слова перед «в» в «все» просто нет и ни одна якорная
|
|
271
|
+
// пара не срабатывала — вся ё-замена была мёртвым кодом (#132). Границу задаём
|
|
272
|
+
// явным lookaround по кириллице.
|
|
273
|
+
export const YO_EDGE_L = '(?<![а-яёА-ЯЁ])'
|
|
274
|
+
export const YO_EDGE_R = '(?![а-яёА-ЯЁ])'
|
|
275
|
+
|
|
276
|
+
/** Восстанавливает регистр совпадения: «ЕЩЕ» -> «ЕЩЁ», «Еще» -> «Ещё». */
|
|
277
|
+
export const yoCase = (src, repl) => {
|
|
278
|
+
if (src === src.toUpperCase() && src !== src.toLowerCase()) return repl.toUpperCase()
|
|
279
|
+
if (src[0] === src[0].toUpperCase()) return repl[0].toUpperCase() + repl.slice(1)
|
|
280
|
+
return repl
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** pairs — [[«еще», «ещё»], ...] из build.py (ручные + корпусные, за вычетом
|
|
284
|
+
* омографов). Регистр разбирает yoCase, поэтому пара нужна одна на слово. */
|
|
285
|
+
export const makeTypoYo = (pairs) => {
|
|
286
|
+
const compiled = pairs.map((p) => [new RegExp(YO_EDGE_L + p[0] + YO_EDGE_R, 'gi'), p[1]])
|
|
287
|
+
return (text) => {
|
|
288
|
+
for (const [re, repl] of compiled) text = text.replace(re, (match) => yoCase(match, repl))
|
|
289
|
+
return text
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ------------------------------------------------------- фикс раскладки
|
|
294
|
+
|
|
295
|
+
// Раскладочная карта шире поисковой: у конвертера есть «/» (слеш команды) и
|
|
296
|
+
// «`» (ё), поисковой транслитерации они не нужны.
|
|
297
|
+
export const LAYOUT_LAT_TO_CYR = { ...EN_RU_KEYS, '/': '.', '`': 'ё' }
|
|
298
|
+
|
|
299
|
+
export const LAYOUT_CYR_TO_LAT = (() => {
|
|
300
|
+
const out = {}
|
|
301
|
+
for (const k in LAYOUT_LAT_TO_CYR) out[LAYOUT_LAT_TO_CYR[k]] = k
|
|
302
|
+
return out
|
|
303
|
+
})()
|
|
304
|
+
|
|
305
|
+
export const translit = (word, map) => {
|
|
306
|
+
let out = ''
|
|
307
|
+
for (const ch of word.toLowerCase()) out += map[ch] !== undefined ? map[ch] : ch
|
|
308
|
+
return out
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** freq — частотный корпус, localDict — выученные за сессию слова (#67).
|
|
312
|
+
* Оба приходят снаружи: в бандле это встроенный список и Set в памяти,
|
|
313
|
+
* в тесте — фикстуры. */
|
|
314
|
+
export const makeLayout = (freq, localDict = new Set()) => {
|
|
315
|
+
const ruWordFraction = (text) => {
|
|
316
|
+
const words = text.toLowerCase().split(/[^а-яё]+/).filter(Boolean)
|
|
317
|
+
if (!words.length) return 0
|
|
318
|
+
return words.filter((w) => freq.has(w) || localDict.has(w)).length / words.length
|
|
319
|
+
}
|
|
320
|
+
const candidate = (value, direction) => {
|
|
321
|
+
if (direction === 'lat2cyr') {
|
|
322
|
+
const converted = translit(value, LAYOUT_LAT_TO_CYR)
|
|
323
|
+
if (!/[а-яё]{2}/.test(converted)) return null
|
|
324
|
+
if (ruWordFraction(converted) < 0.7) return null
|
|
325
|
+
return { converted }
|
|
326
|
+
}
|
|
327
|
+
// cyr2lat — только для команды в инпуте (/...)
|
|
328
|
+
const converted = translit(value, LAYOUT_CYR_TO_LAT)
|
|
329
|
+
return converted.startsWith('/') ? { converted } : null
|
|
330
|
+
}
|
|
331
|
+
const learnWords = (text) => {
|
|
332
|
+
for (const w of text.toLowerCase().split(/[^а-яё]+/).filter(Boolean)) {
|
|
333
|
+
if (w.length >= 3) localDict.add(w)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return { ruWordFraction, candidate, learnWords, localDict }
|
|
337
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-russian-lang",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Russian localization for the DeepSeek Harness web UI: adds Русский as the third option in the native Settings - General - Language menu. Translates the full core surface (100% coverage of DSH 0.1.1-rc.2) plus 16 community plugin namespaces -
|
|
3
|
+
"version": "0.1.32",
|
|
4
|
+
"description": "Russian localization for the DeepSeek Harness web UI: adds Русский as the third option in the native Settings - General - Language menu. Translates the full core surface (100% coverage of DSH 0.1.1-rc.2) plus 16 community plugin namespaces - 51 namespaces, 2899 strings (about 64% hand-reviewed, the rest machine-drafted and tracked as status: draft), with Russian plural forms, count-key plurals, a typography pass (guillemets, em dash, nbsp), a wrong-layout input hint and browser spellcheck. The locale runtime snapshot is extended at runtime on unpatched cores; no files in the DSH installation are modified. The choice is persisted through the plugin-owned russian-lang namespace.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|