export type EditorLocale = 'ru' | 'en'; export const EDITOR_LOCALE_STORAGE_KEY = 'dnd_editor_locale'; function primaryLanguageTag(lang: string): string { const trimmed = lang.trim().toLowerCase(); if (!trimmed) return ''; const sep = trimmed.search(/[-_]/); return sep === -1 ? trimmed : trimmed.slice(0, sep); } /** * Выбор `ru` / `en` по языку ОС/браузера, если пользователь ещё не сохранил язык в `localStorage`. * В Electron совпадает с локалью системы (Chromium подставляет `navigator.languages`). */ export function inferEditorLocaleFromSystem(languages?: readonly string[]): EditorLocale { let list: string[]; if (languages !== undefined) { list = [...languages]; } else if (typeof navigator !== 'undefined') { list = [...navigator.languages]; if (navigator.language) { list.push(navigator.language); } list = list.filter((x) => x.trim() !== ''); } else { list = []; } for (const lang of list) { const tag = primaryLanguageTag(lang); if (tag === 'en') return 'en'; if (tag === 'ru') return 'ru'; } return 'ru'; } export function normalizeEditorLocale(raw: string | null | undefined): EditorLocale { if (raw == null) { return inferEditorLocaleFromSystem(); } const trimmed = raw.trim(); if (trimmed === '') { return inferEditorLocaleFromSystem(); } const s = trimmed.toLowerCase(); if (s === 'en') return 'en'; if (s === 'ru') return 'ru'; return inferEditorLocaleFromSystem(); } /** Flat message table; `{name}` placeholders supported in `translate`. */ export const EDITOR_MESSAGES: Record> = { ru: { 'common.close': 'Закрыть', 'common.cancel': 'Отмена', 'common.save': 'Сохранить', 'common.understood': 'Понятно', 'common.message': 'Сообщение', 'common.error': 'Ошибка', 'common.delete': 'Удалить', 'common.closeMenu': 'Закрыть меню', 'notice.campaignAudioEmpty': 'Аудио не добавлено. Проверьте формат файла.', 'license.checkingTitle': 'Проверка лицензии…', 'license.checkingWait': 'Подождите.', 'license.requiredTitle': 'Требуется лицензия', 'license.requiredHint': 'Укажите ключ в меню «Настройки» → «Указать ключ». До активации доступно только меню «Настройки».', 'license.tokenTitle': 'Указать ключ', 'license.tokenKey': 'КЛЮЧ', 'license.tokenPlaceholder': 'Продуктовый ключ DND-...', 'license.tokenSaving': 'Сохранение…', 'license.eulaTitle': 'Лицензионное соглашение', 'license.eulaReject': 'Не принимаю', 'license.eulaAccept': 'Принимаю условия', 'license.eulaNoteEn': 'The binding legal text below is in Russian. If you need an English summary, contact support.', 'license.aboutTitle': 'О лицензии', 'license.aboutDevSkip': 'Режим разработки: проверка лицензии отключена (DND_SKIP_LICENSE).', 'license.aboutStatus': 'СТАТУС', 'license.aboutProduct': 'ПРОДУКТ', 'license.aboutLicenseId': 'ID ЛИЦЕНЗИИ', 'license.aboutExpiry': 'ОКОНЧАНИЕ', 'license.aboutDevice': 'УСТРОЙСТВО', 'license.aboutNoData': 'Нет данных лицензии.', 'license.reason.ok': 'Активна', 'license.reason.none': 'Ключ не указан', 'license.reason.expired': 'Срок действия истёк', 'license.reason.bad_signature': 'Недействительная подпись', 'license.reason.bad_payload': 'Неверный формат токена', 'license.reason.malformed': 'Повреждённый токен', 'license.reason.not_yet_valid': 'Ещё не действует', 'license.reason.wrong_device': 'Другой привязанный компьютер', 'license.reason.revoked_remote': 'Отозвана на сервере', 'presentation.overlay': 'Презентация запущена', 'presentation.title': 'Презентация запущена', 'presentation.body': 'Редактор заблокирован. Закройте окна «Презентация» и «Панель управления», чтобы продолжить.', 'zip.progress': 'Прогресс операции', 'zip.importTitle': 'Импорт проекта', 'zip.exportTitle': 'Экспорт проекта', 'top.settings': 'Настройки', 'top.project': 'Проект', 'top.file': 'Файл', 'top.backToProjects': 'К списку проектов', 'top.appVersion': 'Версия приложения', 'top.run': 'Запустить', 'top.afterLicense': 'Доступно после активации лицензии', 'top.setStartScene': 'Назначьте начальную сцену на графе (ПКМ по узлу)', 'menu.enterKey': 'Указать ключ', 'menu.aboutLicense': 'О лицензии', 'menu.language': 'Язык', 'menu.langRu': 'Русский', 'menu.langEn': 'English', 'projectMenu.home': 'Начальный экран', 'projectMenu.import': 'Импорт', 'projectMenu.export': 'Экспорт', 'projectMenu.noProjects': 'Нет сохранённых проектов', 'fileMenu.rename': 'Переименовать проект', 'scenes.search': 'Поиск сцен…', 'scenes.new': '+ Новая сцена', 'scenes.inspectorGame': 'Свойства игры', 'scenes.inspectorScene': 'Свойства сцены', 'scenes.selectHint': 'Выберите сцену слева, чтобы редактировать её свойства.', 'scenes.openProjectHint': 'Откройте проект, чтобы редактировать кампанию и сцены.', 'rename.title': 'Переименовать проект', 'rename.projectName': 'НАЗВАНИЕ ПРОЕКТА', 'rename.projectPlaceholder': 'Название проекта…', 'rename.projectMin': 'Минимум 3 символа.', 'rename.projectDup': 'Проект с таким названием уже существует.', 'rename.fileName': 'НАЗВАНИЕ ФАЙЛА ПРОЕКТА', 'rename.fileInvalid': 'Минимум 3 символа, без символов <>:"/\\|?*', 'rename.fileDup': 'Файл проекта с таким названием уже существует.', 'rename.saving': 'Сохранение…', 'export.title': 'Экспорт проекта', 'export.project': 'ПРОЕКТ', 'export.hint': 'Далее откроется окно сохранения: укажите имя и папку для файла .dnd.zip — будет создана копия архива проекта.', 'export.exporting': 'Экспорт…', 'export.saveAs': 'Сохранить как…', 'confirmDelete.title': 'Удаление проекта', 'confirmDelete.body': 'Удалить проект «{name}» безвозвратно? Файл и кэш будут стёрты с диска.', 'confirmDelete.failedTitle': 'Не удалось удалить', 'picker.title': 'Проекты', 'picker.newPlaceholder': 'Название нового проекта…', 'picker.create': 'Создать проект', 'picker.existing': 'СУЩЕСТВУЮЩИЕ', 'picker.lockedHint': 'Открытие и создание — после активации лицензии. Список показывает файлы в папке приложения.', 'picker.empty': 'Пока нет проектов.', 'picker.projectMenu': 'Меню проекта', 'picker.openDisabled': 'Открытие проекта — после активации лицензии', 'picker.defaultName': 'Моя кампания', 'campaign.label': 'АУДИО ИГРЫ', 'campaign.noFiles': 'Файлов пока нет. Добавьте аудио.', 'campaign.auto': 'Авто', 'campaign.loop': 'Цикл', 'campaign.removeTitle': 'Убрать из кампании', 'campaign.upload': 'Загрузить', 'scene.title': 'НАЗВАНИЕ СЦЕНЫ', 'scene.description': 'ОПИСАНИЕ', 'scene.preview': 'ПРЕВЬЮ СЦЕНЫ', 'scene.previewHint': 'Файл изображения (PNG, JPG, WebP, GIF и т.д.).', 'scene.previewEmpty': 'Превью не задано', 'scene.previewBusy': 'Загрузка и оптимизация изображения…', 'scene.change': 'Изменить', 'scene.clear': 'Очистить', 'scene.autostart': 'Автостарт', 'scene.rotate': 'Повернуть', 'scene.audio': 'АУДИО СЦЕНЫ', 'scene.removeTitle': 'Убрать из сцены', 'scene.branching': 'ВЕТВЛЕНИЯ', 'scene.branchingHint': 'Перетащите сцену из списка на граф. С одной карточки можно задать несколько вариантов — по одной связи на каждую целевую сцену. Повторно к той же сцене (включая вторую карточку той же сцены на графе) подключить нельзя.', 'sceneCard.current': 'ТЕКУЩАЯ', 'sceneCard.menu': 'Меню сцены', 'graph.badgeStart': 'НАЧАЛО', 'graph.untitled': 'Без названия', 'graph.videoBadge': 'Видео', 'graph.audioBadge': 'Аудио', 'graph.loop': 'Цикл', 'graph.autoplay': 'Автостарт', 'graph.previewAutostart': 'Авто превью', 'graph.videoLoop': 'Цикл видео', 'graph.zoomBar': 'Масштаб графа', 'graph.zoomIn': 'Увеличить', 'graph.zoomOut': 'Уменьшить', 'graph.fitAll': 'Показать всё', 'graph.startScene': 'Начальная сцена', 'graph.unsetStartScene': 'Снять метку «Начальная сцена»', 'control.remoteTitle': 'ПУЛЬТ УПРАВЛЕНИЯ', 'control.effects': 'ЭФФЕКТЫ', 'control.tools': 'Инструменты', 'control.fieldEffects': 'Эффекты поля', 'control.actionEffects': 'Эффекты действий', 'control.eraser': 'Ластик', 'control.clearEffects': 'Очистить эффекты', 'control.fog': 'Туман', 'control.rain': 'Дождь', 'control.fire': 'Огонь', 'control.water': 'Вода', 'control.lightning': 'Молния', 'control.sunbeam': 'Луч света', 'control.freeze': 'Заморозка', 'control.poisonCloud': 'Облако яда', 'control.brushRadius': 'Радиус кисти', 'control.storyLine': 'СЮЖЕТНАЯ ЛИНИЯ', 'control.gotoScene': 'Перейти к этой сцене', 'control.currentSceneBadge': 'ТЕКУЩАЯ СЦЕНА', 'control.passed': 'Пройдено', 'control.noActiveScene': 'Нет активной сцены.', 'control.screenPreview': 'Предпросмотр экрана', 'control.stopPresentation': 'Выключить демонстрацию', 'control.videoBrushHint': 'Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для изображения).', 'control.branches': 'Варианты ветвления', 'control.option': 'ОПЦИЯ {n}', 'control.unnamed': 'Без названия', 'control.switchScene': 'Переключить', 'control.noBranches': 'Нет вариантов перехода.', 'control.endPresentation': 'Завершить показ', 'control.music': 'Музыка', 'control.sceneMusic': 'МУЗЫКА СЦЕНЫ', 'control.gameMusic': 'МУЗЫКА ИГРЫ', 'control.noSceneAudio': 'В текущей сцене нет аудио.', 'control.noGameAudio': 'В игре нет аудио.', 'control.modeAuto': 'Авто', 'control.modeManual': 'Ручн.', 'control.once': 'Один раз', 'control.loop': 'Цикл', 'control.scrubSeek': 'Клик — перемотка', 'control.durationUnknown': 'Длительность неизвестна', 'control.pauseSceneMusic': 'Пауза (сцена)', 'control.pauseSceneMusicTitle': 'В сцене есть музыка', 'control.pauseCampaignTitle': 'Пауза: в сцене есть музыка', 'control.playFailed': 'Не удалось запустить.', 'control.audioAutoplayBlocked': 'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.', 'control.audioNoUrl': 'URL не получен', 'control.audioNoUrlDetail': 'Не удалось получить dnd://asset URL для аудио.', 'control.audioBlocked': 'Ошибка/блок', 'control.audioError': 'Ошибка', 'control.audioMediaError': 'MediaError code={code} (1=ABORTED, 2=NETWORK, 3=DECODE, 4=SRC_NOT_SUPPORTED)', 'control.audioLoading': 'Загрузка…', 'control.audioPlaying': 'Играет', 'control.audioPaused': 'Пауза', 'control.audioStopped': 'Остановлено', 'control.previewTrackLabel': 'Превью без субтитров', 'control.transportPlay': 'Воспроизведение', 'control.transportPause': 'Пауза', 'control.transportStop': 'Стоп', }, en: { 'common.close': 'Close', 'common.cancel': 'Cancel', 'common.save': 'Save', 'common.understood': 'OK', 'common.message': 'Message', 'common.error': 'Error', 'common.delete': 'Delete', 'common.closeMenu': 'Close menu', 'notice.campaignAudioEmpty': 'No audio was added. Check the file format.', 'license.checkingTitle': 'Checking license…', 'license.checkingWait': 'Please wait.', 'license.requiredTitle': 'License required', 'license.requiredHint': 'Enter your key via Settings → Enter license key. Until activation, only Settings is available.', 'license.tokenTitle': 'Enter license key', 'license.tokenKey': 'KEY', 'license.tokenPlaceholder': 'DND product key…', 'license.tokenSaving': 'Saving…', 'license.eulaTitle': 'End User License Agreement', 'license.eulaReject': 'Decline', 'license.eulaAccept': 'I accept the terms', 'license.eulaNoteEn': 'The binding legal text below is in Russian. If you need an English summary, contact support.', 'license.aboutTitle': 'About license', 'license.aboutDevSkip': 'Development mode: license checks are disabled (DND_SKIP_LICENSE).', 'license.aboutStatus': 'STATUS', 'license.aboutProduct': 'PRODUCT', 'license.aboutLicenseId': 'LICENSE ID', 'license.aboutExpiry': 'EXPIRES', 'license.aboutDevice': 'DEVICE', 'license.aboutNoData': 'No license data.', 'license.reason.ok': 'Active', 'license.reason.none': 'No key provided', 'license.reason.expired': 'Expired', 'license.reason.bad_signature': 'Invalid signature', 'license.reason.bad_payload': 'Invalid token format', 'license.reason.malformed': 'Malformed token', 'license.reason.not_yet_valid': 'Not yet valid', 'license.reason.wrong_device': 'Wrong bound device', 'license.reason.revoked_remote': 'Revoked on server', 'presentation.overlay': 'Presentation running', 'presentation.title': 'Presentation running', 'presentation.body': 'The editor is locked. Close the Presentation and Control windows to continue.', 'zip.progress': 'Operation progress', 'zip.importTitle': 'Import project', 'zip.exportTitle': 'Export project', 'top.settings': 'Settings', 'top.project': 'Project', 'top.file': 'File', 'top.backToProjects': 'Back to projects', 'top.appVersion': 'App version', 'top.run': 'Run', 'top.afterLicense': 'Available after license activation', 'top.setStartScene': 'Set a start scene on the graph (right‑click a node)', 'menu.enterKey': 'Enter license key', 'menu.aboutLicense': 'About license', 'menu.language': 'Language', 'menu.langRu': 'Русский', 'menu.langEn': 'English', 'projectMenu.home': 'Home', 'projectMenu.import': 'Import', 'projectMenu.export': 'Export', 'projectMenu.noProjects': 'No saved projects', 'fileMenu.rename': 'Rename project', 'scenes.search': 'Search scenes…', 'scenes.new': '+ New scene', 'scenes.inspectorGame': 'Game properties', 'scenes.inspectorScene': 'Scene properties', 'scenes.selectHint': 'Select a scene on the left to edit its properties.', 'scenes.openProjectHint': 'Open a project to edit the campaign and scenes.', 'rename.title': 'Rename project', 'rename.projectName': 'PROJECT NAME', 'rename.projectPlaceholder': 'Project name…', 'rename.projectMin': 'At least 3 characters.', 'rename.projectDup': 'A project with this name already exists.', 'rename.fileName': 'PROJECT FILE NAME', 'rename.fileInvalid': 'At least 3 characters; forbidden characters <>:"/\\|?*', 'rename.fileDup': 'A project file with this name already exists.', 'rename.saving': 'Saving…', 'export.title': 'Export project', 'export.project': 'PROJECT', 'export.hint': 'A save dialog will open: choose a name and folder for the .dnd.zip file — a copy of the project archive will be created.', 'export.exporting': 'Exporting…', 'export.saveAs': 'Save as…', 'confirmDelete.title': 'Delete project', 'confirmDelete.body': 'Permanently delete project “{name}”? The file and cache will be removed from disk.', 'confirmDelete.failedTitle': 'Could not delete', 'picker.title': 'Projects', 'picker.newPlaceholder': 'New project name…', 'picker.create': 'Create project', 'picker.existing': 'EXISTING', 'picker.lockedHint': 'Opening and creating projects require an active license. The list still shows files in the app folder.', 'picker.empty': 'No projects yet.', 'picker.projectMenu': 'Project menu', 'picker.openDisabled': 'Open project — after license activation', 'picker.defaultName': 'My campaign', 'campaign.label': 'GAME AUDIO', 'campaign.noFiles': 'No files yet. Add audio.', 'campaign.auto': 'Auto', 'campaign.loop': 'Loop', 'campaign.removeTitle': 'Remove from campaign', 'campaign.upload': 'Upload', 'scene.title': 'SCENE TITLE', 'scene.description': 'DESCRIPTION', 'scene.preview': 'SCENE PREVIEW', 'scene.previewHint': 'Image file (PNG, JPG, WebP, GIF, etc.).', 'scene.previewEmpty': 'No preview', 'scene.previewBusy': 'Loading and optimizing image…', 'scene.change': 'Change', 'scene.clear': 'Clear', 'scene.autostart': 'Autostart', 'scene.rotate': 'Rotate', 'scene.audio': 'SCENE AUDIO', 'scene.removeTitle': 'Remove from scene', 'scene.branching': 'BRANCHING', 'scene.branchingHint': 'Drag a scene from the list onto the graph. One card can branch to several targets — one link per target scene. You cannot link twice to the same target (including a second card of the same scene).', 'sceneCard.current': 'CURRENT', 'sceneCard.menu': 'Scene menu', 'graph.badgeStart': 'START', 'graph.untitled': 'Untitled', 'graph.videoBadge': 'Video', 'graph.audioBadge': 'Audio', 'graph.loop': 'Loop', 'graph.autoplay': 'Autoplay', 'graph.previewAutostart': 'Preview autostart', 'graph.videoLoop': 'Video loop', 'graph.zoomBar': 'Graph zoom', 'graph.zoomIn': 'Zoom in', 'graph.zoomOut': 'Zoom out', 'graph.fitAll': 'Fit view', 'graph.startScene': 'Start scene', 'graph.unsetStartScene': 'Clear start scene mark', 'control.remoteTitle': 'CONTROL PANEL', 'control.effects': 'EFFECTS', 'control.tools': 'Tools', 'control.fieldEffects': 'Field effects', 'control.actionEffects': 'Action effects', 'control.eraser': 'Eraser', 'control.clearEffects': 'Clear effects', 'control.fog': 'Fog', 'control.rain': 'Rain', 'control.fire': 'Fire', 'control.water': 'Water', 'control.lightning': 'Lightning', 'control.sunbeam': 'Sunbeam', 'control.freeze': 'Freeze', 'control.poisonCloud': 'Poison cloud', 'control.brushRadius': 'Brush radius', 'control.storyLine': 'STORYLINE', 'control.gotoScene': 'Go to this scene', 'control.currentSceneBadge': 'CURRENT SCENE', 'control.passed': 'Visited', 'control.noActiveScene': 'No active scene.', 'control.screenPreview': 'Screen preview', 'control.stopPresentation': 'Stop presentation', 'control.videoBrushHint': 'Video preview: effect brush is disabled (like on the presentation screen — overlay is for images only).', 'control.branches': 'Branch options', 'control.option': 'OPTION {n}', 'control.unnamed': 'Untitled', 'control.switchScene': 'Switch', 'control.noBranches': 'No transitions available.', 'control.endPresentation': 'End presentation', 'control.music': 'Music', 'control.sceneMusic': 'SCENE MUSIC', 'control.gameMusic': 'GAME MUSIC', 'control.noSceneAudio': 'No audio in the current scene.', 'control.noGameAudio': 'No game audio.', 'control.modeAuto': 'Auto', 'control.modeManual': 'Manual', 'control.once': 'Once', 'control.loop': 'Loop', 'control.scrubSeek': 'Click to seek', 'control.durationUnknown': 'Duration unknown', 'control.pauseSceneMusic': 'Paused (scene)', 'control.pauseSceneMusicTitle': 'Scene has music', 'control.pauseCampaignTitle': 'Paused: scene has music', 'control.playFailed': 'Could not start playback.', 'control.audioAutoplayBlocked': 'Autoplay was blocked (user gesture required) or playback failed.', 'control.audioNoUrl': 'No URL', 'control.audioNoUrlDetail': 'Could not get dnd://asset URL for audio.', 'control.audioBlocked': 'Error / blocked', 'control.audioError': 'Error', 'control.audioMediaError': 'MediaError code={code} (1=ABORTED, 2=NETWORK, 3=DECODE, 4=SRC_NOT_SUPPORTED)', 'control.audioLoading': 'Loading…', 'control.audioPlaying': 'Playing', 'control.audioPaused': 'Paused', 'control.audioStopped': 'Stopped', 'control.previewTrackLabel': 'Preview (no captions)', 'control.transportPlay': 'Play', 'control.transportPause': 'Pause', 'control.transportStop': 'Stop', }, }; export function translateEditorMessage( locale: EditorLocale, key: string, vars?: Record, ): string { let s = EDITOR_MESSAGES[locale][key] ?? EDITOR_MESSAGES.ru[key] ?? key; if (vars) { for (const [k, v] of Object.entries(vars)) { s = s.split(`{${k}}`).join(String(v)); } } return s; }