diff --git a/apps/desktop/installer/locales/es_ES.json b/apps/desktop/installer/locales/es_ES.json index c160a1db5a..d4fbfd972f 100644 --- a/apps/desktop/installer/locales/es_ES.json +++ b/apps/desktop/installer/locales/es_ES.json @@ -1,5 +1,5 @@ { - "nsisLanguage": "SPANISH", + "nsisLanguage": "SPANISHINTERNATIONAL", "strings": { "INSTALLER_INSTALL": "Instalar ahora", "INSTALLER_FINISH": "Finalizar", diff --git a/apps/desktop/scripts/installer-locales.mjs b/apps/desktop/scripts/installer-locales.mjs index 539f2b163a..5785a99021 100644 --- a/apps/desktop/scripts/installer-locales.mjs +++ b/apps/desktop/scripts/installer-locales.mjs @@ -10,7 +10,8 @@ const defaultDirectory = fileURLToPath(new URL('../installer/locales/', import.m * @returns {{id: string, nsisLanguage: string, strings: Record, uninstaller: Record}[]} Complete language resources. */ export function readInstallerLocales(directory = defaultDirectory) { - const locales = readdirSync(directory).filter(name => name.endsWith('.json')).sort().map(name => { + const locales = readdirSync(directory).filter(name => name.endsWith('.json')).sort((a, b) => + a === 'en_US.json' ? -1 : b === 'en_US.json' ? 1 : a.localeCompare(b)).map(name => { if (!/^[a-z]{2}_[A-Z]{2}\.json$/u.test(name)) throw new Error(`${name}: invalid installer language filename`) /** @type {unknown} */ const resource = JSON.parse(readFileSync(`${directory}/${name}`, 'utf8')) @@ -25,6 +26,7 @@ export function readInstallerLocales(directory = defaultDirectory) { const result = {} for (const [key, text] of Object.entries(value)) { if (typeof text !== 'string' || text.trim() === '') throw new Error(`${name}: ${key} must be nonempty text`) + if (/[\u200b\ufeff]/u.test(text)) throw new Error(`${name}: ${key} contains an invisible locale character`) result[key] = text } return result diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 839d8ee4d0..e5dcec40c8 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -557,7 +557,7 @@ export const es = { showAllApplications: 'Mostrar todo', quitApplication: 'Salir de DeepSeek Harness', openApplication: 'Abrir DeepSeek Harness', - quit: 'dejar de fumar', + quit: 'Salir', cancel: 'Cancelar', quitTitle: '¿Salir de DeepSeek Harness?', quitActiveTasks: 'Las tareas en ejecución se interrumpirán.', @@ -664,7 +664,7 @@ export const es = { policyLoginFailed: 'El inicio de sesión de Feishu no se completó. Por favor inténtalo de nuevo.', policyLoginLoading: 'Cargando página de inicio de sesión...', mandatoryNoRelease: 'No se encontró ninguna actualización compatible. Verifique nuevamente o comuníquese con el soporte.', - mandatoryRefresh: 'comprobar de nuevo', + mandatoryRefresh: 'Comprobar de nuevo', mandatoryPage: 'Descargar desde el sitio web oficial.', mandatoryCopy: 'Copiar enlace de descarga', mandatoryPageFailed: 'No se pudo abrir la página de descarga oficial. Copie el enlace y ábralo en su navegador.', @@ -702,7 +702,7 @@ export const fr = { cliCommandNotInstalled: 'Ajoutez la commande Bureau à votre terminal.', cliCommandBroken: 'La commande Bureau doit être réparée.', cliCommandInstall: 'Installer', - cliCommandRepair: 'Réparation', + cliCommandRepair: 'Réparer', cliCommandRemove: 'Supprimer', cliCommandClose: 'Fermer', cliCommandSwitch: 'Continuer avec la commande Bureau ?', @@ -718,7 +718,7 @@ export const fr = { cliCommandChanged: "La commande ou PATH a changé pendant que cette boîte de dialogue était ouverte. Ouvrez à nouveau la commande Gérer dsh pour vérifier l'état actuel.", cliCommandOwnershipError: "L'enregistrement ou la sauvegarde de la commande a été modifiée. Aucune commande sans rapport n'a été supprimée.", cliCommandFailed: "La commande n'a pas pu être mise à jour. Vérifiez que l'application est installée et que la destination est accessible en écriture, puis réessayez.", - application: 'Demande', + application: 'Application', fileMenu: 'Fichier', closePage: 'Fermer la page ou la fenêtre', aboutMenu: 'À propos de DeepSeek Harness', @@ -864,12 +864,12 @@ export const fr = { /** German dictionary, checked against the English keys. */ export const de = { cliCommandMenu: 'dsh-Befehl verwalten…', - cliCommandTitle: 'Verwalten Sie den dsh-Befehl', + cliCommandTitle: 'dsh-Befehl verwalten', cliCommandLocation: 'Desktop-Befehl: {path}', cliCommandSelected: 'Aktueller dsh-Befehl: {path}', cliCommandTarget: 'Aktuelles Launcher-Ziel: {path}', cliCommandShadowed: 'Ein anderer dsh hat Vorrang. Entfernen Sie diese Installation oder ordnen Sie sie neu an, um standardmäßig den Desktop-Befehl zu verwenden. Sie können den Desktop-Befehl auch über seinen vollständigen Pfad ausführen.', - cliCommandSelectionUnknown: 'Ihr Shell-Befehl konnte nicht überprüft werden. Ein Alias ​​oder eine andere dsh-Installation kann Vorrang haben.', + cliCommandSelectionUnknown: 'Ihr Shell-Befehl konnte nicht überprüft werden. Ein Alias oder eine andere dsh-Installation kann Vorrang haben.', cliCommandInstalled: 'Der Desktop-Befehl ist installiert.', cliCommandNotInstalled: 'Fügen Sie den Desktop-Befehl zu Ihrem Terminal hinzu.', cliCommandBroken: 'Der Desktop-Befehl muss repariert werden.', @@ -890,7 +890,7 @@ export const de = { cliCommandChanged: 'Der Befehl oder PATH hat sich geändert, während dieses Dialogfeld geöffnet war. Öffnen Sie den Befehl „dsh verwalten“ erneut, um den aktuellen Status zu überprüfen.', cliCommandOwnershipError: 'Die Befehlsregistrierung oder Sicherung wurde geändert. Es wurde kein nicht verwandter Befehl entfernt.', cliCommandFailed: 'Der Befehl konnte nicht aktualisiert werden. Überprüfen Sie, ob die Anwendung installiert ist und das Ziel beschreibbar ist, und versuchen Sie es dann erneut.', - application: 'Bewerbung', + application: 'Anwendung', fileMenu: 'Datei', closePage: 'Seite oder Fenster schließen', aboutMenu: 'Über DeepSeek Harness', @@ -898,10 +898,10 @@ export const de = { aboutVersion: 'Version V{version}', hideApplication: 'DeepSeek Harness ausblenden', hideOtherApplications: 'Andere ausblenden', - showAllApplications: 'Alle anzeigen', - quitApplication: 'Beenden Sie DeepSeek Harness', - openApplication: 'Öffnen Sie DeepSeek Harness', - quit: 'Hör auf', + showAllApplications: 'Alle einblenden', + quitApplication: 'DeepSeek Harness beenden', + openApplication: 'DeepSeek Harness öffnen', + quit: 'Beenden', cancel: 'Abbrechen', quitTitle: 'DeepSeek Harness beenden?', quitActiveTasks: 'Laufende Aufgaben werden unterbrochen.', @@ -914,17 +914,17 @@ export const de = { delete: 'Löschen', undo: 'Rückgängig machen', redo: 'Wiederholen', - cut: 'Schneiden', + cut: 'Ausschneiden', copy: 'Kopieren', paste: 'Einfügen', - selectAll: 'Wählen Sie „Alle“ aus', + selectAll: 'Alles auswählen', startupFailed: 'DeepSeek Harness ist nicht verfügbar', fatalSummary: 'Die Anwendung konnte nicht gestartet werden oder wurde unerwartet gestoppt.', startupAddressInUse: 'Eine andere DSH-Instanz (z. B. dsh Web oder die Desktop-App) wird ausgeführt. Sie können nicht gleichzeitig starten. Beenden Sie die andere laufende DSH-Instanz und starten Sie sie neu.', diagnosticTruncated: '… Fehlerdetails gekürzt.', reportWrittenTo: 'Diagnosebericht: {path}', startupReinstallAdvice: 'Wenn Anwendungsdateien fehlen oder beschädigt sind, schließen Sie die Anwendung und installieren Sie sie erneut. Ihre Aufgaben werden separat gespeichert.', - exitApplication: 'Ausstieg', + exitApplication: 'Beenden', restartApplication: 'Neustart', recoveryOperationFailed: 'Der Wiederherstellungsvorgang ist fehlgeschlagen', disableThirdPartyPlugins: 'Deaktivieren Sie Plugins von Drittanbietern, sichern Sie den Profil-Patch und starten Sie neu', @@ -934,7 +934,7 @@ export const de = { welcomeTaglineBrand: 'DeepSeek Harness', welcomeTaglineAfter: '', welcomeDescription: 'Potenzial aufbauen. Entdecken Sie Intelligenz.', - welcomeAuthStarting: 'Eröffnungsanmeldung…', + welcomeAuthStarting: 'Anmeldung wird geöffnet…', welcomeAuthWaiting: 'Browser wurde nicht automatisch geöffnet?', welcomeAuthWaitingDescription: 'Kopieren Sie den Anmeldelink und öffnen Sie ihn in Ihrem Browser, um sich anzumelden.', welcomeAuthExchanging: 'Anmeldung wird abgeschlossen…', @@ -945,23 +945,23 @@ export const de = { welcomeAuthCopied: 'Kopiert', welcomeAuthCopyFailed: 'Konnte nicht kopiert werden. Versuchen Sie es erneut.', welcomeAuthCancel: 'Abbrechen', - welcomeAuthRetry: 'Melden Sie sich erneut an', - welcomeSignIn: 'Melden Sie sich an', - welcomeApiKey: 'Fügen Sie den API-Schlüssel hinzu', + welcomeAuthRetry: 'Erneut anmelden', + welcomeSignIn: 'Anmelden', + welcomeApiKey: 'API-Schlüssel hinzufügen', welcomeKeyTitle: 'Fügen Sie einen API-Schlüssel hinzu, um loszulegen', welcomeKeyDescription: 'Konfigurieren Sie offizielle DeepSeek-Modelle für die Verwendung von Harness', - welcomeKeyPlaceholder: 'Geben Sie den Schlüssel API ein', + welcomeKeyPlaceholder: 'API-Schlüssel eingeben', welcomeKeySave: 'Speichern und fortfahren', welcomeKeyLater: 'Später einrichten', welcomeKeyBack: 'Zurück zur Anmeldung', welcomeSessionExpired: 'Sie haben sich von Ihrem Konto abgemeldet. Bitte melden Sie sich erneut an.', welcomeKeyBlank: 'Geben Sie einen API-Schlüssel ein.', welcomeKeyInvalid: 'Geben Sie den API-Schlüssel selbst ein, ohne Anführungszeichen, Leerzeichen oder eine Umgebungsvariablenzuweisung.', - welcomeKeyFailed: 'Der Schlüssel API konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.', + welcomeKeyFailed: 'Der API-Schlüssel konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.', welcomeContinueFailed: 'Der Arbeitsbereich konnte nicht geöffnet werden. Bitte versuchen Sie es erneut.', checkUpdatesMenu: 'Nach Updates suchen…', reloadPageMenu: 'Seite neu laden', - restartAppHostMenu: 'Starten Sie App und Host neu', + restartAppHostMenu: 'Anwendung und Host neu starten', updateCheckFailedTitle: 'Update-Überprüfung fehlgeschlagen', updateCheckFailed: 'Es konnte nicht nach Updates gesucht werden. Bitte versuchen Sie es später noch einmal.', updateDownloadFailed: 'Das Update konnte nicht heruntergeladen werden. Bitte versuchen Sie es erneut.', @@ -970,9 +970,9 @@ export const de = { updateDownloadNetworkFailed: 'Das Update konnte nicht heruntergeladen werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.', updateInstallNetworkFailed: 'Das Update konnte nicht installiert werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.', unknownError: 'Unbekannter Fehler', - updateCheckTitle: 'Suchen Sie nach Updates', + updateCheckTitle: 'Nach Updates suchen', updateCurrentDetail: 'Aktuelle Version: {version}', - updateCurrent: 'Du bist auf dem Laufenden!', + updateCurrent: 'Sie verwenden die neueste Version.', updateChecking: 'Suche nach Updates…', updateDownload: 'Update herunterladen', updateDownloadedTitle: 'Die Version {version} ist zur Installation bereit', @@ -987,7 +987,7 @@ export const de = { updateRetry: 'Update erneut versuchen', updateActiveTasks: 'Aufgaben sind noch in Bearbeitung', updateActiveTasksDetail: 'Durch die Aktualisierung werden die laufenden Aufgaben gestoppt und die App neu gestartet. Weitermachen?', - updateStopTasks: 'Stoppen Sie Aufgaben und aktualisieren Sie sie', + updateStopTasks: 'Aufgaben beenden und Update installieren', updateTasksChanged: 'Neue Aufgaben haben begonnen. Bestätigen Sie erneut, um die Aufgaben zu stoppen und zu aktualisieren.', updateTasksUnavailable: 'Der Aufgabenstatus ist nicht verfügbar. Versuchen Sie erneut, die Aktualisierung durchzuführen, wenn der Arbeitsbereich bereit ist.', updateFullscreenTimeout: 'Der Vollbildmodus konnte nicht beendet werden. Beenden Sie den Vollbildmodus und versuchen Sie dann erneut, das Update durchzuführen.', @@ -1008,7 +1008,7 @@ export const de = { policyLoginFailed: 'Die Feishu-Anmeldung wurde nicht abgeschlossen. Bitte versuchen Sie es erneut.', policyLoginLoading: 'Anmeldeseite wird geladen…', mandatoryNoRelease: 'Es wurde kein kompatibles Update gefunden. Überprüfen Sie es erneut oder wenden Sie sich an den Support.', - mandatoryRefresh: 'Überprüfen Sie es noch einmal', + mandatoryRefresh: 'Erneut prüfen', mandatoryPage: 'Von der offiziellen Website herunterladen', mandatoryCopy: 'Download-Link kopieren', mandatoryPageFailed: 'Die offizielle Download-Seite konnte nicht geöffnet werden. Kopieren Sie den Link und öffnen Sie ihn in Ihrem Browser.', @@ -1019,14 +1019,14 @@ export const de = { mandatoryVersion: 'Neue Version: {version}', mandatoryReadyDetail: 'Die App wird während des Updates geschlossen und nach Abschluss automatisch wieder geöffnet.', mandatoryDeferred: 'Laufende Aufgaben werden weiterhin ausgeführt. Schließen Sie das Update ab, bevor Sie die App erneut verwenden.', - mandatoryContinue: 'Weiter aktualisieren', + mandatoryContinue: 'Update fortsetzen', mandatoryInspecting: 'Aufgaben prüfen…', mandatoryStopping: 'Aufgaben stoppen…', mandatoryRestarting: 'Die App wird in Kürze neu gestartet. Bitte warten.', mandatoryDownloadFailed: 'Die Update-Dateien konnten nicht heruntergeladen oder vorbereitet werden. Bitte versuchen Sie es erneut.', mandatoryInstallFailed: 'Das Update wurde nicht installiert. Überprüfen Sie die Aufgaben erneut und versuchen Sie es erneut.', mandatoryOpenHelp: 'Seite wurde nicht geöffnet?', - mandatoryReopen: 'Öffnen Sie die offizielle Download-Seite erneut', + mandatoryReopen: 'Offizielle Downloadseite erneut öffnen', mandatoryCopied: 'Link kopiert', mandatoryCopyFailed: 'Der Link konnte nicht kopiert werden. Wählen Sie es aus und kopieren Sie es unten.', mandatoryAddress: 'Download-Link', @@ -1037,25 +1037,25 @@ export const de = { export const ru = { cliCommandMenu: 'Управление командой dsh…', cliCommandTitle: 'Управление командой dsh', - cliCommandLocation: 'Команда рабочего стола: {path}', + cliCommandLocation: 'Команда Desktop: {path}', cliCommandSelected: 'Текущая команда dsh: {path}.', cliCommandTarget: 'Текущая цель лаунчера: {path}', - cliCommandShadowed: 'Другой dsh имеет приоритет. Удалите или измените порядок установки, чтобы использовать команду «Рабочий стол» по умолчанию. Вы также можете запустить команду «Рабочий стол» по ее полному пути.', + cliCommandShadowed: 'Другой dsh имеет приоритет. Удалите или измените порядок установки, чтобы использовать команду «Desktop» по умолчанию. Вы также можете запустить команду «Desktop» по ее полному пути.', cliCommandSelectionUnknown: 'Не удалось проверить вашу команду оболочки. Псевдоним или другая установка dsh может иметь приоритет.', - cliCommandInstalled: 'Команда «Рабочий стол» установлена.', - cliCommandNotInstalled: 'Добавьте команду «Рабочий стол» в свой терминал.', + cliCommandInstalled: 'Команда «Desktop» установлена.', + cliCommandNotInstalled: 'Добавьте команду «Desktop» в свой терминал.', cliCommandBroken: 'Команда Desktop требует ремонта.', cliCommandInstall: 'Установить', - cliCommandRepair: 'Ремонт', + cliCommandRepair: 'Исправить', cliCommandRemove: 'Удалить', cliCommandClose: 'Закрыть', - cliCommandSwitch: 'Продолжить команду «Рабочий стол»?', + cliCommandSwitch: 'Продолжить команду «Desktop»?', cliCommandPreserve: 'Существующая команда будет сохранена. Другие установки и файлы запуска оболочки не будут изменены.', cliCommandContinue: 'Продолжить', cliCommandInstallApp: 'Установите Desktop в папку «Приложения», прежде чем управлять командой dsh.', cliCommandUpdating: 'Идет установка обновления. Управляйте командой после завершения установки.', cliCommandNewTerminal: 'Откройте новый терминал и запустите dsh --version.', - cliCommandRemoved: 'Регистрация команд рабочего стола удалена.', + cliCommandRemoved: 'Регистрация команд Desktop удалена.', cliCommandPreviousRestored: 'Предыдущий лаунчер восстановлен.', cliCommandOtherKept: 'Остальные командные установки остались на месте.', cliCommandBackupKept: 'Предыдущий лаунчер сохранился по адресу: {path}.', @@ -1144,7 +1144,7 @@ export const ru = { unknownError: 'Неизвестная ошибка', updateCheckTitle: 'Проверьте наличие обновлений', updateCurrentDetail: 'Текущая версия: {version}.', - updateCurrent: 'Вы в курсе!', + updateCurrent: 'У вас последняя версия.', updateChecking: 'Проверка обновлений…', updateDownload: 'Скачать обновление', updateDownloadedTitle: 'Версия {version} готова к установке.', @@ -1186,7 +1186,7 @@ export const ru = { mandatoryPageFailed: 'Не удалось открыть официальную страницу загрузки. Скопируйте ссылку и откройте ее в браузере.', mandatoryViewDownload: 'Открыть страницу загрузки', mandatoryViewPageFailed: 'Не удалось открыть страницу загрузки. Пожалуйста, попробуйте еще раз.', - mandatoryActionFailed: 'Акция провалилась. Пожалуйста, попробуйте еще раз. Вы сможете снова использовать приложение после завершения обновления.', + mandatoryActionFailed: 'Не удалось выполнить действие. Повторите попытку. Вы сможете снова использовать приложение после завершения обновления.', mandatoryReady: 'Обновление готово', mandatoryVersion: 'Новая версия: {version}', mandatoryReadyDetail: 'Приложение закроется во время обновления и автоматически откроется после его завершения.', @@ -1234,7 +1234,7 @@ export const pt = { cliCommandChanged: 'O comando ou PATH foi alterado enquanto esta caixa de diálogo estava aberta. Abra o comando Gerenciar dsh novamente para revisar o estado atual.', cliCommandOwnershipError: 'O registro ou backup do comando foi alterado. Nenhum comando não relacionado foi removido.', cliCommandFailed: 'O comando não pôde ser atualizado. Verifique se o aplicativo está instalado e se o destino é gravável e tente novamente.', - application: 'Aplicação', + application: 'Aplicativo', fileMenu: 'Arquivo', closePage: 'Fechar página ou janela', aboutMenu: 'Sobre DeepSeek Harness', @@ -1294,7 +1294,7 @@ export const pt = { welcomeApiKey: 'Adicionar chave API', welcomeKeyTitle: 'Adicione uma chave API para começar', welcomeKeyDescription: 'Configure modelos DeepSeek oficiais para começar a usar o Harness', - welcomeKeyPlaceholder: 'Insira a tecla API', + welcomeKeyPlaceholder: 'Insira a chave de API', welcomeKeySave: 'Salvar e continuar', welcomeKeyLater: 'Configurar mais tarde', welcomeKeyBack: 'Voltar para fazer login', @@ -1373,10 +1373,12 @@ export const pt = { mandatoryReopen: 'Abra a página oficial de download novamente', mandatoryCopied: 'Link copiado', mandatoryCopyFailed: 'Não foi possível copiar o link. Selecione e copie abaixo.', - mandatoryAddress: 'Baixar link', + mandatoryAddress: 'Link de download', mandatoryNotification: 'Retorne ao aplicativo para confirmar a instalação e reinicie.', } satisfies DesktopMessages +const dictionaries = { en, zh, ja, es, fr, de, ru, pt } + /** Locale payload exposed to the Desktop-owned renderer. */ export interface DesktopLocale { readonly id: 'en' | 'zh-CN' | 'ja' | 'es' | 'fr' | 'de' | 'ru' | 'pt' @@ -1386,14 +1388,11 @@ export interface DesktopLocale { /** Resolve Electron's locale to one shipped Desktop dictionary. */ export function resolveDesktopLocale(locale: string): DesktopLocale { const primary = locale.toLowerCase().split('-')[0] - const dictionaries = { ja, es, fr, de, ru, pt } if (primary !== undefined && Object.hasOwn(dictionaries, primary)) { const id = primary as keyof typeof dictionaries - return { id, messages: dictionaries[id] } + return { id: id === 'zh' ? 'zh-CN' : id, messages: dictionaries[id] } } - return primary === 'zh' - ? { id: 'zh-CN', messages: zh } - : { id: 'en', messages: en } + return { id: 'en', messages: en } } /** @@ -1404,10 +1403,10 @@ export function resolveDesktopLocale(locale: string): DesktopLocale { */ export function resolveDesktopStartupLocale(preference: string | null, languages: readonly string[]): DesktopLocale { const selected = preference?.toLowerCase() - if (selected !== undefined && ['zh', 'en', 'ja', 'es', 'fr', 'de', 'ru', 'pt'].includes(selected)) return resolveDesktopLocale(selected) + if (selected !== undefined && Object.hasOwn(dictionaries, selected)) return resolveDesktopLocale(selected) for (const language of languages) { const primary = language.toLowerCase().split('-')[0] - if (primary !== undefined && ['zh', 'en', 'ja', 'es', 'fr', 'de', 'ru', 'pt'].includes(primary)) return resolveDesktopLocale(primary) + if (primary !== undefined && Object.hasOwn(dictionaries, primary)) return resolveDesktopLocale(primary) } return resolveDesktopLocale('en') } diff --git a/apps/desktop/tests/installer-locales.spec.ts b/apps/desktop/tests/installer-locales.spec.ts index 7602679f9b..ba842d8e18 100644 --- a/apps/desktop/tests/installer-locales.spec.ts +++ b/apps/desktop/tests/installer-locales.spec.ts @@ -1,3 +1,5 @@ +import { SHIPPED_LOCALE_IDS } from '../../../scripts/shipped-locales.ts' +import { createRequire } from 'node:module' /** Per-language installer resources feed both the packaging language list and the NSIS compiler. */ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -16,7 +18,7 @@ function fixture(): string { it('generates Japanese NSIS strings from the same files that select packaged languages', () => { const locales = readInstallerLocales() - expect(locales.map(locale => locale.id)).toEqual(['de_DE', 'en_US', 'es_ES', 'fr_FR', 'ja_JP', 'pt_BR', 'ru_RU', 'zh_CN']) + expect(locales.map(locale => locale.id)).toEqual(['en_US', 'de_DE', 'es_ES', 'fr_FR', 'ja_JP', 'pt_BR', 'ru_RU', 'zh_CN']) const source = renderInstallerStrings(locales) expect(source).toContain('LangString INSTALLER_INSTALL ${LANG_JAPANESE} "今すぐインストール"') expect(source).toContain('LangString INSTALLER_NATIVE_PROGRESS ${LANG_JAPANESE} "インストールを準備しています… %d%%"') @@ -41,6 +43,8 @@ it.each([ ['extra format specifier', (resource: ReturnType[number]) => { resource.strings.INSTALLER_NATIVE_PROGRESS = '%d%% %q' }, 'format placeholders'], ['translated command', (resource: ReturnType[number]) => { resource.strings.INSTALLER_THEME_ERROR = 'Use /THEME=claro.' }, 'format placeholders'], ['duplicate language', (resource: ReturnType[number]) => { resource.nsisLanguage = 'ENGLISH' }, 'duplicate NSIS language'], + ['invisible character', (resource: ReturnType[number]) => { resource.strings.INSTALLER_INSTALL = 'Install\u200b' }, 'invisible'], + ['byte-order mark', (resource: ReturnType[number]) => { resource.strings.INSTALLER_INSTALL = 'Install\ufeff' }, 'invisible'], ['invalid compiler token', (resource: ReturnType[number]) => { resource.nsisLanguage = 'JA\n!error' }, 'invalid NSIS language'], ])('rejects %s', (_name, mutate, diagnostic) => { const directory = fixture() @@ -55,3 +59,18 @@ it('escapes literal NSIS syntax without changing translated text', () => { expect(renderInstallerStrings([{ ...locale, strings: { INSTALLER_INSTALL: '"$name"\nnext\rline' } }])) .toContain('"$\\"$$name$\\"$\\nnext$\\rline"') }) + +it('uses the real electron-builder language identifiers and English fallback', () => { + const require = createRequire(import.meta.url) + const { LangConfigurator, createAddLangsMacro } = require('app-builder-lib/out/targets/nsis/nsisLang.js') as { + LangConfigurator: new (options: { installerLanguages: string[] }) => object + createAddLangsMacro: (generator: { macro: (name: string, lines: string[]) => void }, languages: object) => void + } + const locales = readInstallerLocales() + expect(locales[0]?.id).toBe('en_US') + expect(locales.map(locale => locale.id.split('_')[0]).sort()).toEqual([...SHIPPED_LOCALE_IDS].sort()) + createAddLangsMacro({ macro: (_name, lines) => { + expect(lines.map(line => /MUI_LANGUAGE "([^" ]+)"/u.exec(line)?.[1]?.toUpperCase())) + .toEqual(locales.map(locale => locale.nsisLanguage)) + } }, new LangConfigurator({ installerLanguages: locales.map(locale => locale.id) })) +}) diff --git a/apps/desktop/tests/installer-packaging.spec.ts b/apps/desktop/tests/installer-packaging.spec.ts index 57da6058df..1e438dedf4 100644 --- a/apps/desktop/tests/installer-packaging.spec.ts +++ b/apps/desktop/tests/installer-packaging.spec.ts @@ -1,3 +1,4 @@ +import { readInstallerLocales } from '../scripts/installer-locales.mjs' import { tmpdir } from 'node:os' import { readFileSync } from 'node:fs' import { Arch, Platform } from 'electron-builder' @@ -54,7 +55,7 @@ describe('installer preparation preserves application dependencies', () => { expect(appInfo.copyright).toBe(`Copyright © ${new Date().getFullYear()} DeepSeek Harness`) if (platform === 'win32') { expect(expandMacro(config.nsis.uninstallDisplayName, null, appInfo, {}, false)).toBe('DeepSeek Harness') - expect(config.nsis.installerLanguages).toEqual(['en_US', 'ja_JP', 'zh_CN']) + expect(config.nsis.installerLanguages).toEqual(readInstallerLocales().map(locale => locale.id)) } const aboutIcon = config.extraResources.find(resource => resource.to === 'icon.png') expect(aboutIcon).toBeDefined() diff --git a/apps/desktop/tests/locale.spec.ts b/apps/desktop/tests/locale.spec.ts index fcf8f747dc..b53a02e8fb 100644 --- a/apps/desktop/tests/locale.spec.ts +++ b/apps/desktop/tests/locale.spec.ts @@ -1,8 +1,9 @@ +import { SHIPPED_LOCALE_IDS } from '../../../scripts/shipped-locales.ts' import { describe, expect, it } from 'vitest' import { en, formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale, ja, zh, es, fr, de, ru, pt } from '../src/locale.ts' describe('desktop locale dictionaries', () => { - it('ships the same key set in English, Chinese, and Japanese', () => { + it('ships baseline and Japanese keys and falls back for unsupported languages', () => { expect(Object.keys(zh)).toEqual(Object.keys(en)) expect(Object.keys(ja)).toEqual(Object.keys(en)) expect(resolveDesktopLocale('JA-jp')).toEqual({ id: 'ja', messages: ja }) @@ -25,6 +26,11 @@ describe('desktop locale dictionaries', () => { } }) + it.each(SHIPPED_LOCALE_IDS)('resolves every shipped language %s without fallback', (id) => { + expect(resolveDesktopLocale(id).id).toBe(id === 'zh' ? 'zh-CN' : id) + expect(resolveDesktopStartupLocale(null, [id]).id).toBe(id === 'zh' ? 'zh-CN' : id) + }) + it('formats named values without consuming unknown placeholders', () => { expect(formatDesktopMessage('{name}@{version} {missing}', { name: 'plugin', version: '1.2.3' })) .toBe('plugin@1.2.3 {missing}') diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index b688a293e7..2e6a2bd48e 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -1,3 +1,4 @@ +import { SHIPPED_LOCALE_IDS } from '../../../scripts/shipped-locales.ts' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' @@ -51,7 +52,7 @@ describe('desktop macOS release signature', () => { expect(config.protocols).toEqual([{ name: 'DeepSeek Harness', schemes: ['dsh'] }]) expect(portablePath(config.directories.output)).toContain('/.desktop-build/targets/mac-arm64/artifacts') expect(config.mac.extendInfo.NSMicrophoneUsageDescription).toContain('microphone') - expect(config.mac.extendInfo.CFBundleLocalizations).toEqual(['en', 'zh_CN', 'ja', 'es', 'fr', 'de', 'ru', 'pt']) + expect(config.mac.extendInfo.CFBundleLocalizations).toEqual(SHIPPED_LOCALE_IDS.map(id => id === 'zh' ? 'zh_CN' : id)) expect(config.mac.entitlementsInherit).toBe(config.mac.entitlements) const entitlements = readFileSync(config.mac.entitlements, 'utf8') for (const key of ['com.apple.security.cs.allow-jit', 'com.apple.security.cs.allow-unsigned-executable-memory', diff --git a/apps/web/tests/additional-locales.e2e.ts b/apps/web/tests/additional-locales.e2e.ts index d23b4da924..007aab3e14 100644 --- a/apps/web/tests/additional-locales.e2e.ts +++ b/apps/web/tests/additional-locales.e2e.ts @@ -47,6 +47,9 @@ it.each(languages)('selects %s from the browser, localizes metadata and persists return { panel: element.scrollWidth <= element.clientWidth + 1, content: content.scrollWidth <= content.clientWidth + 1 } })).toEqual({ panel: true, content: true }) await dialog.getByRole('button', { name: label, exact: true }).click() + await page.getByRole('menuitem', { name: label, exact: true }).click() + await expect.poll(() => readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')).toContain(`preference: ${id}`) + await dialog.getByRole('button', { name: label, exact: true }).click() await page.getByRole('menuitem', { name: 'English', exact: true }).click() await expect.poll(() => readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')).toContain('preference: en') const warningsBeforeReload = tripwire.warnings.length diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 4110b72e95..a3ecc0de18 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -977,18 +977,18 @@ describe('web e2e: settings modal and General preferences', () => { it('opens a browser asking for no shipped language in English', async () => { // The product default for "no usable signal": a French browser ships - // neither zh nor en, so resolution falls to FALLBACK_LOCALE (en) rather + // names no shipped language, so resolution falls to FALLBACK_LOCALE (en) rather // than to Chinese. const fresh = await launchWebScaffold({ developerTools: false }) - const frPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'fr-FR' }) - const frTripwire = watchConsole(frPage) - onTestFailed(() => saveSettingsFailureShot(frPage, 'settings-unshipped-language')) + const unsupportedPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'it-IT' }) + const unsupportedTripwire = watchConsole(unsupportedPage) + onTestFailed(() => saveSettingsFailureShot(unsupportedPage, 'settings-unshipped-language')) try { - await frPage.goto(fresh.authenticatedUrl, { waitUntil: 'load' }) - await frPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - expect(await frPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() - await openSettings(frPage, 'en') - const dialog = frPage.getByRole('dialog', { name: 'Settings' }) + await unsupportedPage.goto(fresh.authenticatedUrl, { waitUntil: 'load' }) + await unsupportedPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + expect(await unsupportedPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() + await openSettings(unsupportedPage, 'en') + const dialog = unsupportedPage.getByRole('dialog', { name: 'Settings' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 }) // A locale-owned nav label proves the dictionaries resolved to en. @@ -996,17 +996,17 @@ describe('web e2e: settings modal and General preferences', () => { // The markup already ships `en`, so this alone cannot prove the sync ran // — the zh scenario above is the discriminating half. Asserted here too // so a future change that resolves en but writes the wrong tag is caught. - expect(await frPage.evaluate(() => document.documentElement.lang)).toBe('en') + expect(await unsupportedPage.evaluate(() => document.documentElement.lang)).toBe('en') // Golden of the English fallback dialog — the visible output this change // produces. The zh golden above covers the detected-locale surface, so // the pair pins both directions of the resolution. await dialog.getByText(`Current version: ${version}`, { exact: true }).waitFor() - const snapshot = await captureStableAria(frPage, '[role="dialog"]', fresh.workspaceCwd, versionCapture) + const snapshot = await captureStableAria(unsupportedPage, '[role="dialog"]', fresh.workspaceCwd, versionCapture) await compareOrRefreshGolden(DIALOG_EN_EXPECTED, snapshot, MODE) - expect(frTripwire.pageErrors).toEqual([]) - expect(frTripwire.warnings).toEqual([]) + expect(unsupportedTripwire.pageErrors).toEqual([]) + expect(unsupportedTripwire.warnings).toEqual([]) } finally { - await frPage.close() + await unsupportedPage.close() await fresh.close() } }, 90_000) diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index a475185694..a90c3133e5 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/README.i18n.yaml @@ -9,8 +9,8 @@ en: 9365090d78467dea zh: 74b6fbb9769d795f /deepseek-ai-dsh-client-locale/summary: - en: d11ef1c4b5b4fe86 - zh: 86eb53d8578eadbf + en: cbbe8f61f5e19f39 + zh: 2cec7ec41f1a7e84 /deepseek-ai-dsh-client-locale/table-of-contents: en: d152484eb41ac6b4 zh: bda879a892f240f0 @@ -21,8 +21,8 @@ en: 2b82458e185ba321 zh: e55a7e709a3f0158 /deepseek-ai-dsh-client-locale/use-this-package/registering-a-dictionary: - en: 525a74ea5e5f152f - zh: 06fd145b189f37ee + en: f19407c3ce8a94cc + zh: 3e5300ce7fa02987 /deepseek-ai-dsh-client-locale/use-this-package/resolving-package-text: en: 3b63e84fe0fdf8f4 zh: e97b5d1b92dff7ec diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index 7838364e68..0ed329b47a 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh-client-locale` to switch the web GUI between the shipped English, Chinese, Japanese, Spanish, French, German, Russian, and Portuguese locales or languages added by client plugins. User selections take effect immediately; every authenticated browser persists them as `locale.preference` in `$DSH_HOME/cordis.patch.yml` through the Host settings service. New browsers use the first supported language requested by the browser until the Host read delivers a stored preference. Plugin authors add typed namespace dictionaries and translate through the public locale API; slot-rendered copy updates without a reload. +Use `dsh-client-locale` to switch the web GUI between the shipped English, Chinese, Japanese, Spanish, French, German, Russian, and Brazilian Portuguese (`pt`, including `pt-PT` browser requests) locales or languages added by client plugins. User selections take effect immediately; every authenticated browser persists them as `locale.preference` in `$DSH_HOME/cordis.patch.yml` through the Host settings service. New browsers use the first supported language requested by the browser until the Host read delivers a stored preference. Plugin authors add typed namespace dictionaries and translate through the public locale API; slot-rendered copy updates without a reload. ## Table of Contents @@ -37,7 +37,7 @@ Native shells may provide `__DSH_LOCALE__` with an asynchronous `read()` and an ### Registering a dictionary -Call `ctx.locale.register(ns, { zh, en, ja })` with a namespace merged into `LocaleNamespaceMap`; the compiler checks every key against the namespace's typed key union and requires both baseline locales (`zh` and `en`). Additional locale ids are optional, and their dictionaries are checked against the same namespace keys. Consumers translate through `ctx.locale.bind(ns)` or the framework-injected `t` seat. A dictionary registered after the UI is already mounted is picked up without a remount. Preserve named placeholders, slash commands, and API field names verbatim in translations; `pnpm run check:i18n` checks first-party dictionaries. +Call `ctx.locale.register(ns, { zh, en, ja })` with a namespace merged into `LocaleNamespaceMap`; the compiler checks every key against the namespace's typed key union and requires both baseline locales (`zh` and `en`). Additional locale ids are optional, and their dictionaries are checked against the same namespace keys. Consumers translate through `ctx.locale.bind(ns)` or the framework-injected `t` seat. A dictionary registered after the UI is already mounted is picked up without a remount. Preserve named placeholders, slash commands, and API field names verbatim in translations; `pnpm run check:i18n` checks first-party dictionaries and rejects zero-width spaces (U+200B) and byte-order marks (U+FEFF) in locale resources. ### Resolving package text diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index 9054fdedee..4c2d8037ed 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用 `dsh-client-locale` 可在 web GUI 中切换内置的英文、中文、日语、西班牙语、法语、德语、俄语和葡萄牙语 locale,或 client 插件添加的语言。用户选择会立即生效;每个已认证浏览器都通过 Host settings 服务把选择以 `locale.preference` 持久化到 `$DSH_HOME/cordis.patch.yml`。全新浏览器会使用浏览器请求的第一个受支持语言,直到 Host 读取送达已存储偏好。插件作者可添加类型化命名空间字典,并通过公开 locale API 翻译;经 slot 渲染的文案无需重新加载即可随语言切换更新。 +使用 `dsh-client-locale` 可在 web GUI 中切换内置的英文、中文、日语、西班牙语、法语、德语、俄语和巴西葡萄牙语(`pt`,也匹配 `pt-PT` 浏览器请求)locale,或 client 插件添加的语言。用户选择会立即生效;每个已认证浏览器都通过 Host settings 服务把选择以 `locale.preference` 持久化到 `$DSH_HOME/cordis.patch.yml`。全新浏览器会使用浏览器请求的第一个受支持语言,直到 Host 读取送达已存储偏好。插件作者可添加类型化命名空间字典,并通过公开 locale API 翻译;经 slot 渲染的文案无需重新加载即可随语言切换更新。 ## 目录 @@ -37,7 +37,7 @@ kind: "package-reference" ### 注册字典 -用已合并进 `LocaleNamespaceMap` 的命名空间调用 `ctx.locale.register(ns, { zh, en, ja })`;编译器会对照该命名空间的类型化键并集检查每个键,并要求两个基础 locale(`zh` 和 `en`)齐全。其他 locale id 为可选项,其字典同样按该命名空间的键检查。消费方通过 `ctx.locale.bind(ns)` 或框架注入的 `t` 席位翻译。UI 已挂载后再注册的字典无需重新挂载即可生效。 翻译必须原样保留命名占位符、斜杠命令和 API 字段名;`pnpm run check:i18n` 检查仓库内的字典。 +用已合并进 `LocaleNamespaceMap` 的命名空间调用 `ctx.locale.register(ns, { zh, en, ja })`;编译器会对照该命名空间的类型化键并集检查每个键,并要求两个基础 locale(`zh` 和 `en`)齐全。其他 locale id 为可选项,其字典同样按该命名空间的键检查。消费方通过 `ctx.locale.bind(ns)` 或框架注入的 `t` 席位翻译。UI 已挂载后再注册的字典无需重新挂载即可生效。翻译必须原样保留命名占位符、斜杠命令和 API 字段名;`pnpm run check:i18n` 检查仓库内的字典,并拒绝语言资源中的零宽空格(U+200B)和字节顺序标记(U+FEFF)。 ### 解析包文本 diff --git a/packages/client/locale/src/locales/de.ts b/packages/client/locale/src/locales/de.ts index 3acbad94b3..dd88cf6fc4 100644 --- a/packages/client/locale/src/locales/de.ts +++ b/packages/client/locale/src/locales/de.ts @@ -41,7 +41,7 @@ export const de = { 'truncated': 'Abgeschnitten', 'json.label': 'JSON', 'markdown.footnotes': 'Fußnoten', - 'markdown.truncatedCharacters': '… bei {total}-Zeichen abgeschnitten', + 'markdown.truncatedCharacters': '… nach {total} Zeichen abgeschnitten', 'number.thousand': '{value}K', 'number.million': '{value}M', } satisfies Record diff --git a/packages/client/locale/src/locales/es.ts b/packages/client/locale/src/locales/es.ts index 8ff4607619..7eb95374e1 100644 --- a/packages/client/locale/src/locales/es.ts +++ b/packages/client/locale/src/locales/es.ts @@ -38,10 +38,10 @@ export const es = { 'workspace.defaultName': 'Espacio de trabajo predeterminado', 'unknown': 'Desconocido', 'none': 'Ninguno', - 'truncated': 'truncado', + 'truncated': 'Truncado', 'json.label': 'JSON', 'markdown.footnotes': 'Notas a pie de página', - 'markdown.truncatedCharacters': '… truncado en caracteres {total}', + 'markdown.truncatedCharacters': '… truncado a {total} caracteres', 'number.thousand': '{value}K', 'number.million': '{value}M', } satisfies Record diff --git a/packages/client/locale/src/locales/fr.ts b/packages/client/locale/src/locales/fr.ts index 2585b59af0..c57f1a3b11 100644 --- a/packages/client/locale/src/locales/fr.ts +++ b/packages/client/locale/src/locales/fr.ts @@ -41,7 +41,7 @@ export const fr = { 'truncated': 'Tronqué', 'json.label': 'JSON', 'markdown.footnotes': 'Notes de bas de page', - 'markdown.truncatedCharacters': '… tronqué aux caractères {total}', + 'markdown.truncatedCharacters': '… tronqué à {total} caractères', 'number.thousand': '{value}K', 'number.million': '{value}M', } satisfies Record diff --git a/packages/client/locale/src/locales/ru.ts b/packages/client/locale/src/locales/ru.ts index 040ac417ed..a8a6d829da 100644 --- a/packages/client/locale/src/locales/ru.ts +++ b/packages/client/locale/src/locales/ru.ts @@ -41,7 +41,7 @@ export const ru = { 'truncated': 'Усечено', 'json.label': 'JSON', 'markdown.footnotes': 'Сноски', - 'markdown.truncatedCharacters': '… усекается до символов {total}', + 'markdown.truncatedCharacters': '… обрезано до {total} символов', 'number.thousand': '{value}K', 'number.million': '{value}M', } satisfies Record diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index e45dc44e69..43920d60c0 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -1,3 +1,4 @@ +import { SHIPPED_LOCALE_IDS } from '../../../../scripts/shipped-locales.ts' /** locale apply wiring: service + dictionaries provision, declaration-aware * Language row registration, snapshot projection into the row store, and * recovery after an HMR collapse of the declaring entry. */ @@ -155,7 +156,7 @@ describe('locale apply', () => { const { entry, instance, face } = faceOf(b.slots) // The inject-time re-sync sealed the init window: the mirror is current. expect(instance.getSnapshot().active).toBe('en') - expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en', 'ja', 'es', 'fr', 'de', 'ru', 'pt']) + expect(instance.getSnapshot().options.map(o => o.id).sort()).toEqual([...SHIPPED_LOCALE_IDS].sort()) // Copy rides the standard locale seat: the entry declares the namespace. expect(entry.locale).toBe(SETTINGS_NS) expect(locale.bind(SETTINGS_NS)('language.title')).toBe('Language') diff --git a/packages/client/locale/tests/document-language.client.spec.ts b/packages/client/locale/tests/document-language.client.spec.ts index 9e72b5149c..a513d988b5 100644 --- a/packages/client/locale/tests/document-language.client.spec.ts +++ b/packages/client/locale/tests/document-language.client.spec.ts @@ -103,7 +103,7 @@ describe('document language', () => { const observer = new MutationObserver(() => {}) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] }) try { - const remove = locale.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' }) + const remove = locale.addLanguage({ id: 'it', label: 'Italiano', fallback: 'en' }) expect(observer.takeRecords()).toHaveLength(0) remove() expect(observer.takeRecords()).toHaveLength(0) diff --git a/packages/client/ui-agent-preset/src/client/guide-locales.ts b/packages/client/ui-agent-preset/src/client/guide-locales.ts index 7e4392dfca..d92283aad2 100644 --- a/packages/client/ui-agent-preset/src/client/guide-locales.ts +++ b/packages/client/ui-agent-preset/src/client/guide-locales.ts @@ -205,7 +205,7 @@ export const guideEs = { '### Lo que llega al modelo', 'Los resultados de la herramienta llegan primero al programa, que puede filtrarlos y combinarlos. El modelo recibe lo que el programa imprime o devuelve; Los resultados de la imagen se adjuntan por separado. Las llamadas a herramientas anidadas aún se registran y permanecen sujetas a los permisos de la herramienta.', '### Comparado con el modo Estándar', - 'Ambos modos pueden manejar tareas de codificación y por lotes. El modo estándar expone herramientas individuales directamente; PTC organiza llamadas a herramientas en código. El ajuste preestablecido actual PTC ​​deja las herramientas de administración de complementos y flujo de trabajo deshabilitadas. La velocidad y el uso de tokens dependen de la tarea y de cómo el programa maneja sus resultados.', + 'Ambos modos pueden manejar tareas de codificación y por lotes. El modo estándar expone herramientas individuales directamente; PTC organiza llamadas a herramientas en código. El ajuste preestablecido actual PTC deja las herramientas de administración de complementos y flujo de trabajo deshabilitadas. La velocidad y el uso de tokens dependen de la tarea y de cómo el programa maneja sus resultados.', ].join('\n\n\n\n'), guidePtcUsage: [ '### Verifique un conjunto de archivos de configuración', diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 8ff3f7192e..6f0e6eda52 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -1585,7 +1585,7 @@ export const pt = { 'message.turnUsage.reasoning': ' ({tokens} raciocínio)', 'message.turnUsage.count': '{count} tok', 'duration.secondUnit': 's', - 'duration.minuteUnit': 'eu ', + 'duration.minuteUnit': 'm ', 'duration.hourUnit': 'h ', 'command.running': 'Correndo…', 'command.failed': 'Falha no comando', diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index cf2516d005..f17eb0fcdb 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -39,7 +39,7 @@ import { derivePresentationPolicy, type CollapseTiming, type FoldedReplies } fro import { CHAT_FLOW_INJECT, CHAT_NODE_INJECT } from '../src/client/apply.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' import { ChatFlow } from '../src/client/chat/ChatFlow.tsx' -import { en, zh } from '../src/client/locale.ts' +import { en, zh, pt } from '../src/client/locale.ts' import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx' import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx' import { @@ -4612,3 +4612,7 @@ describe('ChatView', () => { }) const fileDrag = { prepare: vi.fn(async () => ({ start: () => true, release: () => {} })), reportFailure: vi.fn() } + +it('formats Portuguese elapsed minutes with numeric units', () => { + expect(formatRunDuration(90_000, key => pt[key])).toBe('1m 30s') +}) diff --git a/packages/client/ui-commands/src/client/locales.ts b/packages/client/ui-commands/src/client/locales.ts index c669964471..ae0517b5f4 100644 --- a/packages/client/ui-commands/src/client/locales.ts +++ b/packages/client/ui-commands/src/client/locales.ts @@ -161,7 +161,7 @@ export const fr = { 'search.placeholder': 'Rechercher…', 'search.aria': 'Options de filtrage', 'status.loading': 'Options de chargement…', - 'status.applying': 'Candidature…', + 'status.applying': 'Application…', 'status.empty': 'Aucune option', 'overlay.aria': 'Options /{command}', 'listbox.aria': '/{command} correspondances', @@ -193,7 +193,7 @@ export const de = { 'search.placeholder': 'Suchen…', 'search.aria': 'Filteroptionen', 'status.loading': 'Ladeoptionen…', - 'status.applying': 'Bewerben…', + 'status.applying': 'Wird angewendet…', 'status.empty': 'Keine Optionen', 'overlay.aria': '/{command}-Optionen', 'listbox.aria': '/{command} stimmt überein', diff --git a/packages/client/ui-jobs/src/client/locales.ts b/packages/client/ui-jobs/src/client/locales.ts index a978744d96..700009d9a5 100644 --- a/packages/client/ui-jobs/src/client/locales.ts +++ b/packages/client/ui-jobs/src/client/locales.ts @@ -140,10 +140,10 @@ export const ja = { /** Spanish dictionary, checked against the English keys. */ export const es = { - 'count.live.one': 'Trabajo en segundo plano {count} ejecutándose', - 'count.live.other': 'Trabajos en segundo plano {count} en ejecución', - 'count.idle.one': 'Trabajo en segundo plano {count}', - 'count.idle.other': 'Trabajos en segundo plano {count}', + 'count.live.one': 'Tareas en segundo plano en ejecución: {count}', + 'count.live.other': 'Tareas en segundo plano en ejecución: {count}', + 'count.idle.one': 'Tareas en segundo plano: {count}', + 'count.idle.other': 'Tareas en segundo plano: {count}', 'list.aria': 'Trabajos en segundo plano', 'section.live': 'corriendo', 'section.settledCount': 'Terminado {count}', @@ -184,10 +184,10 @@ export const es = { /** French dictionary, checked against the English keys. */ export const fr = { - 'count.live.one': "Travail en arrière-plan {count} en cours d'exécution", - 'count.live.other': "Travaux en arrière-plan {count} en cours d'exécution", - 'count.idle.one': 'Travail en arrière-plan {count}', - 'count.idle.other': "Travaux d'arrière-plan {count}", + 'count.live.one': 'Tâches en arrière-plan en cours : {count}', + 'count.live.other': 'Tâches en arrière-plan en cours : {count}', + 'count.idle.one': 'Tâches en arrière-plan : {count}', + 'count.idle.other': 'Tâches en arrière-plan : {count}', 'list.aria': 'Travaux en arrière-plan', 'section.live': 'Courir', 'section.settledCount': 'Terminé {count}', @@ -228,10 +228,10 @@ export const fr = { /** German dictionary, checked against the English keys. */ export const de = { - 'count.live.one': '{count} Hintergrundjob läuft', - 'count.live.other': '{count} Hintergrundjobs werden ausgeführt', - 'count.idle.one': '{count} Hintergrundjob', - 'count.idle.other': '{count} Hintergrundjobs', + 'count.live.one': 'Laufende Hintergrundaufgaben: {count}', + 'count.live.other': 'Laufende Hintergrundaufgaben: {count}', + 'count.idle.one': 'Hintergrundaufgaben: {count}', + 'count.idle.other': 'Hintergrundaufgaben: {count}', 'list.aria': 'Hintergrundjobs', 'section.live': 'Laufen', 'section.settledCount': 'Fertig {count}', @@ -272,10 +272,10 @@ export const de = { /** Russian dictionary, checked against the English keys. */ export const ru = { - 'count.live.one': '{count} фоновое задание выполняется', - 'count.live.other': '{count} выполняются фоновые задания', - 'count.idle.one': '{count} фоновое задание', - 'count.idle.other': '{count} фоновые задания', + 'count.live.one': 'Фоновые задачи выполняются: {count}', + 'count.live.other': 'Фоновые задачи выполняются: {count}', + 'count.idle.one': 'Фоновые задачи: {count}', + 'count.idle.other': 'Фоновые задачи: {count}', 'list.aria': 'Фоновые задания', 'section.live': 'Бег', 'section.settledCount': 'Закончено {count}', @@ -316,10 +316,10 @@ export const ru = { /** Portuguese dictionary, checked against the English keys. */ export const pt = { - 'count.live.one': '{count} trabalho em segundo plano em execução', - 'count.live.other': '{count} jobs em segundo plano em execução', - 'count.idle.one': '{count} tarefa em segundo plano', - 'count.idle.other': '{count} trabalhos em segundo plano', + 'count.live.one': 'Tarefas em segundo plano em execução: {count}', + 'count.live.other': 'Tarefas em segundo plano em execução: {count}', + 'count.idle.one': 'Tarefas em segundo plano: {count}', + 'count.idle.other': 'Tarefas em segundo plano: {count}', 'list.aria': 'Trabalhos em segundo plano', 'section.live': 'Correndo', 'section.settledCount': 'Concluído {count}', diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx index e6866c4182..4dcf892f87 100644 --- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx +++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx @@ -5,7 +5,7 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { JobsSnapshot, JobView, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { JobListAction, type JobListActionProps } from '../src/client/JobListAction.tsx' -import { zh } from '../src/client/locales.ts' +import { zh, ru } from '../src/client/locales.ts' afterEach(() => { cleanup() @@ -590,3 +590,12 @@ describe('JobListAction human kill', () => { expect(document.querySelector('[data-kill-state]')).toBeNull() }) }) + +it.each([1, 2, 5, 21])('renders count-independent Russian labels for %i jobs', (count) => { + const jobs = Array.from({ length: count }, (_, i) => job({ id: `job-${i}` as JobView['id'] })) + const translate: JobListActionProps['t'] = makeTranslate(ru) + const view = render() + expect(screen.getByRole('button', { name: `Фоновые задачи выполняются: ${count}` })).toBeDefined() + view.rerender( ({ ...item, status: 'completed', finishedAt: 1_700_000_003_000 })))} t={translate} />) + expect(screen.getByRole('button', { name: `Фоновые задачи: ${count}` })).toBeDefined() +}) diff --git a/packages/client/ui-schedule/src/client/task-manager-locales.ts b/packages/client/ui-schedule/src/client/task-manager-locales.ts index 2cd2aa0080..87689effed 100644 --- a/packages/client/ui-schedule/src/client/task-manager-locales.ts +++ b/packages/client/ui-schedule/src/client/task-manager-locales.ts @@ -627,7 +627,7 @@ export const fr = { 'timing.time': 'Temps', 'timing.hour': 'Heure', 'timing.minute': 'Minutes', - 'timing.second': 'Deuxième', + 'timing.second': 'Seconde', 'timing.prevMonth': 'Mois précédent', 'timing.nextMonth': 'Le mois prochain', 'timing.zone': 'Fuseau horaire', @@ -768,7 +768,7 @@ export const de = { 'timing.time': 'Zeit', 'timing.hour': 'Stunde', 'timing.minute': 'Minute', - 'timing.second': 'Zweitens', + 'timing.second': 'Sekunde', 'timing.prevMonth': 'Vorheriger Monat', 'timing.nextMonth': 'Nächsten Monat', 'timing.zone': 'Zeitzone', @@ -803,7 +803,7 @@ export const de = { 'rule.weekdayOption': 'Wochentag {weekday}', 'rule.unsaved': 'Nicht gespeicherte Änderungen', 'rule.save': 'Änderungen speichern', - 'rule.saving': 'Sparen…', + 'rule.saving': 'Speichern…', 'rule.cancel': 'Abbrechen', 'rule.invalidTitle': 'Geben Sie einen Aufgabennamen mit maximal 120 Zeichen ein.', 'rule.invalidPrompt': 'Geben Sie eine Aufgabenanweisung ein.', @@ -909,7 +909,7 @@ export const ru = { 'timing.time': 'Время', 'timing.hour': 'Час', 'timing.minute': 'минута', - 'timing.second': 'Второй', + 'timing.second': 'Секунда', 'timing.prevMonth': 'Предыдущий месяц', 'timing.nextMonth': 'В следующем месяце', 'timing.zone': 'Часовой пояс', diff --git a/packages/client/ui-settings-agent-loop/src/client/locales.ts b/packages/client/ui-settings-agent-loop/src/client/locales.ts index 40b097d049..e9b79dd83f 100644 --- a/packages/client/ui-settings-agent-loop/src/client/locales.ts +++ b/packages/client/ui-settings-agent-loop/src/client/locales.ts @@ -108,7 +108,7 @@ export const de = { readOnly: 'Diese Bereitstellung speichert Einstellungen schreibgeschützt.', unavailable: 'Dieses Plugin ist nicht geladen und kann daher derzeit nicht konfiguriert werden.', save: 'Speichern', - saving: 'Sparen…', + saving: 'Speichern…', saveFailed: 'Die Bereitstellung hat diese Werte nicht akzeptiert. Sie mussten sie korrigieren.', invalidNumber: 'Geben Sie eine Zahl ein oder lassen Sie das Feld leer, um den Standardwert zu verwenden.', } satisfies Record diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index b17bcb3f11..533b7aed02 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -462,7 +462,7 @@ export const es = { loadFailed: 'Error al cargar el directorio de proveedores', conflict: 'Alguien más cambió esta configuración mientras esta tarjeta estaba abierta. Ciérrelo y vuelva a abrirlo para editar los valores actuales.', retry: 'Reintentar', - keyInput: 'Tecla API', + keyInput: 'Clave de API', keyReveal: 'Mostrar secreto', keyHide: 'Ocultar secreto', keyPlaceholder: 'Ingrese su clave API', @@ -593,8 +593,8 @@ export const fr = { provider: 'Fournisseur', close: 'Fermer', cancel: 'Annuler', - apply: 'Postuler', - applying: 'Candidature…', + apply: 'Appliquer', + applying: 'Application…', savedProvider: '{provider} enregistré.', credentialConfigured: 'Clé API configurée', credentialMissing: 'Clé API manquante', @@ -660,8 +660,8 @@ export const fr = { modelIdRequired: 'L’ID du modèle est requis.', modelIdDuplicate: "L'ID du modèle doit être unique.", modelNameInvalid: 'Le nom d’affichage ne peut pas être vide.', - modelContextInvalid: 'La fenêtre contextuelle doit être un nombre positif, comme 131072, 256 Ko ou 1 M.', - modelMaxTokensInvalid: 'Le nombre maximum de jetons de sortie doit être un nombre positif, comme 8 192, 64 Ko ou 1 M.', + modelContextInvalid: 'La fenêtre contextuelle doit être un nombre positif, comme 131072, 256K ou 1M.', + modelMaxTokensInvalid: 'Le nombre maximum de jetons de sortie doit être un nombre positif, comme 8192, 64K ou 1M.', advancedHint: "D'autres champs se trouvent dans cordis.patch.yml ; modifiez cette section directement.", modelCapacityInvalid: 'Une capacité doit être un nombre, éventuellement suivi du suffixe K ou M.', modelDuplicate: 'Chaque ID de modèle peut apparaître une fois.', @@ -727,15 +727,15 @@ export const de = { addMode: 'So fügen Sie hinzu', addCatalog: 'Drittanbieter von Modellen', addCustom: 'Kundenspezifisches Modell API', - addCatalogHint: 'Wählen Sie OpenAI, Anthropic, Kimi oder einen anderen Anbieter aus dem integrierten Katalog aus und geben Sie den Schlüssel API ein.', + addCatalogHint: 'Wählen Sie OpenAI, Anthropic, Kimi oder einen anderen Anbieter aus dem integrierten Katalog aus und geben Sie den API-Schlüssel ein.', addCustomHint: 'Verbinden Sie ein Relay, einen selbst gehosteten Server oder einen anderen OpenAI- oder Anthropic-kompatiblen Endpunkt über seine Basis-URL, sein Protokoll und seine Modelle.', addCatalogExhausted: 'Jeder Kataloganbieter ist bereits konfiguriert.', addCustomUnavailable: 'Es ist kein API-Protokoll zur Deklaration verfügbar.', provider: 'Anbieter', close: 'Schließen', cancel: 'Abbrechen', - apply: 'Bewerben', - applying: 'Bewerben…', + apply: 'Anwenden', + applying: 'Wird angewendet…', savedProvider: 'Gespeichert {provider}.', credentialConfigured: 'API-Schlüssel konfiguriert', credentialMissing: 'API-Schlüssel fehlt', @@ -795,14 +795,14 @@ export const de = { reorderModel: 'Modell neu anordnen', reorderModelHelp: 'Ziehen Sie, um die Reihenfolge neu anzuordnen, oder verwenden Sie die Aufwärts- und Abwärtspfeiltasten.', modelsEmpty: 'Im Selektor werden keine Modelle angezeigt. Nicht gelistete Ausweise können weiterhin direkt versendet werden.', - keyBlank: 'Geben Sie den Schlüssel API ein oder lassen Sie das Feld leer, um den gespeicherten Schlüssel beizubehalten.', + keyBlank: 'Geben Sie den API-Schlüssel ein oder lassen Sie das Feld leer, um den gespeicherten Schlüssel beizubehalten.', keyBlankNew: 'Geben Sie den API-Schlüssel ein oder lassen Sie das Feld leer, wenn dieser Anbieter auf andere Weise authentifiziert.', keyIllegalCharacters: 'Dieser API-Schlüssel hat kein gültiges Format. Bitte überprüfen Sie es.', modelIdRequired: 'Modell-ID ist erforderlich.', modelIdDuplicate: 'Die Modell-ID muss eindeutig sein.', modelNameInvalid: 'Der Anzeigename darf nicht leer sein.', - modelContextInvalid: 'Das Kontextfenster muss eine positive Zahl sein, z. B. 131072, 256 KB oder 1 Mio.', - modelMaxTokensInvalid: 'Die maximale Anzahl an Ausgabetokens muss eine positive Zahl sein, z. B. 8192, 64 KB oder 1 Mio.', + modelContextInvalid: 'Das Kontextfenster muss eine positive Zahl sein, z. B. 131072, 256K oder 1M.', + modelMaxTokensInvalid: 'Die maximale Anzahl an Ausgabetokens muss eine positive Zahl sein, z. B. 8192, 64K oder 1M.', advancedHint: 'Andere Felder befinden sich in cordis.patch.yml; Bearbeiten Sie diesen Abschnitt direkt.', modelCapacityInvalid: 'Eine Kapazität muss eine Zahl sein, optional mit dem Suffix K oder M.', modelDuplicate: 'Jede Modell-ID darf einmal vorkommen.', @@ -845,7 +845,7 @@ export const de = { onboardingDescription: 'Konfigurieren Sie den offiziellen DeepSeek-Anbieter, um mit der Erstellung zu beginnen.', onboardingLater: 'Später konfigurieren', onboardingSave: 'Speichern und fortfahren', - onboardingSaving: 'Sparen…', + onboardingSaving: 'Speichern…', keyRequired: 'Geben Sie einen API-Schlüssel ein, um fortzufahren.', } satisfies Record @@ -942,8 +942,8 @@ export const ru = { modelIdRequired: 'Требуется идентификатор модели.', modelIdDuplicate: 'Идентификатор модели должен быть уникальным.', modelNameInvalid: 'Отображаемое имя не может быть пустым.', - modelContextInvalid: 'Контекстное окно должно иметь положительное значение, например 131072, 256 КБ или 1 М.', - modelMaxTokensInvalid: 'Максимальное количество выходных токенов должно иметь положительное значение, например 8192, 64 КБ или 1 М.', + modelContextInvalid: 'Контекстное окно должно иметь положительное значение, например 131072, 256K или 1M.', + modelMaxTokensInvalid: 'Максимальное количество выходных токенов должно иметь положительное значение, например 8192, 64K или 1M.', advancedHint: 'Остальные поля находятся в файле cordis.patch.yml; отредактируйте этот раздел напрямую.', modelCapacityInvalid: 'Емкость должна быть числом, возможно с суффиксом K или M.', modelDuplicate: 'Каждый идентификатор модели может появиться один раз.', @@ -1128,5 +1128,5 @@ export const pt = { onboardingLater: 'Configurar mais tarde', onboardingSave: 'Salvar e continuar', onboardingSaving: 'Salvando…', - keyRequired: 'Insira uma tecla API para continuar.', + keyRequired: 'Insira uma chave de API para continuar.', } satisfies Record diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index da48676b6f..08ce75a428 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -1,3 +1,4 @@ +import { es, fr, de, ru, pt } from '../src/client/locales.ts' // @vitest-environment jsdom /** Section, setup-card, and hand-written editor behavior over a scripted wire face. */ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' @@ -2423,3 +2424,16 @@ it('renders the localized account row as signed in without an edit action', asyn expect(within(item).getByText(en.accountSignedIn)).toBeTruthy() expect(within(item).queryByText(en.edit)).toBeNull() }) + +it.each([es, fr, de, ru, pt])('keeps capacity examples in translated validation messages parseable', (dictionary) => { + for (const [key, examples] of [ + ['modelContextInvalid', ['131072', '256K', '1M']], + ['modelMaxTokensInvalid', ['8192', '64K', '1M']], + ] as const) { + for (const example of examples) { + const token = dictionary[key].match(new RegExp(`(? diff --git a/packages/client/ui-settings-subagent/src/client/locales.ts b/packages/client/ui-settings-subagent/src/client/locales.ts index c33f16f00d..6a575814d8 100644 --- a/packages/client/ui-settings-subagent/src/client/locales.ts +++ b/packages/client/ui-settings-subagent/src/client/locales.ts @@ -232,7 +232,7 @@ export const de = { readOnly: 'Diese Bereitstellung speichert Einstellungen schreibgeschützt.', unavailable: 'Dieses Plugin ist nicht geladen und kann daher derzeit nicht konfiguriert werden.', save: 'Speichern', - saving: 'Sparen…', + saving: 'Speichern…', saveFailed: 'Die Bereitstellung hat diese Werte nicht akzeptiert. Sie mussten sie korrigieren.', subagentTitle: 'Subagent', subagentDescription: 'Legen Sie die Rekursionstiefe, Anzahl und Modelle des Subagenten fest.', diff --git a/packages/client/ui-settings-web-search/src/client/locales.ts b/packages/client/ui-settings-web-search/src/client/locales.ts index 83c1574a59..1290fb9000 100644 --- a/packages/client/ui-settings-web-search/src/client/locales.ts +++ b/packages/client/ui-settings-web-search/src/client/locales.ts @@ -120,7 +120,7 @@ export const es = { useSearchApiHint: 'Desactivado de forma predeterminada: la búsqueda se ejecuta a través de Mensajes DeepSeek. Para conocer las características y los precios del nuevo Web Search API, consulte el ', searchGuide: 'Guía de búsqueda y recuperación', searchGuideUrl: 'https://api-docs.deepseek.com/guides/search', - apiKey: 'Tecla API', + apiKey: 'Clave de API', apiKeyHint: 'Almacenado fuera del archivo de configuración. Déjelo en blanco para conservar la clave actual.', apiKeySet: 'Se configura una clave.', apiKeyUnset: 'No hay ninguna clave configurada; solo se pueden buscar conversaciones que utilicen un modelo de cuenta DeepSeek, a través del punto final predeterminado.', @@ -199,7 +199,7 @@ export const de = { readOnly: 'Diese Bereitstellung speichert Einstellungen schreibgeschützt.', unavailable: 'Dieses Plugin ist nicht geladen und kann daher derzeit nicht konfiguriert werden.', save: 'Speichern', - saving: 'Sparen…', + saving: 'Speichern…', saveFailed: 'Die Bereitstellung hat diese Werte nicht akzeptiert. Sie mussten sie korrigieren.', invalidNumber: 'Geben Sie eine Zahl ein oder lassen Sie das Feld leer, um den Standardwert zu verwenden.', invalidCount: 'Geben Sie eine Ganzzahl zwischen 1 und 50 ein oder lassen Sie das Feld leer, um den Standardwert zu verwenden.', diff --git a/packages/client/ui-shortcuts/src/client/locales.ts b/packages/client/ui-shortcuts/src/client/locales.ts index f0eb8bbf06..6261df423a 100644 --- a/packages/client/ui-shortcuts/src/client/locales.ts +++ b/packages/client/ui-shortcuts/src/client/locales.ts @@ -232,7 +232,7 @@ export const de = { 'global-hint': 'Von überall aus öffnen', 'clear-search': 'Suche löschen', title: 'Tastaturkürzel', open: 'Öffnen Sie Tastaturkürzel', settings: 'Tastaturkürzel', view: 'Verknüpfungen bearbeiten', description: 'Verfügbare Verknüpfungen und Eingabeaktionen anzeigen und bearbeiten', search: 'Suchverknüpfungen', close: 'Tastaturkürzel schließen', - application: 'Bewerbung', input: 'Nachrichteneingabe', menus: 'Menüs und Dialoge', approval: 'Genehmigungsbereich', unbound: 'Keine Abkürzung', + application: 'Anwendung', input: 'Nachrichteneingabe', menus: 'Menüs und Dialoge', approval: 'Genehmigungsbereich', unbound: 'Keine Abkürzung', empty: 'Keine passenden Verknüpfungen', move: 'Menüauswahl verschieben', select: 'Menüpunkt auswählen', dismiss: 'Menü oder oberen Dialog schließen', } satisfies Record diff --git a/packages/client/ui-sidebar-browser/tests/apply.client.spec.ts b/packages/client/ui-sidebar-browser/tests/apply.client.spec.ts index 68f338a40d..c2a7078d60 100644 --- a/packages/client/ui-sidebar-browser/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-browser/tests/apply.client.spec.ts @@ -11,7 +11,7 @@ import { BrowserTitle } from '../src/client/view/BrowserTitle.tsx' import type { BrowserInjected } from '../src/client/browser/BrowserController.ts' import { BROWSER_ID, BROWSER_KIND } from '../src/client/definition.tsx' import { apply, inject } from '../src/client/index.ts' -import { en, ja, zh } from '../src/client/locales.ts' +import { en, ja, zh, es, fr, de, ru, pt } from '../src/client/locales.ts' import { createBrowserStore } from '../src/client/browser/store.ts' import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' import type { DesktopBrowserBridge, DesktopBrowserLeaseId } from '../src/types.ts' @@ -125,7 +125,7 @@ describe('ui-sidebar-browser apply', () => { expect(definition?.title('sidebar://browser')).toBe('type.label') expect(definition?.guide?.map(entry => [entry.order, entry.title(), entry.description?.()])) .toEqual([[30, 'guide.title', 'guide.description']]) - expect(dictionaries.get('sidebarBrowser')).toEqual({ zh, en, ja }) + expect(dictionaries.get('sidebarBrowser')).toEqual({ zh, en, ja, es, fr, de, ru, pt }) expect(dictionaries.get('sidebarBrowser')).toMatchObject({ zh: { 'guide.description': '浏览网页' }, en: { 'guide.description': 'Browse web pages' }, diff --git a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts index e1f236c578..b1a8caac0a 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts @@ -31,7 +31,7 @@ import { LazyPdfBody } from '../src/client/pdf/LazyPdfBody.tsx' import { LazyExcelBody } from '../src/client/excel/LazyExcelBody.tsx' import { PDF_BODY_ID } from '../src/client/pdf/index.ts' import { CodeBody } from '../src/client/code/CodeBody.tsx' -import { en, ja, zh } from '../src/client/locales.ts' +import { en, ja, zh, es, fr, de, ru, pt } from '../src/client/locales.ts' import type { textFace } from '../src/client/face.ts' import type { TextStore } from '../src/client/store.ts' import { FILE, SESSION, TAB_ID, byteResult, createResources, page } from './fixtures.client.ts' @@ -86,7 +86,7 @@ describe('ui-sidebar-documentpreview apply', () => { const { tabs, registered, dictionaries } = await boot() expect(tabs.get(TEXTPREVIEW_KIND)?.priority).toBe('fallback') expect(tabs.get(TEXTPREVIEW_KIND)?.id).toBe(TEXTPREVIEW_ID) - expect(dictionaries.get('sidebarDocumentPreview')).toEqual({ zh, en, ja }) + expect(dictionaries.get('sidebarDocumentPreview')).toEqual({ zh, en, ja, es, fr, de, ru, pt }) // The seat key is the implementation's id, not the kind: an extension may // take the kind over, and the seat must still find this body. expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([ diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts index b5df1bdaaf..9d8feb861b 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts @@ -6,7 +6,7 @@ import { binaryDocumentPath, DocumentPreviewRegistry } from '../src/client/docum import { Config } from '../src/config.ts' import { apply } from '../src/client/excel/index.ts' import { LazyExcelBody } from '../src/client/excel/LazyExcelBody.tsx' -import { en, ja, zh } from '../src/client/excel/locales.ts' +import { en, ja, zh, es, fr, de, ru, pt } from '../src/client/excel/locales.ts' it('registers complete binary reads and removes the slot and locale on disposal', async () => { const ctx = new Context() @@ -28,7 +28,7 @@ it('registers complete binary reads and removes the slot and locale on disposal' expect(candidate.title()).toBe('Spreadsheet') expect(binaryDocumentPath(registry.getSnapshot(), path)).toBe(/\.(xlsx|xls)$/iu.test(path)) } - expect(registerLocale).toHaveBeenCalledWith('sidebarExcel', { zh, en, ja }) + expect(registerLocale).toHaveBeenCalledWith('sidebarExcel', { zh, en, ja, es, fr, de, ru, pt }) expect(register).toHaveBeenCalledOnce() expect(register.mock.calls[0]![1]).toBe(LazyExcelBody) const options = register.mock.calls[0]![0] as { inject: () => unknown } diff --git a/packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts index a77c9b7c6f..eaf1b73f01 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts @@ -7,7 +7,7 @@ import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' import { DocumentPreviewRegistry } from '../src/client/document/registry.ts' import { apply, HTML_BODY_ID, htmlBodyDefinition } from '../src/client/html/index.ts' import { HtmlBody } from '../src/client/html/HtmlBody.tsx' -import { en, ja, zh } from '../src/client/html/locales.ts' +import { en, ja, zh, es, fr, de, ru, pt } from '../src/client/html/locales.ts' import type { HtmlBodyProps } from '../src/client/html/HtmlBody.tsx' type Registration = { @@ -54,7 +54,7 @@ describe('HTML registration', () => { await fiber.await() expect(registry.candidates('INDEX.HTM').map(entry => entry.id)).toEqual([HTML_BODY_ID]) expect(registry.getSnapshot()[0]?.title()).toBe(en.title) - expect(dictionaries.get('documentHtml')).toEqual({ zh, en, ja }) + expect(dictionaries.get('documentHtml')).toEqual({ zh, en, ja, es, fr, de, ru, pt }) expect(register).toHaveBeenCalledOnce() const registration = register.mock.calls[0]?.[0] expect(registration).toMatchObject({ name: 'sidebar.right.tab.document', key: HTML_BODY_ID, locale: 'documentHtml' }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/image-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/image-registration.client.spec.ts index 63e428e35f..dfacf0e27d 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/image-registration.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/image-registration.client.spec.ts @@ -6,7 +6,7 @@ import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' import { DocumentPreviewRegistry } from '../src/client/document/registry.ts' import { ImageBody } from '../src/client/image/ImageBody.tsx' import { apply, BINARY_IMAGE_EXTENSIONS, IMAGE_BODY_ID, IMAGE_EXTENSIONS, imageBodyDefinition } from '../src/client/image/index.ts' -import { en, ja, zh } from '../src/client/image/locales.ts' +import { en, ja, zh, es, fr, de, ru, pt } from '../src/client/image/locales.ts' import type { ZoomInjected, ZoomStore } from '../src/client/zoom/store.ts' let dispose: (() => Promise) | undefined @@ -64,7 +64,7 @@ describe('image registration', () => { expect(registry.candidates(`ASSET.${extension}`).map(entry => entry.id)).toEqual([IMAGE_BODY_ID]) } expect(registry.getSnapshot()[0]?.title()).toBe(en.title) - expect(dictionaries.get('sidebarImage')).toEqual({ zh, en, ja }) + expect(dictionaries.get('sidebarImage')).toEqual({ zh, en, ja, es, fr, de, ru, pt }) expect(register).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ name: 'sidebar.right.tab.document', key: IMAGE_BODY_ID, locale: 'sidebarImage', }), ImageBody) diff --git a/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts index 334beb3da1..7e30ba921a 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts @@ -13,7 +13,7 @@ import { Config } from '../src/config.ts' import { OfficeBody, type OfficeBodyInjected } from '../src/client/office/OfficeBody.tsx' import type { OfficeStore } from '../src/client/office/store.ts' import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' -import { en, ja, zh } from '../src/client/office/locales.ts' +import { en, ja, zh, es, fr, de, ru, pt } from '../src/client/office/locales.ts' import { en as documentEn } from '../src/client/locales.ts' vi.mock('../src/client/office/face.ts', async (importOriginal) => { @@ -91,7 +91,7 @@ async function harness(config: Partial = {}, missing?: 'remote it.each(['remote', 'render', 'files'] as const)('keeps Word and PowerPoint registration and guidance when %s is absent', async (missing) => { const h = await harness(undefined, missing) try { - expect(h.locale.register).toHaveBeenCalledWith('sidebarOffice', { zh, en, ja }) + expect(h.locale.register).toHaveBeenCalledWith('sidebarOffice', { zh, en, ja, es, fr, de, ru, pt }) for (const path of ['a.DOC', 'b.DOCX', 'c.PPT', 'd.pptx']) { expect(h.registry.candidates(path)[0]!.binaryExtensions).toEqual(['doc', 'docx', 'ppt', 'pptx']) expect(h.registry.candidates(path)[0]!.title()).toBe(en.title) diff --git a/packages/client/ui-sidebar-documentpreview/tests/pdf-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/pdf-registration.client.spec.ts index a55c457e4c..965269c977 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/pdf-registration.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/pdf-registration.client.spec.ts @@ -16,7 +16,7 @@ const renderedPdf = vi.hoisted(() => vi.fn(() => null)) vi.mock('../src/client/pdf/pdf.tsx', () => ({ PdfBody: renderedPdf })) import { apply, PDF_BODY_ID } from '../src/client/pdf/index.ts' import { LazyPdfBody } from '../src/client/pdf/LazyPdfBody.tsx' -import { en, ja, zh } from '../src/client/pdf/locales.ts' +import { en, ja, zh, es, fr, de, ru, pt } from '../src/client/pdf/locales.ts' afterEach(() => { cleanup(); renderedPdf.mockClear() }) @@ -58,7 +58,7 @@ describe('PDF registration', () => { { id: PDF_BODY_ID, extensions: ['pdf'], priority: 'builtin', loading: 'bytes-complete', wrap: false }, ]) expect(previews.candidates('report.pdf')[0]!.title()).toBe('PDF') - expect(dictionaries.get('sidebarPdf')).toEqual({ zh, en, ja }) + expect(dictionaries.get('sidebarPdf')).toEqual({ zh, en, ja, es, fr, de, ru, pt }) expect(entries[0]).toMatchObject({ name: 'sidebar.right.tab.document', key: PDF_BODY_ID, locale: 'sidebarPdf' }) const instance = entries[0]!.store.create() const face = entries[0]!.inject('s1' as SessionId, instance.actions) diff --git a/packages/client/ui-sidebar-files/tests/apply.client.spec.ts b/packages/client/ui-sidebar-files/tests/apply.client.spec.ts index 003df8b934..53f5e51ebd 100644 --- a/packages/client/ui-sidebar-files/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-files/tests/apply.client.spec.ts @@ -16,7 +16,7 @@ import { apply, inject } from '../src/client/index.ts' import { apply as hostApply } from '../src/index.ts' import { FilesBody } from '../src/client/FilesBody.tsx' import { FilesTitle } from '../src/client/FilesTitle.tsx' -import { en, ja, zh } from '../src/client/locales.ts' +import { en, ja, zh, es, fr, de, ru, pt } from '../src/client/locales.ts' interface Recorded { name: string @@ -94,7 +94,7 @@ describe('ui-sidebar-files apply', () => { expect(definition?.title('sidebar://files')).toBe('type.label') expect(definition?.guide?.map(entry => [entry.order, entry.title(), entry.description?.()])) .toEqual([[10, 'guide.title', 'guide.description']]) - expect(dictionaries.get('sidebarFiles')).toEqual({ zh, en, ja }) + expect(dictionaries.get('sidebarFiles')).toEqual({ zh, en, ja, es, fr, de, ru, pt }) // The seat key is the implementation's id, not the kind: an extension may // take the kind over, and the seat must still find this body. expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([ diff --git a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts index 59c620cd3b..6b40b7e4cb 100644 --- a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts @@ -27,7 +27,7 @@ import { ExpandButton } from '../src/client/shell/ExpandButton.tsx' import { GuideBody } from '../src/client/tabs/guide/GuideBody.tsx' import { GuideTitle } from '../src/client/tabs/guide/GuideTitle.tsx' import { GUIDE_ID } from '../src/client/tabs/guide/definition.ts' -import { en, ja, zh } from '../src/client/locales.ts' +import { en, ja, zh, es, fr, de, ru, pt } from '../src/client/locales.ts' const SHORTCUT_CATALOG: readonly never[] = [] @@ -124,7 +124,7 @@ describe('ui-sidebar-right apply', () => { expect(ctx.sidebarRightTabs).toBeInstanceOf(SidebarRightTabRegistry) expect(ctx.sidebarRight).toBeInstanceOf(SidebarRightController) expect('adopt' in ctx.sidebarRight).toBe(false) - expect(dictionaries.get('sidebarRight')).toEqual({ zh, en, ja }) + expect(dictionaries.get('sidebarRight')).toEqual({ zh, en, ja, es, fr, de, ru, pt }) const guide = ctx.sidebarRightTabs.get('guide') expect(guide?.id).toBe(GUIDE_ID) expect(guide?.priority).toBe('builtin') diff --git a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts index 9161768ab2..618ad175cb 100644 --- a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts @@ -18,7 +18,7 @@ import { TerminalGuide, type TerminalGuideInjected } from '../src/client/Termina import { LazyTerminalBody } from '../src/client/LazyTerminalBody.tsx' import { TerminalTitle } from '../src/client/TerminalTitle.tsx' import type { TerminalBodyInjected } from '../src/client/face.ts' -import { en, ja, zh } from '../src/client/locales.ts' +import { en, ja, zh, es, fr, de, ru, pt } from '../src/client/locales.ts' vi.mock('@xterm/xterm', () => ({ Terminal: vi.fn() })) const SHORTCUT_CATALOG: readonly never[] = [] @@ -97,7 +97,7 @@ it('registers terminal views without recovery or cleanup slots, then releases co expect(renderToStaticMarkup(createElement(Icon, { size: 22 }))).toContain('width="22"') expect(renderToStaticMarkup(createElement(Icon))).toContain('width="36"') expect(definition.multiple).toBe(true) - expect(h.dictionaries.get('sidebarTerminal')).toEqual({ en, zh, ja }) + expect(h.dictionaries.get('sidebarTerminal')).toEqual({ en, zh, ja, es, fr, de, ru, pt }) expect(h.entries.map(entry => [entry.name, entry.component, entry.locale])).toEqual([ ['sidebar.right.tab.guide.entry', TerminalGuide, 'sidebarTerminal'], ['sidebar.right.pane.tab', LazyTerminalBody, 'sidebarTerminal'], diff --git a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts index 8d589aecca..b160c954ad 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -1,3 +1,4 @@ +import { es, fr, de, ru, pt } from '../src/client/locales.ts' /** * ui-skill browser half: source and keyed toolview registration + * locale dictionaries + source duplicate-name proof + @@ -141,6 +142,7 @@ describe('apply', () => { expect(entry?.component).toBe(SkillToolRow) expect(presentation.dictionaries).toEqual([{ namespace: 'skill', dictionaries: { + es, fr, de, ru, pt, zh: { 'row.title': '加载技能', 'row.running': '正在加载 skill', diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index 23308f6ed9..b6c8e26929 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -664,7 +664,7 @@ export const es = { 'timing.sessionTimestampsRunning': 'Marcas de tiempo de sesión (en ejecución)', 'timing.request': 'Solicitar tiempo', 'unit.milliseconds': '{value} ms', - 'unit.seconds': '{value}', + 'unit.seconds': '{value} s', 'unit.tokens': 'Tok {value}', 'unit.tokensPerSecond': '{value} tok/s', 'usage.tokens': 'Fichas', @@ -1449,7 +1449,7 @@ export const pt = { 'timing.request': 'Tempo de solicitação', 'unit.milliseconds': '{value} ms', 'unit.seconds': '{value} segundos', - 'unit.tokens': '{value} valor', + 'unit.tokens': '{value} tok', 'unit.tokensPerSecond': '{value} tok/s', 'usage.tokens': 'Fichas', 'usage.reasoning': 'Raciocínio', diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index d847f29dc1..0789d4ae97 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -1,3 +1,5 @@ +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { es, pt } from '../src/client/locales.ts' // @vitest-environment jsdom /** Trajectory ledger selection, details, status, and fold behavior. */ @@ -1350,3 +1352,8 @@ describe('TrajectoryTable', () => { expect(onInspectApplied).not.toHaveBeenCalled() }) }) + +it('preserves seconds and token units in translated metric labels', () => { + expect(makeTranslate(es)('unit.seconds', { value: '1.25' })).toBe('1.25 s') + expect(makeTranslate(pt)('unit.tokens', { value: 42 })).toBe('42 tok') +}) diff --git a/packages/credentials/credentials-local/locale/de.json b/packages/credentials/credentials-local/locale/de.json index 0d5f4fc5fe..35c3fc917b 100644 --- a/packages/credentials/credentials-local/locale/de.json +++ b/packages/credentials/credentials-local/locale/de.json @@ -1,6 +1,6 @@ { "meta": { "title": "Lokaler Speicher für Anmeldeinformationen", - "description": "Speichern Sie Anmeldeinformationen wie den Schlüssel API und lesen Sie Umgebungsvariablen und lokale Anmeldeinformationsdateien in der Reihenfolge ihrer Priorität." + "description": "Speichern Sie Anmeldeinformationen wie den API-Schlüssel und lesen Sie Umgebungsvariablen und lokale Anmeldeinformationsdateien in der Reihenfolge ihrer Priorität." } } diff --git a/packages/experimental/client-ui-cot-translation/src/client/locales.ts b/packages/experimental/client-ui-cot-translation/src/client/locales.ts index 8e3ef8230b..ab461c4f40 100644 --- a/packages/experimental/client-ui-cot-translation/src/client/locales.ts +++ b/packages/experimental/client-ui-cot-translation/src/client/locales.ts @@ -138,7 +138,7 @@ export const de = { translating: 'Argumentation übersetzen', invalidProvider: 'Wählen Sie einen Übersetzungsdienst.', invalidLanguage: 'Geben Sie auto oder einen Sprachcode ein, z. B. zh, en oder ja.', overridden: 'Überschrieben', reset: 'Auf Standard zurücksetzen', unavailable: 'Übersetzungseinstellungen sind nicht verfügbar.', readOnly: 'Diese Bereitstellung speichert Einstellungen schreibgeschützt.', - saveFailed: 'Die Bereitstellung hat diese Werte nicht akzeptiert. Sie mussten sie korrigieren.', save: 'Speichern', saving: 'Sparen…', + saveFailed: 'Die Bereitstellung hat diese Werte nicht akzeptiert. Sie mussten sie korrigieren.', save: 'Speichern', saving: 'Speichern…', copy: 'Kopieren', copied: 'Kopiert', codeTitle: 'Codeblock', wrap: 'Zeilen umbrechen', unwrap: 'Zeilen abwickeln', footnotes: 'Fußnoten', } satisfies Record diff --git a/packages/experimental/client-ui-voice-input/src/client/locales.ts b/packages/experimental/client-ui-voice-input/src/client/locales.ts index b97a84d3b1..8460775175 100644 --- a/packages/experimental/client-ui-voice-input/src/client/locales.ts +++ b/packages/experimental/client-ui-voice-input/src/client/locales.ts @@ -90,7 +90,7 @@ export const zh = { inputLevel: '麦克风输入电平', deviceUnavailable: '不可用', deviceMissing: '麦克风不可用,请选择其他输入设备', - auto: '自动识别', zh: '中文', en: '英语', yue: '粤语', ja: '日语', es: '日语', fr: '日语', de: '日语', ru: '日语', pt: '日语', ko: '韩语', + auto: '自动识别', zh: '中文', en: '英语', yue: '粤语', ja: '日语', es: '西班牙语', fr: '法语', de: '德语', ru: '俄语', pt: '葡萄牙语', ko: '韩语', start: '开始录音', stop: '停止并识别', cancel: '取消', insert: '插入文字', loading: '正在读取识别服务…', requesting: '请允许使用麦克风…', recording: '正在录音…', interrupted: '录音中断,请重试。', @@ -194,7 +194,7 @@ export const en: Record = { inputLevel: 'Microphone input level', deviceUnavailable: 'Unavailable', deviceMissing: 'Microphone unavailable. Choose another input device.', - auto: 'Detect automatically', zh: 'Chinese', en: 'English', yue: 'Cantonese', ja: 'Japanese', es: 'Japanese', fr: 'Japanese', de: 'Japanese', ru: 'Japanese', pt: 'Japanese', ko: 'Korean', + auto: 'Detect automatically', zh: 'Chinese', en: 'English', yue: 'Cantonese', ja: 'Japanese', es: 'Spanish', fr: 'French', de: 'German', ru: 'Russian', pt: 'Portuguese', ko: 'Korean', start: 'Start recording', stop: 'Stop and transcribe', cancel: 'Cancel', insert: 'Insert text', loading: 'Loading recognition services…', requesting: 'Allow microphone access to continue…', recording: 'Recording…', interrupted: 'Recording was interrupted. Please try again.', @@ -307,7 +307,7 @@ export const ja = { 'zh': '中国語', 'en': '英語', 'yue': '広東語', - 'ja': '日本語', es: '日本語', fr: '日本語', de: '日本語', ru: '日本語', pt: '日本語', + 'ja': '日本語', es: 'スペイン語', fr: 'フランス語', de: 'ドイツ語', ru: 'ロシア語', pt: 'ポルトガル語', 'ko': '韓国語', 'start': '録音を開始', 'stop': '停止して文字起こし', @@ -415,7 +415,7 @@ export const es = { inputLevel: 'Nivel de entrada del micrófono', deviceUnavailable: 'No disponible', deviceMissing: 'Micrófono no disponible. Elija otro dispositivo de entrada.', - auto: 'Detectar automáticamente', zh: 'chino', en: 'ingles', yue: 'cantonés', ja: 'japonés', es: 'japonés', fr: 'japonés', de: 'japonés', ru: 'japonés', pt: 'japonés', ko: 'coreano', + auto: 'Detectar automáticamente', zh: 'chino', en: 'ingles', yue: 'cantonés', ja: 'japonés', es: 'español', fr: 'francés', de: 'alemán', ru: 'ruso', pt: 'portugués', ko: 'coreano', start: 'Empezar a grabar', stop: 'Detener y transcribir', cancel: 'Cancelar', insert: 'Insertar texto', loading: 'Cargando servicios de reconocimiento…', requesting: 'Permitir el acceso al micrófono para continuar...', recording: 'Grabando…', interrupted: 'La grabación fue interrumpida. Por favor inténtalo de nuevo.', @@ -516,7 +516,7 @@ export const fr = { inputLevel: "Niveau d'entrée du microphone", deviceUnavailable: 'Indisponible', deviceMissing: "Micro indisponible. Choisissez un autre périphérique d'entrée.", - auto: 'Détecter automatiquement', zh: 'Chinois', en: 'Anglais', yue: 'Cantonais', ja: 'Japonais', es: 'Japonais', fr: 'Japonais', de: 'Japonais', ru: 'Japonais', pt: 'Japonais', ko: 'Coréen', + auto: 'Détecter automatiquement', zh: 'Chinois', en: 'Anglais', yue: 'Cantonais', ja: 'Japonais', es: 'Espagnol', fr: 'Français', de: 'Allemand', ru: 'Russe', pt: 'Portugais', ko: 'Coréen', start: "Commencer l'enregistrement", stop: 'Arrêtez et transcrivez', cancel: 'Annuler', insert: 'Insérer du texte', loading: 'Chargement des services de reconnaissance…', requesting: 'Autoriser l’accès au microphone pour continuer…', recording: 'Enregistrement…', interrupted: "L'enregistrement a été interrompu. Veuillez réessayer.", @@ -617,7 +617,7 @@ export const de = { inputLevel: 'Mikrofoneingangspegel', deviceUnavailable: 'Nicht verfügbar', deviceMissing: 'Mikrofon nicht verfügbar. Wählen Sie ein anderes Eingabegerät.', - auto: 'Automatisch erkennen', zh: 'Chinesisch', en: 'Englisch', yue: 'Kantonesisch', ja: 'Japanisch', es: 'Japanisch', fr: 'Japanisch', de: 'Japanisch', ru: 'Japanisch', pt: 'Japanisch', ko: 'Koreanisch', + auto: 'Automatisch erkennen', zh: 'Chinesisch', en: 'Englisch', yue: 'Kantonesisch', ja: 'Japanisch', es: 'Spanisch', fr: 'Französisch', de: 'Deutsch', ru: 'Russisch', pt: 'Portugiesisch', ko: 'Koreanisch', start: 'Starten Sie die Aufnahme', stop: 'Stoppen Sie und transkribieren Sie', cancel: 'Abbrechen', insert: 'Text einfügen', loading: 'Erkennungsdienste werden geladen…', requesting: 'Mikrofonzugriff erlauben, um fortzufahren…', recording: 'Aufnahme…', interrupted: 'Die Aufnahme wurde unterbrochen. Bitte versuchen Sie es erneut.', @@ -718,7 +718,7 @@ export const ru = { inputLevel: 'Входной уровень микрофона', deviceUnavailable: 'Недоступно', deviceMissing: 'Микрофон недоступен. Выберите другое устройство ввода.', - auto: 'Обнаруживать автоматически', zh: 'китайский', en: 'английский', yue: 'Кантонский диалект', ja: 'японский', es: 'японский', fr: 'японский', de: 'японский', ru: 'японский', pt: 'японский', ko: 'корейский', + auto: 'Обнаруживать автоматически', zh: 'китайский', en: 'английский', yue: 'Кантонский диалект', ja: 'японский', es: 'испанский', fr: 'французский', de: 'немецкий', ru: 'русский', pt: 'португальский', ko: 'корейский', start: 'Начать запись', stop: 'Остановись и перепиши', cancel: 'Отмена', insert: 'Вставить текст', loading: 'Загрузка служб распознавания…', requesting: 'Разрешите доступ к микрофону, чтобы продолжить…', recording: 'Запись…', interrupted: 'Запись была прервана. Пожалуйста, попробуйте еще раз.', @@ -819,7 +819,7 @@ export const pt = { inputLevel: 'Nível de entrada do microfone', deviceUnavailable: 'Indisponível', deviceMissing: 'Microfone indisponível. Escolha outro dispositivo de entrada.', - auto: 'Detectar automaticamente', zh: 'Chinês', en: 'Inglês', yue: 'cantonês', ja: 'Japonês', es: 'Japonês', fr: 'Japonês', de: 'Japonês', ru: 'Japonês', pt: 'Japonês', ko: 'Coreano', + auto: 'Detectar automaticamente', zh: 'Chinês', en: 'Inglês', yue: 'cantonês', ja: 'Japonês', es: 'espanhol', fr: 'francês', de: 'alemão', ru: 'russo', pt: 'português', ko: 'Coreano', start: 'Comece a gravar', stop: 'Pare e transcreva', cancel: 'Cancelar', insert: 'Inserir texto', loading: 'Carregando serviços de reconhecimento…', requesting: 'Permitir que o acesso ao microfone continue…', recording: 'Gravando…', interrupted: 'A gravação foi interrompida. Por favor, tente novamente.', diff --git a/packages/experimental/client-ui-voice-input/tests/preparation.client.spec.tsx b/packages/experimental/client-ui-voice-input/tests/preparation.client.spec.tsx index 94fc23af4c..c080992866 100644 --- a/packages/experimental/client-ui-voice-input/tests/preparation.client.spec.tsx +++ b/packages/experimental/client-ui-voice-input/tests/preparation.client.spec.tsx @@ -10,7 +10,7 @@ import { PreparationCard, VoicePreparation } from '../src/client/PreparationCard import type { VoiceInputProps } from '../src/client/VoiceInput.tsx' import type { SpeechReadiness } from '../src/client/readiness.ts' import { Recording } from '../src/client/audio.ts' -import { zh } from '../src/client/locales.ts' +import { zh, en, ja, es, fr, de, ru, pt } from '../src/client/locales.ts' afterEach(() => { cleanup(); vi.useRealTimers() }) const id = 'local' as SpeechProviderId, t = makeTranslate(zh, commonZh) @@ -129,7 +129,7 @@ it('persists settings and reports disconnection in the detail card', async () => expect(screen.getByText(zh.cloud)).toBeTruthy() expect(within(screen.getByLabelText(zh.language)).getAllByRole('option').map(item => item.value)) .toEqual(['auto', 'en', 'zh', 'ja', 'fr']) - expect(within(screen.getByLabelText(zh.language)).getByRole('option', { name: 'fr' })).toBeTruthy() + expect(within(screen.getByLabelText(zh.language)).getByRole('option', { name: zh.fr })).toBeTruthy() configure.mockRejectedValueOnce(new Error('settings are read-only')) fireEvent.change(screen.getByLabelText(zh.language), { target: { value: 'zh' } }) await screen.findByText('语音识别失败:settings are read-only') @@ -212,3 +212,14 @@ it('locks source changes while a preparation request is pending and hides them d b.rerender() expect(screen.queryByLabelText(zh.sourceChoice)).toBeNull() }) + +it('gives each speech recognition language a distinct localized name', () => { + for (const dictionary of [zh, en, ja, es, fr, de, ru, pt]) { + const names = [ + dictionary.zh, dictionary.en, dictionary.yue, dictionary.ja, dictionary.es, + dictionary.fr, dictionary.de, dictionary.ru, dictionary.pt, dictionary.ko, + ] + expect(new Set(names).size).toBe(names.length) + } + expect(zh.fr).toBe('法语') +}) diff --git a/packages/experimental/cua/src/client/locales.ts b/packages/experimental/cua/src/client/locales.ts index 8dae1d7b70..8763456dad 100644 --- a/packages/experimental/cua/src/client/locales.ts +++ b/packages/experimental/cua/src/client/locales.ts @@ -192,7 +192,7 @@ export const fr = { permissionHint: "Gérez les autorisations dans les paramètres système pour l'application nommée par macOS.", granted: 'Autorisé', unknown: 'Inconnu', request: "Demander l'autorisation", settings: 'Ouvrir les paramètres système', retry: 'Réessayer', allowedApps: 'Applications toujours autorisées', - blockedApps: 'Candidatures non autorisées', retryApproval: 'Autoriser la demande à nouveau', + blockedApps: 'Applications non autorisées', retryApproval: 'Autoriser la demande à nouveau', retryAllowed: "La prochaine sélection de candidatures demandera à nouveau ; l'accès n'a pas été accordé", noApps: 'Aucune application toujours autorisée ou bloquée.', revoke: 'Révoquer', revoked: "Accès à l'application révoqué", requested: "Consultez l'invite macOS ou ouvrez les paramètres système si aucune invite n'apparaît.", opened: 'Paramètres système ouverts', @@ -229,7 +229,7 @@ export const de = { permissionHint: 'Verwalten Sie Berechtigungen in den Systemeinstellungen für die Anwendung mit dem Namen macOS.', granted: 'Erlaubt', unknown: 'Unbekannt', request: 'Bitte um Erlaubnis', settings: 'Öffnen Sie die Systemeinstellungen', retry: 'Erneut versuchen', allowedApps: 'Immer zulässige Anwendungen', - blockedApps: 'Bewerbungen nicht erlaubt', retryApproval: 'Erlaube noch einmal nachzufragen', + blockedApps: 'Nicht erlaubte Anwendungen', retryApproval: 'Erlaube noch einmal nachzufragen', retryAllowed: 'Bei der nächsten Anwendungsauswahl werden Sie erneut gefragt; Der Zugriff wurde nicht gewährt', noApps: 'Keine immer erlaubten oder blockierten Anwendungen.', revoke: 'Widerrufen', revoked: 'Anwendungszugriff widerrufen', requested: 'Überprüfen Sie die macOS-Eingabeaufforderung oder öffnen Sie die Systemeinstellungen, wenn keine Eingabeaufforderung angezeigt wird', opened: 'Systemeinstellungen geöffnet', diff --git a/packages/experimental/cua/src/locales.ts b/packages/experimental/cua/src/locales.ts index 9cd8b219e1..bb20c860c9 100644 --- a/packages/experimental/cua/src/locales.ts +++ b/packages/experimental/cua/src/locales.ts @@ -19,7 +19,7 @@ export const ja = { question: 'Computer Use によるこのアプリの起動、読み取り、画面キャプチャ、操作を許可しますか?', detail: 'セッションの許可は DSH の再起動または Computer Use の無効化まで有効です。', approve: 'このセッションで許可', -} satisfies typeof en +} satisfies Record /** Spanish dictionary, checked against the English keys. */ export const es = { diff --git a/packages/preset/agent-preset/skills/cordis-plugin-development/SKILL.md b/packages/preset/agent-preset/skills/cordis-plugin-development/SKILL.md index ea246d47fa..90a9f98e7d 100644 --- a/packages/preset/agent-preset/skills/cordis-plugin-development/SKILL.md +++ b/packages/preset/agent-preset/skills/cordis-plugin-development/SKILL.md @@ -1,17 +1,17 @@ --- name: cordis-plugin-development -description: Use when creating, reviewing, installing, enabling, disabling, configuring, or debugging Harness plugins, bundles, features, tools, MCP connections, pages, panels, decorations or widgets. Visual requests without another destination mean an installed UI plugin in Harness Web UI. +description: Use when designing, reviewing, adding, enabling, disabling, installing, configuring, or debugging a plugin, bundle, feature, page, panel, tool, or MCP connection in the current Harness profile, including a shipped plugin that is disabled by default, and for any visual object, decoration, or widget request that names no other destination, which means an installed UI plugin rendered in the Harness Web UI. --- # Persistent Harness plugins -Author a workspace bundle, then install it in the current profile with `plugin_manager` (`action: install_bundle`, absolute package directory as `target`). It persists across sessions and restarts. Load `editing-cordis-compositions` for agent preset changes. +For implementation, use ordinary workspace files to author a bundle, then `plugin_manager` with `action: install_bundle` and the absolute package directory as `target` to install it in the current profile. Changes affect every session in that profile and survive restart. Load `editing-cordis-compositions` for agent preset changes. Do not write the profile's `package.json` or `cordis.patch.yml`, create packages under `$DSH_HOME`, or run pnpm in the profile directory: `install_bundle` performs those steps, and each hand-made write outside the workspace needs its own approval. Every `plugin_manager` action, including `list_plugins` and `list_bundles`, also needs approval without Full access, so call it only when its result decides the next step. ## Design or review -Inspect the plugin against current APIs using the applicable references below. Report choices or findings; write files, install bundles, or change profile state only when implementation is requested. +Read the applicable references below and inspect the proposed or existing plugin against current APIs. Report design choices or findings instead of following the installation workflow. Do not write files, install bundles, or change profile state unless the user requests implementation. ## Enable a shipped plugin @@ -35,7 +35,7 @@ A shipped bundle can resolve a plugin row and leave it `disabled`. The row id an ## Read next -The table lists every file in the base directory reported by `skill`; do not enumerate it. In every deployment, including source checkouts, use file-read/file-write tools to copy these files into the workspace, then read the copies to verify them. Desktop stores the directory in `app.asar`: only Host file reads work there. Shell commands, native glob/search tools, `node`, and pnpm cannot read it. Never install or syntax-check a template in place; copy its contents into the workspace first. +The files below live in this skill's base directory, which the `skill` tool reported, and the table is the complete list: do not enumerate that directory. In every deployment, including a source checkout, read these files with the file-read tool, write copies into the workspace with the file-write tool, and verify a copy by reading it back. In Desktop the directory sits inside `app.asar`, which only the Host process's own file reads can open; shell commands (`ls`, `cat`, `cp`, `cmp`), the glob and search tools (they run a native ripgrep process), `node`, and pnpm all fail on it. Never install or syntax-check a template in place; copy its contents into the workspace first. | Task | File | |---|---| @@ -43,8 +43,8 @@ The table lists every file in the base directory reported by `skill`; do not enu | Web UI: Client manifest, module loader, slot registration | `references/ui-plugin.md` | | Connecting an MCP server through a configuration-only bundle | `references/mcp-bundle.md` | | Page/panel design checks and verification without browser control | `references/verification.md` | -| UI plugin template and display resources | `templates/decoration/package.json`, `templates/decoration/cordis.patch.yml`, `templates/decoration/index.js`, `templates/decoration/client.js`, `templates/decoration/locale/en.json`, `templates/decoration/locale/ja.json`, `templates/decoration/locale/es.json`, `templates/decoration/locale/fr.json`, `templates/decoration/locale/de.json`, `templates/decoration/locale/ru.json`, `templates/decoration/locale/pt.json`, `templates/decoration/icon.svg` | -| MCP template and display resources | `templates/mcp/package.json`, `templates/mcp/cordis.patch.yml`, `templates/mcp/locale/en.json`, `templates/mcp/locale/ja.json`, `templates/mcp/locale/es.json`, `templates/mcp/locale/fr.json`, `templates/mcp/locale/de.json`, `templates/mcp/locale/ru.json`, `templates/mcp/locale/pt.json`, `templates/mcp/icon.svg` | +| UI plugin template, including display resources | `templates/decoration/package.json`, `templates/decoration/cordis.patch.yml`, `templates/decoration/index.js`, `templates/decoration/client.js`, `templates/decoration/locale`, `templates/decoration/icon.svg` | +| MCP bundle template, including display resources | `templates/mcp/package.json`, `templates/mcp/cordis.patch.yml`, `templates/mcp/locale`, `templates/mcp/icon.svg` | | Loader patch dialect and the list of installable plugin packages | the `cordis-composition-reference` skill | | Choosing extension points, contexts, and state mechanisms for upgrade stability and performance | `references/practices.md` | | Sharing application operations between UI actions and agent tools | `references/user-actions.md` | diff --git a/packages/preset/agent-preset/tests/skills.spec.ts b/packages/preset/agent-preset/tests/skills.spec.ts index 83c26c4729..23ff399c2a 100644 --- a/packages/preset/agent-preset/tests/skills.spec.ts +++ b/packages/preset/agent-preset/tests/skills.spec.ts @@ -126,7 +126,7 @@ describe('the shipped creator skills', () => { ...locales, manifest.dsh.bundle.patch]) for (const resource of resources) { const file = resource.replace(/^\.\//u, '') - expect(body('cordis-plugin-development'), file).toContain(`\`templates/${name}/${file}\``) + expect(body('cordis-plugin-development'), file).toContain(`\`templates/${name}/${file.startsWith('locale/') ? 'locale' : file}\``) expect(statSync(join(dir, file)).isFile(), file).toBe(true) if (file !== 'package.json') expect(manifest.files.some(pattern => matchesGlob(file, pattern)), file).toBe(true) } diff --git a/packages/sandbox/sandbox/src/locales.ts b/packages/sandbox/sandbox/src/locales.ts index 264f9c2dbf..0de279118f 100644 --- a/packages/sandbox/sandbox/src/locales.ts +++ b/packages/sandbox/sandbox/src/locales.ts @@ -17,7 +17,7 @@ export const ja = { /** Spanish dictionary, checked against the English keys. */ export const es = { - escalation: (mode: string, justification: string): string => `Permite esta operación con permisos ${mode}: ${justification}`, + escalation: (mode: string, justification: string): string => `Permitir esta operación con permisos ${mode}: ${justification}`, } satisfies typeof en /** French dictionary, checked against the English keys. */ diff --git a/scripts/locale-dictionary-parity.spec.ts b/scripts/locale-dictionary-parity.spec.ts index 2488ed22e1..572e2391a2 100644 --- a/scripts/locale-dictionary-parity.spec.ts +++ b/scripts/locale-dictionary-parity.spec.ts @@ -27,7 +27,7 @@ import { fileURLToPath } from 'node:url' import ts from 'typescript' import { describe, expect, it } from 'vitest' -import { SHIPPED_LOCALE_IDS, localeTokens, type ShippedLocaleId } from './shipped-locales.ts' +import { SHIPPED_LOCALE_IDS, localeTokens, hasInvisibleLocaleCharacters, type ShippedLocaleId } from './shipped-locales.ts' const root = fileURLToPath(new URL('..', import.meta.url)) @@ -326,7 +326,13 @@ describe('shipped locale dictionaries', () => { const perFile = new Map() for (const file of files) { const dicts = dictionariesIn(file) - if (dicts.length > 0) perFile.set(relative(file), dicts) + if (dicts.length > 0) { + expect(hasInvisibleLocaleCharacters(readFileSync(file, 'utf8')), relative(file)).toBe(false) + for (const dictionary of dicts) { + for (const text of Object.values(dictionary.templates)) expect(hasInvisibleLocaleCharacters(text), relative(file)).toBe(false) + } + perFile.set(relative(file), dicts) + } } const groups = new Map>() @@ -372,26 +378,25 @@ describe('shipped locale dictionaries', () => { if (zhOnly.length > 0) problems.push(`${zh.file} ${zh.name} has keys absent from ${en.name}: ${zhOnly.join(', ')}`) if (enOnly.length > 0) problems.push(`${en.file} ${en.name} has keys absent from ${zh.name}: ${enOnly.join(', ')}`) for (const language of SHIPPED_LOCALE_IDS.filter(id => id !== 'en' && id !== 'zh')) { - const ja = slot.get(language) - if (ja === undefined) { + const translatedDictionary = slot.get(language) + if (translatedDictionary === undefined) { problems.push(`${key} has no ${language} dictionary`) } else { - const missing = en.keys.filter(k => !ja.keys.includes(k)) - const extra = ja.keys.filter(k => !en.keys.includes(k)) + const missing = en.keys.filter(k => !translatedDictionary.keys.includes(k)) + const extra = translatedDictionary.keys.filter(k => !en.keys.includes(k)) if (missing.length > 0 || extra.length > 0) { - problems.push(`${ja.file} ${ja.name}: missing [${missing.join(', ')}], extra [${extra.join(', ')}]`) + problems.push(`${translatedDictionary.file} ${translatedDictionary.name}: missing [${missing.join(', ')}], extra [${extra.join(', ')}]`) } for (const [name, text] of Object.entries(en.templates)) { - const translated = ja.templates[name] + const translated = translatedDictionary.templates[name] if (translated === undefined) continue const placeholders = (value: string): string => localeTokens(value).join(', ') if (placeholders(text) !== placeholders(translated)) { - problems.push(`${ja.file} ${ja.name}.${name} changes placeholders or literal tokens from ${en.name}`) + problems.push(`${translatedDictionary.file} ${translatedDictionary.name}.${name} changes placeholders or literal tokens from ${en.name}`) } } } } - } // The shipped dictionary count only grows; a collapse means discovery or diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 5735eddf96..a6dc2095e2 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -393,7 +393,7 @@ function i18nGates(includeDictionaries: boolean): Gate[] { return [ pnpmScript('installer-locales', 'verify-installer-locales', { label: 'Windows installer locales' }), pnpmScript('locale-metadata', 'verify-locale-metadata', { label: 'plugin locale metadata' }), - ...includeDictionaries ? [pnpmExec('locale-dictionaries', ['vitest', 'run', 'scripts/locale-dictionary-parity.spec.ts'], { label: 'locale dictionaries' })] : [], + ...includeDictionaries ? [pnpmExec('locale-dictionaries', ['vitest', 'run', 'scripts/locale-dictionary-parity.spec.ts', 'scripts/shipped-locales.spec.ts', 'packages/client/locale/tests/apply.client.spec.ts'], { label: 'locale dictionaries' })] : [], ] } diff --git a/scripts/shipped-locales.spec.ts b/scripts/shipped-locales.spec.ts index be318f117e..ead03238f7 100644 --- a/scripts/shipped-locales.spec.ts +++ b/scripts/shipped-locales.spec.ts @@ -1,6 +1,9 @@ +import { readFileSync } from 'node:fs' +import * as desktop from '../apps/desktop/src/locale.ts' +import { readInstallerLocales } from '../apps/desktop/scripts/installer-locales.mjs' /** Translations preserve executable command examples and named interpolation fields. */ import { expect, it } from 'vitest' -import { localeTokens } from './shipped-locales.ts' +import { SHIPPED_LOCALE_IDS, localeTokens, hasInvisibleLocaleCharacters } from './shipped-locales.ts' it('retains repeated placeholders, commands, and API identifiers', () => { expect(localeTokens('Use /plan off, /compact and /THEME=dark with {count}, {count} and reasoning_effort.')) @@ -20,3 +23,22 @@ it.each([['/plan off', '/plan désactivé'], ['/THEME=light', '/THEME=claro'], [ it('recognizes commands at the start of copy or after CJK punctuation', () => { expect(localeTokens('/THEME=auto、/THEME=light')).toEqual(['/THEME=auto', '/THEME=light']) }) + +it.each(['\u200b', '\ufeff'])('rejects an invisible translation character', (character) => { + expect(hasInvisibleLocaleCharacters(`text${character}text`)).toBe(true) + expect(hasInvisibleLocaleCharacters('normal text')).toBe(false) +}) + +it('matches Desktop dictionaries, resolution and installer resources to the shipped catalog', () => { + const dictionaries = Object.keys(desktop).filter(name => /^[a-z]{2}$/u.test(name)).sort() + expect(dictionaries).toEqual([...SHIPPED_LOCALE_IDS].sort()) + for (const id of SHIPPED_LOCALE_IDS) { + expect(desktop.resolveDesktopLocale(id).id).toBe(id === 'zh' ? 'zh-CN' : id) + expect(desktop.resolveDesktopStartupLocale(null, [id]).id).toBe(id === 'zh' ? 'zh-CN' : id) + } + expect(readInstallerLocales().map(locale => locale.id.split('_')[0]).sort()).toEqual(dictionaries) +}) + +it('rejects invisible characters in the Desktop locale source', () => { + expect(hasInvisibleLocaleCharacters(readFileSync(new URL('../apps/desktop/src/locale.ts', import.meta.url), 'utf8'))).toBe(false) +}) diff --git a/scripts/shipped-locales.ts b/scripts/shipped-locales.ts index 62c9debada..17dccaec6b 100644 --- a/scripts/shipped-locales.ts +++ b/scripts/shipped-locales.ts @@ -12,3 +12,12 @@ export type ShippedLocaleId = typeof SHIPPED_LOCALE_IDS[number] export function localeTokens(text: string): string[] { return (text.match(/\{\w+\}|(? { const root = fixture() const directory = join(root, 'packages/demo/plugin/locale') - const meta = { title: 'Name {name}', description: 'Description.', detail: 'docs/DETAIL.md' } + const meta = { title: 'Name {name}', detail: 'docs/DETAIL.md' } for (const language of SHIPPED_LOCALE_IDS) writeFileSync(join(directory, `${language}.json`), JSON.stringify({ meta })) expect(localeMetadataProblems(root).join('\n')).toContain('missing detail resource') mkdirSync(join(directory, '../docs')) @@ -103,7 +103,7 @@ it('requires sentence punctuation for English descriptions', () => { const file = join(root, 'packages/demo/plugin/locale/en.json') writeFileSync(file, JSON.stringify({ meta: { title: 'Name {name}', description: 'Description' } })) expect(localeMetadataProblems(root).join('\n')).toContain('sentence punctuation') - writeFileSync(file, JSON.stringify({ meta: { title: 'Name {name}', description: 'Description.' } })) + writeFileSync(file, JSON.stringify({ meta: { title: 'Name {name}', description: 'English description.' } })) expect(localeMetadataProblems(root)).toEqual([]) }) @@ -135,6 +135,21 @@ it.each(['es', 'fr', 'de', 'ru', 'pt'])('rejects missing %s metadata', (language it.each(['es', 'fr', 'de', 'ru', 'pt'])('rejects untranslated Chinese descriptions in %s metadata', (language) => { const root = fixture() + writeFileSync(join(root, 'packages/demo/plugin/locale/en.json'), JSON.stringify({ meta: { title: 'Name {name}', description: 'English description.' } })) writeFileSync(join(root, `packages/demo/plugin/locale/${language}.json`), JSON.stringify({ meta: { title: 'Name {name}', description: '描述' } })) expect(localeMetadataProblems(root).join('\n')).toContain(`${language}.json: meta.description contains untranslated source text`) }) + +it.each(['es', 'fr', 'de', 'ru', 'pt'])('rejects copied English descriptions in %s metadata', (language) => { + const root = fixture() + writeFileSync(join(root, 'packages/demo/plugin/locale/en.json'), JSON.stringify({ meta: { title: 'Name {name}', description: 'English description.' } })) + writeFileSync(join(root, `packages/demo/plugin/locale/${language}.json`), JSON.stringify({ meta: { title: 'Name {name}', description: 'English description.' } })) + expect(localeMetadataProblems(root).join('\n')).toContain(`${language}.json: meta.description contains untranslated source text`) +}) + +it.each(['\u200b', '\ufeff', String.raw`\u200b`, String.raw`\ufeff`])('rejects invisible characters in JSON resources', (character) => { + const root = fixture() + const file = join(root, 'packages/demo/plugin/locale/es.json') + writeFileSync(file, readFileSync(file, 'utf8').replace('Localized description.', `Localized${character} description.`)) + expect(localeMetadataProblems(root).join('\n')).toContain('invisible locale character') +}) diff --git a/scripts/verify-locale-metadata.ts b/scripts/verify-locale-metadata.ts index 8bada0cb47..a0e18e3519 100644 --- a/scripts/verify-locale-metadata.ts +++ b/scripts/verify-locale-metadata.ts @@ -3,7 +3,7 @@ import { existsSync, globSync, readFileSync } from 'node:fs' import { basename, posix, join, resolve } from 'node:path' import { detailPath } from '../packages/util/plugin-detail/src/document.ts' import { PRODUCT_PACKAGE_POLICY } from './product-package-policy.ts' -import { SHIPPED_LOCALE_IDS, localeTokens } from './shipped-locales.ts' +import { SHIPPED_LOCALE_IDS, localeTokens, hasInvisibleLocaleCharacters } from './shipped-locales.ts' import { collectConfigCatalog } from './gen-config-catalog.ts' /** Flatten metadata strings and reject values that cannot be translated. */ @@ -35,7 +35,9 @@ export function localeMetadataProblems(root: string): string[] { try { const read = (language: string): Record => { const file = join(root, directory, `${language}.json`) - const value: unknown = JSON.parse(readFileSync(file, 'utf8')) + const source = readFileSync(file, 'utf8') + if (hasInvisibleLocaleCharacters(source) || /\\u(?:200b|feff)/iu.test(source)) throw new Error(`${language}.json: invisible locale character`) + const value: unknown = JSON.parse(source) const dictionary = strings(value) const detail = detailPath(dictionary['meta.detail'], `${directory}/${language}.json: meta.detail`) const packageDir = directory.slice(0, directory.lastIndexOf('/locale')) @@ -55,7 +57,8 @@ export function localeMetadataProblems(root: string): string[] { if (translated[key] === undefined) problems.push(`${directory}/${language}.json: missing ${key}`) else if (expected[key] === undefined) problems.push(`${directory}/${language}.json: unexpected ${key}`) else { - if (language !== 'ja' && key === 'meta.description' && /\p{Script=Han}/u.test(expected[key]) && translated[key] === expected[key]) { + if (language !== 'ja' && key === 'meta.description' && ((zh[key] !== undefined && /\p{Script=Han}/u.test(zh[key]) && translated[key] === zh[key]) + || (en[key] !== undefined && translated[key] === en[key]))) { problems.push(`${directory}/${language}.json: ${key} contains untranslated source text`) } const placeholders = (text: string): string => localeTokens(text).join(',') diff --git a/tsconfig.client.json b/tsconfig.client.json index cbca1a66a3..162b755731 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -39,6 +39,7 @@ "packages/extensions/ui-cordis/src/css-modules.d.ts", "packages/client/tsdown.client.ts", "scripts/client-build-environment.ts", + "scripts/shipped-locales.ts", "scripts/bundle-input-isolation.ts", "scripts/experimental-package-policy.ts", "scripts/*.client.spec.ts",