Snapshot of uncommitted work: landing, bots, prod infra, partner cabinet, backoffice stage 1

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 01:23:32 +04:00
1 parent 45d7512d2b
commit d452824c62
314 files changed
+44394 -121

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
/**
* Проверка переводов (критерий приёмки «нет ключей без перевода»):
* - в каждом языке есть все ключи ru, без пустых значений и лишних ключей;
* - плейсхолдеры {{x}} совпадают с исходником;
* - отчёт по статусам needs_review / approved.
* Код выхода 1 при любой ошибке — для CI.
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
import { dynamicKeys } from './dynamic-keys.ts';
import { placeholders, readLocale, readReview, ROOT, statusOf, TARGETS } from './lib.ts';
const ru = readLocale('ru');
const review = readReview();
const errors: string[] = [];
for (const [key, value] of Object.entries(ru)) {
if (!value.trim()) errors.push(`ru: пустое значение ${key}`);
}
// ключи-литералы в коде (вида t(«a.b»)) должны существовать в ru
const NAMESPACES = Object.keys(JSON.parse(readFileSync(path.join(ROOT, 'locales/ru.json'), 'utf8')) as object);
const KEY_RE = new RegExp(`['\`](?:${NAMESPACES.join('|')})\\.[a-zA-Z0-9_.]+(?=['\`])`, 'g');
function walk(dir: string, out: string[] = []) {
for (const name of readdirSync(dir)) {
if (['node_modules', 'generated', '.next', 'dist', '.turbo'].includes(name)) continue;
const p = path.join(dir, name);
if (statSync(p).isDirectory()) walk(p, out);
else if (/\.(ts|tsx)$/.test(name) && !/\.test\.ts$/.test(name)) out.push(p);
}
return out;
}
const repo = path.resolve(ROOT, '../..');
const pluralBase = (k: string) => k.replace(/_(one|few|many|other)$/, '');
const known = new Set(Object.keys(ru).flatMap((k) => [k, pluralBase(k)]));
for (const file of [...walk(path.join(repo, 'apps')), ...walk(path.join(repo, 'packages'))]) {
for (const m of readFileSync(file, 'utf8').matchAll(KEY_RE)) {
const key = m[0].slice(1);
if (!known.has(key) && !key.endsWith('.')) errors.push(`ключ из кода отсутствует в ru: ${key} (${path.relative(repo, file)})`);
}
}
for (const key of dynamicKeys()) if (!known.has(key)) errors.push(`динамический ключ отсутствует в ru: ${key}`);
for (const locale of TARGETS) {
const msgs = readLocale(locale);
const counts = { approved: 0, needs_review: 0, missing: 0 };
for (const [key, source] of Object.entries(ru)) {
const status = statusOf(review, locale, key, source, msgs[key]);
counts[status]++;
if (status === 'missing') errors.push(`${locale}: нет перевода ${key}`);
else if (placeholders(source) !== placeholders(msgs[key]!)) errors.push(`${locale}: плейсхолдеры не совпадают в ${key}`);
}
for (const key of Object.keys(msgs)) if (!(key in ru)) errors.push(`${locale}: лишний ключ ${key}`);
console.log(`${locale}: ${Object.keys(ru).length} ключей · approved ${counts.approved} · needs_review ${counts.needs_review} · missing ${counts.missing}`);
}
if (errors.length) {
console.error(`\n${errors.length} ошибок:\n` + errors.slice(0, 50).join('\n'));
process.exit(1);
}
console.log('i18n: все ключи переведены.');
+37
View File
@@ -0,0 +1,37 @@
/**
* Ключи, которые строятся динамически из домена (статусы сценариев, кнопки, чек-листы,
* параметры услуг, районы, роли). Используется в check.ts.
*/
import { CATALOG, FLOWS, flowStatuses, PROVIDER_ROLES, SERVICE_FLOWS, SETTING_KEYS, YEREVAN_DISTRICTS } from '@barev/core';
export function dynamicKeys(): string[] {
const keys: string[] = [];
for (const flow of SERVICE_FLOWS) {
for (const s of flowStatuses(flow)) keys.push(`status.${flow}.${s}`, `notify.status.${flow}.${s}`);
for (const t of FLOWS[flow].transitions) if (t.action) keys.push(`pro.action.${t.action}`, `admin.events.${t.action}`);
for (const e of FLOWS[flow].events) keys.push(`pro.action.${e.code}`, `admin.events.${e.code}`, `notify.event.${e.code}`);
keys.push(`client.detail.providerLabel.${flow}`);
}
for (const s of CATALOG) {
keys.push(`services.${s.code}.name`, `services.${s.code}.description`);
for (const c of s.checklist) keys.push(`checklist.${c.code}`);
if (s.pricing.kind === 'rooms') {
for (const [code, e] of Object.entries(s.pricing.extras)) {
keys.push(`extras.${code}.name`);
if (e.unit !== 'service') keys.push(`extras.${code}.per`);
}
}
for (const f of s.paramsSchema.fields) {
if (f.type === 'extras') continue;
keys.push(`params.${s.code}.${f.key}.label`);
if (f.type === 'select') for (const o of f.options) keys.push(`params.${s.code}.${f.key}.${o}`, `params.${s.code}.${f.key}.${o}_hint`);
}
}
for (const d of YEREVAN_DISTRICTS) keys.push(`districts.${d}`);
for (const r of PROVIDER_ROLES) keys.push(`roles.${r}`);
for (const k of SETTING_KEYS) keys.push(`admin.settings.${k.replace('.', '_')}`);
// приложение: статусы шагов переезда, категории партнёров (названия шагов и документов — в базе, RelocationItem)
for (const s of ['TODO', 'IN_PROGRESS', 'DONE', 'SKIPPED']) keys.push(`client.move.status.${s}`);
for (const c of ['cafe', 'restaurant', 'shop', 'beauty', 'other']) keys.push(`client.partners.cat.${c}`);
return keys;
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Выгрузка для носителей языка: i18n-review/<lang>.csv
* key, ru, translation, status, approved
* Носитель правит колонку translation и ставит "yes" в approved, затем: pnpm i18n:import <файл>.
*
* pnpm i18n:export — все языки, только needs_review
* pnpm i18n:export -- --all — все ключи
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { csvEscape, readLocale, readReview, ROOT, statusOf, TARGETS } from './lib.ts';
const all = process.argv.includes('--all');
const ru = readLocale('ru');
const review = readReview();
const outDir = path.resolve(ROOT, '../../i18n-review');
mkdirSync(outDir, { recursive: true });
for (const locale of TARGETS) {
const msgs = readLocale(locale);
const rows = [['key', 'ru', 'translation', 'status', 'approved']];
for (const [key, source] of Object.entries(ru)) {
const status = statusOf(review, locale, key, source, msgs[key]);
if (!all && status === 'approved') continue;
rows.push([key, source, msgs[key] ?? '', status, '']);
}
const file = path.join(outDir, `${locale}.csv`);
// BOM — чтобы Excel открыл UTF-8 с армянским и китайским
writeFileSync(file, '' + rows.map((r) => r.map(csvEscape).join(',')).join('\n') + '\n');
console.log(`${file}: ${rows.length - 1} строк`);
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Загрузка проверенных переводов: pnpm i18n:import -- i18n-review/hy.csv [i18n-review/zh.csv …]
* Язык берётся из имени файла. Строки с approved=yes помечаются проверенными для текущего русского текста.
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { isLocale } from '../src/index.ts';
import { parseCsv, placeholders, readLocale, readReview, sourceHash, writeLocale, writeReview } from './lib.ts';
const files = process.argv.slice(2).filter((a) => !a.startsWith('--'));
if (!files.length) {
console.error('Укажите CSV-файлы: pnpm i18n:import -- i18n-review/hy.csv');
process.exit(1);
}
const ru = readLocale('ru');
const order = Object.keys(ru);
const review = readReview();
for (const file of files) {
const locale = path.basename(file, '.csv');
if (!isLocale(locale) || locale === 'ru') throw new Error(`Не понимаю язык файла ${file}`);
const [header, ...rows] = parseCsv(readFileSync(path.resolve(process.env.INIT_CWD ?? process.cwd(), file), 'utf8').replace(/^\uFEFF/, ''));
const col = (name: string) => header!.indexOf(name);
const msgs = readLocale(locale);
let updated = 0;
let approved = 0;
for (const r of rows) {
const key = r[col('key')]!;
const translation = r[col('translation')]?.trim();
if (!(key in ru) || !translation) continue;
if (placeholders(ru[key]!) !== placeholders(translation)) {
console.warn(`${locale}: пропущен ${key} — плейсхолдеры не совпадают с русским`);
continue;
}
if (msgs[key] !== translation) updated++;
msgs[key] = translation;
if (/^(yes|да|y|1|ok)$/i.test(r[col('approved')]?.trim() ?? '')) {
review[locale][key] = sourceHash(ru[key]!);
approved++;
}
}
writeLocale(locale, msgs, order);
console.log(`${locale}: обновлено ${updated}, проверено ${approved}`);
}
writeReview(review);
+79
View File
@@ -0,0 +1,79 @@
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { flatten, LOCALES, type Locale, type Messages } from '../src/index.ts';
export const ROOT = path.resolve(import.meta.dirname, '..');
export const TARGETS = LOCALES.filter((l) => l !== 'ru') as Exclude<Locale, 'ru'>[];
type Tree = { [k: string]: string | Tree };
export function readLocale(locale: Locale): Messages {
return flatten(JSON.parse(readFileSync(path.join(ROOT, 'locales', `${locale}.json`), 'utf8')) as Tree);
}
export function writeLocale(locale: Locale, flat: Messages, order: string[]) {
const tree: Tree = {};
for (const key of order) {
if (flat[key] === undefined) continue;
const parts = key.split('.');
let node = tree;
for (const p of parts.slice(0, -1)) node = (node[p] ??= {}) as Tree;
node[parts.at(-1)!] = flat[key]!;
}
writeFileSync(path.join(ROOT, 'locales', `${locale}.json`), JSON.stringify(tree, null, 2) + '\n');
}
/**
* review.json хранит для каждого языка { ключ: хеш русского текста на момент проверки }.
* Если русский текст изменился, хеш не совпадёт — ключ снова needs_review.
*/
export type Review = Record<Exclude<Locale, 'ru'>, Record<string, string>>;
export const readReview = (): Review => JSON.parse(readFileSync(path.join(ROOT, 'review.json'), 'utf8'));
export const writeReview = (r: Review) => writeFileSync(path.join(ROOT, 'review.json'), JSON.stringify(r, null, 2) + '\n');
export const sourceHash = (s: string) => createHash('sha256').update(s).digest('hex').slice(0, 12);
export type Status = 'approved' | 'needs_review' | 'missing';
export function statusOf(review: Review, locale: Exclude<Locale, 'ru'>, key: string, ru: string, value: string | undefined): Status {
if (value === undefined || value === '') return 'missing';
return review[locale][key] === sourceHash(ru) ? 'approved' : 'needs_review';
}
export const placeholders = (s: string) => [...s.matchAll(/\{\{\s*(\w+)\s*\}\}/g)].map((m) => m[1]).sort().join(',');
export function csvEscape(v: string) {
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
}
export function parseCsv(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let cell = '';
let quoted = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i]!;
if (quoted) {
if (ch === '"' && text[i + 1] === '"') {
cell += '"';
i++;
} else if (ch === '"') quoted = false;
else cell += ch;
} else if (ch === '"') quoted = true;
else if (ch === ',') {
row.push(cell);
cell = '';
} else if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i++;
row.push(cell);
rows.push(row);
row = [];
cell = '';
} else cell += ch;
}
if (cell || row.length) {
row.push(cell);
rows.push(row);
}
return rows.filter((r) => r.some((c) => c !== ''));
}