From ae4a337241e98d5ff01e70766db2bf251b9f360c Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Sun, 2 Aug 2026 21:55:30 -0400 Subject: [PATCH 001/256] fix(api): migrate language listings to /v3/languages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v2/languages and GET /v2/glossary-language-pairs are formally deprecated (2026-05-18); /v3/languages serves both via the resource parameter. One role-flagged list replaces the per-type and pairs contracts: source/target lists derive from usable_as_source/ usable_as_target, and glossary pairs from the source×target cross-product minus identity, which reproduces the v2 pair set exactly (verified live, 992 pairs, zero difference). The v3 response no longer reports formality support, so the [F] markers now come from a new supportsFormality field in the language registry, seeded from the last v2 target response. Command output is unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CHANGELOG.md | 4 + src/api/glossary-client.ts | 41 ++++-- src/api/translation-client.ts | 37 ++++-- src/data/language-registry.ts | 27 ++-- tests/e2e/mock-deepl-server.cjs | 30 ++--- tests/helpers/nock-setup.ts | 16 +-- .../deepl-client.integration.test.ts | 49 +++---- tests/unit/deepl-client.test.ts | 124 ++++++++++++------ tests/unit/glossary-client.test.ts | 38 +++++- tests/unit/language-registry.test.ts | 26 +++- tests/unit/translation-client.test.ts | 18 ++- 11 files changed, 269 insertions(+), 141 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94829214..7c1c8637 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **cli**: `deepl correct` command (alias `c`) — spelling and grammar correction without rewording, via the Write API's `/v2/write/correct` endpoint. Supports the same input handling and workflow flags as `write` (`--check` with exit code 8, `--fix`/`--backup`, `--diff`, `--interactive`, `--output`/`--in-place`, `--format json`, `--no-cache`), but not `--style`/`--tone`, which the correct endpoint does not accept. Results are cached under a separate `correct:` namespace so corrections and rephrasings of the same text never collide. +### Changed + +- **api**: Language listings migrated from the formally deprecated `GET /v2/languages` and `GET /v2/glossary-language-pairs` endpoints to `GET /v3/languages` (`resource=translate_text` / `resource=glossary`). Command output is unchanged: source/target lists derive from the v3 `usable_as_source`/`usable_as_target` flags, glossary pairs from the source×target cross-product (verified identical to the v2 pair list), and the `[F]` formality markers now come from the language registry since the v3 response no longer reports formality support. + - **http**: `NO_PROXY` / `no_proxy` are honoured, with the standard semantics — `*` for everything, a leading dot or `*.` for subdomains, and an optional `host:port` that must agree. A corporate `HTTPS_PROXY` was previously applied to every request, including one aimed at localhost. - **auth**: `deepl auth set-key --no-verify` stores a key without validating it against the API. Validation ran before persisting, so on a network without proxy configuration both documented setup paths — `auth set-key` and `init` — failed and discarded the key; an unreachable API now also names `DEEPL_API_KEY` as the zero-network alternative. - **ci**: `npm run check-deps` fails the build when a package imported by `src/` is missing from `dependencies`, including one declared only under `devDependencies`. It runs in CI and in the publish job, and matches package names as quoted strings so indirect loads such as `requireModule('php-parser')` count as references. diff --git a/src/api/glossary-client.ts b/src/api/glossary-client.ts index 323d9862..fc7efbfd 100644 --- a/src/api/glossary-client.ts +++ b/src/api/glossary-client.ts @@ -2,11 +2,10 @@ import { HttpClient, DeepLClientOptions } from './http-client.js'; import { Language, GlossaryInfo, GlossaryLanguagePair, normalizeGlossaryInfo, GlossaryApiResponse } from '../types/index.js'; import { ValidationError } from '../utils/errors.js'; -interface DeepLGlossaryLanguagePairsResponse { - supported_languages: Array<{ - source_lang: string; - target_lang: string; - }>; +interface DeepLV3GlossaryLanguageResponse { + lang: string; + usable_as_source?: boolean; + usable_as_target?: boolean; } export class GlossaryClient extends HttpClient { @@ -14,16 +13,36 @@ export class GlossaryClient extends HttpClient { super(apiKey, options); } + /** + * Lists glossary language pairs via GET /v3/languages?resource=glossary + * (the v2 pairs endpoint is deprecated). v3 returns one role-flagged + * language list instead of pairs; the source×target cross-product minus + * identity reproduces the v2 pair set exactly (verified live: 992 pairs, + * zero difference in either direction). + */ async getGlossaryLanguages(): Promise { - const response = await this.makeRequest( + const response = await this.makeRequest( 'GET', - '/v2/glossary-language-pairs' + '/v3/languages', + { resource: 'glossary' } ); - return response.supported_languages.map((pair) => ({ - sourceLang: this.normalizeLanguage(pair.source_lang), - targetLang: this.normalizeLanguage(pair.target_lang), - })); + const sources = response.filter((lang) => lang.usable_as_source); + const targets = response.filter((lang) => lang.usable_as_target); + + const pairs: GlossaryLanguagePair[] = []; + for (const source of sources) { + for (const target of targets) { + if (source.lang === target.lang) { + continue; + } + pairs.push({ + sourceLang: this.normalizeLanguage(source.lang), + targetLang: this.normalizeLanguage(target.lang), + }); + } + } + return pairs; } async createGlossary( diff --git a/src/api/translation-client.ts b/src/api/translation-client.ts index 21a62601..aaa31a99 100644 --- a/src/api/translation-client.ts +++ b/src/api/translation-client.ts @@ -2,6 +2,7 @@ import { HttpClient, DeepLClientOptions } from './http-client.js'; import { TranslationOptions, Language, TranslationMemory } from '../types/index.js'; import { NetworkError } from '../utils/errors.js'; import { normalizeFormality } from '../utils/formality.js'; +import { LANGUAGE_REGISTRY } from '../data/language-registry.js'; import { Logger } from '../utils/logger.js'; // DeepL's /v3/translation_memories endpoint paginates via `page` (0-indexed) and @@ -43,10 +44,11 @@ interface DeepLUsageResponse { }>; } -interface DeepLLanguageResponse { - language: string; +interface DeepLV3LanguageResponse { + lang: string; name: string; - supports_formality?: boolean; + usable_as_source?: boolean; + usable_as_target?: boolean; } export interface TranslationResult { @@ -273,21 +275,34 @@ export class TranslationClient extends HttpClient { } } + /** + * Lists languages via GET /v3/languages (v2 is deprecated). One response + * carries both roles as usable_as_source/usable_as_target flags, filtered + * here to preserve the per-type contract. The v3 response no longer reports + * formality support, so that comes from the static language registry. + */ async getSupportedLanguages( type: 'source' | 'target' ): Promise { try { - const response = await this.makeRequest( + const response = await this.makeRequest( 'GET', - '/v2/languages', - { type } + '/v3/languages', + { resource: 'translate_text' } ); - return response.map((lang) => ({ - language: this.normalizeLanguage(lang.language), - name: lang.name, - ...(lang.supports_formality !== undefined && { supportsFormality: lang.supports_formality }), - })); + return response + .filter((lang) => (type === 'source' ? lang.usable_as_source : lang.usable_as_target)) + .map((lang) => { + const code = this.normalizeLanguage(lang.lang); + return { + language: code, + name: lang.name, + ...(type === 'target' && { + supportsFormality: LANGUAGE_REGISTRY.get(code)?.supportsFormality ?? false, + }), + }; + }); } catch (error) { throw this.handleError(error); } diff --git a/src/data/language-registry.ts b/src/data/language-registry.ts index 47296ec2..f74024f6 100644 --- a/src/data/language-registry.ts +++ b/src/data/language-registry.ts @@ -24,12 +24,17 @@ export type LanguageCategory = 'core' | 'regional' | 'extended'; * @property category - Feature-availability tier * @property targetOnly - When true, the language can only be used as a translation target * (not as a source). Applies to regional variants like 'en-gb' and 'pt-br'. + * @property supportsFormality - When true, the language supports the formality + * parameter as a translation target. Static because GET /v3/languages does not + * report formality support (the v2 endpoint did); values mirror the last + * /v2/languages?type=target response (captured 2026-08-01). */ export interface LanguageEntry { code: string; name: string; category: LanguageCategory; targetOnly?: boolean; + supportsFormality?: boolean; } const ENTRIES: LanguageEntry[] = [ @@ -38,27 +43,27 @@ const ENTRIES: LanguageEntry[] = [ { code: 'bg', name: 'Bulgarian', category: 'core' }, { code: 'cs', name: 'Czech', category: 'core' }, { code: 'da', name: 'Danish', category: 'core' }, - { code: 'de', name: 'German', category: 'core' }, + { code: 'de', name: 'German', category: 'core', supportsFormality: true }, { code: 'el', name: 'Greek', category: 'core' }, { code: 'en', name: 'English', category: 'core' }, - { code: 'es', name: 'Spanish', category: 'core' }, + { code: 'es', name: 'Spanish', category: 'core', supportsFormality: true }, { code: 'et', name: 'Estonian', category: 'core' }, { code: 'fi', name: 'Finnish', category: 'core' }, - { code: 'fr', name: 'French', category: 'core' }, + { code: 'fr', name: 'French', category: 'core', supportsFormality: true }, { code: 'he', name: 'Hebrew', category: 'core' }, { code: 'hu', name: 'Hungarian', category: 'core' }, { code: 'id', name: 'Indonesian', category: 'core' }, - { code: 'it', name: 'Italian', category: 'core' }, - { code: 'ja', name: 'Japanese', category: 'core' }, + { code: 'it', name: 'Italian', category: 'core', supportsFormality: true }, + { code: 'ja', name: 'Japanese', category: 'core', supportsFormality: true }, { code: 'ko', name: 'Korean', category: 'core' }, { code: 'lt', name: 'Lithuanian', category: 'core' }, { code: 'lv', name: 'Latvian', category: 'core' }, { code: 'nb', name: 'Norwegian Bokmål', category: 'core' }, - { code: 'nl', name: 'Dutch', category: 'core' }, - { code: 'pl', name: 'Polish', category: 'core' }, + { code: 'nl', name: 'Dutch', category: 'core', supportsFormality: true }, + { code: 'pl', name: 'Polish', category: 'core', supportsFormality: true }, { code: 'pt', name: 'Portuguese', category: 'core' }, { code: 'ro', name: 'Romanian', category: 'core' }, - { code: 'ru', name: 'Russian', category: 'core' }, + { code: 'ru', name: 'Russian', category: 'core', supportsFormality: true }, { code: 'sk', name: 'Slovak', category: 'core' }, { code: 'sl', name: 'Slovenian', category: 'core' }, { code: 'sv', name: 'Swedish', category: 'core' }, @@ -70,9 +75,9 @@ const ENTRIES: LanguageEntry[] = [ // Regional variants (target-only) { code: 'en-gb', name: 'English (British)', category: 'regional', targetOnly: true }, { code: 'en-us', name: 'English (American)', category: 'regional', targetOnly: true }, - { code: 'es-419', name: 'Spanish (Latin America)', category: 'regional', targetOnly: true }, - { code: 'pt-br', name: 'Portuguese (Brazilian)', category: 'regional', targetOnly: true }, - { code: 'pt-pt', name: 'Portuguese (European)', category: 'regional', targetOnly: true }, + { code: 'es-419', name: 'Spanish (Latin America)', category: 'regional', targetOnly: true, supportsFormality: true }, + { code: 'pt-br', name: 'Portuguese (Brazilian)', category: 'regional', targetOnly: true, supportsFormality: true }, + { code: 'pt-pt', name: 'Portuguese (European)', category: 'regional', targetOnly: true, supportsFormality: true }, { code: 'zh-hans', name: 'Chinese (Simplified)', category: 'regional', targetOnly: true }, { code: 'zh-hant', name: 'Chinese (Traditional)', category: 'regional', targetOnly: true }, diff --git a/tests/e2e/mock-deepl-server.cjs b/tests/e2e/mock-deepl-server.cjs index 30ce5f26..5c96f69a 100644 --- a/tests/e2e/mock-deepl-server.cjs +++ b/tests/e2e/mock-deepl-server.cjs @@ -201,27 +201,15 @@ function handleRequest(req, res, body) { return; } - if (method === 'GET' && url.startsWith('/v2/languages')) { - const parsedUrl = new URL(url, 'http://127.0.0.1'); - const type = parsedUrl.searchParams.get('type'); - - var languages; - if (type === 'source') { - languages = [ - { language: 'EN', name: 'English' }, - { language: 'DE', name: 'German' }, - { language: 'FR', name: 'French' }, - { language: 'ES', name: 'Spanish' }, - ]; - } else { - languages = [ - { language: 'EN-US', name: 'English (American)', supports_formality: false }, - { language: 'EN-GB', name: 'English (British)', supports_formality: false }, - { language: 'DE', name: 'German', supports_formality: true }, - { language: 'FR', name: 'French', supports_formality: true }, - { language: 'ES', name: 'Spanish', supports_formality: true }, - ]; - } + if (method === 'GET' && url.startsWith('/v3/languages')) { + var languages = [ + { lang: 'en', name: 'English', usable_as_source: true, usable_as_target: true }, + { lang: 'de', name: 'German', usable_as_source: true, usable_as_target: true }, + { lang: 'fr', name: 'French', usable_as_source: true, usable_as_target: true }, + { lang: 'es', name: 'Spanish', usable_as_source: true, usable_as_target: true }, + { lang: 'en-us', name: 'English (American)', usable_as_source: false, usable_as_target: true }, + { lang: 'en-gb', name: 'English (British)', usable_as_source: false, usable_as_target: true }, + ]; res.writeHead(200); res.end(JSON.stringify(languages)); diff --git a/tests/helpers/nock-setup.ts b/tests/helpers/nock-setup.ts index 1f86ed13..509f2f1b 100644 --- a/tests/helpers/nock-setup.ts +++ b/tests/helpers/nock-setup.ts @@ -41,17 +41,17 @@ export function mockAuthError(scope: nock.Scope): nock.Scope { export function mockLanguagesResponse( scope: nock.Scope, - languages: Array<{ language: string; name: string; supports_formality?: boolean }> = [ - { language: 'DE', name: 'German', supports_formality: true }, - { language: 'EN', name: 'English', supports_formality: false }, - { language: 'ES', name: 'Spanish', supports_formality: true }, - { language: 'FR', name: 'French', supports_formality: true }, + languages: Array<{ lang: string; name: string; usable_as_source?: boolean; usable_as_target?: boolean }> = [ + { lang: 'de', name: 'German', usable_as_source: true, usable_as_target: true }, + { lang: 'en', name: 'English', usable_as_source: true, usable_as_target: true }, + { lang: 'es', name: 'Spanish', usable_as_source: true, usable_as_target: true }, + { lang: 'fr', name: 'French', usable_as_source: true, usable_as_target: true }, ], - type: 'source' | 'target' = 'target', + resource: string = 'translate_text', ): nock.Scope { return scope - .get('/v2/languages') - .query({ type }) + .get('/v3/languages') + .query({ resource }) .reply(200, languages); } diff --git a/tests/integration/deepl-client.integration.test.ts b/tests/integration/deepl-client.integration.test.ts index 2b15bc15..581ceb7e 100644 --- a/tests/integration/deepl-client.integration.test.ts +++ b/tests/integration/deepl-client.integration.test.ts @@ -487,16 +487,17 @@ describe('DeepLClient Integration', () => { }); describe('getSupportedLanguages()', () => { - it('should make correct HTTP GET request for source languages', async () => { + it('should make correct HTTP GET request and filter source languages', async () => { const client = new DeepLClient(API_KEY); clients.push(client); const scope = nock(FREE_API_URL) - .get('/v2/languages') - .query({ type: 'source' }) + .get('/v3/languages') + .query({ resource: 'translate_text' }) .reply(200, [ - { language: 'EN', name: 'English' }, - { language: 'DE', name: 'German' }, + { lang: 'en', name: 'English', usable_as_source: true, usable_as_target: true }, + { lang: 'de', name: 'German', usable_as_source: true, usable_as_target: true }, + { lang: 'en-gb', name: 'English (British)', usable_as_source: false, usable_as_target: true }, ]); const result = await client.getSupportedLanguages('source'); @@ -508,23 +509,23 @@ describe('DeepLClient Integration', () => { expect(scope.isDone()).toBe(true); }); - it('should make correct HTTP GET request for target languages', async () => { + it('should filter target languages from the same response shape', async () => { const client = new DeepLClient(API_KEY); clients.push(client); const scope = nock(FREE_API_URL) - .get('/v2/languages') - .query({ type: 'target' }) + .get('/v3/languages') + .query({ resource: 'translate_text' }) .reply(200, [ - { language: 'ES', name: 'Spanish' }, - { language: 'FR', name: 'French' }, + { lang: 'es', name: 'Spanish', usable_as_source: true, usable_as_target: true }, + { lang: 'fr', name: 'French', usable_as_source: true, usable_as_target: true }, ]); const result = await client.getSupportedLanguages('target'); expect(result).toEqual([ - { language: 'es', name: 'Spanish' }, - { language: 'fr', name: 'French' }, + { language: 'es', name: 'Spanish', supportsFormality: true }, + { language: 'fr', name: 'French', supportsFormality: true }, ]); expect(scope.isDone()).toBe(true); }); @@ -534,25 +535,25 @@ describe('DeepLClient Integration', () => { clients.push(client); nock(FREE_API_URL) - .get('/v2/languages') - .query({ type: 'source' }) - .reply(200, [{ language: 'EN-US', name: 'English (American)' }]); + .get('/v3/languages') + .query({ resource: 'translate_text' }) + .reply(200, [{ lang: 'EN-US', name: 'English (American)', usable_as_source: true }]); const result = await client.getSupportedLanguages('source'); expect(result[0]?.language).toBe('en-us'); }); - it('should parse supports_formality for target languages', async () => { + it('should source formality support from the registry for targets', async () => { const client = new DeepLClient(API_KEY); clients.push(client); nock(FREE_API_URL) - .get('/v2/languages') - .query({ type: 'target' }) + .get('/v3/languages') + .query({ resource: 'translate_text' }) .reply(200, [ - { language: 'DE', name: 'German', supports_formality: true }, - { language: 'EN-US', name: 'English (American)', supports_formality: false }, + { lang: 'de', name: 'German', usable_as_source: true, usable_as_target: true }, + { lang: 'en-us', name: 'English (American)', usable_as_source: false, usable_as_target: true }, ]); const result = await client.getSupportedLanguages('target'); @@ -561,14 +562,14 @@ describe('DeepLClient Integration', () => { expect(result[1]?.supportsFormality).toBe(false); }); - it('should omit supportsFormality when not in response', async () => { + it('should omit supportsFormality for source languages', async () => { const client = new DeepLClient(API_KEY); clients.push(client); nock(FREE_API_URL) - .get('/v2/languages') - .query({ type: 'source' }) - .reply(200, [{ language: 'EN', name: 'English' }]); + .get('/v3/languages') + .query({ resource: 'translate_text' }) + .reply(200, [{ lang: 'en', name: 'English', usable_as_source: true }]); const result = await client.getSupportedLanguages('source'); diff --git a/tests/unit/deepl-client.test.ts b/tests/unit/deepl-client.test.ts index cc66a25c..39939bb5 100644 --- a/tests/unit/deepl-client.test.ts +++ b/tests/unit/deepl-client.test.ts @@ -812,11 +812,12 @@ describe('DeepLClient', () => { describe('getSupportedLanguages()', () => { it('should return supported source languages', async () => { nock(baseUrl) - .get('/v2/languages') - .query({ type: 'source' }) + .get('/v3/languages') + .query({ resource: 'translate_text' }) .reply(200, [ - { language: 'EN', name: 'English' }, - { language: 'ES', name: 'Spanish' }, + { lang: 'en', name: 'English', usable_as_source: true, usable_as_target: true }, + { lang: 'es', name: 'Spanish', usable_as_source: true, usable_as_target: true }, + { lang: 'en-gb', name: 'English (British)', usable_as_source: false, usable_as_target: true }, ]); const languages = await client.getSupportedLanguages('source'); @@ -824,15 +825,16 @@ describe('DeepLClient', () => { expect(languages).toHaveLength(2); expect(languages[0]?.language).toBe('en'); expect(languages[0]?.name).toBe('English'); + expect(languages[0]?.supportsFormality).toBeUndefined(); }); it('should return supported target languages', async () => { nock(baseUrl) - .get('/v2/languages') - .query({ type: 'target' }) + .get('/v3/languages') + .query({ resource: 'translate_text' }) .reply(200, [ - { language: 'ES', name: 'Spanish' }, - { language: 'FR', name: 'French' }, + { lang: 'es', name: 'Spanish', usable_as_source: true, usable_as_target: true }, + { lang: 'fr', name: 'French', usable_as_source: true, usable_as_target: true }, ]); const languages = await client.getSupportedLanguages('target'); @@ -841,10 +843,42 @@ describe('DeepLClient', () => { expect(languages[0]?.language).toBe('es'); }); + it('should exclude source-only languages from target results', async () => { + nock(baseUrl) + .get('/v3/languages') + .query({ resource: 'translate_text' }) + .reply(200, [ + { lang: 'de', name: 'German', usable_as_source: true, usable_as_target: true }, + { lang: 'xx', name: 'Source Only', usable_as_source: true, usable_as_target: false }, + ]); + + const languages = await client.getSupportedLanguages('target'); + + expect(languages).toHaveLength(1); + expect(languages[0]?.language).toBe('de'); + }); + + it('should mark formality support on targets from the registry', async () => { + nock(baseUrl) + .get('/v3/languages') + .query({ resource: 'translate_text' }) + .reply(200, [ + { lang: 'de', name: 'German', usable_as_source: true, usable_as_target: true }, + { lang: 'ja', name: 'Japanese', usable_as_source: true, usable_as_target: true }, + { lang: 'ko', name: 'Korean', usable_as_source: true, usable_as_target: true }, + ]); + + const languages = await client.getSupportedLanguages('target'); + + expect(languages.find(l => l.language === 'de')?.supportsFormality).toBe(true); + expect(languages.find(l => l.language === 'ja')?.supportsFormality).toBe(true); + expect(languages.find(l => l.language === 'ko')?.supportsFormality).toBe(false); + }); + it('should handle language API errors', async () => { nock(baseUrl) - .get('/v2/languages') - .query({ type: 'source' }) + .get('/v3/languages') + .query({ resource: 'translate_text' }) .reply(500); await expect( @@ -1891,50 +1925,59 @@ describe('DeepLClient', () => { }); describe('getGlossaryLanguages()', () => { - it('should return supported glossary language pairs', async () => { + it('should derive pairs as the cross-product minus identity', async () => { nock(baseUrl) - .get('/v2/glossary-language-pairs') - .reply(200, { - supported_languages: [ - { source_lang: 'en', target_lang: 'de' }, - { source_lang: 'de', target_lang: 'en' }, - { source_lang: 'en', target_lang: 'fr' }, - ], - }); + .get('/v3/languages') + .query({ resource: 'glossary' }) + .reply(200, [ + { lang: 'en', name: 'English', usable_as_source: true, usable_as_target: true }, + { lang: 'de', name: 'German', usable_as_source: true, usable_as_target: true }, + { lang: 'fr', name: 'French', usable_as_source: true, usable_as_target: true }, + ]); + + const pairs = await client.getGlossaryLanguages(); + + // 3 languages, both roles each: 3×3 minus 3 identity pairs + expect(pairs).toHaveLength(6); + expect(pairs).toContainEqual({ sourceLang: 'en', targetLang: 'de' }); + expect(pairs).toContainEqual({ sourceLang: 'de', targetLang: 'en' }); + expect(pairs).toContainEqual({ sourceLang: 'en', targetLang: 'fr' }); + expect(pairs).not.toContainEqual({ sourceLang: 'en', targetLang: 'en' }); + }); + + it('should respect role flags when building pairs', async () => { + nock(baseUrl) + .get('/v3/languages') + .query({ resource: 'glossary' }) + .reply(200, [ + { lang: 'en', name: 'English', usable_as_source: true, usable_as_target: true }, + { lang: 'xx', name: 'Target Only', usable_as_source: false, usable_as_target: true }, + ]); const pairs = await client.getGlossaryLanguages(); - expect(pairs).toHaveLength(3); - expect(pairs[0]?.sourceLang).toBe('en'); - expect(pairs[0]?.targetLang).toBe('de'); - expect(pairs[1]?.sourceLang).toBe('de'); - expect(pairs[1]?.targetLang).toBe('en'); + expect(pairs).toEqual([{ sourceLang: 'en', targetLang: 'xx' }]); }); it('should normalize language codes to lowercase', async () => { nock(baseUrl) - .get('/v2/glossary-language-pairs') - .reply(200, { - supported_languages: [ - { source_lang: 'EN', target_lang: 'DE' }, - { source_lang: 'EN-US', target_lang: 'ES' }, - ], - }); + .get('/v3/languages') + .query({ resource: 'glossary' }) + .reply(200, [ + { lang: 'EN', name: 'English', usable_as_source: true, usable_as_target: false }, + { lang: 'DE', name: 'German', usable_as_source: false, usable_as_target: true }, + ]); const pairs = await client.getGlossaryLanguages(); - expect(pairs[0]?.sourceLang).toBe('en'); - expect(pairs[0]?.targetLang).toBe('de'); - expect(pairs[1]?.sourceLang).toBe('en-us'); - expect(pairs[1]?.targetLang).toBe('es'); + expect(pairs).toEqual([{ sourceLang: 'en', targetLang: 'de' }]); }); it('should handle empty response', async () => { nock(baseUrl) - .get('/v2/glossary-language-pairs') - .reply(200, { - supported_languages: [], - }); + .get('/v3/languages') + .query({ resource: 'glossary' }) + .reply(200, []); const pairs = await client.getGlossaryLanguages(); @@ -1943,7 +1986,8 @@ describe('DeepLClient', () => { it('should handle API errors', async () => { nock(baseUrl) - .get('/v2/glossary-language-pairs') + .get('/v3/languages') + .query({ resource: 'glossary' }) .reply(403, { message: 'Authentication failed', }); diff --git a/tests/unit/glossary-client.test.ts b/tests/unit/glossary-client.test.ts index af5ec197..a036daac 100644 --- a/tests/unit/glossary-client.test.ts +++ b/tests/unit/glossary-client.test.ts @@ -41,14 +41,13 @@ describe('GlossaryClient', () => { }); describe('getGlossaryLanguages()', () => { - it('should return supported glossary language pairs', async () => { + it('should derive language pairs from the v3 role-flagged list', async () => { mockAxiosInstance.request.mockResolvedValue({ - data: { - supported_languages: [ - { source_lang: 'EN', target_lang: 'DE' }, - { source_lang: 'EN', target_lang: 'FR' }, - ], - }, + data: [ + { lang: 'en', name: 'English', usable_as_source: true, usable_as_target: false }, + { lang: 'de', name: 'German', usable_as_source: false, usable_as_target: true }, + { lang: 'fr', name: 'French', usable_as_source: false, usable_as_target: true }, + ], status: 200, headers: {}, }); @@ -59,6 +58,31 @@ describe('GlossaryClient', () => { { sourceLang: 'en', targetLang: 'de' }, { sourceLang: 'en', targetLang: 'fr' }, ]); + expect(mockAxiosInstance.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + url: '/v3/languages', + params: { resource: 'glossary' }, + }), + ); + }); + + it('should exclude identity pairs', async () => { + mockAxiosInstance.request.mockResolvedValue({ + data: [ + { lang: 'en', name: 'English', usable_as_source: true, usable_as_target: true }, + { lang: 'de', name: 'German', usable_as_source: true, usable_as_target: true }, + ], + status: 200, + headers: {}, + }); + + const result = await client.getGlossaryLanguages(); + + expect(result).toEqual([ + { sourceLang: 'en', targetLang: 'de' }, + { sourceLang: 'de', targetLang: 'en' }, + ]); }); it('should handle API errors', async () => { diff --git a/tests/unit/language-registry.test.ts b/tests/unit/language-registry.test.ts index 47624feb..3926add9 100644 --- a/tests/unit/language-registry.test.ts +++ b/tests/unit/language-registry.test.ts @@ -66,13 +66,13 @@ describe('Language Registry', () => { describe('specific language entries', () => { it('should include known core languages', () => { expect(LANGUAGE_REGISTRY.get('en')).toEqual({ code: 'en', name: 'English', category: 'core' }); - expect(LANGUAGE_REGISTRY.get('de')).toEqual({ code: 'de', name: 'German', category: 'core' }); - expect(LANGUAGE_REGISTRY.get('ja')).toEqual({ code: 'ja', name: 'Japanese', category: 'core' }); + expect(LANGUAGE_REGISTRY.get('de')).toEqual({ code: 'de', name: 'German', category: 'core', supportsFormality: true }); + expect(LANGUAGE_REGISTRY.get('ja')).toEqual({ code: 'ja', name: 'Japanese', category: 'core', supportsFormality: true }); }); it('should include known regional variants', () => { expect(LANGUAGE_REGISTRY.get('en-gb')).toEqual({ code: 'en-gb', name: 'English (British)', category: 'regional', targetOnly: true }); - expect(LANGUAGE_REGISTRY.get('pt-br')).toEqual({ code: 'pt-br', name: 'Portuguese (Brazilian)', category: 'regional', targetOnly: true }); + expect(LANGUAGE_REGISTRY.get('pt-br')).toEqual({ code: 'pt-br', name: 'Portuguese (Brazilian)', category: 'regional', targetOnly: true, supportsFormality: true }); }); it('should include known extended languages', () => { @@ -217,4 +217,24 @@ describe('Language Registry', () => { expect(codes.has('en-gb')).toBe(false); }); }); + + describe('supportsFormality', () => { + it('should mark the formality-capable languages', () => { + for (const code of ['de', 'es', 'es-419', 'fr', 'it', 'ja', 'nl', 'pl', 'pt-br', 'pt-pt', 'ru']) { + expect(LANGUAGE_REGISTRY.get(code)?.supportsFormality).toBe(true); + } + }); + + it('should leave non-formality languages unmarked', () => { + for (const code of ['en', 'en-us', 'ko', 'zh', 'ar', 'ace']) { + expect(LANGUAGE_REGISTRY.get(code)?.supportsFormality).toBeUndefined(); + } + }); + + it('should never mark an extended language', () => { + getExtendedLanguageCodes().forEach(code => { + expect(LANGUAGE_REGISTRY.get(code)?.supportsFormality).toBeUndefined(); + }); + }); + }); }); diff --git a/tests/unit/translation-client.test.ts b/tests/unit/translation-client.test.ts index 3952e2e2..a6128519 100644 --- a/tests/unit/translation-client.test.ts +++ b/tests/unit/translation-client.test.ts @@ -328,8 +328,9 @@ describe('TranslationClient', () => { it('should return source languages', async () => { mockAxiosInstance.request.mockResolvedValue({ data: [ - { language: 'EN', name: 'English' }, - { language: 'DE', name: 'German', supports_formality: true }, + { lang: 'en', name: 'English', usable_as_source: true, usable_as_target: true }, + { lang: 'de', name: 'German', usable_as_source: true, usable_as_target: true }, + { lang: 'en-gb', name: 'English (British)', usable_as_source: false, usable_as_target: true }, ], status: 200, headers: {}, @@ -340,13 +341,19 @@ describe('TranslationClient', () => { expect(result).toHaveLength(2); expect(result[0]!.language).toBe('en'); expect(result[0]!.name).toBe('English'); - expect(result[1]!.supportsFormality).toBe(true); + expect(mockAxiosInstance.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + url: '/v3/languages', + params: { resource: 'translate_text' }, + }), + ); }); - it('should return target languages', async () => { + it('should return target languages with registry-sourced formality', async () => { mockAxiosInstance.request.mockResolvedValue({ data: [ - { language: 'ES', name: 'Spanish', supports_formality: true }, + { lang: 'es', name: 'Spanish', usable_as_source: true, usable_as_target: true }, ], status: 200, headers: {}, @@ -356,6 +363,7 @@ describe('TranslationClient', () => { expect(result).toHaveLength(1); expect(result[0]!.language).toBe('es'); + expect(result[0]!.supportsFormality).toBe(true); }); }); From e3520a5cffdfed865b2a31dbbeb004d2925c4f86 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Sun, 2 Aug 2026 22:43:43 -0400 Subject: [PATCH 002/256] feat(cli)!: remove retired enable_beta_languages flag and dead usage fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: --enable-beta-languages is removed from translate. The API deprecated the parameter with "has no effect" — beta languages are part of the regular language set — so the flag had become a silent no-op; scripts passing it now exit with an unknown-option error. Also removes the always-zero speech_to_text_milliseconds_count/_limit handling from GET /v2/usage: the dedicated Speech-to-Text section and table row could only ever display zero. Voice usage stays visible in the Product Breakdown, which reads the live per-product minutes data. The Admin API's per-key speech_to_text_milliseconds usage limit is a different, still-current field and is untouched. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CHANGELOG.md | 5 ++ README.md | 3 - docs/API.md | 1 - examples/29-advanced-translate.sh | 18 +---- src/api/document-client.ts | 3 - src/api/translation-client.ts | 13 ---- src/cli/commands/register-translate.ts | 2 - .../translate/text-translation-handler.ts | 3 - src/cli/commands/translate/types.ts | 1 - src/cli/commands/usage.ts | 31 -------- src/types/api.ts | 2 - .../cli-translate.integration.test.ts | 23 ++---- tests/unit/deepl-client-document.test.ts | 30 +------- tests/unit/deepl-client.test.ts | 75 ++----------------- tests/unit/translation-client.test.ts | 4 - tests/unit/translation-service.test.ts | 13 ---- tests/unit/usage-command.test.ts | 55 ++------------ 17 files changed, 27 insertions(+), 255 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c1c8637..6b906e60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **api**: Language listings migrated from the formally deprecated `GET /v2/languages` and `GET /v2/glossary-language-pairs` endpoints to `GET /v3/languages` (`resource=translate_text` / `resource=glossary`). Command output is unchanged: source/target lists derive from the v3 `usable_as_source`/`usable_as_target` flags, glossary pairs from the source×target cross-product (verified identical to the v2 pair list), and the `[F]` formality markers now come from the language registry since the v3 response no longer reports formality support. +### Removed + +- **cli**: **BREAKING**: The `--enable-beta-languages` flag on `translate` is gone. The API deprecated the underlying `enable_beta_languages` parameter with "has no effect" — beta languages are simply part of the regular language set now — so the flag had become a silent no-op. Scripts passing it will exit with an unknown-option error; remove the flag. +- **usage**: The dedicated "Speech-to-Text Usage" section (text output) and "Speech-to-text" row (table output) are gone, along with the `speechToTextMilliseconds*` fields they read. The API deprecated `speech_to_text_milliseconds_count`/`_limit` on `GET /v2/usage` ("Always returns 0"), so the section could only ever display zero. Voice usage remains visible in the Product Breakdown, which reads the live per-product minutes data. The Admin API's per-key `speech_to_text_milliseconds` usage limit is a different, still-current field and is unaffected. + - **http**: `NO_PROXY` / `no_proxy` are honoured, with the standard semantics — `*` for everything, a leading dot or `*.` for subdomains, and an optional `host:port` that must agree. A corporate `HTTPS_PROXY` was previously applied to every request, including one aimed at localhost. - **auth**: `deepl auth set-key --no-verify` stores a key without validating it against the API. Validation ran before persisting, so on a network without proxy configuration both documented setup paths — `auth set-key` and `init` — failed and discarded the key; an unreachable API now also names `DEEPL_API_KEY` as the zero-network alternative. - **ci**: `npm run check-deps` fails the build when a package imported by `src/` is missing from `dependencies`, including one declared only under `devDependencies`. It runs in CI and in the publish job, and matches package names as quoted strings so indirect loads such as `requireModule('php-parser')` count as references. diff --git a/README.md b/README.md index c09aba58..4327076d 100644 --- a/README.md +++ b/README.md @@ -533,9 +533,6 @@ deepl translate "Cost analysis" --to es,fr,de --format table --show-billed-chara # Preview what would be translated without making API calls (file/directory mode) deepl translate ./docs --to es --dry-run -# Include beta languages that are not yet stable -deepl translate "Hello" --to my --enable-beta-languages - # Specify tag handling version (v2 improves structure handling, requires --tag-handling) deepl translate page.html --to es --tag-handling html --tag-handling-version v2 diff --git a/docs/API.md b/docs/API.md index b8995b50..cae4895a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -256,7 +256,6 @@ Translate text directly, from stdin, from files, or entire directories. Supports - `--tm-threshold N` - Minimum match score 0–100 (default 75, requires `--translation-memory`). Invalid use exits 6 (ValidationError); unresolvable/misconfigured TM exits 7 (ConfigError). - `--custom-instruction INSTRUCTION` - Custom instruction for translation (repeatable, max 10, max 300 chars each). Forces `quality_optimized` model. Cannot be used with `latency_optimized`. - `--style-id UUID` - Style rule ID for translation (Pro API only). Forces `quality_optimized` model. Cannot be used with `latency_optimized`. Use `deepl style-rules list` to see available IDs. -- `--enable-beta-languages` - Include beta languages that are not yet stable (forward-compatibility with new DeepL languages) - `--no-cache` - Bypass cache for this translation (useful for testing/forcing fresh translation) - `--dry-run` - Show what would be translated without performing the operation diff --git a/examples/29-advanced-translate.sh b/examples/29-advanced-translate.sh index 0b8777ee..4054c948 100755 --- a/examples/29-advanced-translate.sh +++ b/examples/29-advanced-translate.sh @@ -54,17 +54,11 @@ echo " --tag-handling-version v2 Improved structure handling" echo " (requires --tag-handling xml or --tag-handling html)" echo -# Example 2: Beta languages -echo "=== 2. Beta Languages ===" +# Example 2: Listing languages +echo "=== 2. Listing Languages ===" echo -echo "Include beta languages that are not yet stable:" -echo " deepl translate 'Hello world' --to ar --enable-beta-languages" -echo -echo "Beta languages may have lower quality but provide forward-compatibility" -echo "as DeepL adds support for new languages." -echo -echo "List available languages (including beta):" +echo "List available languages:" echo " deepl languages --source" echo " deepl languages --target" echo @@ -151,8 +145,7 @@ EOF echo "Combining multiple advanced flags:" echo " deepl translate complex.html --to de \\" echo " --tag-handling html --tag-handling-version v2 \\" -echo " --formality more --preserve-code \\" -echo " --enable-beta-languages" +echo " --formality more --preserve-code" echo deepl translate "$TEMP_DIR/complex.html" --to de --tag-handling html --tag-handling-version v2 --formality more --preserve-code --output "$TEMP_DIR/complex.de.html" echo @@ -166,9 +159,6 @@ echo "Tag handling:" echo " --tag-handling Enable tag handling mode" echo " --tag-handling-version Tag handling version (v2 = improved)" echo -echo "Beta languages:" -echo " --enable-beta-languages Include unstable/beta languages" -echo echo "API endpoint:" echo " --api-url Custom API endpoint URL" echo diff --git a/src/api/document-client.ts b/src/api/document-client.ts index 6849094b..9e52804f 100644 --- a/src/api/document-client.ts +++ b/src/api/document-client.ts @@ -67,9 +67,6 @@ export class DocumentClient extends HttpClient { if (options.enableDocumentMinification) { formData.append('enable_document_minification', '1'); } - if (options.enableBetaLanguages) { - formData.append('enable_beta_languages', '1'); - } return { data: formData, diff --git a/src/api/translation-client.ts b/src/api/translation-client.ts index aaa31a99..97cb47d6 100644 --- a/src/api/translation-client.ts +++ b/src/api/translation-client.ts @@ -30,8 +30,6 @@ interface DeepLUsageResponse { api_key_unit_limit?: number; account_unit_count?: number; account_unit_limit?: number; - speech_to_text_milliseconds_count?: number; - speech_to_text_milliseconds_limit?: number; start_time?: string; end_time?: string; products?: Array<{ @@ -86,8 +84,6 @@ export interface UsageInfo { apiKeyUnitLimit?: number; accountUnitCount?: number; accountUnitLimit?: number; - speechToTextMillisecondsCount?: number; - speechToTextMillisecondsLimit?: number; startTime?: string; endTime?: string; products?: ProductUsage[]; @@ -201,12 +197,6 @@ export class TranslationClient extends HttpClient { if (response.end_time) { usage.endTime = response.end_time; } - if (response.speech_to_text_milliseconds_count !== undefined) { - usage.speechToTextMillisecondsCount = response.speech_to_text_milliseconds_count; - } - if (response.speech_to_text_milliseconds_limit !== undefined) { - usage.speechToTextMillisecondsLimit = response.speech_to_text_milliseconds_limit; - } if (response.api_key_unit_count !== undefined) { usage.apiKeyUnitCount = response.api_key_unit_count; } @@ -387,9 +377,6 @@ export class TranslationClient extends HttpClient { params['tag_handling_version'] = options.tagHandlingVersion; } - if (options.enableBetaLanguages) { - params['enable_beta_languages'] = '1'; - } return params; } diff --git a/src/cli/commands/register-translate.ts b/src/cli/commands/register-translate.ts index 9e0c56f4..da6c6c75 100644 --- a/src/cli/commands/register-translate.ts +++ b/src/cli/commands/register-translate.ts @@ -67,7 +67,6 @@ export function registerTranslate( .addOption(new Option('--format ', 'Output format').choices(['text', 'json', 'table']).default('text')) .option('--show-billed-characters', 'Request and display actual billed character count for cost transparency') .optionsGroup('Advanced:') - .option('--enable-beta-languages', 'Include beta languages that are not yet stable (forward-compatibility)') .option('--no-cache', 'Bypass cache for this translation (useful for testing)') .option('--api-url ', 'Custom API endpoint (e.g., https://api-free.deepl.com/v2 or internal test URLs)') .addHelpText('after', ` @@ -109,7 +108,6 @@ Examples: tmThreshold?: number; customInstruction?: string[]; styleId?: string; - enableBetaLanguages?: boolean; format?: string; apiUrl?: string; dryRun?: boolean; diff --git a/src/cli/commands/translate/text-translation-handler.ts b/src/cli/commands/translate/text-translation-handler.ts index 8597ccbe..0c36e9c1 100644 --- a/src/cli/commands/translate/text-translation-handler.ts +++ b/src/cli/commands/translate/text-translation-handler.ts @@ -137,9 +137,6 @@ export class TextTranslationHandler { translationOptions.tagHandlingVersion = options.tagHandlingVersion; } - if (options.enableBetaLanguages) { - translationOptions.enableBetaLanguages = true; - } const result = await this.ctx.translationService.translate( text, diff --git a/src/cli/commands/translate/types.ts b/src/cli/commands/translate/types.ts index 7f4f510d..ff2e8343 100644 --- a/src/cli/commands/translate/types.ts +++ b/src/cli/commands/translate/types.ts @@ -32,7 +32,6 @@ export interface TranslateOptions { tmThreshold?: number; customInstruction?: string[]; styleId?: string; - enableBetaLanguages?: boolean; tagHandlingVersion?: string; cache?: boolean; format?: string; diff --git a/src/cli/commands/usage.ts b/src/cli/commands/usage.ts index 606bd8e9..dba29db0 100644 --- a/src/cli/commands/usage.ts +++ b/src/cli/commands/usage.ts @@ -103,27 +103,6 @@ export class UsageCommand { lines.push(` Used: ${formatNumber(usage.apiKeyCharacterCount)} / ${limitStr}`); } - if (usage.speechToTextMillisecondsCount !== undefined) { - const sttCount = usage.speechToTextMillisecondsCount; - const sttLimit = usage.speechToTextMillisecondsLimit ?? 0; - const sttPercentage = sttLimit > 0 - ? ((sttCount / sttLimit) * 100).toFixed(1) - : '0.0'; - const sttRemaining = sttLimit - sttCount; - const isHighStt = sttLimit > 0 && (sttCount / sttLimit) > 0.8; - - lines.push(''); - lines.push(chalk.bold('Speech-to-Text Usage:')); - const sttColor = isHighStt ? chalk.yellow : chalk.green; - lines.push(` Used: ${sttColor(this.formatMilliseconds(sttCount))} / ${this.formatMilliseconds(sttLimit)} (${sttColor(sttPercentage + '%')})`); - lines.push(` Remaining: ${this.formatMilliseconds(sttRemaining)}`); - - if (isHighStt) { - lines.push(''); - lines.push(chalk.yellow('Warning: You are approaching your speech-to-text limit')); - } - } - if (usage.products && usage.products.length > 0) { lines.push(''); lines.push(chalk.bold('Product Breakdown:')); @@ -207,16 +186,6 @@ export class UsageCommand { ]); } - if (usage.speechToTextMillisecondsCount !== undefined) { - const sttLimit = usage.speechToTextMillisecondsLimit ?? 0; - table.push([ - 'Speech-to-text', - this.formatMilliseconds(usage.speechToTextMillisecondsCount), - sttLimit === 0 ? 'unlimited' : this.formatMilliseconds(sttLimit), - pct(usage.speechToTextMillisecondsCount, sttLimit), - ]); - } - let output = table.toString(); if (usage.products && usage.products.length > 0) { diff --git a/src/types/api.ts b/src/types/api.ts index ce08378a..a69538c3 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -30,7 +30,6 @@ export interface TranslationOptions { customInstructions?: string[]; styleId?: string; tagHandlingVersion?: 'v1' | 'v2'; - enableBetaLanguages?: boolean; } export interface TranslationMemory { @@ -134,7 +133,6 @@ export interface DocumentTranslationOptions { glossaryId?: string; outputFormat?: DocumentOutputFormat; enableDocumentMinification?: boolean; - enableBetaLanguages?: boolean; } // Glossary Types diff --git a/tests/integration/cli-translate.integration.test.ts b/tests/integration/cli-translate.integration.test.ts index af2217f4..33065663 100644 --- a/tests/integration/cli-translate.integration.test.ts +++ b/tests/integration/cli-translate.integration.test.ts @@ -718,13 +718,13 @@ describe('Translate CLI Integration', () => { }); }); - describe('--enable-beta-languages flag', () => { - it('should show --enable-beta-languages in help text', () => { + describe('--enable-beta-languages flag (removed)', () => { + it('should not appear in help text', () => { const result = translateHelp; - expect(result).toContain('--enable-beta-languages'); + expect(result).not.toContain('--enable-beta-languages'); }); - it('should accept --enable-beta-languages flag', () => { + it('should be rejected as an unknown option', () => { expect.assertions(1); try { runCLI('deepl translate "Hello" --to es --enable-beta-languages', { @@ -732,20 +732,7 @@ describe('Translate CLI Integration', () => { }); } catch (error: any) { const output = error.stderr ?? error.stdout; - expect(output).not.toMatch(/unknown.*option/i); - } - }); - - it('should accept --enable-beta-languages with other options', () => { - expect.assertions(1); - try { - runCLI( - 'deepl translate "Hello" --to es --enable-beta-languages --formality more', - { stdio: 'pipe' } - ); - } catch (error: any) { - const output = error.stderr ?? error.stdout; - expect(output).not.toMatch(/unknown.*option/i); + expect(output).toMatch(/unknown.*option/i); } }); }); diff --git a/tests/unit/deepl-client-document.test.ts b/tests/unit/deepl-client-document.test.ts index 64e6c17b..ed1c206a 100644 --- a/tests/unit/deepl-client-document.test.ts +++ b/tests/unit/deepl-client-document.test.ts @@ -178,35 +178,7 @@ describe('DeepLClient - Document Translation', () => { ); }); - it('should include enable_beta_languages parameter when enabled', async () => { - const mockResponse = { - data: { - document_id: 'doc-id', - document_key: 'doc-key', - }, - }; - - mockAxiosInstance.request.mockResolvedValue(mockResponse); - - const fileBuffer = Buffer.from('test content'); - await client.uploadDocument(fileBuffer, { - targetLang: 'es', - filename: 'document.pdf', - enableBetaLanguages: true, - }); - - expect(mockAxiosInstance.request).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'POST', - url: '/v2/document', - }) - ); - - const callArgs = mockAxiosInstance.request.mock.calls[0][0]; - expect(callArgs.data).toBeDefined(); - }); - - it('should NOT include enable_beta_languages parameter when not specified', async () => { + it('should never include the retired enable_beta_languages parameter', async () => { const mockResponse = { data: { document_id: 'doc-id', diff --git a/tests/unit/deepl-client.test.ts b/tests/unit/deepl-client.test.ts index 39939bb5..9a878472 100644 --- a/tests/unit/deepl-client.test.ts +++ b/tests/unit/deepl-client.test.ts @@ -328,30 +328,7 @@ describe('DeepLClient', () => { expect(nock.isDone()).toBe(true); }); - it('should send enable_beta_languages parameter when enabled', async () => { - nock(baseUrl) - .post('/v2/translate', (body) => { - - return body.enable_beta_languages === '1'; - }) - .reply(200, { - translations: [ - { - detected_source_language: 'EN', - text: 'Hola', - }, - ], - }); - - await client.translate('Hello', { - targetLang: 'es', - enableBetaLanguages: true, - }); - - expect(nock.isDone()).toBe(true); - }); - - it('should not send enable_beta_languages when not requested', async () => { + it('should never send the retired enable_beta_languages parameter', async () => { nock(baseUrl) .post('/v2/translate', (body) => { @@ -756,21 +733,21 @@ describe('DeepLClient', () => { await expect(client.getUsage()).rejects.toThrow(); }); - it('should parse speech-to-text milliseconds fields', async () => { + it('should ignore the deprecated speech_to_text_milliseconds_* fields', async () => { nock(baseUrl) .get('/v2/usage') .reply(200, { character_count: 12345, character_limit: 500000, - speech_to_text_milliseconds_count: 120000, - speech_to_text_milliseconds_limit: 36000000, + speech_to_text_milliseconds_count: 0, + speech_to_text_milliseconds_limit: 0, }); const usage = await client.getUsage(); expect(usage.characterCount).toBe(12345); - expect(usage.speechToTextMillisecondsCount).toBe(120000); - expect(usage.speechToTextMillisecondsLimit).toBe(36000000); + expect(usage).not.toHaveProperty('speechToTextMillisecondsCount'); + expect(usage).not.toHaveProperty('speechToTextMillisecondsLimit'); }); it('should parse products with billing_unit', async () => { @@ -794,19 +771,6 @@ describe('DeepLClient', () => { expect(products[1]!.billingUnit).toBe('milliseconds'); }); - it('should omit speech-to-text fields when not present in response', async () => { - nock(baseUrl) - .get('/v2/usage') - .reply(200, { - character_count: 12345, - character_limit: 500000, - }); - - const usage = await client.getUsage(); - - expect(usage.speechToTextMillisecondsCount).toBeUndefined(); - expect(usage.speechToTextMillisecondsLimit).toBeUndefined(); - }); }); describe('getSupportedLanguages()', () => { @@ -1895,33 +1859,6 @@ describe('DeepLClient', () => { // Note: In batch translation, billed_characters is for the entire batch, // not per translation. The API client returns it at the response level. }); - - it('should send enable_beta_languages in batch translation', async () => { - nock(baseUrl) - .post('/v2/translate', (body) => { - - return body.enable_beta_languages === '1'; - }) - .reply(200, { - translations: [ - { - detected_source_language: 'EN', - text: 'Hola', - }, - { - detected_source_language: 'EN', - text: 'Adiós', - }, - ], - }); - - await client.translateBatch(['Hello', 'Goodbye'], { - targetLang: 'es', - enableBetaLanguages: true, - }); - - expect(nock.isDone()).toBe(true); - }); }); describe('getGlossaryLanguages()', () => { diff --git a/tests/unit/translation-client.test.ts b/tests/unit/translation-client.test.ts index a6128519..c6db35bf 100644 --- a/tests/unit/translation-client.test.ts +++ b/tests/unit/translation-client.test.ts @@ -270,8 +270,6 @@ describe('TranslationClient', () => { api_key_character_limit: 500, start_time: '2024-01-01', end_time: '2024-12-31', - speech_to_text_milliseconds_count: 5000, - speech_to_text_milliseconds_limit: 60000, api_key_unit_count: 10, api_key_unit_limit: 100, account_unit_count: 20, @@ -287,8 +285,6 @@ describe('TranslationClient', () => { expect(result.apiKeyCharacterLimit).toBe(500); expect(result.startTime).toBe('2024-01-01'); expect(result.endTime).toBe('2024-12-31'); - expect(result.speechToTextMillisecondsCount).toBe(5000); - expect(result.speechToTextMillisecondsLimit).toBe(60000); expect(result.apiKeyUnitCount).toBe(10); expect(result.apiKeyUnitLimit).toBe(100); expect(result.accountUnitCount).toBe(20); diff --git a/tests/unit/translation-service.test.ts b/tests/unit/translation-service.test.ts index 9955861d..42da2158 100644 --- a/tests/unit/translation-service.test.ts +++ b/tests/unit/translation-service.test.ts @@ -197,19 +197,6 @@ describe('TranslationService', () => { tagHandling: 'xml', }); }); - - it('should pass enableBetaLanguages to DeepL client', async () => { - mockDeepLClient.translate.mockResolvedValue({ text: 'Hola' }); - - await translationService.translate('Hello', { - targetLang: 'es', - enableBetaLanguages: true, - }); - - expect(mockDeepLClient.translate).toHaveBeenCalledWith('Hello', expect.objectContaining({ - enableBetaLanguages: true, - })); - }); }); describe('translateBatch()', () => { diff --git a/tests/unit/usage-command.test.ts b/tests/unit/usage-command.test.ts index 7ea05d53..02cec009 100644 --- a/tests/unit/usage-command.test.ts +++ b/tests/unit/usage-command.test.ts @@ -301,47 +301,7 @@ describe('UsageCommand', () => { expect(formatted).toContain('880,000'); }); - it('should display speech-to-text usage when available', () => { - const formatted = usageCommand.formatUsage({ - characterCount: 2150000, - characterLimit: 20000000, - speechToTextMillisecondsCount: 3661000, - speechToTextMillisecondsLimit: 36000000, - }); - - expect(formatted).toContain('Speech-to-Text Usage:'); - expect(formatted).toContain('1h 1m 1s'); - expect(formatted).toContain('10h 0m 0s'); - expect(formatted).toContain('10.2%'); - }); - - it('should show warning for high speech-to-text usage', () => { - const formatted = usageCommand.formatUsage({ - characterCount: 100, - characterLimit: 500000, - speechToTextMillisecondsCount: 30000000, - speechToTextMillisecondsLimit: 36000000, - }); - - expect(formatted).toContain('Speech-to-Text Usage:'); - expect(formatted).toContain('83.3%'); - expect(formatted).toContain('Warning: You are approaching your speech-to-text limit'); - }); - - it('should format zero speech-to-text usage in seconds, not milliseconds', () => { - const formatted = usageCommand.formatUsage({ - characterCount: 100, - characterLimit: 500000, - speechToTextMillisecondsCount: 0, - speechToTextMillisecondsLimit: 36000000, - }); - - expect(formatted).toContain('Speech-to-Text Usage:'); - expect(formatted).toContain('0s'); - expect(formatted).not.toContain('0ms'); - }); - - it('should omit speech-to-text section when not available', () => { + it('should not render a dedicated speech-to-text section (voice usage lives in the product breakdown)', () => { const formatted = usageCommand.formatUsage({ characterCount: 123456, characterLimit: 500000, @@ -416,14 +376,12 @@ describe('UsageCommand', () => { expect(formatted).not.toContain('textTranslation'); }); - it('should display full Pro response with speech-to-text', () => { + it('should display full Pro response with voice usage in the product breakdown', () => { const formatted = usageCommand.formatUsage({ characterCount: 2150000, characterLimit: 20000000, apiKeyCharacterCount: 1880000, apiKeyCharacterLimit: 0, - speechToTextMillisecondsCount: 120000, - speechToTextMillisecondsLimit: 36000000, startTime: '2025-04-24T14:58:02Z', endTime: '2025-05-24T14:58:02Z', products: [ @@ -433,7 +391,8 @@ describe('UsageCommand', () => { }); expect(formatted).toContain('Character Usage:'); - expect(formatted).toContain('Speech-to-Text Usage:'); + expect(formatted).not.toContain('Speech-to-Text Usage:'); + expect(formatted).toContain('speech_to_text: 2m 0s'); expect(formatted).toContain('Billing Period:'); expect(formatted).toContain('API Key Usage:'); expect(formatted).toContain('Product Breakdown:'); @@ -465,7 +424,7 @@ describe('UsageCommand', () => { expect(result).toContain('—'); // pct() returns em-dash when limit=0 }); - it('should add API key, account-unit, and STT rows when those fields are present', () => { + it('should add API key and account-unit rows when those fields are present', () => { const result = usageCommand.formatUsageTable({ characterCount: 100, characterLimit: 500, @@ -473,12 +432,10 @@ describe('UsageCommand', () => { accountUnitLimit: 10, apiKeyUnitCount: 2, apiKeyUnitLimit: 4, - speechToTextMillisecondsCount: 60000, - speechToTextMillisecondsLimit: 600000, }); expect(result).toContain('Account units'); expect(result).toContain('API key units'); - expect(result).toContain('Speech-to-text'); + expect(result).not.toContain('Speech-to-text'); }); it('should append a Product Breakdown table when products are present', () => { From d1c9a9e82320461adc8f0fd092895f4051b28059 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Sun, 2 Aug 2026 23:21:54 -0400 Subject: [PATCH 003/256] feat(api): send glossary_ids when a request names several glossaries Add a shared normalizer that picks the glossary wire parameter: one ID goes out as glossary_id, keeping the existing shape, and two to five as glossary_ids. It rejects a sixth glossary and the glossary_id/glossary_ids combination the API refuses, and never sorts the list, because the API applies the last glossary that defines a conflicting source term. The two endpoints need different encodings for the list, verified against the live API. Form-urlencoded POST /v2/translate parses repeated glossary_ids fields as an ordered list, so the existing array serialization is already correct. Multipart POST /v2/document keeps only the first of several repeated fields and silently applies that one glossary, so the document client comma-joins the IDs; whitespace around the commas voids the parameter. --- src/api/document-client.ts | 16 ++++- src/api/translation-client.ts | 6 +- src/types/api.ts | 8 +++ src/utils/glossary-params.ts | 70 +++++++++++++++++++++ tests/unit/document-client.test.ts | 77 +++++++++++++++++++++++ tests/unit/glossary-params.test.ts | 88 +++++++++++++++++++++++++++ tests/unit/translation-client.test.ts | 78 ++++++++++++++++++++++++ 7 files changed, 339 insertions(+), 4 deletions(-) create mode 100644 src/utils/glossary-params.ts create mode 100644 tests/unit/glossary-params.test.ts diff --git a/src/api/document-client.ts b/src/api/document-client.ts index 9e52804f..020084df 100644 --- a/src/api/document-client.ts +++ b/src/api/document-client.ts @@ -2,6 +2,10 @@ import { HttpClient, DeepLClientOptions } from './http-client.js'; import { DocumentTranslationOptions, DocumentHandle, DocumentStatus } from '../types/index.js'; import { ValidationError } from '../utils/errors.js'; import { normalizeFormality } from '../utils/formality.js'; +import { + resolveGlossaryWireParams, + encodeGlossaryIdsForMultipart, +} from '../utils/glossary-params.js'; interface DeepLDocumentUploadResponse { document_id: string; @@ -58,8 +62,16 @@ export class DocumentClient extends HttpClient { if (options.formality) { formData.append('formality', normalizeFormality(options.formality, 'text')); } - if (options.glossaryId) { - formData.append('glossary_id', options.glossaryId); + const glossaryParams = resolveGlossaryWireParams(options); + if (glossaryParams) { + if ('glossary_id' in glossaryParams) { + formData.append('glossary_id', glossaryParams.glossary_id); + } else { + formData.append( + 'glossary_ids', + encodeGlossaryIdsForMultipart(glossaryParams.glossary_ids), + ); + } } if (options.outputFormat) { formData.append('output_format', options.outputFormat); diff --git a/src/api/translation-client.ts b/src/api/translation-client.ts index 97cb47d6..c10ffa0e 100644 --- a/src/api/translation-client.ts +++ b/src/api/translation-client.ts @@ -2,6 +2,7 @@ import { HttpClient, DeepLClientOptions } from './http-client.js'; import { TranslationOptions, Language, TranslationMemory } from '../types/index.js'; import { NetworkError } from '../utils/errors.js'; import { normalizeFormality } from '../utils/formality.js'; +import { resolveGlossaryWireParams } from '../utils/glossary-params.js'; import { LANGUAGE_REGISTRY } from '../data/language-registry.js'; import { Logger } from '../utils/logger.js'; @@ -315,8 +316,9 @@ export class TranslationClient extends HttpClient { params['formality'] = normalizeFormality(options.formality, 'text'); } - if (options.glossaryId) { - params['glossary_id'] = options.glossaryId; + const glossaryParams = resolveGlossaryWireParams(options); + if (glossaryParams) { + Object.assign(params, glossaryParams); } if (options.translationMemoryId) { diff --git a/src/types/api.ts b/src/types/api.ts index a69538c3..747fc923 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -13,6 +13,12 @@ export interface TranslationOptions { sourceLang?: Language; targetLang: Language; glossaryId?: string; + /** + * Two to five glossaries applied to one request. Their entries are merged; + * when more than one defines the same source term the last one wins, so the + * order is significant. Mutually exclusive with `glossaryId`. + */ + glossaryIds?: string[]; translationMemoryId?: string; translationMemoryThreshold?: number; formality?: Formality; @@ -131,6 +137,8 @@ export interface DocumentTranslationOptions { filename?: string; formality?: Formality; glossaryId?: string; + /** See `TranslationOptions.glossaryIds`. */ + glossaryIds?: string[]; outputFormat?: DocumentOutputFormat; enableDocumentMinification?: boolean; } diff --git a/src/utils/glossary-params.ts b/src/utils/glossary-params.ts new file mode 100644 index 00000000..ff86e062 --- /dev/null +++ b/src/utils/glossary-params.ts @@ -0,0 +1,70 @@ +import { ValidationError } from './errors.js'; + +/** The API rejects requests naming more than five glossaries. */ +export const MAX_GLOSSARIES_PER_REQUEST = 5; + +export interface GlossarySelection { + glossaryId?: string; + glossaryIds?: string[]; +} + +export type GlossaryWireParams = + | { glossary_id: string } + | { glossary_ids: string[] }; + +/** + * Pick the glossary parameter to send for a resolved glossary selection. + * + * A single glossary always goes out as `glossary_id`, even when it arrived via + * `glossaryIds`, so single-glossary requests keep the wire shape — and the + * cache keys — they had before `glossary_ids` existed. Two or more go out as + * `glossary_ids`, which the API refuses to accept alongside `glossary_id`. + * + * The list order is preserved and never sorted: when several glossaries define + * the same source term, the API applies the last one that names it. + */ +export function resolveGlossaryWireParams( + selection: GlossarySelection, +): GlossaryWireParams | undefined { + const ids = selection.glossaryIds; + + if (selection.glossaryId && ids && ids.length > 0) { + throw new ValidationError( + 'Cannot combine glossaryId with glossaryIds', + 'Pass every glossary through glossaryIds; a single entry is sent as glossary_id automatically.', + ); + } + + if (selection.glossaryId) { + return { glossary_id: selection.glossaryId }; + } + + if (!ids || ids.length === 0) { + return undefined; + } + + if (ids.length > MAX_GLOSSARIES_PER_REQUEST) { + throw new ValidationError( + `A maximum of ${MAX_GLOSSARIES_PER_REQUEST} glossaries can be used per request, got ${ids.length}`, + 'Merge entries into fewer glossaries, or pass fewer --glossary flags.', + ); + } + + const [only] = ids; + if (ids.length === 1 && only) { + return { glossary_id: only }; + } + + return { glossary_ids: ids }; +} + +/** + * Encode `glossary_ids` for multipart requests. Unlike form-urlencoded bodies, + * multipart uploads do not parse repeated `glossary_ids` fields as a list — the + * API keeps only the first and silently applies that one glossary — so the IDs + * travel as a single comma-joined value. Whitespace around the commas makes the + * API ignore the parameter outright. + */ +export function encodeGlossaryIdsForMultipart(ids: string[]): string { + return ids.join(','); +} diff --git a/tests/unit/document-client.test.ts b/tests/unit/document-client.test.ts index d3b252f3..b9a51ea3 100644 --- a/tests/unit/document-client.test.ts +++ b/tests/unit/document-client.test.ts @@ -121,6 +121,83 @@ describe('DocumentClient', () => { expect(mockAxiosInstance.request).toHaveBeenCalled(); }); + describe('glossary params', () => { + const A = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + const B = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + + beforeEach(() => { + mockAxiosInstance.request.mockResolvedValue({ + data: { document_id: 'doc-1', document_key: 'key-1' }, + status: 200, + headers: {}, + }); + }); + + const getMultipartBody = (): string => { + const call = mockAxiosInstance.request.mock.calls[0]?.[0]; + return (call?.data?.getBuffer?.() as Buffer | undefined)?.toString('utf8') ?? ''; + }; + + const upload = async (options: Record): Promise => { + await client.uploadDocument(Buffer.from('content'), { + targetLang: 'de', + filename: 'doc.txt', + ...options, + } as any); + }; + + it('should send glossary_id for a single glossaryId', async () => { + await upload({ glossaryId: A }); + const body = getMultipartBody(); + expect(body).toContain('name="glossary_id"'); + expect(body).toContain(A); + expect(body).not.toContain('name="glossary_ids"'); + }); + + it('should send glossary_id when glossaryIds holds exactly one ID', async () => { + await upload({ glossaryIds: [A] }); + const body = getMultipartBody(); + expect(body).toContain('name="glossary_id"'); + expect(body).not.toContain('name="glossary_ids"'); + }); + + /** + * Multipart uploads keep only the first of several repeated fields, so the + * IDs must arrive comma-joined or every glossary after the first is + * silently dropped by the API. + */ + it('should comma-join several glossary IDs into one glossary_ids field', async () => { + await upload({ glossaryIds: [A, B] }); + const body = getMultipartBody(); + expect(body).toContain('name="glossary_ids"'); + expect(body).toContain(`${A},${B}`); + expect(body).not.toContain('name="glossary_id"\r\n'); + expect(body.match(/name="glossary_ids"/g)).toHaveLength(1); + }); + + it('should not pad the joined IDs with whitespace, which voids the parameter', async () => { + await upload({ glossaryIds: [A, B] }); + expect(getMultipartBody()).not.toContain(`${A}, ${B}`); + }); + + it('should keep the caller order, since the last glossary wins', async () => { + await upload({ glossaryIds: [B, A] }); + expect(getMultipartBody()).toContain(`${B},${A}`); + }); + + it('should reject more than five glossaries before uploading', async () => { + await expect( + upload({ glossaryIds: [A, B, A, B, A, B] }), + ).rejects.toThrow(/maximum of 5 glossaries/); + expect(mockAxiosInstance.request).not.toHaveBeenCalled(); + }); + + it('should reject glossaryId combined with glossaryIds', async () => { + await expect(upload({ glossaryId: A, glossaryIds: [B] })).rejects.toThrow(/Cannot combine/); + expect(mockAxiosInstance.request).not.toHaveBeenCalled(); + }); + }); + it('should handle API errors', async () => { const axiosError = { isAxiosError: true, diff --git a/tests/unit/glossary-params.test.ts b/tests/unit/glossary-params.test.ts new file mode 100644 index 00000000..5b7532d8 --- /dev/null +++ b/tests/unit/glossary-params.test.ts @@ -0,0 +1,88 @@ +/** + * Tests for glossary wire-parameter selection (glossary_id vs glossary_ids) + */ + +import { + resolveGlossaryWireParams, + encodeGlossaryIdsForMultipart, + MAX_GLOSSARIES_PER_REQUEST, +} from '../../src/utils/glossary-params.js'; +import { ValidationError } from '../../src/utils/errors.js'; + +const A = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +const B = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; +const C = 'cccccccc-cccc-cccc-cccc-cccccccccccc'; + +describe('resolveGlossaryWireParams', () => { + it('should return undefined when no glossary is selected', () => { + expect(resolveGlossaryWireParams({})).toBeUndefined(); + }); + + it('should return undefined for an empty glossaryIds list', () => { + expect(resolveGlossaryWireParams({ glossaryIds: [] })).toBeUndefined(); + }); + + it('should send a lone glossaryId as glossary_id', () => { + expect(resolveGlossaryWireParams({ glossaryId: A })).toEqual({ glossary_id: A }); + }); + + it('should send a single-entry glossaryIds list as glossary_id', () => { + expect(resolveGlossaryWireParams({ glossaryIds: [A] })).toEqual({ glossary_id: A }); + }); + + it('should send two or more IDs as glossary_ids', () => { + expect(resolveGlossaryWireParams({ glossaryIds: [A, B] })).toEqual({ glossary_ids: [A, B] }); + }); + + it('should preserve the caller order, since the last glossary wins conflicts', () => { + expect(resolveGlossaryWireParams({ glossaryIds: [C, A, B] })).toEqual({ + glossary_ids: [C, A, B], + }); + }); + + it('should accept exactly the maximum number of glossaries', () => { + const ids = [A, B, C, A, B]; + expect(ids).toHaveLength(MAX_GLOSSARIES_PER_REQUEST); + expect(resolveGlossaryWireParams({ glossaryIds: ids })).toEqual({ glossary_ids: ids }); + }); + + it('should reject more than the maximum number of glossaries', () => { + expect.assertions(3); + const ids = [A, B, C, A, B, C]; + expect(() => resolveGlossaryWireParams({ glossaryIds: ids })).toThrow(ValidationError); + try { + resolveGlossaryWireParams({ glossaryIds: ids }); + } catch (error) { + expect((error as ValidationError).message).toContain('maximum of 5 glossaries'); + expect((error as ValidationError).message).toContain('got 6'); + } + }); + + it('should reject glossaryId combined with glossaryIds, which the API refuses', () => { + expect.assertions(2); + expect(() => resolveGlossaryWireParams({ glossaryId: A, glossaryIds: [B] })).toThrow( + ValidationError, + ); + try { + resolveGlossaryWireParams({ glossaryId: A, glossaryIds: [B] }); + } catch (error) { + expect((error as ValidationError).message).toContain('Cannot combine'); + } + }); + + it('should ignore an empty glossaryIds list alongside glossaryId', () => { + expect(resolveGlossaryWireParams({ glossaryId: A, glossaryIds: [] })).toEqual({ + glossary_id: A, + }); + }); +}); + +describe('encodeGlossaryIdsForMultipart', () => { + it('should join IDs with commas and no whitespace', () => { + expect(encodeGlossaryIdsForMultipart([A, B])).toBe(`${A},${B}`); + }); + + it('should preserve order', () => { + expect(encodeGlossaryIdsForMultipart([B, A])).toBe(`${B},${A}`); + }); +}); diff --git a/tests/unit/translation-client.test.ts b/tests/unit/translation-client.test.ts index c6db35bf..083f2a47 100644 --- a/tests/unit/translation-client.test.ts +++ b/tests/unit/translation-client.test.ts @@ -244,6 +244,84 @@ describe('TranslationClient', () => { }); }); + describe('glossary params', () => { + const A = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + const B = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + const C = 'cccccccc-cccc-cccc-cccc-cccccccccccc'; + + beforeEach(() => { + mockAxiosInstance.request.mockResolvedValue({ + data: { translations: [{ text: 'Hola' }] }, + status: 200, + headers: {}, + }); + }); + + const getRequestBody = (): string => { + const call = mockAxiosInstance.request.mock.calls[0]?.[0]; + return (call?.data ?? '') as string; + }; + + it('should send glossary_id for a single glossaryId', async () => { + await client.translate('Hello', { targetLang: 'es', glossaryId: A }); + const body = getRequestBody(); + expect(body).toContain(`glossary_id=${A}`); + expect(body).not.toContain('glossary_ids'); + }); + + it('should send glossary_id when glossaryIds holds exactly one ID', async () => { + await client.translate('Hello', { targetLang: 'es', glossaryIds: [A] }); + const body = getRequestBody(); + expect(body).toContain(`glossary_id=${A}`); + expect(body).not.toContain('glossary_ids'); + }); + + it('should send repeated glossary_ids fields for several glossaries', async () => { + await client.translate('Hello', { targetLang: 'es', glossaryIds: [A, B, C] }); + const body = getRequestBody(); + expect(body).toContain(`glossary_ids=${A}`); + expect(body).toContain(`glossary_ids=${B}`); + expect(body).toContain(`glossary_ids=${C}`); + expect(body).not.toMatch(/(^|&)glossary_id=/); + }); + + it('should keep the caller order on the wire, since the last glossary wins', async () => { + await client.translate('Hello', { targetLang: 'es', glossaryIds: [C, A] }); + expect(getRequestBody()).toContain(`glossary_ids=${C}&glossary_ids=${A}`); + }); + + it('should omit glossary params entirely when none are set', async () => { + await client.translate('Hello', { targetLang: 'es' }); + expect(getRequestBody()).not.toContain('glossary'); + }); + + it('should reject more than five glossaries before sending a request', async () => { + await expect( + client.translate('Hello', { targetLang: 'es', glossaryIds: [A, B, C, A, B, C] }), + ).rejects.toThrow(/maximum of 5 glossaries/); + expect(mockAxiosInstance.request).not.toHaveBeenCalled(); + }); + + it('should reject glossaryId combined with glossaryIds', async () => { + await expect( + client.translate('Hello', { targetLang: 'es', glossaryId: A, glossaryIds: [B] }), + ).rejects.toThrow(/Cannot combine/); + expect(mockAxiosInstance.request).not.toHaveBeenCalled(); + }); + + it('should apply the same shape to translateBatch', async () => { + mockAxiosInstance.request.mockResolvedValue({ + data: { translations: [{ text: 'Hola' }, { text: 'Adios' }] }, + status: 200, + headers: {}, + }); + await client.translateBatch(['Hello', 'Bye'], { targetLang: 'es', glossaryIds: [A, B] }); + const body = getRequestBody(); + expect(body).toContain(`glossary_ids=${A}`); + expect(body).toContain(`glossary_ids=${B}`); + }); + }); + describe('getUsage()', () => { it('should return usage information', async () => { mockAxiosInstance.request.mockResolvedValue({ From be8bacb5dc8543386c592c2f25f909686776862d Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Sun, 2 Aug 2026 23:22:00 -0400 Subject: [PATCH 004/256] fix(services): key the translation cache on the effective glossary parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generateCacheKey hashed the raw options, so naming one glossary via glossaryIds keyed differently from the identical request built with glossaryId, and an empty glossaryIds list keyed differently from no glossary at all — in both cases the same API request cached twice. Key on the normalized wire parameter instead. glossaryIds is appended after the existing fields, per the fixed-order contract, so keys for requests that do not use it are unchanged. It is hashed in the caller's order rather than sorted: reordering the list changes which glossary wins a conflicting term, and therefore the translation. --- src/services/translation.ts | 13 ++++++- tests/unit/translation-service.test.ts | 51 ++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/services/translation.ts b/src/services/translation.ts index feadb3a3..af8be411 100644 --- a/src/services/translation.ts +++ b/src/services/translation.ts @@ -12,6 +12,7 @@ import { Logger } from '../utils/logger.js'; import { mapWithConcurrency, MULTI_TARGET_CONCURRENCY } from '../utils/concurrency.js'; import { ValidationError } from '../utils/errors.js'; import { errorMessage } from '../utils/error-message.js'; +import { resolveGlossaryWireParams } from '../utils/glossary-params.js'; import { preserveCodeBlocks, preserveVariables, restorePlaceholders } from '../utils/text-preservation.js'; export { MULTI_TARGET_CONCURRENCY }; @@ -390,8 +391,17 @@ export class TranslationService { * encodings of the same visible string produce distinct cache keys. This is * intentional — the API receives the un-normalized bytes, so the cache keys * on exactly what is sent. + * + * New fields are appended after the existing ones so that keys for requests + * not using them stay unchanged. `glossaryIds` is hashed in the caller's + * order rather than sorted, because reordering the list changes which + * glossary wins a conflicting term and therefore the translation itself. */ private generateCacheKey(text: string, options: TranslationOptions): string { + // Keyed on the parameter the request will actually carry, so the two ways of + // naming one glossary share a key and an empty selection keys as no glossary + const glossary = resolveGlossaryWireParams(options); + // Create a stable representation with deterministic property order // Property order matters because JSON.stringify() preserves insertion order const cacheData = { @@ -399,7 +409,7 @@ export class TranslationService { targetLang: options.targetLang, // 2. Target language sourceLang: options.sourceLang, // 3. Source language (if specified) formality: options.formality, // 4. Formality level - glossaryId: options.glossaryId, // 5. Glossary ID + glossaryId: glossary && 'glossary_id' in glossary ? glossary.glossary_id : undefined, // 5. Glossary ID context: options.context, // 6. Context hint modelType: options.modelType, // 7. Model type affects output quality splitSentences: options.splitSentences, // 8. Sentence splitting behavior @@ -407,6 +417,7 @@ export class TranslationService { tagHandlingVersion: options.tagHandlingVersion, // 10. Tag handling version customInstructions: options.customInstructions, // 11. Custom instructions styleId: options.styleId, // 12. Style rules + glossaryIds: glossary && 'glossary_ids' in glossary ? glossary.glossary_ids : undefined, // 13. Multi-glossary selection (order-significant) // Note: preserveFormatting doesn't affect translation output, so not cached }; diff --git a/tests/unit/translation-service.test.ts b/tests/unit/translation-service.test.ts index 42da2158..c80340bd 100644 --- a/tests/unit/translation-service.test.ts +++ b/tests/unit/translation-service.test.ts @@ -1156,6 +1156,57 @@ describe('TranslationService', () => { expect(key1).toBe(key2); }); + describe('multi-glossary cache keys', () => { + const A = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + const B = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + + const keysFor = async ( + optionSets: Array>, + ): Promise => { + mockCacheService.get.mockReturnValue(null); + mockDeepLClient.translate.mockResolvedValue({ text: 'Hola' }); + mockCacheService.set.mockClear(); + + for (const options of optionSets) { + await translationService.translate('Hello', { targetLang: 'es', ...options } as any); + } + + return mockCacheService.set.mock.calls.map((call) => call[0]); + }; + + it('should key a single glossary the same whether it arrives as glossaryId or glossaryIds', async () => { + const [viaSingular, viaList] = await keysFor([ + { glossaryId: A }, + { glossaryIds: [A] }, + ]); + expect(viaSingular).toBe(viaList); + }); + + it('should separate one glossary from two', async () => { + const [one, two] = await keysFor([{ glossaryIds: [A] }, { glossaryIds: [A, B] }]); + expect(one).not.toBe(two); + }); + + /** Reordering changes which glossary wins a conflicting term, so the keys must differ. */ + it('should separate the two orderings of the same glossaries', async () => { + const [ab, ba] = await keysFor([{ glossaryIds: [A, B] }, { glossaryIds: [B, A] }]); + expect(ab).not.toBe(ba); + }); + + it('should reuse the key for an identical multi-glossary request', async () => { + const [first, second] = await keysFor([ + { glossaryIds: [A, B] }, + { glossaryIds: [A, B] }, + ]); + expect(first).toBe(second); + }); + + it('should leave keys for glossary-free requests unchanged by the new field', async () => { + const [none, empty] = await keysFor([{}, { glossaryIds: [] }]); + expect(none).toBe(empty); + }); + }); + it('should use cached result when options are provided in different order', async () => { // Set up cache to return a hit for the second call let callCount = 0; From a268f7774a48d0be1491c356f0b64ee412762b24 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Sun, 2 Aug 2026 23:22:09 -0400 Subject: [PATCH 005/256] feat(cli): make translate --glossary repeatable, up to 5 glossaries Each value still goes through the existing name-or-ID resolution, so names and UUIDs can be mixed; they resolve sequentially to let the service's resolution cache serve the later lookups. One glossary is passed on as glossaryId so the request and its cache key are byte-identical to before, several as glossaryIds. A sixth --glossary fails before any glossary lookup or translation request. The dry-run summary lists every requested glossary in the order given, since the last one wins a conflicting term. watch and sync keep their single-glossary configuration. --- src/cli/commands/register-translate.ts | 22 ++- .../translate/translation-options-factory.ts | 17 ++- src/cli/commands/translate/types.ts | 2 +- tests/e2e/cli-multi-glossary.e2e.test.ts | 90 +++++++++++ .../cli-translate.integration.test.ts | 140 +++++++++++++++++- tests/unit/file-translation-handler.test.ts | 8 +- tests/unit/text-translation-handler.test.ts | 4 +- tests/unit/translate-command.test.ts | 24 +-- tests/unit/translate-utils.test.ts | 6 +- .../unit/translation-options-factory.test.ts | 8 +- 10 files changed, 288 insertions(+), 33 deletions(-) create mode 100644 tests/e2e/cli-multi-glossary.e2e.test.ts diff --git a/src/cli/commands/register-translate.ts b/src/cli/commands/register-translate.ts index da6c6c75..9351bf83 100644 --- a/src/cli/commands/register-translate.ts +++ b/src/cli/commands/register-translate.ts @@ -4,6 +4,7 @@ import chalk from 'chalk'; import { Logger } from '../../utils/logger.js'; import { ValidationError } from '../../utils/errors.js'; import { createTranslateCommand, type ServiceDeps } from './service-factory.js'; +import { MAX_GLOSSARIES_PER_REQUEST } from '../../utils/glossary-params.js'; export function registerTranslate( program: Command, @@ -23,7 +24,11 @@ export function registerTranslate( .optionsGroup('Translation Quality:') .addOption(new Option('--formality ', 'Formality level (formal/informal are aliases for more/less)').choices(['default', 'more', 'less', 'prefer_more', 'prefer_less', 'formal', 'informal'])) .option('--context ', 'Additional context to improve translation quality') - .option('--glossary ', 'Use glossary by name or ID') + .option( + '--glossary ', + `Use glossary by name or ID (repeatable, max ${MAX_GLOSSARIES_PER_REQUEST}; when several define the same term, the last one wins)`, + (val: string, prev: string[] | undefined) => (prev ?? []).concat([val]), + ) .option( '--translation-memory ', 'Use translation memory by name or UUID (forces quality_optimized model). Run "deepl tm list" to see available TMs.', @@ -77,6 +82,7 @@ Examples: $ echo "Hello" | deepl translate --to ja $ deepl translate report.pdf --to de --output-format docx $ deepl translate "Hello" --to es --formality more --glossary my-glossary + $ deepl translate "Hello" --to es --glossary base-terms --glossary project-overrides $ deepl translate page.html --to fr --tag-handling html $ deepl translate "Hello" --to es --custom-instruction "Use informal language" $ deepl translate ./docs --to es --dry-run @@ -103,7 +109,7 @@ Examples: recursive?: boolean; pattern?: string; concurrency?: number; - glossary?: string; + glossary?: string[]; translationMemory?: string; tmThreshold?: number; customInstruction?: string[]; @@ -126,6 +132,13 @@ Examples: } } + if (options.glossary && options.glossary.length > MAX_GLOSSARIES_PER_REQUEST) { + throw new ValidationError( + `--glossary can be given at most ${MAX_GLOSSARIES_PER_REQUEST} times, got ${options.glossary.length}`, + 'Merge entries into fewer glossaries, or drop some --glossary flags.', + ); + } + if (options.tmThreshold !== undefined && !options.translationMemory) { throw new ValidationError( '--tm-threshold requires --translation-memory', @@ -183,8 +196,9 @@ Examples: if (options.from) { lines.push(chalk.yellow(`[dry-run] Source language: ${options.from}`)); } - if (options.glossary) { - lines.push(chalk.yellow(`[dry-run] Glossary: ${options.glossary}`)); + if (options.glossary && options.glossary.length > 0) { + const label = options.glossary.length > 1 ? 'Glossaries' : 'Glossary'; + lines.push(chalk.yellow(`[dry-run] ${label}: ${options.glossary.join(', ')}`)); } if (options.translationMemory) { lines.push(chalk.yellow(`[dry-run] Translation memory: ${options.translationMemory}`)); diff --git a/src/cli/commands/translate/translation-options-factory.ts b/src/cli/commands/translate/translation-options-factory.ts index 14a42b91..b17e24d7 100644 --- a/src/cli/commands/translate/translation-options-factory.ts +++ b/src/cli/commands/translate/translation-options-factory.ts @@ -58,6 +58,7 @@ export interface SharedTmAndGlossaryDeps { export async function applySharedTmAndGlossary< T extends { glossaryId?: string; + glossaryIds?: string[]; translationMemoryId?: string; translationMemoryThreshold?: number; modelType?: TranslationParams['modelType']; @@ -67,8 +68,20 @@ export async function applySharedTmAndGlossary< options: TranslateOptions, deps: SharedTmAndGlossaryDeps, ): Promise { - if (options.glossary) { - base.glossaryId = await resolveGlossaryId(deps.glossaryService, options.glossary); + if (options.glossary && options.glossary.length > 0) { + // Resolved sequentially so the service's resolution cache is populated + // before the next name-or-ID lookup needs the glossary list. + const ids: string[] = []; + for (const nameOrId of options.glossary) { + ids.push(await resolveGlossaryId(deps.glossaryService, nameOrId)); + } + + const [only] = ids; + if (ids.length === 1 && only) { + base.glossaryId = only; + } else { + base.glossaryIds = ids; + } } if (options.translationMemory) { diff --git a/src/cli/commands/translate/types.ts b/src/cli/commands/translate/types.ts index ff2e8343..ee098fbb 100644 --- a/src/cli/commands/translate/types.ts +++ b/src/cli/commands/translate/types.ts @@ -27,7 +27,7 @@ export interface TranslateOptions { recursive?: boolean; pattern?: string; concurrency?: number; - glossary?: string; + glossary?: string[]; translationMemory?: string; tmThreshold?: number; customInstruction?: string[]; diff --git a/tests/e2e/cli-multi-glossary.e2e.test.ts b/tests/e2e/cli-multi-glossary.e2e.test.ts new file mode 100644 index 00000000..42fa7f62 --- /dev/null +++ b/tests/e2e/cli-multi-glossary.e2e.test.ts @@ -0,0 +1,90 @@ +/** + * E2E Tests for repeating --glossary on `deepl translate` + * Covers flag repetition, the five-glossary cap, and dry-run reporting + */ + +import { createTestConfigDir, makeNodeRunCLI } from '../helpers'; + +describe('translate --glossary repetition E2E', () => { + const testConfig = createTestConfigDir('e2e-multi-glossary'); + const { runCLI, runCLIExpectError } = makeNodeRunCLI(testConfig.path); + + afterAll(() => { + testConfig.cleanup(); + }); + + describe('translate --help', () => { + it('should document that --glossary is repeatable', () => { + const output = runCLI('translate --help'); + + expect(output).toContain('--glossary'); + expect(output).toMatch(/repeatable/i); + }); + + it('should document the five-glossary maximum', () => { + expect(runCLI('translate --help')).toMatch(/max 5/i); + }); + + it('should document that the last glossary wins a conflict', () => { + expect(runCLI('translate --help')).toMatch(/last one wins/i); + }); + }); + + describe('the five-glossary cap', () => { + it('should reject a sixth --glossary with a clear message', () => { + const result = runCLIExpectError( + 'translate "Hello" --to de --glossary a --glossary b --glossary c ' + + '--glossary d --glossary e --glossary f', + ); + + expect(result.status).toBeGreaterThan(0); + expect(result.output).toMatch(/at most 5 times/i); + expect(result.output).toContain('got 6'); + }); + + it('should not reject exactly five glossaries during flag validation', () => { + const result = runCLIExpectError( + 'translate "Hello" --to de --dry-run --glossary a --glossary b --glossary c ' + + '--glossary d --glossary e', + ); + + expect(result.status).toBe(0); + expect(result.output).not.toMatch(/at most 5 times/i); + }); + }); + + describe('dry run', () => { + it('should list every requested glossary in order', () => { + const output = runCLI( + 'translate "Hello" --to de --dry-run --glossary base-terms --glossary project-overrides', + { noColor: true }, + ); + + expect(output).toContain('Glossaries: base-terms, project-overrides'); + }); + + it('should keep the singular label for one glossary', () => { + const output = runCLI('translate "Hello" --to de --dry-run --glossary base-terms', { + noColor: true, + }); + + expect(output).toContain('Glossary: base-terms'); + expect(output).not.toContain('Glossaries:'); + }); + + it('should report no glossary line when none is given', () => { + const output = runCLI('translate "Hello" --to de --dry-run', { noColor: true }); + + expect(output).not.toMatch(/Glossar/i); + }); + }); + + describe('argument handling', () => { + it('should require a value for each --glossary', () => { + const result = runCLIExpectError('translate "Hello" --to de --glossary'); + + expect(result.status).toBeGreaterThan(0); + expect(result.output).toMatch(/argument missing|option.*--glossary/i); + }); + }); +}); diff --git a/tests/integration/cli-translate.integration.test.ts b/tests/integration/cli-translate.integration.test.ts index 33065663..9e81d5a3 100644 --- a/tests/integration/cli-translate.integration.test.ts +++ b/tests/integration/cli-translate.integration.test.ts @@ -1238,7 +1238,7 @@ describe('Translate CLI --translation-memory integration (nock)', () => { const options: TranslateOptions = { to: 'de', from: 'en', - glossary: 'my-glossary', + glossary: ['my-glossary'], translationMemory: 'my-tm', cache: false, }; @@ -1251,4 +1251,142 @@ describe('Translate CLI --translation-memory integration (nock)', () => { expect(nock.isDone()).toBe(true); }); }); + + describe('multiple glossaries on one call', () => { + const SECOND_GLOSSARY_UUID = 'ffffffff-eeee-dddd-cccc-bbbbbbbbbbbb'; + + const mockGlossaryList = (): nock.Scope => + nock(DEEPL_FREE_API_URL) + .get('/v3/glossaries') + .reply(200, { + glossaries: [ + { + glossary_id: GLOSSARY_UUID, + name: 'base-terms', + ready: true, + creation_time: '2026-04-19T00:00:00Z', + dictionaries: [{ source_lang: 'EN', target_lang: 'DE', entry_count: 1 }], + }, + { + glossary_id: SECOND_GLOSSARY_UUID, + name: 'project-overrides', + ready: true, + creation_time: '2026-04-19T00:00:00Z', + dictionaries: [{ source_lang: 'EN', target_lang: 'DE', entry_count: 1 }], + }, + ], + }); + + it('resolves each name and sends glossary_ids in the given order', async () => { + const glossaryListScope = mockGlossaryList(); + + const translateScope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => { + expect(body['glossary_ids']).toEqual([GLOSSARY_UUID, SECOND_GLOSSARY_UUID]); + expect(body['glossary_id']).toBeUndefined(); + return true; + }) + .reply(200, { translations: [{ text: 'Hallo', detected_source_language: 'EN' }] }); + + const options: TranslateOptions = { + to: 'de', + from: 'en', + glossary: ['base-terms', 'project-overrides'], + cache: false, + }; + + await handler.translateText('Hi', options); + + expect(glossaryListScope.isDone()).toBe(true); + expect(translateScope.isDone()).toBe(true); + expect(nock.isDone()).toBe(true); + }); + + it('preserves the reversed order, which selects the other winning glossary', async () => { + const glossaryListScope = mockGlossaryList(); + + const translateScope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => { + expect(body['glossary_ids']).toEqual([SECOND_GLOSSARY_UUID, GLOSSARY_UUID]); + return true; + }) + .reply(200, { translations: [{ text: 'Hallo', detected_source_language: 'EN' }] }); + + const options: TranslateOptions = { + to: 'de', + from: 'en', + glossary: ['project-overrides', 'base-terms'], + cache: false, + }; + + await handler.translateText('Hi', options); + + expect(glossaryListScope.isDone()).toBe(true); + expect(translateScope.isDone()).toBe(true); + expect(nock.isDone()).toBe(true); + }); + + it('still sends singular glossary_id for one glossary', async () => { + const glossaryListScope = mockGlossaryList(); + + const translateScope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => { + expect(body['glossary_id']).toBe(GLOSSARY_UUID); + expect(body['glossary_ids']).toBeUndefined(); + return true; + }) + .reply(200, { translations: [{ text: 'Hallo', detected_source_language: 'EN' }] }); + + const options: TranslateOptions = { + to: 'de', + from: 'en', + glossary: ['base-terms'], + cache: false, + }; + + await handler.translateText('Hi', options); + + expect(glossaryListScope.isDone()).toBe(true); + expect(translateScope.isDone()).toBe(true); + expect(nock.isDone()).toBe(true); + }); + + it('accepts a mix of names and UUIDs', async () => { + const glossaryListScope = mockGlossaryList(); + + const translateScope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => { + expect(body['glossary_ids']).toEqual([SECOND_GLOSSARY_UUID, GLOSSARY_UUID]); + return true; + }) + .reply(200, { translations: [{ text: 'Hallo', detected_source_language: 'EN' }] }); + + const options: TranslateOptions = { + to: 'de', + from: 'en', + glossary: [SECOND_GLOSSARY_UUID, 'base-terms'], + cache: false, + }; + + await handler.translateText('Hi', options); + + expect(glossaryListScope.isDone()).toBe(true); + expect(translateScope.isDone()).toBe(true); + expect(nock.isDone()).toBe(true); + }); + + it('fails when one of several glossary names does not exist', async () => { + const glossaryListScope = mockGlossaryList(); + + const options: TranslateOptions = { + to: 'de', + from: 'en', + glossary: ['base-terms', 'no-such-glossary'], + cache: false, + }; + + await expect(handler.translateText('Hi', options)).rejects.toThrow(/no-such-glossary/); + expect(glossaryListScope.isDone()).toBe(true); + }); + }); }); diff --git a/tests/unit/file-translation-handler.test.ts b/tests/unit/file-translation-handler.test.ts index e1deb5cd..b222a2db 100644 --- a/tests/unit/file-translation-handler.test.ts +++ b/tests/unit/file-translation-handler.test.ts @@ -162,7 +162,7 @@ describe('FileTranslationHandler', () => { it('throws ValidationError for --glossary without --from in multi-target', async () => { const err = await handler - .translateFile('/tmp/file.txt', defaultOptions({ to: 'de,fr', output: '/tmp/out', glossary: 'my-glossary' })) + .translateFile('/tmp/file.txt', defaultOptions({ to: 'de,fr', output: '/tmp/out', glossary: ['my-glossary'] })) .catch(e => e); expect(err).toBeInstanceOf(ValidationError); expect((err as Error).message).toContain('Source language (--from) is required'); @@ -193,7 +193,7 @@ describe('FileTranslationHandler', () => { await handler.translateFile('/tmp/file.txt', defaultOptions({ to: 'de,fr', output: '/tmp/out', - from: 'en', glossary: 'my-glossary', + from: 'en', glossary: ['my-glossary'], })); expect(mocks.glossaryService.resolveGlossaryId).toHaveBeenCalledTimes(1); @@ -310,10 +310,10 @@ describe('FileTranslationHandler', () => { mocks.glossaryService.resolveGlossaryId.mockResolvedValue('glossary-123'); await expect( - handler.translateFile('/tmp/file.txt', defaultOptions({ glossary: 'my-glossary' })) + handler.translateFile('/tmp/file.txt', defaultOptions({ glossary: ['my-glossary'] })) ).rejects.toThrow(ValidationError); await expect( - handler.translateFile('/tmp/file.txt', defaultOptions({ glossary: 'my-glossary' })) + handler.translateFile('/tmp/file.txt', defaultOptions({ glossary: ['my-glossary'] })) ).rejects.toThrow('Source language (--from) is required'); }); diff --git a/tests/unit/text-translation-handler.test.ts b/tests/unit/text-translation-handler.test.ts index 442fc72c..9261d418 100644 --- a/tests/unit/text-translation-handler.test.ts +++ b/tests/unit/text-translation-handler.test.ts @@ -250,10 +250,10 @@ describe('TextTranslationHandler', () => { it('should throw ValidationError for glossary without --from', async () => { mocks.glossaryService.resolveGlossaryId.mockResolvedValue('glossary-123'); await expect( - handler.translateText('Hello', defaultOptions({ glossary: 'my-glossary' })) + handler.translateText('Hello', defaultOptions({ glossary: ['my-glossary'] })) ).rejects.toThrow(ValidationError); await expect( - handler.translateText('Hello', defaultOptions({ glossary: 'my-glossary' })) + handler.translateText('Hello', defaultOptions({ glossary: ['my-glossary'] })) ).rejects.toThrow('Source language (--from) is required'); }); diff --git a/tests/unit/translate-command.test.ts b/tests/unit/translate-command.test.ts index 28c70fa6..be69629e 100644 --- a/tests/unit/translate-command.test.ts +++ b/tests/unit/translate-command.test.ts @@ -1189,7 +1189,7 @@ describe('TranslateCommand', () => { const result = await translateCommand.translateText('Hello world', { to: 'de', from: 'en', - glossary: 'my-glossary', + glossary: ['my-glossary'], }); expect(result).toBe('Hallo Welt'); @@ -1212,7 +1212,7 @@ describe('TranslateCommand', () => { const result = await translateCommand.translateText('Hello world', { to: 'fr', from: 'en', - glossary: '01234567-89ab-cdef-0123-456789abcdef', + glossary: ['01234567-89ab-cdef-0123-456789abcdef'], }); expect(result).toBe('Bonjour le monde'); @@ -1228,7 +1228,7 @@ describe('TranslateCommand', () => { await expect( translateCommand.translateText('Hello world', { to: 'de', - glossary: 'my-glossary', + glossary: ['my-glossary'], }) ).rejects.toThrow('Source language (--from) is required when using a glossary'); }); @@ -1237,7 +1237,7 @@ describe('TranslateCommand', () => { await expect( translateCommand.translateText('Hello world', { to: 'de,fr', - glossary: 'my-glossary', + glossary: ['my-glossary'], }) ).rejects.toThrow('Source language (--from) is required when using a glossary'); }); @@ -1251,7 +1251,7 @@ describe('TranslateCommand', () => { translateCommand.translateText('Hello world', { to: 'de', from: 'en', - glossary: 'non-existent', + glossary: ['non-existent'], }) ).rejects.toThrow('Glossary "non-existent" not found'); @@ -1269,7 +1269,7 @@ describe('TranslateCommand', () => { const result = await translateCommand.translateText('Hello', { to: 'de,fr', from: 'en', - glossary: 'tech-terms', + glossary: ['tech-terms'], }); expect(result).toBe('[de] Hallo\n[fr] Bonjour'); @@ -1292,7 +1292,7 @@ describe('TranslateCommand', () => { const result = await translateCommand.translateText('Dear Sir or Madam', { to: 'de', from: 'en', - glossary: 'business-glossary', + glossary: ['business-glossary'], formality: 'more', context: 'Business letter opening', }); @@ -2098,7 +2098,7 @@ describe('TranslateCommand', () => { await expect( translateCommand.translateText('Hello', { to: 'sw', - glossary: 'my-glossary', + glossary: ['my-glossary'], }) ).rejects.toThrow('do not support glossaries'); }); @@ -3322,7 +3322,7 @@ describe('TranslateCommand', () => { to: 'es', from: 'en', output: '/out.txt', - glossary: 'my-glossary', + glossary: ['my-glossary'], }); expect(mockGlossaryService.resolveGlossaryId).toHaveBeenCalledWith('my-glossary'); @@ -3648,7 +3648,7 @@ describe('TranslateCommand', () => { to: 'es', output: '/output', splitSentences: 'on', - glossary: 'my-glossary', + glossary: ['my-glossary'], customInstruction: ['Be formal'], modelType: 'quality_optimized', context: 'technical docs', @@ -3700,7 +3700,7 @@ describe('TranslateCommand', () => { tagHandling: 'xml', modelType: 'quality_optimized', customInstruction: ['Be formal'], - glossary: 'my-glossary', + glossary: ['my-glossary'], preserveCode: true, }); @@ -3783,7 +3783,7 @@ describe('TranslateCommand', () => { it('should not include glossaryId (resolved separately)', () => { const result = buildTranslationOptions({ to: 'es', - glossary: 'my-glossary', + glossary: ['my-glossary'], }); expect(result).not.toHaveProperty('glossaryId'); }); diff --git a/tests/unit/translate-utils.test.ts b/tests/unit/translate-utils.test.ts index 922fcf43..b342c963 100644 --- a/tests/unit/translate-utils.test.ts +++ b/tests/unit/translate-utils.test.ts @@ -161,10 +161,10 @@ describe('translate-utils', () => { it('should throw for extended language with glossary', () => { expect(() => - validateExtendedLanguageConstraints('hi', { ...baseOptions, glossary: 'my-glossary' }) + validateExtendedLanguageConstraints('hi', { ...baseOptions, glossary: ['my-glossary'] }) ).toThrow(ValidationError); expect(() => - validateExtendedLanguageConstraints('hi', { ...baseOptions, glossary: 'my-glossary' }) + validateExtendedLanguageConstraints('hi', { ...baseOptions, glossary: ['my-glossary'] }) ).toThrow(/do not support glossaries/); }); @@ -174,7 +174,7 @@ describe('translate-utils', () => { to: 'de', modelType: 'latency_optimized', formality: 'more', - glossary: 'some-glossary', + glossary: ['some-glossary'], }) ).not.toThrow(); }); diff --git a/tests/unit/translation-options-factory.test.ts b/tests/unit/translation-options-factory.test.ts index f30b619d..c0f3dad7 100644 --- a/tests/unit/translation-options-factory.test.ts +++ b/tests/unit/translation-options-factory.test.ts @@ -22,7 +22,7 @@ describe('translation-options-factory', () => { }); it('maps glossary into options.glossary but does NOT set glossaryId (resolution is async)', () => { - const result = buildBaseTranslationOptions({ to: 'de', glossary: 'my-glossary' }); + const result = buildBaseTranslationOptions({ to: 'de', glossary: ['my-glossary'] }); expect(result.glossaryId).toBeUndefined(); }); @@ -82,7 +82,7 @@ describe('translation-options-factory', () => { it('resolves glossaryId when options.glossary is set', async () => { glossarySvc.resolveGlossaryId.mockResolvedValue('glos-123'); const base: Record = { targetLang: 'de' }; - await applySharedTmAndGlossary(base, { to: 'de', glossary: 'my-glossary' }, { + await applySharedTmAndGlossary(base, { to: 'de', glossary: ['my-glossary'] }, { glossaryService: glossarySvc, translationService: translationSvc, targets: ['de'], @@ -176,7 +176,7 @@ describe('translation-options-factory', () => { to: 'de', from: 'en', formality: 'less', - glossary: 'my-glossary', + glossary: ['my-glossary'], modelType: 'quality_optimized', preserveFormatting: true, }; @@ -209,7 +209,7 @@ describe('translation-options-factory', () => { to: 'de', from: 'en', formality: 'more', - glossary: 'my-glossary', + glossary: ['my-glossary'], translationMemory: 'my-tm', tmThreshold: 85, modelType: 'quality_optimized', From e580119398c047aff48db85f8f460d1a3f7fa1f7 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Sun, 2 Aug 2026 23:22:15 -0400 Subject: [PATCH 006/256] docs: document repeatable --glossary and its precedence rule Record the merge-with-last-wins semantics in API.md, the README feature list and the CHANGELOG, including that reordering the flags is a different request with its own cache entry. Extend the glossary example with a second EN->DE glossary that redefines one term, so running it prints the precedence in both directions. --- CHANGELOG.md | 2 ++ README.md | 6 ++++++ docs/API.md | 20 +++++++++++++++++++- examples/15-glossaries.sh | 28 +++++++++++++++++++++++++++- 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b906e60..db37be6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **translate**: `--glossary` is repeatable, applying up to 5 glossaries to one request via the API's `glossary_ids` parameter. Entries are merged, so terms unique to each glossary all apply; when several glossaries define the same source term the **last** `--glossary` on the command line wins, so the order is significant and is never sorted — reordering the flags is a different request with its own cache entry. Names and UUIDs may be mixed and are resolved independently. A single `--glossary` still goes out as `glossary_id`, leaving existing commands and their cache keys untouched; the new field is appended last in the cache key for the same reason. A 6th `--glossary` exits 6 (ValidationError) before any API call. `watch` and `sync` keep their single-glossary configuration. Verified against the live API, including the two encodings the endpoints require: repeated form fields for `POST /v2/translate`, and one comma-joined value for the multipart `POST /v2/document`, which keeps only the first of several repeated fields and would otherwise silently apply just one glossary. + - **cli**: `deepl correct` command (alias `c`) — spelling and grammar correction without rewording, via the Write API's `/v2/write/correct` endpoint. Supports the same input handling and workflow flags as `write` (`--check` with exit code 8, `--fix`/`--backup`, `--diff`, `--interactive`, `--output`/`--in-place`, `--format json`, `--no-cache`), but not `--style`/`--tone`, which the correct endpoint does not accept. Results are cached under a separate `correct:` namespace so corrections and rephrasings of the same text never collide. ### Changed diff --git a/README.md b/README.md index 4327076d..aa0b3db9 100644 --- a/README.md +++ b/README.md @@ -1280,6 +1280,12 @@ authentication Authentifizierung - **Smart defaults** - `--target` flag only required for multilingual glossaries - **Visual indicators** - 📖 for single-target, 📚 for multilingual glossaries - **Translation integration** - Use `--glossary` flag in translate and watch commands to apply glossary terms +- **Several glossaries at once** - Repeat `--glossary` on `translate` for up to 5 glossaries; entries are merged and the last glossary given wins any conflicting term + +```bash +# Layer project overrides on top of shared base terminology +deepl translate "Hello world" --to de --glossary base-terms --glossary project-overrides +``` ### Translation Memories diff --git a/docs/API.md b/docs/API.md index cae4895a..44463398 100644 --- a/docs/API.md +++ b/docs/API.md @@ -251,7 +251,7 @@ Translate text directly, from stdin, from files, or entire directories. Supports - `--non-splitting-tags TAGS` - Comma-separated XML tags that should not be used to split sentences (requires `--tag-handling xml`) - `--ignore-tags TAGS` - Comma-separated XML tags with content to ignore (requires `--tag-handling xml`) - `--tag-handling-version VERSION` - Tag handling version: `v1`, `v2`. v2 improves XML/HTML structure handling (requires `--tag-handling`) -- `--glossary NAME-OR-ID` - Use glossary by name or ID for consistent terminology +- `--glossary NAME-OR-ID` - Use glossary by name or ID for consistent terminology. Repeatable, up to 5 per request; when several glossaries define the same source term, the last one given wins. Passing a 6th exits 6 (ValidationError). - `--translation-memory NAME-OR-UUID` - Use translation memory by name or UUID (forces `quality_optimized` model). Requires `--from` because TMs are pinned to a specific source→target language pair. Invalid use exits 6 (ValidationError); unresolvable/misconfigured TM exits 7 (ConfigError). - `--tm-threshold N` - Minimum match score 0–100 (default 75, requires `--translation-memory`). Invalid use exits 6 (ValidationError); unresolvable/misconfigured TM exits 7 (ConfigError). - `--custom-instruction INSTRUCTION` - Custom instruction for translation (repeatable, max 10, max 300 chars each). Forces `quality_optimized` model. Cannot be used with `latency_optimized`. @@ -542,6 +542,24 @@ deepl translate "API documentation" --to es --glossary tech-terms deepl translate README.md --to fr --glossary abc-123-def-456 --output README.fr.md ``` +**Multiple glossaries on one request:** + +Repeat `--glossary` to apply up to 5 glossaries to a single request. Their entries are merged, so terms unique to each glossary all apply. When more than one glossary defines the same source term, the **last** `--glossary` on the command line wins — order is significant, and reordering the flags produces a different translation (and a separate cache entry). Names and UUIDs can be mixed; each value is resolved independently. A 6th `--glossary` exits 6 (ValidationError), and an unresolvable name exits 7 (ConfigError) without sending a translation request. + +```bash +# Shared base terminology, overridden by project-specific terms +deepl translate "Hello world" --to de --glossary base-terms --glossary project-overrides + +# Reversing the order makes base-terms win any conflicting entry +deepl translate "Hello world" --to de --glossary project-overrides --glossary base-terms + +# Names and UUIDs can be mixed +deepl translate README.md --to fr --output README.fr.md \ + --glossary abc-123-def-456 --glossary house-style +``` + +A single `--glossary` is still sent as the API's `glossary_id`, so existing commands and their cached results are unaffected. + **Translation memory usage:** Translation memories (TMs) are pinned to a source→target language pair, so `--from` is required. Passing `--translation-memory` forces `quality_optimized` model type; combining it with `--model-type latency_optimized` (or `prefer_quality_optimized`) exits 6 (ValidationError). TM files are authored and uploaded via the DeepL web UI; this CLI resolves the name-or-UUID against `GET /v3/translation_memories` and caches the resolution per run. diff --git a/examples/15-glossaries.sh b/examples/15-glossaries.sh index 5eb921c3..94f9600d 100755 --- a/examples/15-glossaries.sh +++ b/examples/15-glossaries.sh @@ -35,6 +35,13 @@ request Anfrage response Antwort EOF +# Create an override glossary (EN → DE) that redefines one term from the tech +# glossary, so the precedence between two glossaries is visible in the output +cat > "$SAMPLE_DIR/override-glossary.tsv" << 'EOF' +endpoint Schnittstelle +webhook Webhook +EOF + # Create a business terminology glossary (EN → ES) cat > "$SAMPLE_DIR/business-glossary.tsv" << 'EOF' stakeholder parte interesada @@ -59,7 +66,7 @@ echo # from a clean slate. echo "0. Pre-run cleanup of any leftover demo glossaries" if command -v jq &>/dev/null; then - DEMO_NAMES='tech-terms-demo tech-terms-renamed tech-final business-terms-demo multi-demo' + DEMO_NAMES='tech-terms-demo tech-terms-renamed tech-final business-terms-demo multi-demo override-terms-demo' for name in $DEMO_NAMES; do deepl glossary list --format json 2>/dev/null \ | jq -r --arg n "$name" '.[] | select(.name == $n) | .glossary_id' 2>/dev/null \ @@ -195,6 +202,22 @@ deepl translate "The API endpoint requires authentication." --from en --to de -- echo +echo " Repeat --glossary to apply up to 5 glossaries to one request." +echo " Entries are merged; on a term both define, the LAST glossary wins." +deepl glossary create override-terms-demo en de "$SAMPLE_DIR/override-glossary.tsv" + +echo +echo " Both glossaries, override last -> 'endpoint' becomes Schnittstelle:" +deepl translate "The API endpoint requires authentication." --from en --to de \ + --glossary tech-terms-renamed --glossary override-terms-demo + +echo +echo " Same two glossaries reversed -> 'endpoint' stays Endpunkt:" +deepl translate "The API endpoint requires authentication." --from en --to de \ + --glossary override-terms-demo --glossary tech-terms-renamed + +echo + # ═══════════════════════════════════════════════════════ # ADVANCED OPERATIONS # ═══════════════════════════════════════════════════════ @@ -231,6 +254,9 @@ deepl glossary delete tech-final --yes 2>/dev/null || echo " (Already deleted) echo " Deleting business-terms-demo..." deepl glossary delete business-terms-demo --yes 2>/dev/null || echo " (Already deleted)" +echo " Deleting override-terms-demo..." +deepl glossary delete override-terms-demo --yes 2>/dev/null || echo " (Already deleted)" + echo " Deleting multi-demo..." deepl glossary delete multi-demo --yes 2>/dev/null || echo " (Already deleted)" From 4fbc80a4b81c2db7253c06b3c70c1798768eb69f Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 06:44:53 -0400 Subject: [PATCH 007/256] feat(cli): apply --glossary to document translation Document translation accepted --glossary and then discarded it: the handler left glossary out of its supported set, so warnIgnoredOptions reported it as unsupported and no glossary ever reached the API, even though POST /v2/document supports them. Resolve the glossary in the document path and drop the warning. Repeating the flag works here too, with the same last-one-wins precedence. DocumentTranslationOptions and the document client already carried glossaryIds, so no client change was needed. --from is now required with a document glossary, because the API rejects the request otherwise: "source_lang has to be specified in order to use a glossary." Translation memories stay unsupported for documents, so the glossary half of applySharedTmAndGlossary is extracted into applyGlossarySelection and shared rather than pulling TM resolution into the document path. The pre-existing test asserting that document mode warns about --glossary encoded the old behavior and is replaced by one asserting it does not. --- .../translate/document-translation-handler.ts | 15 ++- .../translate/translation-options-factory.ts | 52 +++++--- .../e2e/cli-document-translation.e2e.test.ts | 67 ++++++++++ ...i-document-translation.integration.test.ts | 81 ++++++++++++ .../unit/document-translation-handler.test.ts | 123 ++++++++++++++++++ tests/unit/translate-command.test.ts | 19 ++- 6 files changed, 336 insertions(+), 21 deletions(-) diff --git a/src/cli/commands/translate/document-translation-handler.ts b/src/cli/commands/translate/document-translation-handler.ts index 41ecbd5b..70116a36 100644 --- a/src/cli/commands/translate/document-translation-handler.ts +++ b/src/cli/commands/translate/document-translation-handler.ts @@ -4,7 +4,7 @@ import { ValidationError } from '../../../utils/errors.js'; import type { DocumentTranslationOptions } from '../../../types/api.js'; import type { HandlerContext, TranslateOptions } from './types.js'; import { warnIgnoredOptions, validateLanguageCodes } from './translate-utils.js'; -import { buildBaseTranslationOptions } from './translation-options-factory.js'; +import { buildBaseTranslationOptions, applyGlossarySelection } from './translation-options-factory.js'; export class DocumentTranslationHandler { constructor(public ctx: HandlerContext) {} @@ -16,15 +16,26 @@ export class DocumentTranslationHandler { ); } - const supported = new Set(['from', 'formality', 'outputFormat', 'enableMinification']); + const supported = new Set(['from', 'formality', 'glossary', 'outputFormat', 'enableMinification']); warnIgnoredOptions('document', options, supported); validateLanguageCodes([options.to]); + // The API rejects a document glossary without source_lang: "source_lang has + // to be specified in order to use a glossary." + if (options.glossary && options.glossary.length > 0 && !options.from) { + throw new ValidationError( + 'Source language (--from) is required when using a glossary', + 'Example: deepl translate --from en --to es --glossary my-glossary report.pdf --output report.es.pdf' + ); + } + const outputPath = options.output!; const translationOptions = buildBaseTranslationOptions(options); + await applyGlossarySelection(translationOptions, options, this.ctx.glossaryService); + if (options.outputFormat) { translationOptions.outputFormat = options.outputFormat; } diff --git a/src/cli/commands/translate/translation-options-factory.ts b/src/cli/commands/translate/translation-options-factory.ts index b17e24d7..4e353166 100644 --- a/src/cli/commands/translate/translation-options-factory.ts +++ b/src/cli/commands/translate/translation-options-factory.ts @@ -28,6 +28,42 @@ export function buildBaseTranslationOptions(options: TranslateOptions): Translat return buildBaseLegacy(options); } +/** + * Resolve `--glossary` values to IDs and place them on `base`. One glossary is + * assigned to `glossaryId` so the request keeps the shape — and cache key — it + * had before multiple glossaries were supported; several go to `glossaryIds`, + * in the order given, because the API applies the last glossary that defines a + * conflicting term. + * + * Separate from `applySharedTmAndGlossary` because document translation + * supports glossaries but not translation memories. + */ +export async function applyGlossarySelection< + T extends { glossaryId?: string; glossaryIds?: string[] }, +>( + base: T, + options: TranslateOptions, + glossaryService: GlossaryService, +): Promise { + if (!options.glossary || options.glossary.length === 0) { + return; + } + + // Resolved sequentially so the service's resolution cache is populated + // before the next name-or-ID lookup needs the glossary list. + const ids: string[] = []; + for (const nameOrId of options.glossary) { + ids.push(await resolveGlossaryId(glossaryService, nameOrId)); + } + + const [only] = ids; + if (ids.length === 1 && only) { + base.glossaryId = only; + } else { + base.glossaryIds = ids; + } +} + export interface SharedTmAndGlossaryDeps { glossaryService: GlossaryService; translationService: TranslationService; @@ -68,21 +104,7 @@ export async function applySharedTmAndGlossary< options: TranslateOptions, deps: SharedTmAndGlossaryDeps, ): Promise { - if (options.glossary && options.glossary.length > 0) { - // Resolved sequentially so the service's resolution cache is populated - // before the next name-or-ID lookup needs the glossary list. - const ids: string[] = []; - for (const nameOrId of options.glossary) { - ids.push(await resolveGlossaryId(deps.glossaryService, nameOrId)); - } - - const [only] = ids; - if (ids.length === 1 && only) { - base.glossaryId = only; - } else { - base.glossaryIds = ids; - } - } + await applyGlossarySelection(base, options, deps.glossaryService); if (options.translationMemory) { const cache = deps.tmCache ?? new Map(); diff --git a/tests/e2e/cli-document-translation.e2e.test.ts b/tests/e2e/cli-document-translation.e2e.test.ts index 17e9ff50..9ae90892 100644 --- a/tests/e2e/cli-document-translation.e2e.test.ts +++ b/tests/e2e/cli-document-translation.e2e.test.ts @@ -28,6 +28,73 @@ describe('Document Translation E2E', () => { ); }; + describe('--glossary on a document', () => { + // Handler-level validation runs after the API-key gate, so these need a key + // to be reachable. The dead endpoint keeps the run off the network for the + // cases that get past validation. + const DUMMY_KEY = 'e2e-doc-glossary-key:fx'; + const DEAD_URL = 'http://127.0.0.1:9'; + + const pdfPath = (name: string): string => { + const file = path.join(testDir, `${name}.pdf`); + fs.writeFileSync(file, Buffer.from('%PDF-1.4 test content')); + return file; + }; + + const run = (args: string) => + helpers.runCLIExpectError(args, { apiKey: DUMMY_KEY }); + + it('should require --from, which the API demands for a document glossary', () => { + const file = pdfPath('glossary-doc'); + const out = path.join(testDir, 'glossary-doc.de.pdf'); + + const result = run( + `translate "${file}" --to de --output "${out}" --glossary my-glossary --api-url ${DEAD_URL}`, + ); + + expect(result.status).toBeGreaterThan(0); + expect(result.output).toContain('--from'); + expect(result.output).toMatch(/glossary/i); + }); + + it('should no longer report --glossary as unsupported for documents', () => { + const file = pdfPath('glossary-doc2'); + const out = path.join(testDir, 'glossary-doc.de2.pdf'); + + const result = run( + `translate "${file}" --from en --to de --output "${out}" --glossary my-glossary --api-url ${DEAD_URL}`, + ); + + expect(result.output).not.toMatch(/does not support/i); + }); + + it('should accept a repeated --glossary on a document', () => { + const file = pdfPath('glossary-doc3'); + const out = path.join(testDir, 'glossary-doc.de3.pdf'); + + const result = run( + `translate "${file}" --from en --to de --output "${out}" ` + + `--glossary base-terms --glossary project-overrides --api-url ${DEAD_URL}`, + ); + + expect(result.output).not.toMatch(/unknown option/i); + expect(result.output).not.toMatch(/does not support/i); + }); + + it('should still reject a sixth --glossary on a document', () => { + const file = pdfPath('glossary-doc4'); + const out = path.join(testDir, 'glossary-doc.de4.pdf'); + + const result = run( + `translate "${file}" --from en --to de --output "${out}" ` + + `--glossary a --glossary b --glossary c --glossary d --glossary e --glossary f`, + ); + + expect(result.status).toBeGreaterThan(0); + expect(result.output).toMatch(/at most 5 times/i); + }); + }); + describe('--output-format flag', () => { it('should accept valid output formats', () => { // Create a test file diff --git a/tests/integration/cli-document-translation.integration.test.ts b/tests/integration/cli-document-translation.integration.test.ts index 9c477bd0..72689c9b 100644 --- a/tests/integration/cli-document-translation.integration.test.ts +++ b/tests/integration/cli-document-translation.integration.test.ts @@ -41,6 +41,87 @@ describe('Document Translation Integration', () => { nock.cleanAll(); }); + describe('Service-level: glossaries on the multipart upload', () => { + const A = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + const B = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + + /** + * Runs one upload and hands back the raw multipart body, so the encoding + * the API actually requires is asserted rather than assumed. + */ + const uploadWith = async ( + glossary: { glossaryId?: string; glossaryIds?: string[] }, + label: string, + ): Promise => { + const client = new DeepLClient(API_KEY, { maxRetries: 0 }); + clients.push(client); + const service = new DocumentTranslationService(client); + + const inputPath = path.join(testDir, `glossary-${label}.pdf`); + const outputPath = path.join(testDir, `glossary-${label}-out.pdf`); + fs.writeFileSync(inputPath, Buffer.from('%PDF-1.4 test content')); + + let capturedBody = ''; + nock(FREE_API_URL) + .post('/v2/document', (body: string) => { + capturedBody = body; + return true; + }) + .reply(200, { document_id: `doc-${label}`, document_key: `key-${label}` }); + + nock(FREE_API_URL) + .post(`/v2/document/doc-${label}`) + .reply(200, { document_id: `doc-${label}`, status: 'done', billed_characters: 10 }); + + nock(FREE_API_URL) + .post(`/v2/document/doc-${label}/result`) + .reply(200, Buffer.from('%PDF-1.4 translated')); + + await service.translateDocument(inputPath, outputPath, { + targetLang: 'de', + sourceLang: 'en', + ...glossary, + }); + + return capturedBody; + }; + + it('sends glossary_id for a single glossary', async () => { + const body = await uploadWith({ glossaryId: A }, 'single'); + + expect(body).toContain('name="glossary_id"'); + expect(body).toContain(A); + expect(body).not.toContain('name="glossary_ids"'); + }); + + /** + * Multipart uploads keep only the first of several repeated fields, so the + * IDs must arrive comma-joined with no whitespace or the API silently + * applies just one glossary. + */ + it('comma-joins several glossaries into one glossary_ids field', async () => { + const body = await uploadWith({ glossaryIds: [A, B] }, 'multi'); + + expect(body).toContain('name="glossary_ids"'); + expect(body).toContain(`${A},${B}`); + expect(body.match(/name="glossary_ids"/g)).toHaveLength(1); + expect(body).not.toContain(`${A}, ${B}`); + }); + + it('preserves the given order, which selects the winning glossary', async () => { + const body = await uploadWith({ glossaryIds: [B, A] }, 'reversed'); + + expect(body).toContain(`${B},${A}`); + }); + + it('sends no glossary field when none is selected', async () => { + const body = await uploadWith({}, 'none'); + + expect(body).not.toContain('name="glossary_id"'); + expect(body).not.toContain('name="glossary_ids"'); + }); + }); + describe('Service-level: happy path (upload -> poll -> download)', () => { it('should complete the full document translation workflow', async () => { const client = new DeepLClient(API_KEY, { maxRetries: 0 }); diff --git a/tests/unit/document-translation-handler.test.ts b/tests/unit/document-translation-handler.test.ts index 16331882..03b9fb73 100644 --- a/tests/unit/document-translation-handler.test.ts +++ b/tests/unit/document-translation-handler.test.ts @@ -103,6 +103,129 @@ describe('DocumentTranslationHandler', () => { expect(MockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('document')); }); + describe('glossaries', () => { + const A = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + const B = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + + const resolveTo = (mapping: Record): void => { + mocks.glossaryService.resolveGlossaryId.mockImplementation( + async (nameOrId: string) => { + const id = mapping[nameOrId]; + if (!id) throw new ValidationError(`Glossary "${nameOrId}" not found`); + return id; + }, + ); + }; + + it('should forward a single resolved glossary as glossaryId', async () => { + resolveTo({ 'base-terms': A }); + + await handler.translateDocument( + '/tmp/doc.pdf', + defaultOptions({ from: 'en', glossary: ['base-terms'] }), + ); + + expect(mocks.documentTranslationService.translateDocument).toHaveBeenCalledWith( + '/tmp/doc.pdf', + '/tmp/output.pdf', + expect.objectContaining({ glossaryId: A }), + expect.any(Function), + ); + const passed = mocks.documentTranslationService.translateDocument.mock.calls[0]?.[2]; + expect(passed?.glossaryIds).toBeUndefined(); + }); + + it('should forward several resolved glossaries as glossaryIds in order', async () => { + resolveTo({ 'base-terms': A, 'project-overrides': B }); + + await handler.translateDocument( + '/tmp/doc.pdf', + defaultOptions({ from: 'en', glossary: ['base-terms', 'project-overrides'] }), + ); + + expect(mocks.documentTranslationService.translateDocument).toHaveBeenCalledWith( + '/tmp/doc.pdf', + '/tmp/output.pdf', + expect.objectContaining({ glossaryIds: [A, B] }), + expect.any(Function), + ); + const passed = mocks.documentTranslationService.translateDocument.mock.calls[0]?.[2]; + expect(passed?.glossaryId).toBeUndefined(); + }); + + it('should preserve the reversed order, which changes the winning glossary', async () => { + resolveTo({ 'base-terms': A, 'project-overrides': B }); + + await handler.translateDocument( + '/tmp/doc.pdf', + defaultOptions({ from: 'en', glossary: ['project-overrides', 'base-terms'] }), + ); + + expect(mocks.documentTranslationService.translateDocument).toHaveBeenCalledWith( + '/tmp/doc.pdf', + '/tmp/output.pdf', + expect.objectContaining({ glossaryIds: [B, A] }), + expect.any(Function), + ); + }); + + it('should no longer warn that document mode ignores --glossary', async () => { + const { Logger: MockLogger } = jest.requireMock('../../src/utils/logger'); + resolveTo({ 'base-terms': A }); + + await handler.translateDocument( + '/tmp/doc.pdf', + defaultOptions({ from: 'en', glossary: ['base-terms'] }), + ); + + const warnings = MockLogger.warn.mock.calls.map((call: unknown[]) => String(call[0])); + expect(warnings.some((w: string) => w.includes('--glossary'))).toBe(false); + }); + + /** The API rejects a document glossary without source_lang. */ + it('should require --from when a glossary is given', async () => { + expect.assertions(3); + resolveTo({ 'base-terms': A }); + + await expect( + handler.translateDocument('/tmp/doc.pdf', defaultOptions({ glossary: ['base-terms'] })), + ).rejects.toThrow(ValidationError); + try { + await handler.translateDocument('/tmp/doc.pdf', defaultOptions({ glossary: ['base-terms'] })); + } catch (error) { + expect((error as ValidationError).message).toContain('--from'); + } + expect(mocks.documentTranslationService.translateDocument).not.toHaveBeenCalled(); + }); + + it('should not require --from when no glossary is given', async () => { + await handler.translateDocument('/tmp/doc.pdf', defaultOptions()); + + expect(mocks.documentTranslationService.translateDocument).toHaveBeenCalled(); + }); + + it('should fail without uploading when a glossary name does not resolve', async () => { + resolveTo({ 'base-terms': A }); + + await expect( + handler.translateDocument( + '/tmp/doc.pdf', + defaultOptions({ from: 'en', glossary: ['base-terms', 'no-such-glossary'] }), + ), + ).rejects.toThrow(/no-such-glossary/); + expect(mocks.documentTranslationService.translateDocument).not.toHaveBeenCalled(); + }); + + it('should send no glossary params when the flag is absent', async () => { + await handler.translateDocument('/tmp/doc.pdf', defaultOptions({ from: 'en' })); + + const passed = mocks.documentTranslationService.translateDocument.mock.calls[0]?.[2]; + expect(passed?.glossaryId).toBeUndefined(); + expect(passed?.glossaryIds).toBeUndefined(); + expect(mocks.glossaryService.resolveGlossaryId).not.toHaveBeenCalled(); + }); + }); + it('should pass outputFormat through', async () => { await handler.translateDocument('/tmp/doc.pdf', defaultOptions({ outputFormat: 'pdf' })); diff --git a/tests/unit/translate-command.test.ts b/tests/unit/translate-command.test.ts index be69629e..d8f4904f 100644 --- a/tests/unit/translate-command.test.ts +++ b/tests/unit/translate-command.test.ts @@ -3700,7 +3700,6 @@ describe('TranslateCommand', () => { tagHandling: 'xml', modelType: 'quality_optimized', customInstruction: ['Be formal'], - glossary: ['my-glossary'], preserveCode: true, }); @@ -3710,9 +3709,6 @@ describe('TranslateCommand', () => { expect(warnSpy).toHaveBeenCalledWith( expect.stringMatching(/--split-sentences/) ); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringMatching(/--glossary/) - ); expect(warnSpy).toHaveBeenCalledWith( expect.stringMatching(/--preserve-code/) ); @@ -3730,6 +3726,21 @@ describe('TranslateCommand', () => { expect(warnSpy).not.toHaveBeenCalled(); }); + + it('should not warn about --glossary, which document mode now supports', async () => { + (mockGlossaryService.resolveGlossaryId as jest.Mock).mockResolvedValueOnce( + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + ); + + await (translateCommand as any).fileHandler.documentHandler.translateDocument('/doc.pdf', { + to: 'es', + output: '/output.pdf', + from: 'en', + glossary: ['my-glossary'], + }); + + expect(warnSpy).not.toHaveBeenCalled(); + }); }); it('should not warn for undefined or false boolean options', async () => { From 4e0ce43ba548a17b4a93636065cfb7c454323552 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 06:44:53 -0400 Subject: [PATCH 008/256] docs: document glossary support for document translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the repeatable flag, the mandatory --from, and that translation memories remain unsupported for documents. Also note that glossary matching is context-dependent for documents exactly as it is for text — a term applied in one sentence may be left alone in another, and a bare word list often gets few terms applied. This was mistaken for a document-specific defect while investigating, so it is written down. --- CHANGELOG.md | 2 ++ docs/API.md | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index db37be6d..a29d6d41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **translate**: `--glossary` now applies to document translation (PDF, DOCX, PPTX, XLSX, images, and text-based files routed to the document API). It was previously accepted and then silently discarded with a "document mode does not support --glossary" warning, even though `POST /v2/document` supports glossaries. Repeating the flag works here too, with the same last-one-wins precedence. `--from` is required, because the API rejects a document glossary without a source language; `--translation-memory` remains unsupported for documents. Note that glossary matching is context-dependent for documents exactly as it is for text — a term applied in one sentence may be left alone in another. + - **translate**: `--glossary` is repeatable, applying up to 5 glossaries to one request via the API's `glossary_ids` parameter. Entries are merged, so terms unique to each glossary all apply; when several glossaries define the same source term the **last** `--glossary` on the command line wins, so the order is significant and is never sorted — reordering the flags is a different request with its own cache entry. Names and UUIDs may be mixed and are resolved independently. A single `--glossary` still goes out as `glossary_id`, leaving existing commands and their cache keys untouched; the new field is appended last in the cache key for the same reason. A 6th `--glossary` exits 6 (ValidationError) before any API call. `watch` and `sync` keep their single-glossary configuration. Verified against the live API, including the two encodings the endpoints require: repeated form fields for `POST /v2/translate`, and one comma-joined value for the multipart `POST /v2/document`, which keeps only the first of several repeated fields and would otherwise silently apply just one glossary. - **cli**: `deepl correct` command (alias `c`) — spelling and grammar correction without rewording, via the Write API's `/v2/write/correct` endpoint. Supports the same input handling and workflow flags as `write` (`--check` with exit code 8, `--fix`/`--backup`, `--diff`, `--interactive`, `--output`/`--in-place`, `--format json`, `--no-cache`), but not `--style`/`--tone`, which the correct endpoint does not accept. Results are cached under a separate `correct:` namespace so corrections and rephrasings of the same text never collide. diff --git a/docs/API.md b/docs/API.md index 44463398..32bfa9fa 100644 --- a/docs/API.md +++ b/docs/API.md @@ -410,6 +410,13 @@ deepl translate document.pdf --to es --output document.es.docx --output-format d # Enable document minification for smaller file size (PPTX/DOCX only) deepl translate presentation.pptx --to de --output presentation.de.pptx --enable-minification deepl translate report.docx --to fr --output report.fr.docx --enable-minification + +# Apply a glossary (--from is required for document glossaries) +deepl translate report.docx --from en --to de --output report.de.docx --glossary tech-terms + +# Repeat --glossary for up to 5; the last one wins a conflicting term +deepl translate report.docx --from en --to de --output report.de.docx \ + --glossary base-terms --glossary project-overrides ``` **Supported Document Formats:** @@ -440,6 +447,7 @@ deepl translate report.docx --to fr --output report.fr.docx --enable-minificatio - Large documents may take several seconds to translate - Maximum file sizes: 30MB (document API, all formats), 100 KiB (cached text API) - **Document minification** (`--enable-minification`): Reduces file size for PPTX and DOCX files only. Useful for large presentations and documents. +- **Glossaries**: `--glossary` applies to documents and is repeatable up to 5, with the same last-one-wins precedence as text translation. `--from` is required — the API rejects a document glossary without a source language ("source_lang has to be specified in order to use a glossary"). Glossary matching is context-dependent exactly as it is for text: a term may be applied in one sentence and left alone in another, and a bare newline-separated word list often gets few terms applied. `--translation-memory` remains unsupported for documents. **Directory translation:** From 1733329255c29eeb48065efc69ef9336177c0553 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 06:58:52 -0400 Subject: [PATCH 009/256] fix(docs): add the required --from to every --glossary example A glossary has always required --from on every path, but the flag was missing from six documented invocations, so each would have exited 6 if a reader had copied it. Two predate this work (the API.md glossary-usage block and the inline help example) and the rest came in with the repeatable flag. The Commander help for --glossary now states the --from requirement, following the existing convention of --tm-threshold noting its own, and records that the flag applies to documents as well. Add the document glossary examples and feature note to the README, which the earlier commits left covering only text translation. Fold the glossary into the document handler's existing "only supported options produce no warning" test instead of asserting it separately: glossary is now a supported document option, so it belongs in that list, and a separate test duplicated the handler suite's own coverage. --- README.md | 10 +++++++++- docs/API.md | 10 +++++----- src/cli/commands/register-translate.ts | 7 ++++--- tests/unit/translate-command.test.ts | 16 +++------------- 4 files changed, 21 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index aa0b3db9..0ea31262 100644 --- a/README.md +++ b/README.md @@ -377,6 +377,13 @@ deepl translate contract.pdf --to de --formality more --output contract.de.pdf # Specify source language deepl translate document.pdf --from en --to es --output document.es.pdf +# Apply a glossary (--from is required whenever a glossary is used) +deepl translate report.docx --from en --to de --glossary tech-terms --output report.de.docx + +# Repeat --glossary for up to 5; the last one wins a conflicting term +deepl translate report.docx --from en --to de --output report.de.docx \ + --glossary base-terms --glossary project-overrides + # Convert PDF to DOCX during translation (ONLY supported conversion) deepl translate document.pdf --to es --output-format docx --output document.es.docx # Translates PDF to Spanish and converts to editable Word format @@ -393,6 +400,7 @@ deepl translate document.pdf --to es --output-format docx --output document.es.d - ✅ **Progress Tracking** - Real-time status updates during translation - ✅ **Large Files** - Handles documents up to 30MB - ✅ **Cost Tracking** - Shows billed characters after translation +- ✅ **Glossaries** - `--glossary` applies to documents too, repeatable up to 5 (requires `--from`). Translation memories are not supported for documents. - ✅ **Async Processing** - Documents are translated on DeepL servers with polling **Supported Formats:** @@ -1284,7 +1292,7 @@ authentication Authentifizierung ```bash # Layer project overrides on top of shared base terminology -deepl translate "Hello world" --to de --glossary base-terms --glossary project-overrides +deepl translate "Hello world" --from en --to de --glossary base-terms --glossary project-overrides ``` ### Translation Memories diff --git a/docs/API.md b/docs/API.md index 32bfa9fa..966278d4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -544,10 +544,10 @@ deepl translate complex.xml --to de --tag-handling xml \ ```bash # Use glossary for consistent terminology -deepl translate "API documentation" --to es --glossary tech-terms +deepl translate "API documentation" --from en --to es --glossary tech-terms # Use glossary by ID -deepl translate README.md --to fr --glossary abc-123-def-456 --output README.fr.md +deepl translate README.md --from en --to fr --glossary abc-123-def-456 --output README.fr.md ``` **Multiple glossaries on one request:** @@ -556,13 +556,13 @@ Repeat `--glossary` to apply up to 5 glossaries to a single request. Their entri ```bash # Shared base terminology, overridden by project-specific terms -deepl translate "Hello world" --to de --glossary base-terms --glossary project-overrides +deepl translate "Hello world" --from en --to de --glossary base-terms --glossary project-overrides # Reversing the order makes base-terms win any conflicting entry -deepl translate "Hello world" --to de --glossary project-overrides --glossary base-terms +deepl translate "Hello world" --from en --to de --glossary project-overrides --glossary base-terms # Names and UUIDs can be mixed -deepl translate README.md --to fr --output README.fr.md \ +deepl translate README.md --from en --to fr --output README.fr.md \ --glossary abc-123-def-456 --glossary house-style ``` diff --git a/src/cli/commands/register-translate.ts b/src/cli/commands/register-translate.ts index 9351bf83..e6210757 100644 --- a/src/cli/commands/register-translate.ts +++ b/src/cli/commands/register-translate.ts @@ -26,7 +26,7 @@ export function registerTranslate( .option('--context ', 'Additional context to improve translation quality') .option( '--glossary ', - `Use glossary by name or ID (repeatable, max ${MAX_GLOSSARIES_PER_REQUEST}; when several define the same term, the last one wins)`, + `Use glossary by name or ID (requires --from; repeatable, max ${MAX_GLOSSARIES_PER_REQUEST}; when several define the same term, the last one wins). Applies to text, files and documents.`, (val: string, prev: string[] | undefined) => (prev ?? []).concat([val]), ) .option( @@ -81,8 +81,9 @@ Examples: $ deepl translate ./docs --to de,es,fr --pattern "*.md" $ echo "Hello" | deepl translate --to ja $ deepl translate report.pdf --to de --output-format docx - $ deepl translate "Hello" --to es --formality more --glossary my-glossary - $ deepl translate "Hello" --to es --glossary base-terms --glossary project-overrides + $ deepl translate "Hello" --from en --to es --formality more --glossary my-glossary + $ deepl translate "Hello" --from en --to es --glossary base-terms --glossary project-overrides + $ deepl translate report.docx --from en --to de --glossary tech-terms --output report.de.docx $ deepl translate page.html --to fr --tag-handling html $ deepl translate "Hello" --to es --custom-instruction "Use informal language" $ deepl translate ./docs --to es --dry-run diff --git a/tests/unit/translate-command.test.ts b/tests/unit/translate-command.test.ts index d8f4904f..6256906c 100644 --- a/tests/unit/translate-command.test.ts +++ b/tests/unit/translate-command.test.ts @@ -3715,19 +3715,6 @@ describe('TranslateCommand', () => { }); it('should not warn when only supported options are used in document mode', async () => { - await (translateCommand as any).fileHandler.documentHandler.translateDocument('/doc.pdf', { - to: 'es', - output: '/output.pdf', - from: 'en', - formality: 'more', - outputFormat: 'docx', - enableMinification: true, - }); - - expect(warnSpy).not.toHaveBeenCalled(); - }); - - it('should not warn about --glossary, which document mode now supports', async () => { (mockGlossaryService.resolveGlossaryId as jest.Mock).mockResolvedValueOnce( 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', ); @@ -3736,7 +3723,10 @@ describe('TranslateCommand', () => { to: 'es', output: '/output.pdf', from: 'en', + formality: 'more', glossary: ['my-glossary'], + outputFormat: 'docx', + enableMinification: true, }); expect(warnSpy).not.toHaveBeenCalled(); From a79698829eb12f7127f96e23b0d09fc656db627e Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 07:05:46 -0400 Subject: [PATCH 010/256] docs(watch): note that --glossary needs --from /v2/translate rejects a glossary without a source language ("Use of a glossary requires the source_lang parameter to be specified"), and watch passes --from straight through without checking it, so --glossary alone fails on every file change. Say so in the watch option docs and its inline help. sync needs no equivalent note: it has no --from, taking the source language from the required source_locale field in .deepl-sync.yaml, so its requests always carry one. --- README.md | 2 +- docs/API.md | 2 +- src/cli/commands/register-watch.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0ea31262..3a6fc3ed 100644 --- a/README.md +++ b/README.md @@ -1287,7 +1287,7 @@ authentication Authentifizierung - **Direct updates** - v3 API uses PATCH endpoints for efficient updates (no delete+recreate) - **Smart defaults** - `--target` flag only required for multilingual glossaries - **Visual indicators** - 📖 for single-target, 📚 for multilingual glossaries -- **Translation integration** - Use `--glossary` flag in translate and watch commands to apply glossary terms +- **Translation integration** - Use `--glossary` flag in translate and watch commands to apply glossary terms (always alongside `--from`; the API rejects a glossary without a source language) - **Several glossaries at once** - Repeat `--glossary` on `translate` for up to 5 glossaries; entries are merged and the last glossary given wins any conflicting term ```bash diff --git a/docs/API.md b/docs/API.md index 966278d4..2d8e523e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1102,7 +1102,7 @@ Monitor files or directories for changes and automatically translate them. Suppo - `--formality LEVEL` - Formality level: `default`, `more`, `less`, `prefer_more`, `prefer_less`, `formal`, `informal` - `--preserve-code` - Preserve code blocks - `--preserve-formatting` - Preserve line breaks and whitespace formatting -- `--glossary NAME-OR-ID` - Use glossary by name or ID for consistent terminology +- `--glossary NAME-OR-ID` - Use glossary by name or ID for consistent terminology. Pass `--from` as well: the API refuses a glossary without a source language ("Use of a glossary requires the source_lang parameter to be specified"), and `watch` does not check for it up front, so every file change fails on the API call instead. Unlike `translate`, `watch` takes a single glossary. **Git Integration:** diff --git a/src/cli/commands/register-watch.ts b/src/cli/commands/register-watch.ts index d358340d..2bedbca0 100644 --- a/src/cli/commands/register-watch.ts +++ b/src/cli/commands/register-watch.ts @@ -21,7 +21,7 @@ export function registerWatch( .option('-o, --output ', 'Output directory (default: /translations or same dir for files)') .optionsGroup('Translation Quality:') .addOption(new Option('--formality ', 'Formality level').choices(['default', 'more', 'less', 'prefer_more', 'prefer_less', 'formal', 'informal'])) - .option('--glossary ', 'Use glossary by name or ID') + .option('--glossary ', 'Use glossary by name or ID (pass --from as well; the API rejects a glossary without a source language)') .option('--preserve-code', 'Preserve code blocks and variables during translation') .option('--preserve-formatting', 'Preserve line breaks and whitespace formatting') .optionsGroup('Watch Behavior:') From 00219e215d5b19594bc254310f0cb2a4ea0b48d5 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 08:34:31 -0400 Subject: [PATCH 011/256] fix(voice): fail when a session ends without a translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A voice session that ended with the audio transcribed but no translation for a requested target language resolved successfully, printing an empty translation line and exiting 0. A script consuming the output saw success with the translation missing. Such a session now rejects with a VoiceError (exit 9) naming the untranslated languages. Audio containing no speech concludes no source text and is translated to nothing, which is legitimate, so the check applies only when a source transcript exists. The two frame shapes that reach the same place — a whitespace-only translation and one that stayed tentative without ever concluding — count as missing, because neither reaches the printed output. Unit tests that emitted source frames with no target frame have gained one: under this rule that shape is a failed session, not a valid one. --- CHANGELOG.md | 1 + docs/API.md | 3 + src/services/voice-stream-session.ts | 28 +++++ tests/unit/voice-service.test.ts | 15 +++ tests/unit/voice-stream-session.test.ts | 133 ++++++++++++++++++++++++ 5 files changed, 180 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a29d6d41..e47fc982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **voice**: A session that ends with the audio transcribed but no translation for a requested `--to` language now fails with exit code 9 and names the languages, instead of printing an empty translation line and exiting 0. Silent partial output was the worse failure: a script consuming `deepl voice` output saw success with the translation missing. Audio containing no speech transcribes to nothing and translates to nothing, which is legitimate and still exits 0, so the check only applies when a source transcript exists. Whitespace-only and tentative-but-never-concluded translations count as missing, since neither reaches the printed output. Investigated as an intermittent (~1 in 4) empty translated line; the leading hypothesis was disproved with frame-level traces of live sessions — the server sends `end_of_stream` strictly after `end_of_target_transcript`, and the client only tears the socket down on `end_of_stream`, so there is no client-side teardown race. Reconnect was ruled out too: a socket dropped after end-of-source cannot be resumed (the API returns `410 Gone`) and already exited non-zero. The empty line did not reproduce in 64 live runs across pacing, burst, multi-target, and concurrency variations, so the trigger appears to be server-side and transient — which is exactly why the client needs to detect it rather than report success. - **formats**: TOML reconstruction escapes U+2028/U+2029 (Unicode line/paragraph separators) in double-quoted values, and literal-string values gaining one fall back to double quotes. Written raw, these characters broke the entry-line scan on the *next* sync (JavaScript's `.` excludes line terminators), which re-appended the key as a duplicate and made the third sync fail to parse the file at all — first sync fine, second silently corrupting, third crashing. Found by the property-based round-trip suite. - **formats**: `.properties` reconstruction escapes leading spaces in values (`\ `), which the value parser otherwise strips on the next read — a translation beginning with a space silently lost it on every subsequent sync. Leading tabs, trailing spaces, and newlines were already escaped correctly. Found by the property-based round-trip suite. - **sync**: `--auto-commit` now recognises its own translation output rather than only the files a given run wrote, which fixes two related problems. A translation left on disk by an earlier refused run was classed as an unrelated modification, so auto-commit refused forever and the user had to commit it by hand; it is now committed once the genuinely unrelated changes are dealt with. And in `--watch` mode, where the same path runs once per trigger, a trigger that translated nothing skipped the checks entirely and reported success while a commit was still owed. Staging is also driven by what is actually dirty, so a rewrite that produced identical bytes no longer attempts an empty commit. Ownership is derived from the lockfile's tracked source files, with each file matched to its own bucket so one bucket's `target_path_pattern` cannot claim another's output. diff --git a/docs/API.md b/docs/API.md index 2d8e523e..c195bf32 100644 --- a/docs/API.md +++ b/docs/API.md @@ -999,6 +999,8 @@ deepl voice [options] > **Note:** All formality values (`default`, `formal`, `informal`, `more`, `less`, `prefer_more`, `prefer_less`) are accepted. The voice API natively uses `formal`/`informal` (in addition to `more`/`less`), while the translate API uses `prefer_more`/`prefer_less`. +> **Note:** If the server ends the stream after transcribing the audio but without sending a translation for one of `--to`'s languages, the command fails with exit code 9 and names the languages, rather than printing an empty translation line and exiting 0. Audio containing no speech transcribes to nothing and is translated to nothing, which is not an error and still exits 0. + #### Supported Audio Formats | Extension | Content Type | @@ -3277,6 +3279,7 @@ Voice API call failed for a reason other than authentication, rate limiting, or - `deepl voice` when the plan does not include the Voice API (pre-flight check in the voice client) - Voice streaming URL validation failures (`src/api/voice-client.ts`: non-`wss://` scheme, unparseable URL, disallowed host) - Voice session lifecycle errors (failed to open, unexpected close) +- `deepl voice` when the stream ends with the source transcribed but no translation for a requested target language Remediation: confirm Pro/Enterprise plan, verify the session configuration, and retry. diff --git a/src/services/voice-stream-session.ts b/src/services/voice-stream-session.ts index abc9aff0..7d3f7786 100644 --- a/src/services/voice-stream-session.ts +++ b/src/services/voice-stream-session.ts @@ -136,6 +136,19 @@ export class VoiceStreamSession { this.ws.close(); this.closeInput(); this.finalizeTranscripts(); + + const untranslated = this.untranslatedTargets(); + if (untranslated.length > 0) { + this.fail( + reject, + new VoiceError( + `Voice session ended without a translation for: ${untranslated.join(', ')}.`, + 'The audio was transcribed but the server sent no translated text. Retry the request.', + ), + ); + return; + } + resolve({ sessionId: this.session.session_id, source: this.sourceTranscript, @@ -259,6 +272,21 @@ export class VoiceStreamSession { } } + /** + * Target languages the server ended the stream without translating. Audio + * that contained no speech concludes no source text either and is not + * treated as a failure; only a transcribed source with a missing + * translation is, since that output would otherwise look successful. + */ + private untranslatedTargets(): string[] { + if (this.sourceTranscript.text.trim() === '') { + return []; + } + return Array.from(this.targetTranscripts.values()) + .filter((transcript) => transcript.text.trim() === '') + .map((transcript) => transcript.lang); + } + private finalizeTranscripts(): void { for (const [transcript, parts] of this.textParts) { transcript.text = parts.join(' '); diff --git a/tests/unit/voice-service.test.ts b/tests/unit/voice-service.test.ts index a004ed27..4af71fdf 100644 --- a/tests/unit/voice-service.test.ts +++ b/tests/unit/voice-service.test.ts @@ -338,6 +338,11 @@ describe('VoiceService', () => { concluded: [{ text: 'world', language: 'en', start_time: 0.5, end_time: 1 }], tentative: [], }); + cb.onTargetTranscript?.({ + language: 'de', + concluded: [{ text: 'Hallo Welt', start_time: 0, end_time: 1 }], + tentative: [], + }); cb.onEndOfStream?.(); }); @@ -367,6 +372,11 @@ describe('VoiceService', () => { tentative: [], }); } + cb.onTargetTranscript?.({ + language: 'de', + concluded: [{ text: 'Segmente', start_time: 0, end_time: 1 }], + tentative: [], + }); cb.onEndOfStream?.(); }); @@ -391,6 +401,11 @@ describe('VoiceService', () => { ], tentative: [], }); + cb.onTargetTranscript?.({ + language: 'de', + concluded: [{ text: 'Eins Zwei Drei', start_time: 0, end_time: 1.5 }], + tentative: [], + }); cb.onEndOfStream?.(); }); diff --git a/tests/unit/voice-stream-session.test.ts b/tests/unit/voice-stream-session.test.ts index 0c6ea32e..cf451a5f 100644 --- a/tests/unit/voice-stream-session.test.ts +++ b/tests/unit/voice-stream-session.test.ts @@ -10,6 +10,7 @@ import { VoiceClient } from '../../src/api/voice-client.js'; import { VoiceError } from '../../src/utils/errors.js'; import type { VoiceSessionResponse, + VoiceSessionResult, VoiceTranslateOptions, VoiceStreamCallbacks, } from '../../src/types/voice.js'; @@ -602,6 +603,11 @@ describe('VoiceStreamSession', () => { concluded: [{ text: 'world', language: 'en', start_time: 0.5, end_time: 1 }], tentative: [], }); + callbacks.onTargetTranscript?.({ + language: 'de', + concluded: [{ text: 'Hallo Welt', start_time: 0, end_time: 1 }], + tentative: [], + }); callbacks.onEndOfStream?.(); }); }); @@ -667,6 +673,11 @@ describe('VoiceStreamSession', () => { concluded: [{ text: 'Bonjour', language: 'fr', start_time: 0, end_time: 1 }], tentative: [], }); + callbacks.onTargetTranscript?.({ + language: 'de', + concluded: [{ text: 'Guten Tag', start_time: 0, end_time: 1 }], + tentative: [], + }); callbacks.onEndOfStream?.(); }); }); @@ -680,6 +691,128 @@ describe('VoiceStreamSession', () => { }); }); + describe('incomplete translations', () => { + /** Drives a session to end_of_stream after emitting the given frames. */ + function runWithFrames( + frames: (callbacks: VoiceStreamCallbacks) => void, + sessionOptions: VoiceTranslateOptions = options, + chunks: AsyncGenerator = emptyChunks(), + ): Promise { + const EventEmitter = require('events'); + const mockWs = new EventEmitter(); + mockWs.readyState = 1; + mockWs.send = jest.fn(); + mockWs.close = jest.fn(); + + mockClient.createWebSocket.mockImplementation((_url, _token, callbacks) => { + process.nextTick(() => { + mockWs.emit('open'); + process.nextTick(() => { + frames(callbacks); + callbacks.onEndOfStream?.(); + }); + }); + return mockWs; + }); + + const streamSession = new VoiceStreamSession(mockClient, session, sessionOptions); + return streamSession.run(chunks); + } + + it('should reject when the source was transcribed but a target produced no text', async () => { + await expect( + runWithFrames((callbacks) => { + callbacks.onSourceTranscript?.({ + concluded: [{ text: 'Hello', language: 'en', start_time: 0, end_time: 1 }], + tentative: [], + }); + }), + ).rejects.toThrow(VoiceError); + }); + + it('should name every target language that produced no text', async () => { + expect.assertions(3); + try { + await runWithFrames( + (callbacks) => { + callbacks.onSourceTranscript?.({ + concluded: [{ text: 'Hello', language: 'en', start_time: 0, end_time: 1 }], + tentative: [], + }); + callbacks.onTargetTranscript?.({ + language: 'fr', + concluded: [{ text: 'Bonjour', start_time: 0, end_time: 1 }], + tentative: [], + }); + }, + { targetLangs: ['de', 'fr', 'es'], chunkInterval: 0 }, + ); + } catch (error) { + expect((error as Error).message).toContain('de'); + expect((error as Error).message).toContain('es'); + expect((error as Error).message).not.toContain('fr'); + } + }); + + it('should treat a whitespace-only translation as missing', async () => { + await expect( + runWithFrames((callbacks) => { + callbacks.onSourceTranscript?.({ + concluded: [{ text: 'Hello', language: 'en', start_time: 0, end_time: 1 }], + tentative: [], + }); + callbacks.onTargetTranscript?.({ + language: 'de', + concluded: [{ text: ' ', start_time: 0, end_time: 1 }], + tentative: [], + }); + }), + ).rejects.toThrow(VoiceError); + }); + + it('should reject when the only translated text stayed tentative', async () => { + await expect( + runWithFrames((callbacks) => { + callbacks.onSourceTranscript?.({ + concluded: [{ text: 'Hello', language: 'en', start_time: 0, end_time: 1 }], + tentative: [], + }); + callbacks.onTargetTranscript?.({ + language: 'de', + concluded: [], + tentative: [{ text: 'Hallo', start_time: 0, end_time: 1 }], + }); + }), + ).rejects.toThrow(VoiceError); + }); + + it('should resolve when the audio contained no speech at all', async () => { + const result = await runWithFrames(() => undefined); + + expect(result.source.text).toBe(''); + expect(result.targets[0]!.text).toBe(''); + }); + + it('should close the input generator when rejecting', async () => { + const tracked = trackedChunks(); + + await expect( + runWithFrames( + (callbacks) => { + callbacks.onSourceTranscript?.({ + concluded: [{ text: 'Hello', language: 'en', start_time: 0, end_time: 1 }], + tentative: [], + }); + }, + options, + tracked.chunks, + ), + ).rejects.toThrow(VoiceError); + + expect(tracked.closed()).toBe(true); + }); + }); + describe('callback proxying', () => { it('should proxy all callback types', async () => { const EventEmitter = require('events'); From f5b5c5350fb9acbc938efc7c4eb49e3b8d56bbcb Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 08:47:43 -0400 Subject: [PATCH 012/256] fix(watch): require --from when --glossary is used deepl watch --glossary X without --from started a watch session happily and then failed on every single file change, surfacing a raw server message each time. The API rejects any translation naming a glossary without a source language ("Use of a glossary requires the source_lang parameter to be specified"), which translate already guards against up front for text, files, and documents. watch now rejects the combination before the watcher starts, with the same message the translate handlers use and a watch-shaped example, exiting 6 as they do. The check runs before the glossary name is resolved, so it costs no API call. The option docs and inline help warned the reader that the check was missing; they now describe the requirement instead. sync needs no equivalent guard: it has no --from at all, taking the source language from the required source_locale field in .deepl-sync.yaml, so its requests always carry one. --- CHANGELOG.md | 1 + README.md | 2 +- docs/API.md | 2 +- src/cli/commands/register-watch.ts | 2 +- src/cli/commands/watch.ts | 9 ++++ tests/e2e/cli-watch.e2e.test.ts | 61 +++++++++++++++++++++++++++ tests/unit/watch-command.test.ts | 68 ++++++++++++++++++++++++++++++ 7 files changed, 142 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e47fc982..813c8aea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **watch**: `--glossary` without `--from` now exits 6 before the watcher starts, instead of starting a session that fails on every single file change with a raw server message. The API rejects any translation naming a glossary without a source language ("Use of a glossary requires the source_lang parameter to be specified"), which `translate` already guarded against up front for text, files, and documents; `watch` passed `--from` straight through. A long-running command is the worst place for this, since the operator saw the failure once per edit rather than once at launch. The check runs before the glossary name is resolved, so it costs no API call. `sync` needs no equivalent: it has no `--from` at all, taking the source language from the required `source_locale` field in `.deepl-sync.yaml`, so its requests always carry one. - **voice**: A session that ends with the audio transcribed but no translation for a requested `--to` language now fails with exit code 9 and names the languages, instead of printing an empty translation line and exiting 0. Silent partial output was the worse failure: a script consuming `deepl voice` output saw success with the translation missing. Audio containing no speech transcribes to nothing and translates to nothing, which is legitimate and still exits 0, so the check only applies when a source transcript exists. Whitespace-only and tentative-but-never-concluded translations count as missing, since neither reaches the printed output. Investigated as an intermittent (~1 in 4) empty translated line; the leading hypothesis was disproved with frame-level traces of live sessions — the server sends `end_of_stream` strictly after `end_of_target_transcript`, and the client only tears the socket down on `end_of_stream`, so there is no client-side teardown race. Reconnect was ruled out too: a socket dropped after end-of-source cannot be resumed (the API returns `410 Gone`) and already exited non-zero. The empty line did not reproduce in 64 live runs across pacing, burst, multi-target, and concurrency variations, so the trigger appears to be server-side and transient — which is exactly why the client needs to detect it rather than report success. - **formats**: TOML reconstruction escapes U+2028/U+2029 (Unicode line/paragraph separators) in double-quoted values, and literal-string values gaining one fall back to double quotes. Written raw, these characters broke the entry-line scan on the *next* sync (JavaScript's `.` excludes line terminators), which re-appended the key as a duplicate and made the third sync fail to parse the file at all — first sync fine, second silently corrupting, third crashing. Found by the property-based round-trip suite. - **formats**: `.properties` reconstruction escapes leading spaces in values (`\ `), which the value parser otherwise strips on the next read — a translation beginning with a space silently lost it on every subsequent sync. Leading tabs, trailing spaces, and newlines were already escaped correctly. Found by the property-based round-trip suite. diff --git a/README.md b/README.md index 3a6fc3ed..c4c5ba30 100644 --- a/README.md +++ b/README.md @@ -1287,7 +1287,7 @@ authentication Authentifizierung - **Direct updates** - v3 API uses PATCH endpoints for efficient updates (no delete+recreate) - **Smart defaults** - `--target` flag only required for multilingual glossaries - **Visual indicators** - 📖 for single-target, 📚 for multilingual glossaries -- **Translation integration** - Use `--glossary` flag in translate and watch commands to apply glossary terms (always alongside `--from`; the API rejects a glossary without a source language) +- **Translation integration** - Use `--glossary` flag in translate and watch commands to apply glossary terms (`--from` is required alongside it; the API rejects a glossary without a source language) - **Several glossaries at once** - Repeat `--glossary` on `translate` for up to 5 glossaries; entries are merged and the last glossary given wins any conflicting term ```bash diff --git a/docs/API.md b/docs/API.md index c195bf32..97074b9f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1104,7 +1104,7 @@ Monitor files or directories for changes and automatically translate them. Suppo - `--formality LEVEL` - Formality level: `default`, `more`, `less`, `prefer_more`, `prefer_less`, `formal`, `informal` - `--preserve-code` - Preserve code blocks - `--preserve-formatting` - Preserve line breaks and whitespace formatting -- `--glossary NAME-OR-ID` - Use glossary by name or ID for consistent terminology. Pass `--from` as well: the API refuses a glossary without a source language ("Use of a glossary requires the source_lang parameter to be specified"), and `watch` does not check for it up front, so every file change fails on the API call instead. Unlike `translate`, `watch` takes a single glossary. +- `--glossary NAME-OR-ID` - Use glossary by name or ID for consistent terminology. `--from` is required, because the API refuses a glossary without a source language ("Use of a glossary requires the source_lang parameter to be specified"); omitting it exits 6 before the watcher starts rather than failing on every file change. Unlike `translate`, `watch` takes a single glossary. **Git Integration:** diff --git a/src/cli/commands/register-watch.ts b/src/cli/commands/register-watch.ts index 2bedbca0..5c0b08b5 100644 --- a/src/cli/commands/register-watch.ts +++ b/src/cli/commands/register-watch.ts @@ -21,7 +21,7 @@ export function registerWatch( .option('-o, --output ', 'Output directory (default: /translations or same dir for files)') .optionsGroup('Translation Quality:') .addOption(new Option('--formality ', 'Formality level').choices(['default', 'more', 'less', 'prefer_more', 'prefer_less', 'formal', 'informal'])) - .option('--glossary ', 'Use glossary by name or ID (pass --from as well; the API rejects a glossary without a source language)') + .option('--glossary ', 'Use glossary by name or ID (requires --from; the API rejects a glossary without a source language)') .option('--preserve-code', 'Preserve code blocks and variables during translation') .option('--preserve-formatting', 'Preserve line breaks and whitespace formatting') .optionsGroup('Watch Behavior:') diff --git a/src/cli/commands/watch.ts b/src/cli/commands/watch.ts index eca83b7a..dc37d1c5 100644 --- a/src/cli/commands/watch.ts +++ b/src/cli/commands/watch.ts @@ -84,6 +84,15 @@ export class WatchCommand { ); } + // The API rejects any translation naming a glossary without source_lang, so + // an unguarded watch session fails once per file change instead of at launch. + if (options.glossary && !options.from) { + throw new ValidationError( + 'Source language (--from) is required when using a glossary', + 'Example: deepl watch ./docs --from en --to es --glossary my-glossary' + ); + } + // Get git-staged files if requested let stagedFiles: Set | undefined; if (options.gitStaged) { diff --git a/tests/e2e/cli-watch.e2e.test.ts b/tests/e2e/cli-watch.e2e.test.ts index 85cb71d6..989b709b 100644 --- a/tests/e2e/cli-watch.e2e.test.ts +++ b/tests/e2e/cli-watch.e2e.test.ts @@ -82,6 +82,67 @@ describe('Watch Command E2E', () => { }); }); + describe('watch --glossary without --from', () => { + /** + * The guard sits behind the API key gate, so a key must be present. The + * unreachable baseUrl proves the exit is the guard's own: any run that got + * as far as resolving the glossary name would fail on the network instead. + */ + function deadUrlConfigDir(): string { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-watch-config-')); + fs.writeFileSync(path.join(configDir, 'config.json'), JSON.stringify({ + auth: { apiKey: 'e2e-watch-glossary-key:fx' }, + api: { baseUrl: 'http://127.0.0.1:9', usePro: false }, + defaults: { formality: 'default', preserveFormatting: true }, + cache: { enabled: false, maxSize: 1048576, ttl: 2592000 }, + output: { format: 'text', verbose: false, color: false }, + watch: { debounceMs: 500, autoCommit: false }, + }, null, 2)); + return configDir; + } + + it('should exit 6 without starting a watcher', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-watch-e2e-')); + fs.writeFileSync(path.join(tmpDir, 'readme.md'), 'Hello world'); + const configDir = deadUrlConfigDir(); + const { runCLIExpectError } = makeNodeRunCLI(configDir); + + try { + const { status, output } = runCLIExpectError( + `watch ${tmpDir} --to es --glossary my-glossary`, + { timeout: 20000 }, + ); + + expect(status).toBe(6); + expect(output).toContain('Source language (--from) is required when using a glossary'); + expect(output).not.toContain('Watching for changes'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); + + it('should get past the guard when --from is given', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-watch-e2e-')); + fs.writeFileSync(path.join(tmpDir, 'readme.md'), 'Hello world'); + const configDir = deadUrlConfigDir(); + const { runCLIExpectError } = makeNodeRunCLI(configDir); + + try { + const { status, output } = runCLIExpectError( + `watch ${tmpDir} --from en --to es --glossary my-glossary`, + { timeout: 20000 }, + ); + + expect(output).not.toContain('Source language (--from) is required'); + expect(status).not.toBe(6); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); + }); + describe('watch without required arguments', () => { it('should fail when no path is provided', () => { try { diff --git a/tests/unit/watch-command.test.ts b/tests/unit/watch-command.test.ts index a5375073..61c83693 100644 --- a/tests/unit/watch-command.test.ts +++ b/tests/unit/watch-command.test.ts @@ -8,6 +8,7 @@ import { WatchService } from '../../src/services/watch'; import { FileTranslationService } from '../../src/services/file-translation'; import { TranslationService } from '../../src/services/translation'; import { GlossaryService } from '../../src/services/glossary'; +import { ValidationError } from '../../src/utils/errors'; import { createMockTranslationService, createMockGlossaryService, @@ -401,6 +402,73 @@ describe('WatchCommand', () => { ).rejects.toThrow('No target language specified.'); }); + describe('--glossary without --from', () => { + it('should reject before starting a watcher', async () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); + + await expect( + watchCommand.watch('/some/file.md', { + to: 'es', + glossary: 'my-glossary', + }) + ).rejects.toThrow('Source language (--from) is required when using a glossary'); + + expect(mockWatchService.watch).not.toHaveBeenCalled(); + }); + + it('should reject with a ValidationError carrying a watch example', async () => { + expect.assertions(3); + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); + + try { + await watchCommand.watch('/some/file.md', { + to: 'es', + glossary: 'my-glossary', + }); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).exitCode).toBe(6); + expect((error as ValidationError).suggestion).toContain('deepl watch'); + } + }); + + it('should not resolve the glossary name, which would call the API', async () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); + + await expect( + watchCommand.watch('/some/file.md', { + to: 'es', + glossary: 'my-glossary', + }) + ).rejects.toThrow(ValidationError); + + expect(mockGlossaryService.resolveGlossaryId).not.toHaveBeenCalled(); + }); + + it('should accept --glossary when --from is provided', async () => { + expect.assertions(1); + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); + mockGlossaryService.resolveGlossaryId.mockResolvedValue('resolved-id'); + mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); + + try { + await watchCommand.watch('/some/file.md', { + to: 'es', + from: 'en', + glossary: 'my-glossary', + }); + } catch { + // Expected: the watcher is stubbed to throw once reached + } + + expect(mockGlossaryService.resolveGlossaryId).toHaveBeenCalledWith('my-glossary'); + }); + }); + it('should display initial watch message with all options', async () => { (fs.existsSync as jest.Mock).mockReturnValue(true); (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); From d15bc8992740967a359e2edacf303d09ba8243b1 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 09:15:38 -0400 Subject: [PATCH 013/256] refactor(cli): display language codes in lowercase everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Output mixed three casings: `deepl languages` printed lowercase from the registry, `glossary show` and `tm list` uppercased at display time, `translate`'s table uppercased the target language, and `write`/`correct` used BCP-47 (en-GB, zh-Hans). Lowercase matches the CLI's own normalized form, the registry, what `deepl languages` teaches users to type, and the wire format /v3/languages moved to. Uppercase followed a v2-era docs convention that v3 abandons. Display-time .toUpperCase() is removed from glossary.ts, tm.ts, and formatters.ts. The v3 glossary and translation-memory endpoints already return lowercase, so removal is sufficient — verified against the live API. WRITE_LANGUAGES is lowercased, which changes what write/correct send as target_lang, not just what they display. The Write API accepts any casing and canonicalizes server-side: /v2/write/rephrase and /v2/write/correct return 200 for en-gb, zh-hans, and zh-HANS alike and echo back en-GB / zh-Hans. Since the echoed value is the API's casing rather than ours, WriteImprovement's targetLanguage is typed as the string it is instead of claiming to be one of our codes. Input remains case-insensitive everywhere, so no command line has to change. Wire parameters that are not display are untouched: translate and the glossary create endpoint still send uppercase as those endpoints document. Parseable output shifts casing for scripts: `glossary show` reports "Source language: en" and "en → es", `tm list` renders "brand-terms (en → de, fr)", and `write --format json` reports "language": "en-us". --- CHANGELOG.md | 1 + README.md | 60 ++++---- docs/API.md | 74 ++++----- examples/13-write.sh | 40 ++--- examples/36-write-extended-languages.sh | 10 +- examples/37-correct.sh | 4 +- src/api/write-client.ts | 2 +- src/cli/commands/glossary.ts | 6 +- src/cli/commands/register-write.ts | 7 +- src/cli/commands/tm.ts | 4 +- src/types/api.ts | 13 +- src/utils/formatters.ts | 2 +- .../cli-correct.integration.test.ts | 30 ++-- .../integration/cli-write.integration.test.ts | 68 ++++----- .../deepl-client.integration.test.ts | 8 +- tests/unit/deepl-client-lazy.test.ts | 2 +- tests/unit/deepl-client.test.ts | 36 ++--- tests/unit/formatters.test.ts | 22 +-- tests/unit/glossary-command.test.ts | 10 +- tests/unit/register-correct.test.ts | 2 +- tests/unit/register-write.test.ts | 16 +- tests/unit/text-translation-handler.test.ts | 4 +- tests/unit/tm-command.test.ts | 4 +- tests/unit/translate-command.test.ts | 4 +- tests/unit/write-client.test.ts | 8 +- tests/unit/write-command.test.ts | 142 +++++++++--------- tests/unit/write-service.test.ts | 102 ++++++------- 27 files changed, 342 insertions(+), 339 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 813c8aea..57518f0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **cli**: **Language codes are displayed in lowercase everywhere.** Output previously mixed three casings: `deepl languages` printed lowercase from the registry, `glossary show` and `tm list` uppercased at display time, `translate`'s table uppercased the target language, and `write`/`correct` used BCP-47 (`en-GB`, `zh-Hans`). Lowercase matches the CLI's own normalized form, the registry, what `deepl languages` teaches users to type, and the wire format `/v3/languages` moved to; uppercase followed a v2-era docs convention that v3 abandons. **Scripts scraping these values will see a casing change** — `glossary show` now reports `Source language: en` and `en → es: 5 entries`, `tm list` renders `brand-terms (en → de, fr)`, `translate --format table` labels rows `de`, and `write --format json` reports `"language": "en-us"`. Input remains case-insensitive everywhere, so no command line has to change. `write`/`correct` also send the lowercase code as `target_lang`: the Write API accepts any casing and canonicalizes server-side (verified live on `/v2/write/rephrase` and `/v2/write/correct` — `en-gb`, `zh-hans`, and `zh-HANS` all return 200 and echo back `en-GB` / `zh-Hans`). Wire parameters that are not display are untouched: `translate` and the glossary create endpoint still send uppercase `target_lang`/`source_lang` as those endpoints document. - **api**: Language listings migrated from the formally deprecated `GET /v2/languages` and `GET /v2/glossary-language-pairs` endpoints to `GET /v3/languages` (`resource=translate_text` / `resource=glossary`). Command output is unchanged: source/target lists derive from the v3 `usable_as_source`/`usable_as_target` flags, glossary pairs from the source×target cross-product (verified identical to the v2 pair list), and the `[F]` formality markers now come from the language registry since the v3 response no longer reports formality support. ### Removed diff --git a/README.md b/README.md index c4c5ba30..fa928b15 100644 --- a/README.md +++ b/README.md @@ -592,87 +592,87 @@ See [examples/08-model-type-selection.sh](./examples/08-model-type-selection.sh) Improve your writing with AI-powered grammar, style, and tone suggestions using the **DeepL Write API**. -The `--lang` flag is optional. If omitted, DeepL auto-detects the language and rephrases in the original language. Generic codes `en` and `pt` are also accepted (mapped to `en-US` and `pt-BR` respectively). +The `--lang` flag is optional. If omitted, DeepL auto-detects the language and rephrases in the original language. Generic codes `en` and `pt` are also accepted (mapped to `en-us` and `pt-br` respectively). ```bash # Auto-detect language (--lang is optional) deepl write "This is a sentence." # Specify language explicitly -deepl write "This is a sentence." --lang en-US +deepl write "This is a sentence." --lang en-us -# Use generic language code (en maps to en-US, pt maps to pt-BR) +# Use generic language code (en maps to en-us, pt maps to pt-br) deepl write "This is a sentence." --lang en # Apply business writing style -deepl write "We want to tell you about our new product." --lang en-US --style business +deepl write "We want to tell you about our new product." --lang en-us --style business # Apply academic writing style -deepl write "This shows that the method works." --lang en-US --style academic +deepl write "This shows that the method works." --lang en-us --style academic # Apply casual tone -deepl write "That is interesting." --lang en-US --style casual +deepl write "That is interesting." --lang en-us --style casual # Use confident tone -deepl write "I think this will work." --lang en-US --tone confident +deepl write "I think this will work." --lang en-us --tone confident # Use diplomatic tone -deepl write "Try something else." --lang en-US --tone diplomatic +deepl write "Try something else." --lang en-us --tone diplomatic # Show all alternative improvements deepl write "This is good." --tone enthusiastic --alternatives # Improve files and save to output -deepl write input.txt --lang en-US --output improved.txt +deepl write input.txt --lang en-us --output improved.txt # Edit file in place -deepl write document.md --lang en-US --in-place +deepl write document.md --lang en-us --in-place # Interactive mode - choose from multiple style alternatives # Generates improvements with simple, business, academic, and casual styles -deepl write "Text to improve." --lang en-US --interactive +deepl write "Text to improve." --lang en-us --interactive # Interactive mode with file -deepl write document.md --lang en-US --interactive --in-place +deepl write document.md --lang en-us --interactive --in-place # Interactive mode with specific style (single option) -deepl write "Text to improve." --lang en-US --style business --interactive +deepl write "Text to improve." --lang en-us --style business --interactive # Check if text needs improvement (exit code 0 if no changes needed) -deepl write document.md --lang en-US --check +deepl write document.md --lang en-us --check # Auto-fix files in place -deepl write document.md --lang en-US --fix +deepl write document.md --lang en-us --fix # Auto-fix with backup -deepl write document.md --lang en-US --fix --backup +deepl write document.md --lang en-us --fix --backup # Show diff between original and improved -deepl write file.txt --lang en-US --diff +deepl write file.txt --lang en-us --diff # Show diff for plain text -deepl write "This text could be better." --lang en-US --diff +deepl write "This text could be better." --lang en-us --diff # Bypass cache for this request -deepl write "Fresh improvement please." --lang en-US --no-cache +deepl write "Fresh improvement please." --lang en-us --no-cache ``` **Supported Languages:** - German (`de`) - English (`en`) - generic, defaults to American English -- English - British (`en-GB`) -- English - American (`en-US`) +- English - British (`en-gb`) +- English - American (`en-us`) - Spanish (`es`) - French (`fr`) - Italian (`it`) - Japanese (`ja`) - Korean (`ko`) - Portuguese (`pt`) - generic, defaults to Brazilian Portuguese -- Portuguese - Brazilian (`pt-BR`) -- Portuguese - European (`pt-PT`) +- Portuguese - Brazilian (`pt-br`) +- Portuguese - European (`pt-pt`) - Chinese (`zh`) - generic, defaults to Simplified Chinese -- Chinese - Simplified (`zh-Hans`) +- Chinese - Simplified (`zh-hans`) **Writing Styles:** @@ -1183,8 +1183,8 @@ DeepL glossaries ensure consistent terminology across translations. The v3 Gloss echo -e "API\tAPI\nREST\tREST\nauthentication\tAuthentifizierung" > glossary.tsv deepl glossary create tech-terms en de glossary.tsv # ✓ Glossary created: tech-terms (ID: abc123...) -# Source language: EN -# Target languages: DE +# Source language: en +# Target languages: de # Type: Single target # Total entries: 3 @@ -1282,8 +1282,8 @@ authentication Authentifizierung **Key Features:** -- **Single-target glossaries** - One source language → one target language (e.g., EN → DE) -- **Multilingual glossaries** - One source language → multiple target languages (e.g., EN → ES, FR, DE) +- **Single-target glossaries** - One source language → one target language (e.g., en → de) +- **Multilingual glossaries** - One source language → multiple target languages (e.g., en → es, fr, de) - **Direct updates** - v3 API uses PATCH endpoints for efficient updates (no delete+recreate) - **Smart defaults** - `--target` flag only required for multilingual glossaries - **Visual indicators** - 📖 for single-target, 📚 for multilingual glossaries @@ -1302,8 +1302,8 @@ Reuse approved translations from your account's translation memories. TMs are au ```bash # List translation memories on the account deepl tm list -# brand-terms (EN → DE, FR, JA) -# legal-phrases (EN → FR) +# brand-terms (en → de, fr, ja) +# legal-phrases (en → fr) # JSON output for scripting deepl tm list --format json diff --git a/docs/API.md b/docs/API.md index 97074b9f..6b3af157 100644 --- a/docs/API.md +++ b/docs/API.md @@ -711,7 +711,7 @@ Enhance text quality with AI-powered grammar checking, style improvement, and to **Language:** -- `--lang, -l LANG` - Target language: `de`, `en`, `en-GB`, `en-US`, `es`, `fr`, `it`, `ja`, `ko`, `pt`, `pt-BR`, `pt-PT`, `zh`, `zh-Hans`. Optional — omit to auto-detect the language and rephrase in the original language. +- `--lang, -l LANG` - Target language: `de`, `en`, `en-gb`, `en-us`, `es`, `fr`, `it`, `ja`, `ko`, `pt`, `pt-br`, `pt-pt`, `zh`, `zh-hans`. Optional — omit to auto-detect the language and rephrase in the original language. - `--to LANG` - Long-only alias of `--lang`. Accepts the same language values. Provided for muscle-memory consistency with `deepl translate --to`; the short form `-t` is intentionally **not** bound here (it would collide with `deepl translate -t, --to`). Specifying both `--to` and `--lang` with different values exits with a `ValidationError`. **Style Options (mutually exclusive with tone):** @@ -738,9 +738,9 @@ Enhance text quality with AI-powered grammar checking, style improvement, and to | Target language | `--style` | `--tone` | |-----------------|:---------:|:--------:| -| `en`, `en-GB`, `en-US`, `de` | ✓ | ✓ | -| `es`, `fr`, `it`, `pt`, `pt-BR`, `pt-PT` | ✓ | ✓ | -| `ja`, `ko`, `zh`, `zh-Hans` | — | — | +| `en`, `en-gb`, `en-us`, `de` | ✓ | ✓ | +| `es`, `fr`, `it`, `pt`, `pt-br`, `pt-pt` | ✓ | ✓ | +| `ja`, `ko`, `zh`, `zh-hans` | — | — | When `--style` or `--tone` is set for a target language that does not support it, the server returns a 4xx; the CLI converts that response into a `ValidationError` (exit code 6) that names the unsupported combination and points back to this table. @@ -764,18 +764,18 @@ When `--style` or `--tone` is set for a target language that does not support it - `de` - German - `en` - English (generic, defaults to American English) -- `en-GB` - British English -- `en-US` - American English +- `en-gb` - British English +- `en-us` - American English - `es` - Spanish - `fr` - French - `it` - Italian - `ja` - Japanese - `ko` - Korean - `pt` - Portuguese (generic, defaults to Brazilian Portuguese) -- `pt-BR` - Brazilian Portuguese -- `pt-PT` - European Portuguese +- `pt-br` - Brazilian Portuguese +- `pt-pt` - European Portuguese - `zh` - Chinese (generic, defaults to Simplified Chinese) -- `zh-Hans` - Simplified Chinese +- `zh-hans` - Simplified Chinese #### Examples @@ -789,7 +789,7 @@ deepl write "Me and him went to store." **With explicit language:** ```bash -deepl write "Me and him went to store." --lang en-US +deepl write "Me and him went to store." --lang en-us # → "He and I went to the store." ``` @@ -797,11 +797,11 @@ deepl write "Me and him went to store." --lang en-US ```bash # Business style -deepl write "We want to tell you about our product." --lang en-US --style business +deepl write "We want to tell you about our product." --lang en-us --style business # → "We are pleased to inform you about our product." # Casual style -deepl write "The analysis demonstrates significant findings." --lang en-US --style casual +deepl write "The analysis demonstrates significant findings." --lang en-us --style casual # → "The analysis shows some pretty big findings." ``` @@ -809,67 +809,67 @@ deepl write "The analysis demonstrates significant findings." --lang en-US --sty ```bash # Confident tone -deepl write "I think this might work." --lang en-US --tone confident +deepl write "I think this might work." --lang en-us --tone confident # → "This will work." # Diplomatic tone -deepl write "Your approach is wrong." --lang en-US --tone diplomatic +deepl write "Your approach is wrong." --lang en-us --tone diplomatic # → "Perhaps we could consider an alternative approach." ``` **Show alternatives:** ```bash -deepl write "This is good." --lang en-US --alternatives +deepl write "This is good." --lang en-us --alternatives ``` **File operations:** ```bash # Improve file and save to new location -deepl write document.txt --lang en-US --output improved.txt +deepl write document.txt --lang en-us --output improved.txt # Edit file in place -deepl write document.txt --lang en-US --in-place +deepl write document.txt --lang en-us --in-place # Auto-fix with backup -deepl write document.txt --lang en-US --fix --backup +deepl write document.txt --lang en-us --fix --backup ``` **Interactive mode:** ```bash # Choose from multiple alternatives interactively -deepl write "Text to improve." --lang en-US --interactive +deepl write "Text to improve." --lang en-us --interactive ``` **Check mode:** ```bash # Check if file needs improvement (exit code 8 if changes needed) -deepl write document.md --lang en-US --check +deepl write document.md --lang en-us --check ``` **Diff view:** ```bash # Show differences between original and improved -deepl write file.txt --lang en-US --diff +deepl write file.txt --lang en-us --diff ``` **JSON output:** ```bash # Get machine-readable JSON output -deepl write "This are good." --lang en-US --format json -# {"original":"This are good.","improved":"This is good.","changes":1,"language":"en-US"} +deepl write "This are good." --lang en-us --format json +# {"original":"This are good.","improved":"This is good.","changes":1,"language":"en-us"} ``` **Bypass cache:** ```bash # Force a fresh API call, skipping cached results -deepl write "Improve this text." --lang en-US --no-cache +deepl write "Improve this text." --lang en-us --no-cache ``` --- @@ -897,7 +897,7 @@ Fixes spelling and grammar only, avoiding the broader rewording that `deepl writ **Language:** -- `--lang, -l LANG` - Target language: `de`, `en`, `en-GB`, `en-US`, `es`, `fr`, `it`, `ja`, `ko`, `pt`, `pt-BR`, `pt-PT`, `zh`, `zh-Hans`. Optional — omit to auto-detect the language and correct in the original language. +- `--lang, -l LANG` - Target language: `de`, `en`, `en-gb`, `en-us`, `es`, `fr`, `it`, `ja`, `ko`, `pt`, `pt-br`, `pt-pt`, `zh`, `zh-hans`. Optional — omit to auto-detect the language and correct in the original language. - `--to LANG` - Long-only alias of `--lang`, as on `write`. **Output Modes:** @@ -1685,8 +1685,8 @@ cache caché # Create single-target glossary from TSV file deepl glossary create tech-terms en es glossary.tsv # ✓ Glossary created: tech-terms (ID: abc123...) -# Source language: EN -# Target languages: ES +# Source language: en +# Target languages: es # Type: Single target # Total entries: 3 @@ -1746,8 +1746,8 @@ Show glossary details including name, ID, languages, creation date, and entry co deepl glossary show tech-terms # Name: tech-terms # ID: abc123... -# Source language: EN -# Target languages: DE +# Source language: en +# Target languages: de # Type: Single target # Total entries: 3 # Created: 2024-10-07T12:34:56.000Z @@ -1756,15 +1756,15 @@ deepl glossary show tech-terms deepl glossary show multilingual-terms # Name: multilingual-terms # ID: def456... -# Source language: EN -# Target languages: ES, FR, DE +# Source language: en +# Target languages: es, fr, de # Type: Multilingual # Total entries: 15 # # Language pairs: -# EN → ES: 5 entries -# EN → FR: 5 entries -# EN → DE: 5 entries +# en → es: 5 entries +# en → fr: 5 entries +# en → de: 5 entries # Created: 2024-10-08T10:00:00.000Z ``` @@ -2104,7 +2104,7 @@ List all translation memories on the account. **Output Format (text):** -- Per-TM: `name (source → target[, target...])` — e.g. `brand-terms (EN → DE, FR, JA)`. Control chars and zero-width codepoints are stripped from the rendered name to prevent a malicious API-returned name from corrupting the terminal via ANSI escape sequences. +- Per-TM: `name (source → target[, target...])` — e.g. `brand-terms (en → de, fr, ja)`. Control chars and zero-width codepoints are stripped from the rendered name to prevent a malicious API-returned name from corrupting the terminal via ANSI escape sequences. - Empty list: `No translation memories found` **Output Format (JSON):** @@ -2115,8 +2115,8 @@ Raw `TranslationMemory[]` as returned by `GET /v3/translation_memories` — fiel ```bash deepl tm list -# brand-terms (EN → DE, FR, JA) -# legal-phrases (EN → FR) +# brand-terms (en → de, fr, ja) +# legal-phrases (en → fr) deepl tm list --format json | jq '.[] | select(.name == "brand-terms") | .translation_memory_id' # "3f2504e0-4f89-41d3-9a0c-0305e82c3301" diff --git a/examples/13-write.sh b/examples/13-write.sh index 287ad185..16f627a9 100755 --- a/examples/13-write.sh +++ b/examples/13-write.sh @@ -19,52 +19,52 @@ echo # Basic text improvement echo "1. Basic text improvement:" -deepl write "This is a sentence." --lang en-US +deepl write "This is a sentence." --lang en-us echo # Business writing style echo "2. Business writing style:" -deepl write "We want to tell you about our new product." --lang en-US --style business +deepl write "We want to tell you about our new product." --lang en-us --style business echo # Academic writing style echo "3. Academic writing style:" -deepl write "This shows that the method works." --lang en-US --style academic +deepl write "This shows that the method works." --lang en-us --style academic echo # Casual writing style echo "4. Casual writing style:" -deepl write "That is interesting." --lang en-US --style casual +deepl write "That is interesting." --lang en-us --style casual echo # Simple writing style echo "5. Simple writing style:" -deepl write "The implementation demonstrates efficacy." --lang en-US --style simple +deepl write "The implementation demonstrates efficacy." --lang en-us --style simple echo # Enthusiastic tone echo "6. Enthusiastic tone:" -deepl write "This is good." --lang en-US --tone enthusiastic +deepl write "This is good." --lang en-us --tone enthusiastic echo # Friendly tone echo "7. Friendly tone:" -deepl write "Hello." --lang en-US --tone friendly +deepl write "Hello." --lang en-us --tone friendly echo # Confident tone echo "8. Confident tone:" -deepl write "I think this will work." --lang en-US --tone confident +deepl write "I think this will work." --lang en-us --tone confident echo # Diplomatic tone echo "9. Diplomatic tone:" -deepl write "Try something else." --lang en-US --tone diplomatic +deepl write "Try something else." --lang en-us --tone diplomatic echo # Show alternatives echo "10. Show all alternative improvements:" -deepl write "This is a test." --lang en-US --alternatives +deepl write "This is a test." --lang en-us --alternatives echo # Different languages @@ -82,11 +82,11 @@ echo # Prefer styles (fallback if not supported) echo "14. Prefer business style (with fallback):" -deepl write "We need to discuss this." --lang en-US --style prefer_business +deepl write "We need to discuss this." --lang en-us --style prefer_business echo echo "15. Bypass cache (always call API):" -deepl write "This is a sentence." --lang en-US --no-cache +deepl write "This is a sentence." --lang en-us --no-cache echo # ═══════════════════════════════════════════════════════ @@ -97,11 +97,11 @@ DEMO_FILE="/tmp/deepl-write-demo.txt" echo "Their going to the store tommorow. The weather will be good, I think we should definately go." > "$DEMO_FILE" echo "16. Improve text from a file:" -deepl write "$DEMO_FILE" --lang en-US +deepl write "$DEMO_FILE" --lang en-us echo echo "17. Write improved text to output file:" -deepl write "$DEMO_FILE" --output /tmp/deepl-write-improved.txt --lang en-US +deepl write "$DEMO_FILE" --output /tmp/deepl-write-improved.txt --lang en-us echo " Output saved to /tmp/deepl-write-improved.txt" cat /tmp/deepl-write-improved.txt echo @@ -111,11 +111,11 @@ echo # ═══════════════════════════════════════════════════════ echo "18. Check if text needs improvement (exit 0=clean, 8=changes needed):" -deepl write "Their going to the store" --check --lang en-US || true +deepl write "Their going to the store" --check --lang en-us || true echo echo "19. Check a file for improvements:" -deepl write "$DEMO_FILE" --check --lang en-US || true +deepl write "$DEMO_FILE" --check --lang en-us || true echo echo "20. Auto-fix a file in place:" @@ -136,12 +136,12 @@ echo # ═══════════════════════════════════════════════════════ echo "22. Show diff between original and improved text:" -deepl write "Their going to the store tommorow." --diff --lang en-US +deepl write "Their going to the store tommorow." --diff --lang en-us echo echo "23. Edit file in place:" echo "This text could be more better." > "$DEMO_FILE" -deepl write "$DEMO_FILE" --in-place --lang en-US +deepl write "$DEMO_FILE" --in-place --lang en-us echo " Updated file content:" cat "$DEMO_FILE" echo @@ -151,7 +151,7 @@ echo # ═══════════════════════════════════════════════════════ echo "24. JSON output format:" -deepl write "Their going to the store" --format json --lang en-US +deepl write "Their going to the store" --format json --lang en-us echo # ═══════════════════════════════════════════════════════ @@ -160,7 +160,7 @@ echo # Note: --interactive requires a TTY (won't work in piped scripts) echo "25. Interactive mode (choose from multiple suggestions):" -echo " deepl write \"Their going to the store\" --interactive --lang en-US" +echo " deepl write \"Their going to the store\" --interactive --lang en-us" echo " (Skipped in non-interactive script — try this manually)" echo diff --git a/examples/36-write-extended-languages.sh b/examples/36-write-extended-languages.sh index f01a74cf..a8a1b58c 100755 --- a/examples/36-write-extended-languages.sh +++ b/examples/36-write-extended-languages.sh @@ -1,6 +1,6 @@ #!/bin/bash # Example 36: Write — extended language coverage -# Demonstrates JA/KO/ZH/zh-Hans target languages and +# Demonstrates ja/ko/zh/zh-hans target languages and # tone / style applied to ES/IT/FR/PT variants. set -e @@ -30,8 +30,8 @@ echo "3. Simplified Chinese target (zh)" deepl write "请改进这句话。" --lang zh echo -echo "4. Simplified Chinese target (zh-Hans)" -deepl write "请改进这句话。" --lang zh-Hans +echo "4. Simplified Chinese target (zh-hans)" +deepl write "请改进这句话。" --lang zh-hans echo # Tone / style on Romance variants @@ -48,11 +48,11 @@ deepl write "Les résultats montrent une corrélation." --lang fr --style academ echo echo "8. Portuguese (Brazil) + friendly tone" -deepl write "Podemos ajudar com isso." --lang pt-BR --tone friendly +deepl write "Podemos ajudar com isso." --lang pt-br --tone friendly echo echo "9. Portuguese (Portugal) + confident tone" -deepl write "Vamos entregar no prazo." --lang pt-PT --tone confident +deepl write "Vamos entregar no prazo." --lang pt-pt --tone confident echo # Auto-detect round-trip for a CJK input diff --git a/examples/37-correct.sh b/examples/37-correct.sh index 667a8c93..93fb8acb 100755 --- a/examples/37-correct.sh +++ b/examples/37-correct.sh @@ -26,7 +26,7 @@ deepl correct "This is an test with some mistaks." echo echo "2. Explicit target language" -deepl correct "Their going too the store." --lang en-US +deepl correct "Their going too the store." --lang en-us echo echo "3. The c alias" @@ -34,7 +34,7 @@ deepl c "I has a apple." echo echo "4. Diff view" -deepl correct "This are a example sentence." --lang en-US --diff +deepl correct "This are a example sentence." --lang en-us --diff echo echo "5. Check mode (exit 8 would mean corrections needed)" diff --git a/src/api/write-client.ts b/src/api/write-client.ts index ba708a80..43892fad 100644 --- a/src/api/write-client.ts +++ b/src/api/write-client.ts @@ -89,7 +89,7 @@ export class WriteClient extends HttpClient { return response.improvements.map(improvement => ({ text: improvement.text, - targetLanguage: improvement.target_language as WriteImprovement['targetLanguage'], + targetLanguage: improvement.target_language, detectedSourceLanguage: improvement.detected_source_language, })); } diff --git a/src/cli/commands/glossary.ts b/src/cli/commands/glossary.ts index 910bf527..1490c502 100644 --- a/src/cli/commands/glossary.ts +++ b/src/cli/commands/glossary.ts @@ -241,8 +241,8 @@ export class GlossaryCommand { const lines = [ `Name: ${sanitizeForTerminal(glossary.name)}`, `ID: ${glossary.glossary_id}`, - `Source language: ${glossary.source_lang.toUpperCase()}`, - `Target languages: ${glossary.target_langs.map(l => l.toUpperCase()).join(', ')}`, + `Source language: ${glossary.source_lang}`, + `Target languages: ${glossary.target_langs.join(', ')}`, `Type: ${multilingual ? 'Multilingual' : 'Single target'}`, `Total entries: ${totalEntries}`, `Created: ${createdStr}`, @@ -251,7 +251,7 @@ export class GlossaryCommand { if (multilingual) { lines.push('\nLanguage pairs:'); glossary.dictionaries.forEach(dict => { - lines.push(` ${dict.source_lang.toUpperCase()} → ${dict.target_lang.toUpperCase()}: ${dict.entry_count} entries`); + lines.push(` ${dict.source_lang} → ${dict.target_lang}: ${dict.entry_count} entries`); }); } diff --git a/src/cli/commands/register-write.ts b/src/cli/commands/register-write.ts index 6d731cdc..e0adb500 100644 --- a/src/cli/commands/register-write.ts +++ b/src/cli/commands/register-write.ts @@ -9,10 +9,11 @@ import { isNoInput } from '../../utils/confirm.js'; import { ValidationError } from '../../utils/errors.js'; import { createWriteCommand, type ServiceDeps } from './service-factory.js'; -export const WRITE_LANGUAGES = ['de', 'en', 'en-GB', 'en-US', 'es', 'fr', 'it', 'ja', 'ko', 'pt', 'pt-BR', 'pt-PT', 'zh', 'zh-Hans'] as const; +export const WRITE_LANGUAGES = ['de', 'en', 'en-gb', 'en-us', 'es', 'fr', 'it', 'ja', 'ko', 'pt', 'pt-br', 'pt-pt', 'zh', 'zh-hans'] as const; /** - * Codes are accepted in any casing and normalized to the API's form, matching - * `translate --to` and the lowercase codes `deepl languages` prints. + * Codes are accepted in any casing and normalized to lowercase, matching + * `translate --to` and the codes `deepl languages` prints. The Write API + * accepts any casing and echoes its own (`en-GB`, `zh-Hans`) regardless. */ const WRITE_LANGUAGE_BY_LOWERCASE = new Map( WRITE_LANGUAGES.map((language) => [language.toLowerCase(), language]), diff --git a/src/cli/commands/tm.ts b/src/cli/commands/tm.ts index 2b1dbcfa..d9d31a73 100644 --- a/src/cli/commands/tm.ts +++ b/src/cli/commands/tm.ts @@ -23,8 +23,8 @@ export class TmCommand { } return tms .map(tm => { - const src = tm.source_language.toUpperCase(); - const targets = tm.target_languages.map(t => t.toUpperCase()).join(', '); + const src = tm.source_language; + const targets = tm.target_languages.join(', '); return `${sanitizeName(tm.name)} (${src} \u2192 ${targets})`; }) .join('\n'); diff --git a/src/types/api.ts b/src/types/api.ts index 747fc923..fbdf6eb1 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -48,18 +48,18 @@ export interface TranslationMemory { export type WriteLanguage = | 'de' | 'en' - | 'en-GB' - | 'en-US' + | 'en-gb' + | 'en-us' | 'es' | 'fr' | 'it' | 'ja' | 'ko' | 'pt' - | 'pt-BR' - | 'pt-PT' + | 'pt-br' + | 'pt-pt' | 'zh' - | 'zh-Hans'; + | 'zh-hans'; export type WritingStyle = | 'default' @@ -95,7 +95,8 @@ export interface CorrectOptions { export interface WriteImprovement { text: string; - targetLanguage: WriteLanguage; + /** As echoed by the API, which uses its own casing (`en-GB`, `zh-Hans`). */ + targetLanguage: string; detectedSourceLanguage?: string; } diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts index eac6e94e..b64506a0 100644 --- a/src/utils/formatters.ts +++ b/src/utils/formatters.ts @@ -146,7 +146,7 @@ export function formatMultiTranslationTable( results.forEach((result) => { const row = [ - result.targetLang.toUpperCase(), + result.targetLang, result.text, ]; diff --git a/tests/integration/cli-correct.integration.test.ts b/tests/integration/cli-correct.integration.test.ts index 42126039..ab0046fe 100644 --- a/tests/integration/cli-correct.integration.test.ts +++ b/tests/integration/cli-correct.integration.test.ts @@ -45,7 +45,7 @@ describe('Correct Command Integration', () => { const scope = nock(FREE_API_URL) .post('/v2/write/correct', (body) => { expect(body.text).toBe('This is an test.'); - expect(body.target_lang).toBe('en-US'); + expect(body.target_lang).toBe('en-us'); expect(body.writing_style).toBeUndefined(); expect(body.tone).toBeUndefined(); return true; @@ -54,7 +54,7 @@ describe('Correct Command Integration', () => { improvements: [{ text: 'This is a test.', target_language: 'en-US' }], }); - const result = await writeService.correct('This is an test.', { targetLang: 'en-US' }); + const result = await writeService.correct('This is an test.', { targetLang: 'en-us' }); expect(result).toHaveLength(1); expect(result[0]?.text).toBe('This is a test.'); @@ -90,7 +90,7 @@ describe('Correct Command Integration', () => { ], }); - const result = await writeService.correct('This is an test.', { targetLang: 'en-US' }); + const result = await writeService.correct('This is an test.', { targetLang: 'en-us' }); expect(result).toHaveLength(2); expect(result[0]?.text).toBe('This is a test.'); @@ -105,7 +105,7 @@ describe('Correct Command Integration', () => { }); await expect( - writeService.correct('Perfect text.', { targetLang: 'en-US' }) + writeService.correct('Perfect text.', { targetLang: 'en-us' }) ).rejects.toThrow('No improvements returned'); }); @@ -119,7 +119,7 @@ describe('Correct Command Integration', () => { improvements: [{ text: 'This is a test.', target_language: 'en-US' }], }); - await writeService.correct('This is an test.', { targetLang: 'en-US' }); + await writeService.correct('This is an test.', { targetLang: 'en-us' }); expect(rephraseScope.isDone()).toBe(false); }); @@ -132,7 +132,7 @@ describe('Correct Command Integration', () => { .reply(403, { message: 'Authorization failed' }); await expect( - writeService.correct('Test', { targetLang: 'en-US' }) + writeService.correct('Test', { targetLang: 'en-us' }) ).rejects.toThrow(/auth|API key|forbidden/i); }); @@ -143,7 +143,7 @@ describe('Correct Command Integration', () => { .reply(429, { message: 'Too many requests' }); await expect( - writeService.correct('Test', { targetLang: 'en-US' }) + writeService.correct('Test', { targetLang: 'en-us' }) ).rejects.toThrow(/rate|too many|limit/i); }); @@ -154,7 +154,7 @@ describe('Correct Command Integration', () => { .reply(503, { message: 'Service unavailable' }); await expect( - writeService.correct('Test', { targetLang: 'en-US' }) + writeService.correct('Test', { targetLang: 'en-us' }) ).rejects.toThrow(); }); @@ -177,8 +177,8 @@ describe('Correct Command Integration', () => { improvements: [{ text: 'This is a test.', target_language: 'en-US' }], }); - const first = await writeService.correct('This is an test.', { targetLang: 'en-US' }); - const second = await writeService.correct('This is an test.', { targetLang: 'en-US' }); + const first = await writeService.correct('This is an test.', { targetLang: 'en-us' }); + const second = await writeService.correct('This is an test.', { targetLang: 'en-us' }); expect(first).toEqual(second); expect(scope.isDone()).toBe(true); @@ -199,8 +199,8 @@ describe('Correct Command Integration', () => { improvements: [{ text: 'Rephrased.', target_language: 'en-US' }], }); - const corrected = await writeService.correct('Same text.', { targetLang: 'en-US' }); - const improved = await writeService.improve('Same text.', { targetLang: 'en-US' }); + const corrected = await writeService.correct('Same text.', { targetLang: 'en-us' }); + const improved = await writeService.improve('Same text.', { targetLang: 'en-us' }); expect(corrected[0]?.text).toBe('Corrected.'); expect(improved[0]?.text).toBe('Rephrased.'); @@ -216,8 +216,8 @@ describe('Correct Command Integration', () => { improvements: [{ text: 'This is a test.', target_language: 'en-US' }], }); - await writeService.correct('This is an test.', { targetLang: 'en-US' }, { skipCache: true }); - await writeService.correct('This is an test.', { targetLang: 'en-US' }, { skipCache: true }); + await writeService.correct('This is an test.', { targetLang: 'en-us' }, { skipCache: true }); + await writeService.correct('This is an test.', { targetLang: 'en-us' }, { skipCache: true }); expect(scope.isDone()).toBe(true); }); @@ -234,7 +234,7 @@ describe('Correct Command Integration', () => { ], }); - const result = await writeService.getBestCorrection('Test', { targetLang: 'en-US' }); + const result = await writeService.getBestCorrection('Test', { targetLang: 'en-us' }); expect(result.text).toBe('Best correction.'); }); diff --git a/tests/integration/cli-write.integration.test.ts b/tests/integration/cli-write.integration.test.ts index b441a679..c873363d 100644 --- a/tests/integration/cli-write.integration.test.ts +++ b/tests/integration/cli-write.integration.test.ts @@ -46,14 +46,14 @@ describe('Write Command Integration', () => { const scope = nock(FREE_API_URL) .post('/v2/write/rephrase', (body) => { expect(body.text).toBe('Hello world'); - expect(body.target_lang).toBe('en-US'); + expect(body.target_lang).toBe('en-us'); return true; }) .reply(200, { improvements: [{ text: 'Hello, world!', target_language: 'en-US' }], }); - const result = await writeService.improve('Hello world', { targetLang: 'en-US' }); + const result = await writeService.improve('Hello world', { targetLang: 'en-us' }); expect(result).toHaveLength(1); expect(result[0]?.text).toBe('Hello, world!'); @@ -71,7 +71,7 @@ describe('Write Command Integration', () => { ], }); - const result = await writeService.improve('Hello world', { targetLang: 'en-US' }); + const result = await writeService.improve('Hello world', { targetLang: 'en-us' }); expect(result).toHaveLength(3); expect(result[0]?.text).toBe('Hello, world!'); @@ -87,7 +87,7 @@ describe('Write Command Integration', () => { }); await expect( - writeService.improve('Perfect text', { targetLang: 'en-US' }) + writeService.improve('Perfect text', { targetLang: 'en-us' }) ).rejects.toThrow('No improvements returned'); }); }); @@ -115,14 +115,14 @@ describe('Write Command Integration', () => { it('should still accept explicit target_lang', async () => { const scope = nock(FREE_API_URL) .post('/v2/write/rephrase', (body) => { - expect(body.target_lang).toBe('en-GB'); + expect(body.target_lang).toBe('en-gb'); return true; }) .reply(200, { improvements: [{ text: 'Hello, world!', target_language: 'en-GB' }], }); - const result = await writeService.improve('Hello world', { targetLang: 'en-GB' }); + const result = await writeService.improve('Hello world', { targetLang: 'en-gb' }); expect(result[0]?.text).toBe('Hello, world!'); expect(scope.isDone()).toBe(true); @@ -167,7 +167,7 @@ describe('Write Command Integration', () => { }); const result = await writeService.improve(input, { - targetLang: 'en-US', + targetLang: 'en-us', writingStyle, }); @@ -223,7 +223,7 @@ describe('Write Command Integration', () => { }); const result = await writeService.improve(input, { - targetLang: 'en-US', + targetLang: 'en-us', tone: toneValue, }); @@ -258,7 +258,7 @@ describe('Write Command Integration', () => { expectedExact: undefined, }, { - lang: 'en-GB', + lang: 'en-gb', label: 'British English', input: 'I want to buy this', output: 'I should like to purchase this item', @@ -286,7 +286,7 @@ describe('Write Command Integration', () => { expectedExact: undefined, }, { - lang: 'zh-Hans', + lang: 'zh-hans', label: 'Simplified Chinese', input: '我要买这个', output: '我想购买这个', @@ -318,14 +318,14 @@ describe('Write Command Integration', () => { { lang: 'it', field: 'writing_style' as const, value: 'casual' }, { lang: 'fr', field: 'writing_style' as const, value: 'academic' }, { lang: 'pt', field: 'writing_style' as const, value: 'simple' }, - { lang: 'pt-BR', field: 'writing_style' as const, value: 'business' }, - { lang: 'pt-PT', field: 'writing_style' as const, value: 'casual' }, + { lang: 'pt-br', field: 'writing_style' as const, value: 'business' }, + { lang: 'pt-pt', field: 'writing_style' as const, value: 'casual' }, { lang: 'es', field: 'tone' as const, value: 'friendly' }, { lang: 'it', field: 'tone' as const, value: 'confident' }, { lang: 'fr', field: 'tone' as const, value: 'diplomatic' }, { lang: 'pt', field: 'tone' as const, value: 'enthusiastic' }, - { lang: 'pt-BR', field: 'tone' as const, value: 'friendly' }, - { lang: 'pt-PT', field: 'tone' as const, value: 'confident' }, + { lang: 'pt-br', field: 'tone' as const, value: 'friendly' }, + { lang: 'pt-pt', field: 'tone' as const, value: 'confident' }, ])('should send $field=$value with target_lang=$lang', async ({ lang, field, value }) => { const targetLang = lang as WriteLanguage; const scope = nock(FREE_API_URL) @@ -353,7 +353,7 @@ describe('Write Command Integration', () => { describe('improve() - Error Handling', () => { it('should throw error for empty text', async () => { - await expect(writeService.improve('', { targetLang: 'en-US' })).rejects.toThrow( + await expect(writeService.improve('', { targetLang: 'en-us' })).rejects.toThrow( 'Text cannot be empty' ); }); @@ -361,7 +361,7 @@ describe('Write Command Integration', () => { it('should throw error when both style and tone are specified', async () => { await expect( writeService.improve('Test', { - targetLang: 'en-US', + targetLang: 'en-us', writingStyle: 'business', tone: 'friendly', }) @@ -372,7 +372,7 @@ describe('Write Command Integration', () => { nock(FREE_API_URL).post('/v2/write/rephrase').reply(403, { message: 'Invalid API key' }); await expect( - writeService.improve('Test', { targetLang: 'en-US' }) + writeService.improve('Test', { targetLang: 'en-us' }) ).rejects.toThrow('Authentication failed'); }); @@ -380,7 +380,7 @@ describe('Write Command Integration', () => { nock(FREE_API_URL).post('/v2/write/rephrase').reply(456, { message: 'Quota exceeded' }); await expect( - writeService.improve('Test', { targetLang: 'en-US' }) + writeService.improve('Test', { targetLang: 'en-us' }) ).rejects.toThrow('Quota exceeded'); }); @@ -391,7 +391,7 @@ describe('Write Command Integration', () => { .reply(429, { message: 'Too many requests' }); await expect( - writeService.improve('Test', { targetLang: 'en-US' }) + writeService.improve('Test', { targetLang: 'en-us' }) ).rejects.toThrow('Rate limit exceeded'); }); @@ -410,7 +410,7 @@ describe('Write Command Integration', () => { improvements: [{ text: 'Improved text', target_language: 'en-US' }], }); - await writeService.improve(longText, { targetLang: 'en-US' }); + await writeService.improve(longText, { targetLang: 'en-us' }); expect(scope.isDone()).toBe(true); }); @@ -426,7 +426,7 @@ describe('Write Command Integration', () => { improvements: [{ text: 'Improved text!', target_language: 'en-US' }], }); - await writeService.improve(specialText, { targetLang: 'en-US' }); + await writeService.improve(specialText, { targetLang: 'en-us' }); expect(scope.isDone()).toBe(true); }); @@ -442,7 +442,7 @@ describe('Write Command Integration', () => { improvements: [{ text: 'Hello, 世界 🌍 café!', target_language: 'en-US' }], }); - const result = await writeService.improve(unicodeText, { targetLang: 'en-US' }); + const result = await writeService.improve(unicodeText, { targetLang: 'en-us' }); expect(result[0]?.text).toContain('世界'); expect(result[0]?.text).toContain('🌍'); expect(scope.isDone()).toBe(true); @@ -462,7 +462,7 @@ describe('Write Command Integration', () => { ], }); - const result = await writeService.improve(multilineText, { targetLang: 'en-US' }); + const result = await writeService.improve(multilineText, { targetLang: 'en-us' }); expect(result[0]?.text).toContain('\n'); expect(result[0]?.text.split('\n')).toHaveLength(3); }); @@ -480,7 +480,7 @@ describe('Write Command Integration', () => { }); const result = await writeService.getBestImprovement('Test text', { - targetLang: 'en-US', + targetLang: 'en-us', }); expect(result.text).toBe('First improvement'); @@ -494,7 +494,7 @@ describe('Write Command Integration', () => { }); await expect( - writeService.getBestImprovement('Test', { targetLang: 'en-US' }) + writeService.getBestImprovement('Test', { targetLang: 'en-us' }) ).rejects.toThrow('No improvements returned'); }); }); @@ -516,7 +516,7 @@ describe('Write Command Integration', () => { }); await writeService.improve('Utilize this', { - targetLang: 'en-US', + targetLang: 'en-us', writingStyle: 'prefer_simple', }); @@ -539,7 +539,7 @@ describe('Write Command Integration', () => { }); await writeService.improve('This is good', { - targetLang: 'en-US', + targetLang: 'en-us', tone: 'prefer_enthusiastic', }); @@ -556,8 +556,8 @@ describe('Write Command Integration', () => { improvements: [{ text: 'Improved!', target_language: 'en-US' }], }); - const result1 = await writeService.improve('Test', { targetLang: 'en-US' }); - const result2 = await writeService.improve('Test', { targetLang: 'en-US' }); + const result1 = await writeService.improve('Test', { targetLang: 'en-us' }); + const result2 = await writeService.improve('Test', { targetLang: 'en-us' }); expect(result1).toEqual(result2); expect(scope.isDone()).toBe(true); @@ -571,8 +571,8 @@ describe('Write Command Integration', () => { improvements: [{ text: 'Improved!', target_language: 'en-US' }], }); - await writeService.improve('Test', { targetLang: 'en-US' }); - await writeService.improve('Test', { targetLang: 'en-US' }, { skipCache: true }); + await writeService.improve('Test', { targetLang: 'en-us' }); + await writeService.improve('Test', { targetLang: 'en-us' }, { skipCache: true }); expect(nock.isDone()).toBe(true); }); @@ -590,8 +590,8 @@ describe('Write Command Integration', () => { improvements: [{ text: 'Casual text', target_language: 'en-US' }], }); - const business = await writeService.improve('Test', { targetLang: 'en-US', writingStyle: 'business' }); - const casual = await writeService.improve('Test', { targetLang: 'en-US', writingStyle: 'casual' }); + const business = await writeService.improve('Test', { targetLang: 'en-us', writingStyle: 'business' }); + const casual = await writeService.improve('Test', { targetLang: 'en-us', writingStyle: 'casual' }); expect(business[0]?.text).toBe('Business text'); expect(casual[0]?.text).toBe('Casual text'); @@ -611,7 +611,7 @@ describe('Write Command Integration', () => { .replyWithError('Network error'); await expect( - noRetryWriteService.improve('Test', { targetLang: 'en-US' }) + noRetryWriteService.improve('Test', { targetLang: 'en-us' }) ).rejects.toThrow(); noRetryClient.destroy(); diff --git a/tests/integration/deepl-client.integration.test.ts b/tests/integration/deepl-client.integration.test.ts index 581ceb7e..3dea8691 100644 --- a/tests/integration/deepl-client.integration.test.ts +++ b/tests/integration/deepl-client.integration.test.ts @@ -585,14 +585,14 @@ describe('DeepLClient Integration', () => { const scope = nock(FREE_API_URL) .post('/v2/write/rephrase', (body) => { expect(body.text).toBe('Hello world'); - expect(body.target_lang).toBe('en-US'); + expect(body.target_lang).toBe('en-us'); return true; }) .reply(200, { improvements: [{ text: 'Hello, world!', target_language: 'en-US' }], }); - const result = await client.improveText('Hello world', { targetLang: 'en-US' }); + const result = await client.improveText('Hello world', { targetLang: 'en-us' }); expect(result[0]?.text).toBe('Hello, world!'); expect(result[0]?.targetLanguage).toBe('en-US'); @@ -612,7 +612,7 @@ describe('DeepLClient Integration', () => { improvements: [{ text: 'Improved text', target_language: 'en-US' }], }); - await client.improveText('Test', { targetLang: 'en-US', writingStyle: 'business' }); + await client.improveText('Test', { targetLang: 'en-us', writingStyle: 'business' }); expect(scope.isDone()).toBe(true); }); @@ -629,7 +629,7 @@ describe('DeepLClient Integration', () => { improvements: [{ text: 'Improved text', target_language: 'en-US' }], }); - await client.improveText('Test', { targetLang: 'en-US', tone: 'friendly' }); + await client.improveText('Test', { targetLang: 'en-us', tone: 'friendly' }); expect(scope.isDone()).toBe(true); }); diff --git a/tests/unit/deepl-client-lazy.test.ts b/tests/unit/deepl-client-lazy.test.ts index 580391bc..2d10774e 100644 --- a/tests/unit/deepl-client-lazy.test.ts +++ b/tests/unit/deepl-client-lazy.test.ts @@ -97,7 +97,7 @@ describe('DeepLClient lazy sub-client construction', () => { nock(baseUrl).post('/v2/write/rephrase').reply(200, { improvements: [{ text: 'Improved.', target_language: 'en-US' }], }); - await client.improveText('Test.', { targetLang: 'en-US' }); + await client.improveText('Test.', { targetLang: 'en-us' }); expect((client as any)._writeClient).not.toBeNull(); expect((client as any)._translationClient).toBeUndefined(); diff --git a/tests/unit/deepl-client.test.ts b/tests/unit/deepl-client.test.ts index 9a878472..9ffbbeb7 100644 --- a/tests/unit/deepl-client.test.ts +++ b/tests/unit/deepl-client.test.ts @@ -562,7 +562,7 @@ describe('DeepLClient', () => { { name: 'improveText()', path: '/v2/write/rephrase', - call: (c) => c.improveText('Test', { targetLang: 'en-US' }), + call: (c) => c.improveText('Test', { targetLang: 'en-us' }), }, ]; @@ -1215,7 +1215,7 @@ describe('DeepLClient', () => { }); const result = await client.improveText('This is a sentence.', { - targetLang: 'en-US', + targetLang: 'en-us', }); expect(result).toHaveLength(1); @@ -1230,7 +1230,7 @@ describe('DeepLClient', () => { .reply(200, { improvements: [] }); await expect( - client.improveText('Test', { targetLang: 'en-US' }) + client.improveText('Test', { targetLang: 'en-us' }) ).rejects.toThrow('No improvements returned'); }); }); @@ -1252,7 +1252,7 @@ describe('DeepLClient', () => { }); const result = await client.improveText('This is a sentence.', { - targetLang: 'en-US', + targetLang: 'en-us', writingStyle: 'simple', }); @@ -1276,7 +1276,7 @@ describe('DeepLClient', () => { }); const result = await client.improveText('We want to tell you.', { - targetLang: 'en-US', + targetLang: 'en-us', writingStyle: 'business', }); @@ -1300,7 +1300,7 @@ describe('DeepLClient', () => { }); const result = await client.improveText('This shows it works.', { - targetLang: 'en-US', + targetLang: 'en-us', writingStyle: 'academic', }); @@ -1324,7 +1324,7 @@ describe('DeepLClient', () => { }); const result = await client.improveText('That is interesting.', { - targetLang: 'en-US', + targetLang: 'en-us', writingStyle: 'casual', }); @@ -1350,7 +1350,7 @@ describe('DeepLClient', () => { }); const result = await client.improveText('This is good.', { - targetLang: 'en-US', + targetLang: 'en-us', tone: 'enthusiastic', }); @@ -1374,7 +1374,7 @@ describe('DeepLClient', () => { }); const result = await client.improveText('Hello.', { - targetLang: 'en-US', + targetLang: 'en-us', tone: 'friendly', }); @@ -1398,7 +1398,7 @@ describe('DeepLClient', () => { }); const result = await client.improveText('I think this will work.', { - targetLang: 'en-US', + targetLang: 'en-us', tone: 'confident', }); @@ -1422,7 +1422,7 @@ describe('DeepLClient', () => { }); const result = await client.improveText('Try something else.', { - targetLang: 'en-US', + targetLang: 'en-us', tone: 'diplomatic', }); @@ -1448,7 +1448,7 @@ describe('DeepLClient', () => { }); const result = await client.improveText('Test text.', { - targetLang: 'en-US', + targetLang: 'en-us', }); expect(result[0]?.text).toBe('Improved text.'); @@ -1527,7 +1527,7 @@ describe('DeepLClient', () => { }); const result = await client.improveText('This is a sentence.', { - targetLang: 'en-GB', + targetLang: 'en-gb', }); expect(result[0]?.targetLanguage).toBe('en-GB'); @@ -1550,7 +1550,7 @@ describe('DeepLClient', () => { }); const result = await client.improveText(longText, { - targetLang: 'en-US', + targetLang: 'en-us', }); expect(result[0]?.text.length).toBeGreaterThan(0); @@ -1569,7 +1569,7 @@ describe('DeepLClient', () => { }); const result = await client.improveText('Test: quotes & chars', { - targetLang: 'en-US', + targetLang: 'en-us', }); expect(result[0]?.text).toContain('&'); @@ -1589,7 +1589,7 @@ describe('DeepLClient', () => { }); const result = await client.improveText('Para 1.\n\nPara 2.', { - targetLang: 'en-US', + targetLang: 'en-us', }); expect(result[0]?.text).toContain('\n\n'); @@ -1613,7 +1613,7 @@ describe('DeepLClient', () => { }); const result = await client.improveText('This is a sentence.', { - targetLang: 'en-US', + targetLang: 'en-us', writingStyle: 'prefer_simple', }); @@ -1637,7 +1637,7 @@ describe('DeepLClient', () => { }); const result = await client.improveText('This is good.', { - targetLang: 'en-US', + targetLang: 'en-us', tone: 'prefer_enthusiastic', }); diff --git a/tests/unit/formatters.test.ts b/tests/unit/formatters.test.ts index a50445a0..9ebda438 100644 --- a/tests/unit/formatters.test.ts +++ b/tests/unit/formatters.test.ts @@ -151,11 +151,11 @@ describe('formatters', () => { expect(output).toContain('Characters'); // Verify data rows - expect(output).toContain('ES'); + expect(output).toContain('es'); expect(output).toContain('Hola'); expect(output).toContain('5'); - expect(output).toContain('FR'); + expect(output).toContain('fr'); expect(output).toContain('Bonjour'); expect(output).toContain('7'); }); @@ -170,7 +170,7 @@ describe('formatters', () => { const output = formatMultiTranslationTable(results); - expect(output).toContain('ES'); + expect(output).toContain('es'); expect(output).toContain('Hola'); // Should NOT contain Characters column when billedCharacters is missing expect(output).not.toContain('Characters'); @@ -187,7 +187,7 @@ describe('formatters', () => { const output = formatMultiTranslationTable(results); - expect(output).toContain('DE'); + expect(output).toContain('de'); expect(output).toContain('Dies ist ein sehr langer'); expect(output).toContain('100'); }); @@ -248,10 +248,10 @@ describe('formatters', () => { const output = formatMultiTranslationTable(results); // All languages present - expect(output).toContain('ES'); - expect(output).toContain('FR'); - expect(output).toContain('DE'); - expect(output).toContain('JA'); + expect(output).toContain('es'); + expect(output).toContain('fr'); + expect(output).toContain('de'); + expect(output).toContain('ja'); // All translations present expect(output).toContain('Hola'); @@ -304,7 +304,7 @@ describe('formatters', () => { expect(output).toContain('Language'); expect(output).toContain('Translation'); - expect(output).toContain('ES'); + expect(output).toContain('es'); expect(output).toContain('Hola'); // eslint-disable-next-line no-control-regex const ansiRegex = /\x1b\[[0-9;]*m/; @@ -431,14 +431,14 @@ describe('formatters', () => { const output = formatWriteJson( 'This are good.', 'This is good.', - 'en-US' + 'en-us' ); const parsed = JSON.parse(output); expect(parsed.original).toBe('This are good.'); expect(parsed.improved).toBe('This is good.'); expect(parsed.changes).toBe(1); - expect(parsed.language).toBe('en-US'); + expect(parsed.language).toBe('en-us'); }); it('should set changes to 0 when text is unchanged', () => { diff --git a/tests/unit/glossary-command.test.ts b/tests/unit/glossary-command.test.ts index 9a997af9..28d7eaca 100644 --- a/tests/unit/glossary-command.test.ts +++ b/tests/unit/glossary-command.test.ts @@ -437,8 +437,8 @@ describe('GlossaryCommand', () => { expect(result).toContain('Name: Tech Terms'); expect(result).toContain('ID: 123-456-789'); - expect(result).toContain('Source language: EN'); - expect(result).toContain('Target languages: ES'); + expect(result).toContain('Source language: en'); + expect(result).toContain('Target languages: es'); expect(result).toContain('Total entries: 10'); expect(result).toContain('Type: Single target'); }); @@ -459,12 +459,12 @@ describe('GlossaryCommand', () => { const result = glossaryCommand.formatGlossaryInfo(multilingualGlossary); expect(result).toContain('Name: Multi Terms'); - expect(result).toContain('Target languages: ES, FR'); + expect(result).toContain('Target languages: es, fr'); expect(result).toContain('Type: Multilingual'); expect(result).toContain('Total entries: 8'); expect(result).toContain('Language pairs:'); - expect(result).toContain('EN → ES: 5 entries'); - expect(result).toContain('EN → FR: 3 entries'); + expect(result).toContain('en → es: 5 entries'); + expect(result).toContain('en → fr: 3 entries'); }); it('should include creation time as a locale-independent ISO timestamp', () => { diff --git a/tests/unit/register-correct.test.ts b/tests/unit/register-correct.test.ts index daf589ac..4a098310 100644 --- a/tests/unit/register-correct.test.ts +++ b/tests/unit/register-correct.test.ts @@ -128,7 +128,7 @@ describe('registerCorrect', () => { await program.parseAsync(['node', 'test', 'correct', 'Hello', '--lang', 'EN-us']); expect(mockWriteCommand.improve).toHaveBeenCalledWith( 'Hello', - expect.objectContaining({ lang: 'en-US' }), + expect.objectContaining({ lang: 'en-us' }), ); }); diff --git a/tests/unit/register-write.test.ts b/tests/unit/register-write.test.ts index ca0c55e4..4a1367f9 100644 --- a/tests/unit/register-write.test.ts +++ b/tests/unit/register-write.test.ts @@ -134,7 +134,7 @@ describe('registerWrite', () => { await program.parseAsync(['node', 'test', 'write', 'Hello', '-l', 'en-US']); expect(mockWriteCommand.improve).toHaveBeenCalledWith( 'Hello', - expect.objectContaining({ lang: 'en-US' }), + expect.objectContaining({ lang: 'en-us' }), ); }); @@ -195,7 +195,7 @@ describe('registerWrite', () => { ); }); - it.each(['ja', 'ko', 'zh', 'zh-Hans'])('should accept new target language %s', async (lang) => { + it.each(['ja', 'ko', 'zh', 'zh-hans'])('should accept new target language %s', async (lang) => { mockCreateWriteCommand.mockResolvedValue(mockWriteCommand); mockWriteCommand.improve.mockResolvedValue('ok'); await program.parseAsync(['node', 'test', 'write', 'Hello', '--lang', lang]); @@ -210,11 +210,11 @@ describe('registerWrite', () => { // before validating, so write rejecting them made the CLI's own output // unusable with the command documented as consistent with translate. it.each([ - ['en-us', 'en-US'], - ['en-US', 'en-US'], - ['EN-US', 'en-US'], - ['pt-br', 'pt-BR'], - ['zh-hans', 'zh-Hans'], + ['en-us', 'en-us'], + ['en-US', 'en-us'], + ['EN-US', 'en-us'], + ['pt-br', 'pt-br'], + ['zh-Hans', 'zh-hans'], ['DE', 'de'], ])('should accept --lang %s and normalize it to %s', async (input, canonical) => { mockCreateWriteCommand.mockResolvedValue(mockWriteCommand); @@ -234,7 +234,7 @@ describe('registerWrite', () => { expect(handleError).not.toHaveBeenCalled(); expect(mockWriteCommand.improve).toHaveBeenCalledWith( 'Hello', - expect.objectContaining({ lang: 'pt-BR' }), + expect.objectContaining({ lang: 'pt-br' }), ); }); diff --git a/tests/unit/text-translation-handler.test.ts b/tests/unit/text-translation-handler.test.ts index 9261d418..a6de8a28 100644 --- a/tests/unit/text-translation-handler.test.ts +++ b/tests/unit/text-translation-handler.test.ts @@ -452,8 +452,8 @@ describe('TextTranslationHandler', () => { ]); const result = await handler.translateText('Hello', defaultOptions({ to: 'de,fr', format: 'table' })); - expect(result).toContain('DE'); - expect(result).toContain('FR'); + expect(result).toContain('de'); + expect(result).toContain('fr'); } finally { Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, diff --git a/tests/unit/tm-command.test.ts b/tests/unit/tm-command.test.ts index 32b017e3..4207e3c6 100644 --- a/tests/unit/tm-command.test.ts +++ b/tests/unit/tm-command.test.ts @@ -50,9 +50,9 @@ describe('TmCommand', () => { ]; const out = tmCommand.formatList(tms); expect(out).toContain('brand-terms'); - expect(out).toContain('EN \u2192 DE, FR'); + expect(out).toContain('en \u2192 de, fr'); expect(out).toContain('legal-phrases'); - expect(out).toContain('EN \u2192 FR'); + expect(out).toContain('en \u2192 fr'); }); it('strips ASCII control chars from the name column so a malicious TM cannot corrupt the terminal', () => { diff --git a/tests/unit/translate-command.test.ts b/tests/unit/translate-command.test.ts index 6256906c..34622332 100644 --- a/tests/unit/translate-command.test.ts +++ b/tests/unit/translate-command.test.ts @@ -2518,8 +2518,8 @@ describe('TranslateCommand', () => { format: 'table', }); - expect(result).toContain('ES'); - expect(result).toContain('FR'); + expect(result).toContain('es'); + expect(result).toContain('fr'); expect(result).toContain('Hola'); expect(result).toContain('Bonjour'); } finally { diff --git a/tests/unit/write-client.test.ts b/tests/unit/write-client.test.ts index 4ba83421..6782958d 100644 --- a/tests/unit/write-client.test.ts +++ b/tests/unit/write-client.test.ts @@ -94,7 +94,7 @@ describe('WriteClient', () => { headers: {}, }); - await client.improveText('Test', { targetLang: 'en-GB' }); + await client.improveText('Test', { targetLang: 'en-gb' }); expect(mockAxiosInstance.request).toHaveBeenCalledWith( expect.objectContaining({ @@ -282,7 +282,7 @@ describe('WriteClient', () => { headers: {}, }); - await client.correctText('Test', { targetLang: 'en-GB' }); + await client.correctText('Test', { targetLang: 'en-gb' }); expect(mockAxiosInstance.request).toHaveBeenCalledWith( expect.objectContaining({ @@ -301,10 +301,10 @@ describe('WriteClient', () => { headers: {}, }); - await client.correctText('Test', { targetLang: 'en-US' }); + await client.correctText('Test', { targetLang: 'en-us' }); const body = mockAxiosInstance.request.mock.calls[0][0].data as string; - expect(body).toContain('target_lang=en-US'); + expect(body).toContain('target_lang=en-us'); expect(body).not.toContain('writing_style'); expect(body).not.toContain('tone'); }); diff --git a/tests/unit/write-command.test.ts b/tests/unit/write-command.test.ts index bea18f6e..85d5734a 100644 --- a/tests/unit/write-command.test.ts +++ b/tests/unit/write-command.test.ts @@ -97,13 +97,13 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovements[0]!); const result = await writeCommand.improve('This is a sentence.', { - lang: 'en-US', + lang: 'en-us', }); expect(result).toBe('This is a well-written sentence.'); expect(mockWriteService.getBestImprovement).toHaveBeenCalledWith( 'This is a sentence.', - { targetLang: 'en-US' }, + { targetLang: 'en-us' }, { skipCache: undefined } ); }); @@ -114,7 +114,7 @@ describe('WriteCommand', () => { ); await expect( - writeCommand.improve('', { lang: 'en-US' }) + writeCommand.improve('', { lang: 'en-us' }) ).rejects.toThrow('Text cannot be empty'); }); @@ -142,14 +142,14 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); const result = await writeCommand.improve('This is a sentence.', { - lang: 'en-US', + lang: 'en-us', style: 'simple', }); expect(result).toBe('This is easy to read.'); expect(mockWriteService.getBestImprovement).toHaveBeenCalledWith( 'This is a sentence.', - { targetLang: 'en-US', writingStyle: 'simple' }, + { targetLang: 'en-us', writingStyle: 'simple' }, { skipCache: undefined } ); }); @@ -163,14 +163,14 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); const result = await writeCommand.improve('We want to tell you.', { - lang: 'en-US', + lang: 'en-us', style: 'business', }); expect(result).toBe('We are pleased to inform you.'); expect(mockWriteService.getBestImprovement).toHaveBeenCalledWith( 'We want to tell you.', - { targetLang: 'en-US', writingStyle: 'business' }, + { targetLang: 'en-us', writingStyle: 'business' }, { skipCache: undefined } ); }); @@ -184,14 +184,14 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); const result = await writeCommand.improve('This shows it works.', { - lang: 'en-US', + lang: 'en-us', style: 'academic', }); expect(result).toContain('demonstrates'); expect(mockWriteService.getBestImprovement).toHaveBeenCalledWith( 'This shows it works.', - { targetLang: 'en-US', writingStyle: 'academic' }, + { targetLang: 'en-us', writingStyle: 'academic' }, { skipCache: undefined } ); }); @@ -205,14 +205,14 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); const result = await writeCommand.improve('That is interesting.', { - lang: 'en-US', + lang: 'en-us', style: 'casual', }); expect(result).toContain('cool'); expect(mockWriteService.getBestImprovement).toHaveBeenCalledWith( 'That is interesting.', - { targetLang: 'en-US', writingStyle: 'casual' }, + { targetLang: 'en-us', writingStyle: 'casual' }, { skipCache: undefined } ); }); @@ -228,14 +228,14 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); const result = await writeCommand.improve('This is good.', { - lang: 'en-US', + lang: 'en-us', tone: 'enthusiastic', }); expect(result).toContain('fantastic'); expect(mockWriteService.getBestImprovement).toHaveBeenCalledWith( 'This is good.', - { targetLang: 'en-US', tone: 'enthusiastic' }, + { targetLang: 'en-us', tone: 'enthusiastic' }, { skipCache: undefined } ); }); @@ -249,7 +249,7 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); const result = await writeCommand.improve('Hello.', { - lang: 'en-US', + lang: 'en-us', tone: 'friendly', }); @@ -265,7 +265,7 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); const result = await writeCommand.improve('I think this will work.', { - lang: 'en-US', + lang: 'en-us', tone: 'confident', }); @@ -281,7 +281,7 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); const result = await writeCommand.improve('Try something else.', { - lang: 'en-US', + lang: 'en-us', tone: 'diplomatic', }); @@ -300,7 +300,7 @@ describe('WriteCommand', () => { mockWriteService.improve.mockResolvedValue(mockImprovements); const result = await writeCommand.improve('Test', { - lang: 'en-US', + lang: 'en-us', showAlternatives: true, }); @@ -309,7 +309,7 @@ describe('WriteCommand', () => { expect(result).toContain('Third improvement.'); expect(mockWriteService.improve).toHaveBeenCalledWith( 'Test', - { targetLang: 'en-US' }, + { targetLang: 'en-us' }, { skipCache: undefined } ); }); @@ -323,7 +323,7 @@ describe('WriteCommand', () => { mockWriteService.improve.mockResolvedValue(mockImprovements); const result = await writeCommand.improve('Test', { - lang: 'en-US', + lang: 'en-us', showAlternatives: true, }); @@ -340,7 +340,7 @@ describe('WriteCommand', () => { await expect( writeCommand.improve('Test', { - lang: 'en-US', + lang: 'en-us', style: 'business', tone: 'enthusiastic', }) @@ -355,7 +355,7 @@ describe('WriteCommand', () => { ); await expect( - writeCommand.improve('Test', { lang: 'en-US' }) + writeCommand.improve('Test', { lang: 'en-us' }) ).rejects.toThrow('Service error'); }); @@ -365,7 +365,7 @@ describe('WriteCommand', () => { ); await expect( - writeCommand.improve('Test', { lang: 'en-US' }) + writeCommand.improve('Test', { lang: 'en-us' }) ).rejects.toThrow('Authentication failed'); }); @@ -375,24 +375,24 @@ describe('WriteCommand', () => { ); await expect( - writeCommand.improve('Test', { lang: 'en-US' }) + writeCommand.improve('Test', { lang: 'en-us' }) ).rejects.toThrow('Quota exceeded'); }); }); describe('supported languages', () => { it('should work with all supported Write languages', async () => { - const languages: Array<'de' | 'en' | 'en-GB' | 'en-US' | 'es' | 'fr' | 'it' | 'pt' | 'pt-BR' | 'pt-PT'> = [ + const languages: Array<'de' | 'en' | 'en-gb' | 'en-us' | 'es' | 'fr' | 'it' | 'pt' | 'pt-br' | 'pt-pt'> = [ 'de', 'en', - 'en-GB', - 'en-US', + 'en-gb', + 'en-us', 'es', 'fr', 'it', 'pt', - 'pt-BR', - 'pt-PT', + 'pt-br', + 'pt-pt', ]; for (const lang of languages) { @@ -427,26 +427,26 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); const result = await writeCommand.improveFile(testFile, { - lang: 'en-US', + lang: 'en-us', }); expect(result).toBe('This is improved content.'); expect(mockWriteService.getBestImprovement).toHaveBeenCalledWith( 'Original content', - { targetLang: 'en-US' }, + { targetLang: 'en-us' }, { skipCache: undefined } ); }); it('should throw error for non-existent file', async () => { await expect( - writeCommand.improveFile(join(testDir, 'nonexistent.txt'), { lang: 'en-US' }) + writeCommand.improveFile(join(testDir, 'nonexistent.txt'), { lang: 'en-us' }) ).rejects.toThrow('File not found'); }); it('should throw error for empty file path', async () => { await expect( - writeCommand.improveFile('', { lang: 'en-US' }) + writeCommand.improveFile('', { lang: 'en-us' }) ).rejects.toThrow('File path cannot be empty'); }); }); @@ -465,7 +465,7 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); await writeCommand.improveFile(inputFile, { - lang: 'en-US', + lang: 'en-us', outputFile, }); @@ -485,7 +485,7 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); const result = await writeCommand.improveFile(testFile, { - lang: 'en-US', + lang: 'en-us', inPlace: true, }); @@ -507,11 +507,11 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); - await writeCommand.improveFile(testFile, { lang: 'en-US' }); + await writeCommand.improveFile(testFile, { lang: 'en-us' }); expect(mockWriteService.getBestImprovement).toHaveBeenCalledWith( 'Plain text content', - { targetLang: 'en-US' }, + { targetLang: 'en-us' }, { skipCache: undefined } ); }); @@ -527,11 +527,11 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); - await writeCommand.improveFile(testFile, { lang: 'en-US' }); + await writeCommand.improveFile(testFile, { lang: 'en-us' }); expect(mockWriteService.getBestImprovement).toHaveBeenCalledWith( '# Original Heading', - { targetLang: 'en-US' }, + { targetLang: 'en-us' }, { skipCache: undefined } ); }); @@ -588,7 +588,7 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); const result = await writeCommand.improveWithDiff(original, { - lang: 'en-US', + lang: 'en-us', }); expect(result.original).toBe(original); @@ -609,7 +609,7 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); const result = await writeCommand.improveFileWithDiff(testFile, { - lang: 'en-US', + lang: 'en-us', }); expect(result.original).toBe('Original content'); @@ -628,7 +628,7 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); const result = await writeCommand.checkText('Original text.', { - lang: 'en-US', + lang: 'en-us', }); expect(result.needsImprovement).toBe(true); @@ -647,7 +647,7 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); const result = await writeCommand.checkText(text, { - lang: 'en-US', + lang: 'en-us', }); expect(result.needsImprovement).toBe(false); @@ -666,7 +666,7 @@ describe('WriteCommand', () => { const result = await writeCommand.checkText( 'Line 1 original\nLine 2\nLine 3 original', - { lang: 'en-US' } + { lang: 'en-us' } ); expect(result.needsImprovement).toBe(true); @@ -687,7 +687,7 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); const result = await writeCommand.checkFile(testFile, { - lang: 'en-US', + lang: 'en-us', }); expect(result.needsImprovement).toBe(true); @@ -698,7 +698,7 @@ describe('WriteCommand', () => { it('should throw error for non-existent file', async () => { await expect( writeCommand.checkFile(join(testDir, 'nonexistent.txt'), { - lang: 'en-US', + lang: 'en-us', }) ).rejects.toThrow('File not found'); }); @@ -716,7 +716,7 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); const result = await writeCommand.checkFile(testFile, { - lang: 'en-US', + lang: 'en-us', }); expect(result.needsImprovement).toBe(false); @@ -738,7 +738,7 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); const result = await writeCommand.autoFixFile(testFile, { - lang: 'en-US', + lang: 'en-us', }); expect(result.fixed).toBe(true); @@ -764,7 +764,7 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); const result = await writeCommand.autoFixFile(testFile, { - lang: 'en-US', + lang: 'en-us', }); expect(result.fixed).toBe(false); @@ -788,7 +788,7 @@ describe('WriteCommand', () => { mockWriteService.getBestImprovement.mockResolvedValue(mockImprovement); const result = await writeCommand.autoFixFile(testFile, { - lang: 'en-US', + lang: 'en-us', createBackup: true, }); @@ -803,7 +803,7 @@ describe('WriteCommand', () => { it('should throw error for non-existent file', async () => { await expect( writeCommand.autoFixFile(join(testDir, 'nonexistent.txt'), { - lang: 'en-US', + lang: 'en-us', }) ).rejects.toThrow('File not found'); }); @@ -831,7 +831,7 @@ describe('WriteCommand', () => { mockSelect.mockResolvedValue(1); const result = await writeCommand.improveInteractive('Original text.', { - lang: 'en-US', + lang: 'en-us', }); // Should call API 4 times (once for each style) @@ -850,7 +850,7 @@ describe('WriteCommand', () => { mockSelect.mockResolvedValue(-1); // -1 = keep original const result = await writeCommand.improveInteractive('Original text.', { - lang: 'en-US', + lang: 'en-us', }); expect(result).toBe('Original text.'); @@ -865,7 +865,7 @@ describe('WriteCommand', () => { mockSelect.mockResolvedValue(0); const result = await writeCommand.improveInteractive('Original text.', { - lang: 'en-US', + lang: 'en-us', style: 'business', }); @@ -885,7 +885,7 @@ describe('WriteCommand', () => { mockSelect.mockResolvedValue(0); const result = await writeCommand.improveInteractive('Original.', { - lang: 'en-US', + lang: 'en-us', }); // Should work and return a result @@ -916,7 +916,7 @@ describe('WriteCommand', () => { mockSelect.mockResolvedValue(1); const result = await writeCommand.improveFileInteractive(testFile, { - lang: 'en-US', + lang: 'en-us', }); expect(result.selected).toBe('Business.'); @@ -934,7 +934,7 @@ describe('WriteCommand', () => { mockSelect.mockResolvedValue(0); const result = await writeCommand.improveInteractive('Original.', { - lang: 'en-US', + lang: 'en-us', }); // Should still work with partial results @@ -951,7 +951,7 @@ describe('WriteCommand', () => { mockSelect.mockResolvedValue(0); await writeCommand.improveInteractive('Original.', { - lang: 'en-US', + lang: 'en-us', }); expect(mockLogger.verbose).toHaveBeenCalledWith( @@ -975,7 +975,7 @@ describe('WriteCommand', () => { mockSelect.mockResolvedValue(0); await writeCommand.improveInteractive('Original.', { - lang: 'en-US', + lang: 'en-us', }); expect(mockLogger.verbose).toHaveBeenCalledWith( @@ -993,7 +993,7 @@ describe('WriteCommand', () => { .mockRejectedValueOnce(new Error('API error')); await expect( - writeCommand.improveInteractive('Original text.', { lang: 'en-US' }) + writeCommand.improveInteractive('Original text.', { lang: 'en-us' }) ).rejects.toThrow('No improvements could be generated'); }); @@ -1006,14 +1006,14 @@ describe('WriteCommand', () => { mockSelect.mockResolvedValue(0); const result = await writeCommand.improveInteractive('Original text.', { - lang: 'en-US', + lang: 'en-us', tone: 'enthusiastic', }); expect(mockWriteService.improve).toHaveBeenCalledTimes(1); expect(mockWriteService.improve).toHaveBeenCalledWith( 'Original text.', - { targetLang: 'en-US', tone: 'enthusiastic' }, + { targetLang: 'en-us', tone: 'enthusiastic' }, { skipCache: undefined } ); expect(result).toBe('Enthusiastic improvement!'); @@ -1028,7 +1028,7 @@ describe('WriteCommand', () => { mockSelect.mockResolvedValue(-1); const result = await writeCommand.improveInteractive('Original text.', { - lang: 'en-US', + lang: 'en-us', tone: 'friendly', }); @@ -1050,31 +1050,31 @@ describe('WriteCommand', () => { it('should reject symlinks in improveFile()', async () => { await expect( - writeCommand.improveFile(symlinkPath, { lang: 'en-US' }) + writeCommand.improveFile(symlinkPath, { lang: 'en-us' }) ).rejects.toThrow('Symlinks are not supported for security reasons'); }); it('should reject symlinks in improveFileWithDiff()', async () => { await expect( - writeCommand.improveFileWithDiff(symlinkPath, { lang: 'en-US' }) + writeCommand.improveFileWithDiff(symlinkPath, { lang: 'en-us' }) ).rejects.toThrow('Symlinks are not supported for security reasons'); }); it('should reject symlinks in checkFile()', async () => { await expect( - writeCommand.checkFile(symlinkPath, { lang: 'en-US' }) + writeCommand.checkFile(symlinkPath, { lang: 'en-us' }) ).rejects.toThrow('Symlinks are not supported for security reasons'); }); it('should reject symlinks in autoFixFile()', async () => { await expect( - writeCommand.autoFixFile(symlinkPath, { lang: 'en-US' }) + writeCommand.autoFixFile(symlinkPath, { lang: 'en-us' }) ).rejects.toThrow('Symlinks are not supported for security reasons'); }); it('should reject symlinks in improveFileInteractive()', async () => { await expect( - writeCommand.improveFileInteractive(symlinkPath, { lang: 'en-US' }) + writeCommand.improveFileInteractive(symlinkPath, { lang: 'en-us' }) ).rejects.toThrow('Symlinks are not supported for security reasons'); }); }); @@ -1089,14 +1089,14 @@ describe('WriteCommand', () => { mockWriteService.getBestCorrection.mockResolvedValue(mockCorrection); const result = await writeCommand.improve('This is an test.', { - lang: 'en-US', + lang: 'en-us', correct: true, }); expect(result).toBe('This is a test.'); expect(mockWriteService.getBestCorrection).toHaveBeenCalledWith( 'This is an test.', - { targetLang: 'en-US' }, + { targetLang: 'en-us' }, { skipCache: undefined } ); expect(mockWriteService.getBestImprovement).not.toHaveBeenCalled(); @@ -1139,7 +1139,7 @@ describe('WriteCommand', () => { mockWriteService.getBestCorrection.mockResolvedValue(mockCorrection); const result = await writeCommand.checkText('This is an test.', { - lang: 'en-US', + lang: 'en-us', correct: true, }); @@ -1151,7 +1151,7 @@ describe('WriteCommand', () => { it('should not use the correct endpoint when correct is not set', async () => { mockWriteService.getBestImprovement.mockResolvedValue(mockCorrection); - await writeCommand.improve('Test', { lang: 'en-US' }); + await writeCommand.improve('Test', { lang: 'en-us' }); expect(mockWriteService.getBestCorrection).not.toHaveBeenCalled(); expect(mockWriteService.correct).not.toHaveBeenCalled(); diff --git a/tests/unit/write-service.test.ts b/tests/unit/write-service.test.ts index db1a92ef..34761b93 100644 --- a/tests/unit/write-service.test.ts +++ b/tests/unit/write-service.test.ts @@ -60,25 +60,25 @@ describe('WriteService', () => { mockClient.improveText.mockResolvedValue(mockImprovements); const result = await writeService.improve('This is a sentence.', { - targetLang: 'en-US', + targetLang: 'en-us', }); expect(result).toHaveLength(1); expect(result[0]?.text).toBe('This is a well-written sentence.'); expect(mockClient.improveText).toHaveBeenCalledWith('This is a sentence.', { - targetLang: 'en-US', + targetLang: 'en-us', }); }); it('should throw error for empty text', async () => { await expect( - writeService.improve('', { targetLang: 'en-US' }) + writeService.improve('', { targetLang: 'en-us' }) ).rejects.toThrow('Text cannot be empty'); }); it('should throw error for whitespace-only text', async () => { await expect( - writeService.improve(' ', { targetLang: 'en-US' }) + writeService.improve(' ', { targetLang: 'en-us' }) ).rejects.toThrow('Text cannot be empty'); }); @@ -111,13 +111,13 @@ describe('WriteService', () => { mockClient.improveText.mockResolvedValue(mockImprovements); const result = await writeService.improve('This is a sentence.', { - targetLang: 'en-US', + targetLang: 'en-us', writingStyle: 'simple', }); expect(result[0]?.text).toBe('This is easy to read.'); expect(mockClient.improveText).toHaveBeenCalledWith('This is a sentence.', { - targetLang: 'en-US', + targetLang: 'en-us', writingStyle: 'simple', }); }); @@ -133,7 +133,7 @@ describe('WriteService', () => { mockClient.improveText.mockResolvedValue(mockImprovements); const result = await writeService.improve('We want to tell you.', { - targetLang: 'en-US', + targetLang: 'en-us', writingStyle: 'business', }); @@ -151,7 +151,7 @@ describe('WriteService', () => { mockClient.improveText.mockResolvedValue(mockImprovements); const result = await writeService.improve('This shows it works.', { - targetLang: 'en-US', + targetLang: 'en-us', writingStyle: 'academic', }); @@ -169,7 +169,7 @@ describe('WriteService', () => { mockClient.improveText.mockResolvedValue(mockImprovements); const result = await writeService.improve('That is interesting.', { - targetLang: 'en-US', + targetLang: 'en-us', writingStyle: 'casual', }); @@ -189,7 +189,7 @@ describe('WriteService', () => { mockClient.improveText.mockResolvedValue(mockImprovements); const result = await writeService.improve('This is good.', { - targetLang: 'en-US', + targetLang: 'en-us', tone: 'enthusiastic', }); @@ -207,7 +207,7 @@ describe('WriteService', () => { mockClient.improveText.mockResolvedValue(mockImprovements); const result = await writeService.improve('Hello.', { - targetLang: 'en-US', + targetLang: 'en-us', tone: 'friendly', }); @@ -225,7 +225,7 @@ describe('WriteService', () => { mockClient.improveText.mockResolvedValue(mockImprovements); const result = await writeService.improve('I think this will work.', { - targetLang: 'en-US', + targetLang: 'en-us', tone: 'confident', }); @@ -243,7 +243,7 @@ describe('WriteService', () => { mockClient.improveText.mockResolvedValue(mockImprovements); const result = await writeService.improve('Try something else.', { - targetLang: 'en-US', + targetLang: 'en-us', tone: 'diplomatic', }); @@ -255,7 +255,7 @@ describe('WriteService', () => { it('should throw error when both --style and --tone are specified', async () => { await expect( writeService.improve('Test', { - targetLang: 'en-US', + targetLang: 'en-us', writingStyle: 'business', tone: 'enthusiastic', }) @@ -273,7 +273,7 @@ describe('WriteService', () => { mockClient.improveText.mockResolvedValue(mockImprovements); const result = await writeService.improve('Test text.', { - targetLang: 'en-US', + targetLang: 'en-us', }); expect(result[0]?.text).toBe('Improved text.'); @@ -285,7 +285,7 @@ describe('WriteService', () => { mockClient.improveText.mockRejectedValue(new Error('API error')); await expect( - writeService.improve('Test', { targetLang: 'en-US' }) + writeService.improve('Test', { targetLang: 'en-us' }) ).rejects.toThrow('API error'); }); @@ -295,7 +295,7 @@ describe('WriteService', () => { ); await expect( - writeService.improve('Test', { targetLang: 'en-US' }) + writeService.improve('Test', { targetLang: 'en-us' }) ).rejects.toThrow('Authentication failed'); }); @@ -305,7 +305,7 @@ describe('WriteService', () => { ); await expect( - writeService.improve('Test', { targetLang: 'en-US' }) + writeService.improve('Test', { targetLang: 'en-us' }) ).rejects.toThrow('Quota exceeded'); }); @@ -315,7 +315,7 @@ describe('WriteService', () => { ); await expect( - writeService.improve('Test', { targetLang: 'en-US' }) + writeService.improve('Test', { targetLang: 'en-us' }) ).rejects.toThrow('Rate limit exceeded'); }); }); @@ -333,7 +333,7 @@ describe('WriteService', () => { mockClient.improveText.mockResolvedValue(mockImprovements); const result = await writeService.improve(longText, { - targetLang: 'en-US', + targetLang: 'en-us', }); expect(result[0]?.text.length).toBeGreaterThan(0); @@ -350,7 +350,7 @@ describe('WriteService', () => { mockClient.improveText.mockResolvedValue(mockImprovements); const result = await writeService.improve('Test: quotes & chars', { - targetLang: 'en-US', + targetLang: 'en-us', }); expect(result[0]?.text).toContain('&'); @@ -368,7 +368,7 @@ describe('WriteService', () => { mockClient.improveText.mockResolvedValue(mockImprovements); const result = await writeService.improve('Para 1.\n\nPara 2.', { - targetLang: 'en-US', + targetLang: 'en-us', }); expect(result[0]?.text).toContain('\n\n'); @@ -389,7 +389,7 @@ describe('WriteService', () => { mockClient.improveText.mockResolvedValue(mockImprovements); const result = await writeService.improve('Test', { - targetLang: 'en-US', + targetLang: 'en-us', }); expect(result).toHaveLength(2); @@ -415,7 +415,7 @@ describe('WriteService', () => { mockClient.improveText.mockResolvedValue(mockImprovements); const result = await writeService.getBestImprovement('Test', { - targetLang: 'en-US', + targetLang: 'en-us', }); expect(result.text).toBe('Best improvement.'); @@ -425,7 +425,7 @@ describe('WriteService', () => { mockClient.improveText.mockResolvedValue([]); await expect( - writeService.getBestImprovement('Test', { targetLang: 'en-US' }) + writeService.getBestImprovement('Test', { targetLang: 'en-us' }) ).rejects.toThrow('No improvements available'); }); }); @@ -441,7 +441,7 @@ describe('WriteService', () => { it('should cache results after API call', async () => { mockClient.improveText.mockResolvedValue(mockImprovements); - await writeService.improve('Test', { targetLang: 'en-US' }); + await writeService.improve('Test', { targetLang: 'en-us' }); expect(mockCacheService.set).toHaveBeenCalledTimes(1); expect(mockCacheService.set).toHaveBeenCalledWith( @@ -453,7 +453,7 @@ describe('WriteService', () => { it('should return cached result on hit without calling API', async () => { mockCacheService.get.mockReturnValue(mockImprovements); - const result = await writeService.improve('Test', { targetLang: 'en-US' }); + const result = await writeService.improve('Test', { targetLang: 'en-us' }); expect(result).toEqual(mockImprovements); expect(mockClient.improveText).not.toHaveBeenCalled(); @@ -464,7 +464,7 @@ describe('WriteService', () => { mockConfigService.getValue.mockReturnValue(false); mockClient.improveText.mockResolvedValue(mockImprovements); - await writeService.improve('Test', { targetLang: 'en-US' }); + await writeService.improve('Test', { targetLang: 'en-us' }); expect(mockCacheService.get).not.toHaveBeenCalled(); expect(mockCacheService.set).not.toHaveBeenCalled(); @@ -474,7 +474,7 @@ describe('WriteService', () => { it('should skip cache when skipCache is true', async () => { mockClient.improveText.mockResolvedValue(mockImprovements); - await writeService.improve('Test', { targetLang: 'en-US' }, { skipCache: true }); + await writeService.improve('Test', { targetLang: 'en-us' }, { skipCache: true }); expect(mockCacheService.get).not.toHaveBeenCalled(); expect(mockCacheService.set).not.toHaveBeenCalled(); @@ -484,8 +484,8 @@ describe('WriteService', () => { it('should produce different cache keys for different options', async () => { mockClient.improveText.mockResolvedValue(mockImprovements); - await writeService.improve('Test', { targetLang: 'en-US', writingStyle: 'business' }); - await writeService.improve('Test', { targetLang: 'en-US', writingStyle: 'casual' }); + await writeService.improve('Test', { targetLang: 'en-us', writingStyle: 'business' }); + await writeService.improve('Test', { targetLang: 'en-us', writingStyle: 'casual' }); const key1 = mockCacheService.set.mock.calls[0]![0]; const key2 = mockCacheService.set.mock.calls[1]![0]; @@ -495,8 +495,8 @@ describe('WriteService', () => { it('should produce same cache key for same options (deterministic)', async () => { mockClient.improveText.mockResolvedValue(mockImprovements); - await writeService.improve('Test', { targetLang: 'en-US', writingStyle: 'business' }); - await writeService.improve('Test', { targetLang: 'en-US', writingStyle: 'business' }); + await writeService.improve('Test', { targetLang: 'en-us', writingStyle: 'business' }); + await writeService.improve('Test', { targetLang: 'en-us', writingStyle: 'business' }); const key1 = mockCacheService.set.mock.calls[0]![0]; const key2 = mockCacheService.set.mock.calls[1]![0]; @@ -506,7 +506,7 @@ describe('WriteService', () => { it('should benefit getBestImprovement from cache', async () => { mockCacheService.get.mockReturnValue(mockImprovements); - const result = await writeService.getBestImprovement('Test', { targetLang: 'en-US' }); + const result = await writeService.getBestImprovement('Test', { targetLang: 'en-us' }); expect(result.text).toBe('Improved text.'); expect(mockClient.improveText).not.toHaveBeenCalled(); @@ -525,10 +525,10 @@ describe('WriteService', () => { it('should correct text via client.correctText', async () => { mockClient.correctText.mockResolvedValue(mockCorrections); - const result = await writeService.correct('This is an test.', { targetLang: 'en-US' }); + const result = await writeService.correct('This is an test.', { targetLang: 'en-us' }); expect(result).toEqual(mockCorrections); - expect(mockClient.correctText).toHaveBeenCalledWith('This is an test.', { targetLang: 'en-US' }); + expect(mockClient.correctText).toHaveBeenCalledWith('This is an test.', { targetLang: 'en-us' }); expect(mockClient.improveText).not.toHaveBeenCalled(); }); @@ -541,7 +541,7 @@ describe('WriteService', () => { it('should cache results under the correct: prefix', async () => { mockClient.correctText.mockResolvedValue(mockCorrections); - await writeService.correct('Test', { targetLang: 'en-US' }); + await writeService.correct('Test', { targetLang: 'en-us' }); expect(mockCacheService.set).toHaveBeenCalledTimes(1); expect(mockCacheService.set).toHaveBeenCalledWith( @@ -553,7 +553,7 @@ describe('WriteService', () => { it('should return cached result on hit without calling API', async () => { mockCacheService.get.mockReturnValue(mockCorrections); - const result = await writeService.correct('Test', { targetLang: 'en-US' }); + const result = await writeService.correct('Test', { targetLang: 'en-us' }); expect(result).toEqual(mockCorrections); expect(mockClient.correctText).not.toHaveBeenCalled(); @@ -563,7 +563,7 @@ describe('WriteService', () => { it('should skip cache when skipCache is true', async () => { mockClient.correctText.mockResolvedValue(mockCorrections); - await writeService.correct('Test', { targetLang: 'en-US' }, { skipCache: true }); + await writeService.correct('Test', { targetLang: 'en-us' }, { skipCache: true }); expect(mockCacheService.get).not.toHaveBeenCalled(); expect(mockCacheService.set).not.toHaveBeenCalled(); @@ -574,8 +574,8 @@ describe('WriteService', () => { mockClient.correctText.mockResolvedValue(mockCorrections); mockClient.improveText.mockResolvedValue(mockCorrections); - await writeService.correct('Test', { targetLang: 'en-US' }); - await writeService.improve('Test', { targetLang: 'en-US' }); + await writeService.correct('Test', { targetLang: 'en-us' }); + await writeService.improve('Test', { targetLang: 'en-us' }); const correctKey = mockCacheService.set.mock.calls[0]![0]; const writeKey = mockCacheService.set.mock.calls[1]![0]; @@ -585,8 +585,8 @@ describe('WriteService', () => { it('should produce deterministic cache keys', async () => { mockClient.correctText.mockResolvedValue(mockCorrections); - await writeService.correct('Test', { targetLang: 'en-US' }); - await writeService.correct('Test', { targetLang: 'en-US' }); + await writeService.correct('Test', { targetLang: 'en-us' }); + await writeService.correct('Test', { targetLang: 'en-us' }); const key1 = mockCacheService.set.mock.calls[0]![0]; const key2 = mockCacheService.set.mock.calls[1]![0]; @@ -601,7 +601,7 @@ describe('WriteService', () => { { text: 'Second.', targetLanguage: 'en-US' }, ]); - const result = await writeService.getBestCorrection('Test', { targetLang: 'en-US' }); + const result = await writeService.getBestCorrection('Test', { targetLang: 'en-us' }); expect(result.text).toBe('First.'); }); @@ -611,24 +611,24 @@ describe('WriteService', () => { mockClient.correctText.mockResolvedValue([]); await expect( - writeService.getBestCorrection('Test', { targetLang: 'en-US' }) + writeService.getBestCorrection('Test', { targetLang: 'en-us' }) ).rejects.toThrow('No improvements available'); }); }); describe('supported languages', () => { it('should work with all supported Write languages', async () => { - const languages: Array<'de' | 'en' | 'en-GB' | 'en-US' | 'es' | 'fr' | 'it' | 'pt' | 'pt-BR' | 'pt-PT'> = [ + const languages: Array<'de' | 'en' | 'en-gb' | 'en-us' | 'es' | 'fr' | 'it' | 'pt' | 'pt-br' | 'pt-pt'> = [ 'de', 'en', - 'en-GB', - 'en-US', + 'en-gb', + 'en-us', 'es', 'fr', 'it', 'pt', - 'pt-BR', - 'pt-PT', + 'pt-br', + 'pt-pt', ]; for (const lang of languages) { @@ -658,7 +658,7 @@ describe('WriteService', () => { ]; mockClient.improveText.mockResolvedValue(mockImprovements); - const result = await cachelessService.improve('Test', { targetLang: 'en-US' }); + const result = await cachelessService.improve('Test', { targetLang: 'en-us' }); expect(result).toHaveLength(1); expect(mockClient.improveText).toHaveBeenCalledTimes(1); From fab33e6a18e092f993343d518e370a2c09bbc732 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 09:25:26 -0400 Subject: [PATCH 014/256] test(watch): recover the dropped fsevents add in the filesWatched test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "should track files added and removed while watching" timed out under load at roughly 1 run in 20. The cause is not slowness: instrumenting the wait showed every failure was the add phase with the counter still at 1, while successful adds land in ~20ms and never approach the 5s budget. fsevents drops the notification for a file created in the moment after 'ready' fires, while the stream is still arming, and `ignoreInitial: true` means no rescan recovers it — so the event never arrives at all. The wait now re-touches the file every 500ms, which produces a fresh notification once the stream is delivering. Measured over 200 iterations under CPU contention: 0 failures, with 10 (5%) recovered by a nudge, matching the 4.5% failure rate seen before. Detection power is unchanged, since a watcher that fails to count adds never reaches the expected value however often the file is touched. The timeout message also reports the count it last saw. "Timed out waiting for condition" said nothing about whether the counter had stalled or overshot, which is what made the diagnosis need a separate harness. --- .../integration/cli-watch.integration.test.ts | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/tests/integration/cli-watch.integration.test.ts b/tests/integration/cli-watch.integration.test.ts index 1e948c8a..d48661ed 100644 --- a/tests/integration/cli-watch.integration.test.ts +++ b/tests/integration/cli-watch.integration.test.ts @@ -226,13 +226,33 @@ describe('Watch Service Integration', () => { }); describe('getStats()', () => { - const waitFor = async (condition: () => boolean, timeoutMs = 5000): Promise => { + /** + * `nudge` re-touches the file being waited on. fsevents can drop the + * notification for a file created in the moment after 'ready' fires, while + * the stream is still arming: the event then never arrives at all rather + * than arriving late (observed adds land in ~20ms or not within 5s), and + * `ignoreInitial: true` means no rescan recovers it. Re-touching does not + * weaken the assertion — a watcher that fails to count adds never reaches + * the expected value however often the file is touched. + */ + const waitForFilesWatched = async ( + expected: number, + nudge?: () => void, + timeoutMs = 5000, + ): Promise => { const start = Date.now(); - while (!condition()) { + let lastNudge = start; + while (watchService.getStats().filesWatched !== expected) { if (Date.now() - start > timeoutMs) { - throw new Error('Timed out waiting for condition'); + throw new Error( + `Timed out waiting for filesWatched === ${expected}, last saw ${watchService.getStats().filesWatched}`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + if (nudge && Date.now() - lastNudge > 500) { + lastNudge = Date.now(); + nudge(); } - await new Promise((resolve) => setTimeout(resolve, 10)); } }; @@ -290,10 +310,10 @@ describe('Watch Service Integration', () => { const newFile = path.join(tmpDir, 'b.txt'); fs.writeFileSync(newFile, 'World'); - await waitFor(() => watchService.getStats().filesWatched === 2); + await waitForFilesWatched(2, () => fs.writeFileSync(newFile, 'World again')); fs.unlinkSync(newFile); - await waitFor(() => watchService.getStats().filesWatched === 1); + await waitForFilesWatched(1); }); it('should increment error count on translation failure', async () => { From 49f1f6cf0f7e52cee65af1bfb14a4029d537c3b2 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 11:01:53 -0400 Subject: [PATCH 015/256] fix(languages): source formality from the v3 features matrix GET /v3/languages does report formality support -- as features.formality, which is what v2's supports_formality became. The static supportsFormality snapshot added alongside the v3 migration was therefore unnecessary, and it had already drifted: live v3 reports formality for pt, the registry did not, so `deepl languages --target` omitted [F] for Portuguese. Formality now derives from features.formality presence, and the features matrix is carried through on LanguageInfo for both roles. The 11-entry snapshot and the supportsFormality field are removed from the registry; category and targetOnly are untouched. Verified live: `deepl languages --target` output is byte-identical to before except pt, which now correctly shows [F]. --- src/api/translation-client.ts | 24 +++++- src/data/language-registry.ts | 27 +++---- .../deepl-client.integration.test.ts | 48 ++++++++++-- tests/unit/deepl-client.test.ts | 26 ++++++- tests/unit/language-registry.test.ts | 26 ++----- tests/unit/translation-client.test.ts | 75 ++++++++++++++++++- 6 files changed, 172 insertions(+), 54 deletions(-) diff --git a/src/api/translation-client.ts b/src/api/translation-client.ts index c10ffa0e..67246e4f 100644 --- a/src/api/translation-client.ts +++ b/src/api/translation-client.ts @@ -3,7 +3,6 @@ import { TranslationOptions, Language, TranslationMemory } from '../types/index. import { NetworkError } from '../utils/errors.js'; import { normalizeFormality } from '../utils/formality.js'; import { resolveGlossaryWireParams } from '../utils/glossary-params.js'; -import { LANGUAGE_REGISTRY } from '../data/language-registry.js'; import { Logger } from '../utils/logger.js'; // DeepL's /v3/translation_memories endpoint paginates via `page` (0-indexed) and @@ -46,8 +45,10 @@ interface DeepLUsageResponse { interface DeepLV3LanguageResponse { lang: string; name: string; + status?: string; usable_as_source?: boolean; usable_as_target?: boolean; + features?: LanguageFeatures; } export interface TranslationResult { @@ -90,10 +91,24 @@ export interface UsageInfo { products?: ProductUsage[]; } +/** + * Per-feature support as reported by GET /v3/languages. A feature is supported + * when its key is present; `status` describes maturity, not availability. + * Known values are `stable`, `beta` and `early_access`, but the enum is open, + * so it stays a plain string. + */ +export interface LanguageFeature { + status: string; +} + +/** Feature keys vary by `resource`, so the map is deliberately open-ended. */ +export type LanguageFeatures = Record; + export interface LanguageInfo { language: Language; name: string; supportsFormality?: boolean; + features?: LanguageFeatures; } export class TranslationClient extends HttpClient { @@ -269,8 +284,8 @@ export class TranslationClient extends HttpClient { /** * Lists languages via GET /v3/languages (v2 is deprecated). One response * carries both roles as usable_as_source/usable_as_target flags, filtered - * here to preserve the per-type contract. The v3 response no longer reports - * formality support, so that comes from the static language registry. + * here to preserve the per-type contract. Formality support comes from the + * per-language features matrix, which is what v2's supports_formality became. */ async getSupportedLanguages( type: 'source' | 'target' @@ -290,8 +305,9 @@ export class TranslationClient extends HttpClient { language: code, name: lang.name, ...(type === 'target' && { - supportsFormality: LANGUAGE_REGISTRY.get(code)?.supportsFormality ?? false, + supportsFormality: lang.features?.['formality'] !== undefined, }), + ...(lang.features && { features: lang.features }), }; }); } catch (error) { diff --git a/src/data/language-registry.ts b/src/data/language-registry.ts index f74024f6..47296ec2 100644 --- a/src/data/language-registry.ts +++ b/src/data/language-registry.ts @@ -24,17 +24,12 @@ export type LanguageCategory = 'core' | 'regional' | 'extended'; * @property category - Feature-availability tier * @property targetOnly - When true, the language can only be used as a translation target * (not as a source). Applies to regional variants like 'en-gb' and 'pt-br'. - * @property supportsFormality - When true, the language supports the formality - * parameter as a translation target. Static because GET /v3/languages does not - * report formality support (the v2 endpoint did); values mirror the last - * /v2/languages?type=target response (captured 2026-08-01). */ export interface LanguageEntry { code: string; name: string; category: LanguageCategory; targetOnly?: boolean; - supportsFormality?: boolean; } const ENTRIES: LanguageEntry[] = [ @@ -43,27 +38,27 @@ const ENTRIES: LanguageEntry[] = [ { code: 'bg', name: 'Bulgarian', category: 'core' }, { code: 'cs', name: 'Czech', category: 'core' }, { code: 'da', name: 'Danish', category: 'core' }, - { code: 'de', name: 'German', category: 'core', supportsFormality: true }, + { code: 'de', name: 'German', category: 'core' }, { code: 'el', name: 'Greek', category: 'core' }, { code: 'en', name: 'English', category: 'core' }, - { code: 'es', name: 'Spanish', category: 'core', supportsFormality: true }, + { code: 'es', name: 'Spanish', category: 'core' }, { code: 'et', name: 'Estonian', category: 'core' }, { code: 'fi', name: 'Finnish', category: 'core' }, - { code: 'fr', name: 'French', category: 'core', supportsFormality: true }, + { code: 'fr', name: 'French', category: 'core' }, { code: 'he', name: 'Hebrew', category: 'core' }, { code: 'hu', name: 'Hungarian', category: 'core' }, { code: 'id', name: 'Indonesian', category: 'core' }, - { code: 'it', name: 'Italian', category: 'core', supportsFormality: true }, - { code: 'ja', name: 'Japanese', category: 'core', supportsFormality: true }, + { code: 'it', name: 'Italian', category: 'core' }, + { code: 'ja', name: 'Japanese', category: 'core' }, { code: 'ko', name: 'Korean', category: 'core' }, { code: 'lt', name: 'Lithuanian', category: 'core' }, { code: 'lv', name: 'Latvian', category: 'core' }, { code: 'nb', name: 'Norwegian Bokmål', category: 'core' }, - { code: 'nl', name: 'Dutch', category: 'core', supportsFormality: true }, - { code: 'pl', name: 'Polish', category: 'core', supportsFormality: true }, + { code: 'nl', name: 'Dutch', category: 'core' }, + { code: 'pl', name: 'Polish', category: 'core' }, { code: 'pt', name: 'Portuguese', category: 'core' }, { code: 'ro', name: 'Romanian', category: 'core' }, - { code: 'ru', name: 'Russian', category: 'core', supportsFormality: true }, + { code: 'ru', name: 'Russian', category: 'core' }, { code: 'sk', name: 'Slovak', category: 'core' }, { code: 'sl', name: 'Slovenian', category: 'core' }, { code: 'sv', name: 'Swedish', category: 'core' }, @@ -75,9 +70,9 @@ const ENTRIES: LanguageEntry[] = [ // Regional variants (target-only) { code: 'en-gb', name: 'English (British)', category: 'regional', targetOnly: true }, { code: 'en-us', name: 'English (American)', category: 'regional', targetOnly: true }, - { code: 'es-419', name: 'Spanish (Latin America)', category: 'regional', targetOnly: true, supportsFormality: true }, - { code: 'pt-br', name: 'Portuguese (Brazilian)', category: 'regional', targetOnly: true, supportsFormality: true }, - { code: 'pt-pt', name: 'Portuguese (European)', category: 'regional', targetOnly: true, supportsFormality: true }, + { code: 'es-419', name: 'Spanish (Latin America)', category: 'regional', targetOnly: true }, + { code: 'pt-br', name: 'Portuguese (Brazilian)', category: 'regional', targetOnly: true }, + { code: 'pt-pt', name: 'Portuguese (European)', category: 'regional', targetOnly: true }, { code: 'zh-hans', name: 'Chinese (Simplified)', category: 'regional', targetOnly: true }, { code: 'zh-hant', name: 'Chinese (Traditional)', category: 'regional', targetOnly: true }, diff --git a/tests/integration/deepl-client.integration.test.ts b/tests/integration/deepl-client.integration.test.ts index 3dea8691..e4ee5495 100644 --- a/tests/integration/deepl-client.integration.test.ts +++ b/tests/integration/deepl-client.integration.test.ts @@ -517,15 +517,37 @@ describe('DeepLClient Integration', () => { .get('/v3/languages') .query({ resource: 'translate_text' }) .reply(200, [ - { lang: 'es', name: 'Spanish', usable_as_source: true, usable_as_target: true }, - { lang: 'fr', name: 'French', usable_as_source: true, usable_as_target: true }, + { + lang: 'es', + name: 'Spanish', + usable_as_source: true, + usable_as_target: true, + features: { formality: { status: 'stable' } }, + }, + { + lang: 'fr', + name: 'French', + usable_as_source: true, + usable_as_target: true, + features: { formality: { status: 'stable' } }, + }, ]); const result = await client.getSupportedLanguages('target'); expect(result).toEqual([ - { language: 'es', name: 'Spanish', supportsFormality: true }, - { language: 'fr', name: 'French', supportsFormality: true }, + { + language: 'es', + name: 'Spanish', + supportsFormality: true, + features: { formality: { status: 'stable' } }, + }, + { + language: 'fr', + name: 'French', + supportsFormality: true, + features: { formality: { status: 'stable' } }, + }, ]); expect(scope.isDone()).toBe(true); }); @@ -544,7 +566,7 @@ describe('DeepLClient Integration', () => { expect(result[0]?.language).toBe('en-us'); }); - it('should source formality support from the registry for targets', async () => { + it('should source formality support from the features matrix for targets', async () => { const client = new DeepLClient(API_KEY); clients.push(client); @@ -552,8 +574,20 @@ describe('DeepLClient Integration', () => { .get('/v3/languages') .query({ resource: 'translate_text' }) .reply(200, [ - { lang: 'de', name: 'German', usable_as_source: true, usable_as_target: true }, - { lang: 'en-us', name: 'English (American)', usable_as_source: false, usable_as_target: true }, + { + lang: 'de', + name: 'German', + usable_as_source: true, + usable_as_target: true, + features: { formality: { status: 'stable' }, glossary: { status: 'stable' } }, + }, + { + lang: 'en-us', + name: 'English (American)', + usable_as_source: false, + usable_as_target: true, + features: { glossary: { status: 'stable' } }, + }, ]); const result = await client.getSupportedLanguages('target'); diff --git a/tests/unit/deepl-client.test.ts b/tests/unit/deepl-client.test.ts index 9ffbbeb7..044a0fb5 100644 --- a/tests/unit/deepl-client.test.ts +++ b/tests/unit/deepl-client.test.ts @@ -822,14 +822,32 @@ describe('DeepLClient', () => { expect(languages[0]?.language).toBe('de'); }); - it('should mark formality support on targets from the registry', async () => { + it('should mark formality support on targets from the features matrix', async () => { nock(baseUrl) .get('/v3/languages') .query({ resource: 'translate_text' }) .reply(200, [ - { lang: 'de', name: 'German', usable_as_source: true, usable_as_target: true }, - { lang: 'ja', name: 'Japanese', usable_as_source: true, usable_as_target: true }, - { lang: 'ko', name: 'Korean', usable_as_source: true, usable_as_target: true }, + { + lang: 'de', + name: 'German', + usable_as_source: true, + usable_as_target: true, + features: { formality: { status: 'stable' } }, + }, + { + lang: 'ja', + name: 'Japanese', + usable_as_source: true, + usable_as_target: true, + features: { formality: { status: 'stable' } }, + }, + { + lang: 'ko', + name: 'Korean', + usable_as_source: true, + usable_as_target: true, + features: { glossary: { status: 'stable' } }, + }, ]); const languages = await client.getSupportedLanguages('target'); diff --git a/tests/unit/language-registry.test.ts b/tests/unit/language-registry.test.ts index 3926add9..b7065dc7 100644 --- a/tests/unit/language-registry.test.ts +++ b/tests/unit/language-registry.test.ts @@ -66,13 +66,13 @@ describe('Language Registry', () => { describe('specific language entries', () => { it('should include known core languages', () => { expect(LANGUAGE_REGISTRY.get('en')).toEqual({ code: 'en', name: 'English', category: 'core' }); - expect(LANGUAGE_REGISTRY.get('de')).toEqual({ code: 'de', name: 'German', category: 'core', supportsFormality: true }); - expect(LANGUAGE_REGISTRY.get('ja')).toEqual({ code: 'ja', name: 'Japanese', category: 'core', supportsFormality: true }); + expect(LANGUAGE_REGISTRY.get('de')).toEqual({ code: 'de', name: 'German', category: 'core' }); + expect(LANGUAGE_REGISTRY.get('ja')).toEqual({ code: 'ja', name: 'Japanese', category: 'core' }); }); it('should include known regional variants', () => { expect(LANGUAGE_REGISTRY.get('en-gb')).toEqual({ code: 'en-gb', name: 'English (British)', category: 'regional', targetOnly: true }); - expect(LANGUAGE_REGISTRY.get('pt-br')).toEqual({ code: 'pt-br', name: 'Portuguese (Brazilian)', category: 'regional', targetOnly: true, supportsFormality: true }); + expect(LANGUAGE_REGISTRY.get('pt-br')).toEqual({ code: 'pt-br', name: 'Portuguese (Brazilian)', category: 'regional', targetOnly: true }); }); it('should include known extended languages', () => { @@ -218,22 +218,10 @@ describe('Language Registry', () => { }); }); - describe('supportsFormality', () => { - it('should mark the formality-capable languages', () => { - for (const code of ['de', 'es', 'es-419', 'fr', 'it', 'ja', 'nl', 'pl', 'pt-br', 'pt-pt', 'ru']) { - expect(LANGUAGE_REGISTRY.get(code)?.supportsFormality).toBe(true); - } - }); - - it('should leave non-formality languages unmarked', () => { - for (const code of ['en', 'en-us', 'ko', 'zh', 'ar', 'ace']) { - expect(LANGUAGE_REGISTRY.get(code)?.supportsFormality).toBeUndefined(); - } - }); - - it('should never mark an extended language', () => { - getExtendedLanguageCodes().forEach(code => { - expect(LANGUAGE_REGISTRY.get(code)?.supportsFormality).toBeUndefined(); + describe('formality support', () => { + it('should not carry formality data; GET /v3/languages reports it as features.formality', () => { + LANGUAGE_REGISTRY.forEach(entry => { + expect(entry).not.toHaveProperty('supportsFormality'); }); }); }); diff --git a/tests/unit/translation-client.test.ts b/tests/unit/translation-client.test.ts index 083f2a47..dd2d5334 100644 --- a/tests/unit/translation-client.test.ts +++ b/tests/unit/translation-client.test.ts @@ -424,10 +424,23 @@ describe('TranslationClient', () => { ); }); - it('should return target languages with registry-sourced formality', async () => { + it('should derive target formality from the features matrix', async () => { mockAxiosInstance.request.mockResolvedValue({ data: [ - { lang: 'es', name: 'Spanish', usable_as_source: true, usable_as_target: true }, + { + lang: 'es', + name: 'Spanish', + usable_as_source: true, + usable_as_target: true, + features: { formality: { status: 'stable' }, glossary: { status: 'stable' } }, + }, + { + lang: 'ko', + name: 'Korean', + usable_as_source: true, + usable_as_target: true, + features: { glossary: { status: 'stable' } }, + }, ], status: 200, headers: {}, @@ -435,9 +448,63 @@ describe('TranslationClient', () => { const result = await client.getSupportedLanguages('target'); - expect(result).toHaveLength(1); - expect(result[0]!.language).toBe('es'); expect(result[0]!.supportsFormality).toBe(true); + expect(result[1]!.supportsFormality).toBe(false); + }); + + it('should report formality for a language the static registry never flagged', async () => { + mockAxiosInstance.request.mockResolvedValue({ + data: [ + { + lang: 'pt', + name: 'Portuguese', + usable_as_source: true, + usable_as_target: true, + features: { formality: { status: 'stable' } }, + }, + ], + status: 200, + headers: {}, + }); + + const result = await client.getSupportedLanguages('target'); + + expect(result[0]!.supportsFormality).toBe(true); + }); + + it('should pass the features matrix through for both roles', async () => { + const features = { + glossary: { status: 'stable' }, + style_rules: { status: 'beta' }, + }; + mockAxiosInstance.request.mockResolvedValue({ + data: [ + { lang: 'de', name: 'German', usable_as_source: true, usable_as_target: true, features }, + ], + status: 200, + headers: {}, + }); + + const source = await client.getSupportedLanguages('source'); + const target = await client.getSupportedLanguages('target'); + + expect(source[0]!.features).toEqual(features); + expect(target[0]!.features).toEqual(features); + }); + + it('should omit features when the response carries none', async () => { + mockAxiosInstance.request.mockResolvedValue({ + data: [ + { lang: 'de', name: 'German', usable_as_source: true, usable_as_target: true }, + ], + status: 200, + headers: {}, + }); + + const result = await client.getSupportedLanguages('target'); + + expect(result[0]).not.toHaveProperty('features'); + expect(result[0]!.supportsFormality).toBe(false); }); }); From f6507b304a35bc81e30092836b0cce5ad3bc80c2 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 11:09:43 -0400 Subject: [PATCH 016/256] feat(languages): add --features to show per-language feature support `deepl languages --features` surfaces the features matrix from GET /v3/languages, so glossary, style-rules and translation-memory support no longer has to be discovered by trial and error. Which columns appear is derived from the response rather than a fixed list: a feature gets a column when its value differs across the listed languages, and is otherwise reported once as "All listed languages also support: ...". A uniform column discriminates nothing, and deriving this from the data means a newly reported feature appears without a code change. In practice tag_handling suppresses in both listings and auto_detection only in the source listing, where every language has it. Support is key presence; `status` is maturity, so anything other than stable renders verbatim (`glossary (beta)`) instead of collapsing to yes. --features subsumes the [F] shorthand, so the marker and its legend are dropped when the matrix is shown. Default output is unchanged: text and table rendering are untouched without the flag, and the matrix is stripped from --format json unless asked for. Table output gains a seam (formatDisplayEntriesTable) matching the text path so rendering can be tested without the registry merge. --- src/api/deepl-client.ts | 4 +- src/cli/commands/languages.ts | 215 ++++++++++++++++++++++--- src/cli/commands/register-languages.ts | 44 +++-- tests/unit/languages-command.test.ts | 213 +++++++++++++++++++++++- tests/unit/register-languages.test.ts | 123 +++++++++++++- 5 files changed, 559 insertions(+), 40 deletions(-) diff --git a/src/api/deepl-client.ts b/src/api/deepl-client.ts index 90a3f784..31e77166 100644 --- a/src/api/deepl-client.ts +++ b/src/api/deepl-client.ts @@ -1,5 +1,5 @@ import { HttpClient, DeepLClientOptions } from './http-client.js'; -import { TranslationClient, TranslationResult, isTranslationResult, ProductUsage, UsageInfo, LanguageInfo } from './translation-client.js'; +import { TranslationClient, TranslationResult, isTranslationResult, ProductUsage, UsageInfo, LanguageInfo, LanguageFeature, LanguageFeatures } from './translation-client.js'; import { GlossaryClient } from './glossary-client.js'; import { DocumentClient } from './document-client.js'; import { WriteClient } from './write-client.js'; @@ -31,7 +31,7 @@ import { AdminUsageReport, } from '../types/index.js'; -export { TranslationResult, isTranslationResult, ProductUsage, UsageInfo, LanguageInfo }; +export { TranslationResult, isTranslationResult, ProductUsage, UsageInfo, LanguageInfo, LanguageFeature, LanguageFeatures }; export class DeepLClient { private readonly apiKey: string; diff --git a/src/cli/commands/languages.ts b/src/cli/commands/languages.ts index 7079fc02..48120dc8 100644 --- a/src/cli/commands/languages.ts +++ b/src/cli/commands/languages.ts @@ -1,7 +1,7 @@ import chalk from 'chalk'; import Table from 'cli-table3'; import type { LanguagesService } from '../../services/languages.js'; -import { LanguageInfo } from '../../api/deepl-client.js'; +import { LanguageInfo, type LanguageFeatures } from '../../api/deepl-client.js'; import { getSourceLanguages as getRegistrySourceLanguages, getTargetLanguages as getRegistryTargetLanguages, @@ -13,6 +13,122 @@ export interface LanguageDisplayEntry { name: string; category: 'core' | 'regional' | 'extended'; supportsFormality?: boolean; + features?: LanguageFeatures; +} + +/** Display order for the feature keys /v3/languages is known to report. */ +const KNOWN_FEATURE_ORDER = [ + 'formality', + 'glossary', + 'style_rules', + 'translation_memory', + 'tag_handling', + 'auto_detection', +]; + +const FEATURE_LABELS: Record = { + formality: 'Formality', + glossary: 'Glossary', + style_rules: 'Style Rules', + translation_memory: 'Translation Memory', + tag_handling: 'Tag Handling', + auto_detection: 'Auto Detection', +}; + +function featureLabel(key: string): string { + return ( + FEATURE_LABELS[key] ?? + key + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') + ); +} + +/** + * Cell text for one feature on one language. A feature is supported when the + * API reports the key at all; `status` describes maturity, so anything other + * than `stable` is shown verbatim rather than collapsed to yes. + */ +function featureCell(entry: LanguageDisplayEntry, key: string): string { + const feature = entry.features?.[key]; + if (!feature) return '—'; + return feature.status === 'stable' ? 'yes' : feature.status; +} + +function sortFeatureKeys(keys: string[]): string[] { + return [...keys].sort((a, b) => { + const indexA = KNOWN_FEATURE_ORDER.indexOf(a); + const indexB = KNOWN_FEATURE_ORDER.indexOf(b); + if (indexA !== -1 && indexB !== -1) return indexA - indexB; + if (indexA !== -1) return -1; + if (indexB !== -1) return 1; + return a.localeCompare(b); + }); +} + +/** + * Splits the reported features into those worth a column and those every entry + * shares. A uniform feature discriminates nothing, so it is reported once as a + * note instead of repeated on every row. Deriving this from the response rather + * than a fixed list means a new API feature surfaces without a code change. + */ +export function partitionFeatureKeys(entries: LanguageDisplayEntry[]): { + columns: string[]; + uniform: Array<{ key: string; cell: string }>; +} { + if (entries.length === 0) return { columns: [], uniform: [] }; + + const allKeys = new Set(); + for (const entry of entries) { + for (const key of Object.keys(entry.features ?? {})) allKeys.add(key); + } + + const columns: string[] = []; + const uniform: Array<{ key: string; cell: string }> = []; + for (const key of allKeys) { + const first = featureCell(entries[0]!, key); + if (entries.some(entry => featureCell(entry, key) !== first)) { + columns.push(key); + } else { + uniform.push({ key, cell: first }); + } + } + + return { + columns: sortFeatureKeys(columns), + uniform: sortFeatureKeys(uniform.map(u => u.key)).map( + key => uniform.find(u => u.key === key)!, + ), + }; +} + +function hasAnyFeatures(entries: LanguageDisplayEntry[]): boolean { + return entries.some(entry => Object.keys(entry.features ?? {}).length > 0); +} + +/** Lowercased feature list for prose contexts, e.g. `glossary, style rules`. */ +function featureList(entry: LanguageDisplayEntry, keys: string[]): string { + const supported = keys + .filter(key => featureCell(entry, key) !== '—') + .map(key => { + const cell = featureCell(entry, key); + const label = featureLabel(key).toLowerCase(); + return cell === 'yes' ? label : `${label} (${cell})`; + }); + return supported.length > 0 ? supported.join(', ') : 'none'; +} + +function uniformNote(uniform: Array<{ key: string; cell: string }>): string | undefined { + const supported = uniform.filter(u => u.cell !== '—'); + if (supported.length === 0) return undefined; + const list = supported + .map(u => { + const label = featureLabel(u.key).toLowerCase(); + return u.cell === 'yes' ? label : `${label} (${u.cell})`; + }) + .join(', '); + return `All listed languages also support: ${list}.`; } export class LanguagesCommand { @@ -53,6 +169,7 @@ export class LanguagesCommand { name: apiLang?.name ?? entry.name, category: entry.category, ...(apiLang?.supportsFormality !== undefined && { supportsFormality: apiLang.supportsFormality }), + ...(apiLang?.features && { features: apiLang.features }), }; }); } @@ -72,20 +189,31 @@ export class LanguagesCommand { })); } - formatLanguages(languages: LanguageInfo[], type: 'source' | 'target'): string { + formatLanguages( + languages: LanguageInfo[], + type: 'source' | 'target', + showFeatures = false + ): string { if (languages.length === 0 && !this.service.hasClient()) { const displayEntries = this.getRegistryLanguages(type); - return this.formatDisplayEntries(displayEntries, type); + return this.formatDisplayEntries(displayEntries, type, showFeatures); } const displayEntries = this.mergeWithRegistry(languages, type); - return this.formatDisplayEntries(displayEntries, type); + return this.formatDisplayEntries(displayEntries, type, showFeatures); } - formatDisplayEntries(entries: LanguageDisplayEntry[], type: 'source' | 'target'): string { + formatDisplayEntries( + entries: LanguageDisplayEntry[], + type: 'source' | 'target', + showFeatures = false + ): string { const lines: string[] = []; const header = type === 'source' ? 'Source Languages:' : 'Target Languages:'; - const showFormality = type === 'target' && entries.some(e => e.supportsFormality !== undefined); + const renderFeatures = showFeatures && hasAnyFeatures(entries); + // Formality is one of the feature columns, so the [F] shorthand would say it twice. + const showFormality = + !renderFeatures && type === 'target' && entries.some(e => e.supportsFormality !== undefined); lines.push(chalk.bold(header)); @@ -99,11 +227,16 @@ export class LanguagesCommand { const allEntries = [...coreAndRegional, ...extended]; const maxCodeLength = Math.max(...allEntries.map(e => e.code.length)); + const { columns, uniform } = renderFeatures + ? partitionFeatureKeys(entries) + : { columns: [], uniform: [] }; + const suffix = (entry: LanguageDisplayEntry): string => + renderFeatures ? chalk.gray(` — ${featureList(entry, columns)}`) : ''; coreAndRegional.forEach(entry => { const code = entry.code.padEnd(maxCodeLength + 2); const formalityMarker = showFormality && entry.supportsFormality ? chalk.green(' [F]') : ''; - lines.push(` ${chalk.cyan(code)} ${entry.name}${formalityMarker}`); + lines.push(` ${chalk.cyan(code)} ${entry.name}${formalityMarker}${suffix(entry)}`); }); if (extended.length > 0) { @@ -111,7 +244,7 @@ export class LanguagesCommand { lines.push(chalk.gray(' Extended Languages (quality_optimized only, no formality/glossary):')); extended.forEach(entry => { const code = entry.code.padEnd(maxCodeLength + 2); - lines.push(` ${chalk.gray(code)} ${chalk.gray(entry.name)}`); + lines.push(` ${chalk.gray(code)} ${chalk.gray(entry.name)}${suffix(entry)}`); }); } @@ -120,32 +253,66 @@ export class LanguagesCommand { lines.push(chalk.gray(' [F] = supports formality parameter')); } + const note = renderFeatures ? uniformNote(uniform) : undefined; + if (note) { + lines.push(''); + lines.push(chalk.gray(` ${note}`)); + } + return lines.join('\n'); } - formatAllLanguages(sourceLanguages: LanguageInfo[], targetLanguages: LanguageInfo[]): string { - const sourcePart = this.formatLanguages(sourceLanguages, 'source'); - const targetPart = this.formatLanguages(targetLanguages, 'target'); + formatAllLanguages( + sourceLanguages: LanguageInfo[], + targetLanguages: LanguageInfo[], + showFeatures = false + ): string { + const sourcePart = this.formatLanguages(sourceLanguages, 'source', showFeatures); + const targetPart = this.formatLanguages(targetLanguages, 'target', showFeatures); return `${sourcePart}\n\n${targetPart}`; } /** Format a single language list (source or target) as a cli-table3 table. */ - formatLanguagesTable(languages: LanguageInfo[], type: 'source' | 'target'): string { + formatLanguagesTable( + languages: LanguageInfo[], + type: 'source' | 'target', + showFeatures = false + ): string { const entries = languages.length === 0 && !this.service.hasClient() ? this.getRegistryLanguages(type) : this.mergeWithRegistry(languages, type); + return this.formatDisplayEntriesTable(entries, type, showFeatures); + } + + formatDisplayEntriesTable( + entries: LanguageDisplayEntry[], + type: 'source' | 'target', + showFeatures = false + ): string { const header = type === 'source' ? 'Source Languages' : 'Target Languages'; if (entries.length === 0) { return `${header}: (no languages available)`; } - const showFormality = type === 'target' && entries.some(e => e.supportsFormality !== undefined); - const head = showFormality - ? ['Code', 'Name', 'Category', 'Formality'] - : ['Code', 'Name', 'Category']; - const colWidths = showFormality ? [10, 30, 12, 13] : [10, 36, 12]; + const renderFeatures = showFeatures && hasAnyFeatures(entries); + const { columns, uniform } = renderFeatures + ? partitionFeatureKeys(entries) + : { columns: [], uniform: [] }; + const showFormality = + !renderFeatures && type === 'target' && entries.some(e => e.supportsFormality !== undefined); + + const head = ['Code', 'Name', 'Category']; + const colWidths = [10, renderFeatures ? 24 : showFormality ? 30 : 36, 12]; + if (showFormality) { + head.push('Formality'); + colWidths.push(13); + } + for (const key of columns) { + head.push(featureLabel(key)); + colWidths.push(13); + } const colorDisabled = !isColorEnabled(); const table = new Table({ @@ -160,14 +327,22 @@ export class LanguagesCommand { if (showFormality) { row.push(entry.supportsFormality ? 'yes' : '—'); } + for (const key of columns) { + row.push(featureCell(entry, key)); + } table.push(row); } - return `${header}:\n${table.toString()}`; + const note = renderFeatures ? uniformNote(uniform) : undefined; + return `${header}:\n${table.toString()}${note ? `\n${note}` : ''}`; } /** Format both source and target language tables joined by a blank line. */ - formatAllLanguagesTable(sourceLanguages: LanguageInfo[], targetLanguages: LanguageInfo[]): string { - return `${this.formatLanguagesTable(sourceLanguages, 'source')}\n\n${this.formatLanguagesTable(targetLanguages, 'target')}`; + formatAllLanguagesTable( + sourceLanguages: LanguageInfo[], + targetLanguages: LanguageInfo[], + showFeatures = false + ): string { + return `${this.formatLanguagesTable(sourceLanguages, 'source', showFeatures)}\n\n${this.formatLanguagesTable(targetLanguages, 'target', showFeatures)}`; } } diff --git a/src/cli/commands/register-languages.ts b/src/cli/commands/register-languages.ts index b7db4566..87c171fb 100644 --- a/src/cli/commands/register-languages.ts +++ b/src/cli/commands/register-languages.ts @@ -1,9 +1,23 @@ import { Option, type Command } from 'commander'; import chalk from 'chalk'; import type { ConfigService } from '../../storage/config.js'; +import type { LanguageInfo } from '../../api/deepl-client.js'; import { Logger } from '../../utils/logger.js'; import { createLanguagesCommand, type CreateDeepLClient } from './service-factory.js'; +/** + * The features matrix rides along on every LanguageInfo, so it is stripped from + * JSON unless asked for; existing consumers keep the shape they were written to. + */ +function forJson(languages: LanguageInfo[], includeFeatures: boolean): unknown[] { + if (includeFeatures) return languages; + return languages.map(language => { + const copy = { ...language }; + delete copy.features; + return copy; + }); +} + export function registerLanguages( program: Command, deps: { @@ -19,20 +33,24 @@ export function registerLanguages( .description('List supported source and target languages') .option('-s, --source', 'Show only source languages') .option('--target', 'Show only target languages') + .option('--features', 'Show per-language feature support (requires an API key)') .addOption(new Option('--format ', 'Output format').choices(['text', 'json', 'table']).default('text')) .addHelpText('after', ` Examples: $ deepl languages $ deepl languages --source $ deepl languages --target + $ deepl languages --features + $ deepl languages --target --features --format table $ deepl languages --format json $ deepl languages --format table `) - .action(async (options: { source?: boolean; target?: boolean; format?: string }) => { + .action(async (options: { source?: boolean; target?: boolean; features?: boolean; format?: string }) => { try { const apiKey = getConfigService().getValue('auth.apiKey'); const envKey = process.env['DEEPL_API_KEY']; const hasApiKey = !!(apiKey ?? envKey); + const showFeatures = !!options.features; let client = null; if (hasApiKey) { @@ -40,6 +58,9 @@ Examples: } else { Logger.warn(chalk.yellow('Note: No API key configured. Showing local language registry only.')); Logger.warn(chalk.yellow('Run: deepl auth set-key for API-verified names.\n')); + if (showFeatures) { + Logger.warn(chalk.yellow('Note: --features needs an API key; the local registry carries no feature data.\n')); + } } const languagesCommand = await createLanguagesCommand(client); @@ -47,16 +68,19 @@ Examples: if (options.format === 'json') { if (options.source && !options.target) { const sourceLanguages = await languagesCommand.getSourceLanguages(); - Logger.output(JSON.stringify(sourceLanguages, null, 2)); + Logger.output(JSON.stringify(forJson(sourceLanguages, showFeatures), null, 2)); } else if (options.target && !options.source) { const targetLanguages = await languagesCommand.getTargetLanguages(); - Logger.output(JSON.stringify(targetLanguages, null, 2)); + Logger.output(JSON.stringify(forJson(targetLanguages, showFeatures), null, 2)); } else { const [sourceLanguages, targetLanguages] = await Promise.all([ languagesCommand.getSourceLanguages(), languagesCommand.getTargetLanguages(), ]); - Logger.output(JSON.stringify({ source: sourceLanguages, target: targetLanguages }, null, 2)); + Logger.output(JSON.stringify({ + source: forJson(sourceLanguages, showFeatures), + target: forJson(targetLanguages, showFeatures), + }, null, 2)); } return; } @@ -71,21 +95,21 @@ Examples: if (options.source && !options.target) { const sourceLanguages = await languagesCommand.getSourceLanguages(); output = wantTable - ? languagesCommand.formatLanguagesTable(sourceLanguages, 'source') - : languagesCommand.formatLanguages(sourceLanguages, 'source'); + ? languagesCommand.formatLanguagesTable(sourceLanguages, 'source', showFeatures) + : languagesCommand.formatLanguages(sourceLanguages, 'source', showFeatures); } else if (options.target && !options.source) { const targetLanguages = await languagesCommand.getTargetLanguages(); output = wantTable - ? languagesCommand.formatLanguagesTable(targetLanguages, 'target') - : languagesCommand.formatLanguages(targetLanguages, 'target'); + ? languagesCommand.formatLanguagesTable(targetLanguages, 'target', showFeatures) + : languagesCommand.formatLanguages(targetLanguages, 'target', showFeatures); } else { const [sourceLanguages, targetLanguages] = await Promise.all([ languagesCommand.getSourceLanguages(), languagesCommand.getTargetLanguages(), ]); output = wantTable - ? languagesCommand.formatAllLanguagesTable(sourceLanguages, targetLanguages) - : languagesCommand.formatAllLanguages(sourceLanguages, targetLanguages); + ? languagesCommand.formatAllLanguagesTable(sourceLanguages, targetLanguages, showFeatures) + : languagesCommand.formatAllLanguages(sourceLanguages, targetLanguages, showFeatures); } Logger.output(output); diff --git a/tests/unit/languages-command.test.ts b/tests/unit/languages-command.test.ts index 112df5d8..26c9003b 100644 --- a/tests/unit/languages-command.test.ts +++ b/tests/unit/languages-command.test.ts @@ -1,4 +1,8 @@ -import { LanguagesCommand } from '../../src/cli/commands/languages'; +import { + LanguagesCommand, + partitionFeatureKeys, + type LanguageDisplayEntry, +} from '../../src/cli/commands/languages'; import { LanguageInfo } from '../../src/api/deepl-client'; import { createMockLanguagesService } from '../helpers/mock-factories'; @@ -403,4 +407,211 @@ describe('LanguagesCommand', () => { expect(result.split('\n\n').length).toBeGreaterThanOrEqual(2); }); }); + + describe('partitionFeatureKeys()', () => { + const entry = ( + code: string, + features?: Record, + ): LanguageDisplayEntry => ({ + code, + name: code.toUpperCase(), + category: 'core', + ...(features && { features }), + }); + + it('should keep a feature that discriminates between entries', () => { + const { columns } = partitionFeatureKeys([ + entry('de', { glossary: { status: 'stable' } }), + entry('hi', {}), + ]); + + expect(columns).toEqual(['glossary']); + }); + + it('should suppress a feature supported identically by every entry', () => { + const { columns, uniform } = partitionFeatureKeys([ + entry('de', { tag_handling: { status: 'stable' } }), + entry('hi', { tag_handling: { status: 'stable' } }), + ]); + + expect(columns).toEqual([]); + expect(uniform).toEqual([{ key: 'tag_handling', cell: 'yes' }]); + }); + + it('should order known features first and unknown features alphabetically after', () => { + const all = { + zebra_feature: { status: 'stable' }, + translation_memory: { status: 'stable' }, + alpha_feature: { status: 'stable' }, + formality: { status: 'stable' }, + glossary: { status: 'stable' }, + }; + const { columns } = partitionFeatureKeys([entry('de', all), entry('hi', {})]); + + expect(columns).toEqual([ + 'formality', + 'glossary', + 'translation_memory', + 'alpha_feature', + 'zebra_feature', + ]); + }); + + it('should treat a differing status as discriminating', () => { + const { columns } = partitionFeatureKeys([ + entry('de', { glossary: { status: 'stable' } }), + entry('th', { glossary: { status: 'beta' } }), + ]); + + expect(columns).toEqual(['glossary']); + }); + + it('should return nothing for an empty entry list', () => { + expect(partitionFeatureKeys([])).toEqual({ columns: [], uniform: [] }); + }); + }); + + describe('feature display', () => { + // Asserted through the display-entry layer: formatLanguages() merges against + // the whole registry, so languages absent from the argument would report no + // features and make every column discriminating. + const displayEntries: LanguageDisplayEntry[] = [ + { + code: 'de', + name: 'German', + category: 'core', + supportsFormality: true, + features: { + formality: { status: 'stable' }, + glossary: { status: 'stable' }, + style_rules: { status: 'stable' }, + tag_handling: { status: 'stable' }, + }, + }, + { + code: 'pt', + name: 'Portuguese', + category: 'core', + supportsFormality: true, + features: { + formality: { status: 'stable' }, + glossary: { status: 'stable' }, + tag_handling: { status: 'stable' }, + }, + }, + { + code: 'hi', + name: 'Hindi', + category: 'extended', + supportsFormality: false, + features: { tag_handling: { status: 'stable' } }, + }, + ]; + + it('should list supported features per language in text output', () => { + const formatted = languagesCommand.formatDisplayEntries(displayEntries, 'target', true); + const de = formatted.split('\n').find(l => l.includes('German')); + const pt = formatted.split('\n').find(l => l.includes('Portuguese')); + + expect(de).toContain('formality'); + expect(de).toContain('style rules'); + expect(pt).toContain('glossary'); + expect(pt).not.toContain('style rules'); + }); + + it('should note the features every listed language shares', () => { + const formatted = languagesCommand.formatDisplayEntries(displayEntries, 'target', true); + + expect(formatted).toContain('All listed languages also support: tag handling.'); + }); + + it('should mark a language with no supported features', () => { + const formatted = languagesCommand.formatDisplayEntries(displayEntries, 'target', true); + const hi = formatted.split('\n').find(l => l.includes('Hindi')); + + expect(hi).toContain('none'); + }); + + it('should render features for extended languages too', () => { + const entries: LanguageDisplayEntry[] = [ + { code: 'de', name: 'German', category: 'core', features: {} }, + { + code: 'th', + name: 'Thai', + category: 'extended', + features: { style_rules: { status: 'stable' } }, + }, + ]; + const formatted = languagesCommand.formatDisplayEntries(entries, 'target', true); + const th = formatted.split('\n').find(l => l.includes('Thai')); + + expect(th).toContain('style rules'); + }); + + it('should drop the [F] marker and legend when features are shown', () => { + const formatted = languagesCommand.formatDisplayEntries(displayEntries, 'target', true); + + expect(formatted).not.toContain('[F]'); + }); + + it('should label a non-stable status instead of yes', () => { + const entries: LanguageDisplayEntry[] = [ + { code: 'de', name: 'German', category: 'core', features: { glossary: { status: 'beta' } } }, + { code: 'hi', name: 'Hindi', category: 'extended', features: {} }, + ]; + const formatted = languagesCommand.formatDisplayEntries(entries, 'target', true); + const de = formatted.split('\n').find(l => l.includes('German')); + + expect(de).toContain('glossary (beta)'); + }); + + it('should fall back to the default rendering when no entry reports features', () => { + const noFeatures: LanguageDisplayEntry[] = [ + { code: 'de', name: 'German', category: 'core', supportsFormality: true }, + ]; + const withFlag = languagesCommand.formatDisplayEntries(noFeatures, 'target', true); + const without = languagesCommand.formatDisplayEntries(noFeatures, 'target'); + + expect(withFlag).toBe(without); + }); + + it('should leave default text output untouched when features are not requested', () => { + const withFlagOff = languagesCommand.formatDisplayEntries(displayEntries, 'target'); + + expect(withFlagOff).toContain('[F]'); + expect(withFlagOff).not.toContain('tag handling'); + }); + + it('should thread the flag through formatLanguages to the display layer', () => { + const apiLangs: LanguageInfo[] = [ + { + language: 'de', + name: 'German', + supportsFormality: true, + features: { glossary: { status: 'stable' } }, + }, + ]; + const formatted = languagesCommand.formatLanguages(apiLangs, 'target', true); + const de = formatted.split('\n').find(l => l.includes('German')); + + expect(de).toContain('glossary'); + }); + + it('should add a column per discriminating feature in table output', () => { + const table = languagesCommand.formatDisplayEntriesTable(displayEntries, 'target', true); + + expect(table).toContain('Formality'); + expect(table).toContain('Glossary'); + expect(table).toContain('Style Rules'); + expect(table).not.toContain('Tag Handling'); + expect(table).toContain('All listed languages also support: tag handling.'); + }); + + it('should not add feature columns to table output by default', () => { + const table = languagesCommand.formatDisplayEntriesTable(displayEntries, 'target'); + + expect(table).not.toContain('Glossary'); + expect(table).toContain('Formality'); + }); + }); }); diff --git a/tests/unit/register-languages.test.ts b/tests/unit/register-languages.test.ts index 18c67b58..c8f20e5b 100644 --- a/tests/unit/register-languages.test.ts +++ b/tests/unit/register-languages.test.ts @@ -91,7 +91,7 @@ describe('registerLanguages', () => { mockLanguagesCommandInstance.formatAllLanguages.mockReturnValue('all languages'); await program.parseAsync(['node', 'test', 'languages']); expect(createDeepLClient).toHaveBeenCalled(); - expect(mockLanguagesCommandInstance.formatAllLanguages).toHaveBeenCalledWith(sources, targets); + expect(mockLanguagesCommandInstance.formatAllLanguages).toHaveBeenCalledWith(sources, targets, false); expect(Logger.output).toHaveBeenCalledWith('all languages'); }); @@ -100,7 +100,7 @@ describe('registerLanguages', () => { mockLanguagesCommandInstance.getSourceLanguages.mockResolvedValue(sources); mockLanguagesCommandInstance.formatLanguages.mockReturnValue('source list'); await program.parseAsync(['node', 'test', 'languages', '--source']); - expect(mockLanguagesCommandInstance.formatLanguages).toHaveBeenCalledWith(sources, 'source'); + expect(mockLanguagesCommandInstance.formatLanguages).toHaveBeenCalledWith(sources, 'source', false); expect(Logger.output).toHaveBeenCalledWith('source list'); }); @@ -109,7 +109,7 @@ describe('registerLanguages', () => { mockLanguagesCommandInstance.getTargetLanguages.mockResolvedValue(targets); mockLanguagesCommandInstance.formatLanguages.mockReturnValue('target list'); await program.parseAsync(['node', 'test', 'languages', '--target']); - expect(mockLanguagesCommandInstance.formatLanguages).toHaveBeenCalledWith(targets, 'target'); + expect(mockLanguagesCommandInstance.formatLanguages).toHaveBeenCalledWith(targets, 'target', false); expect(Logger.output).toHaveBeenCalledWith('target list'); }); @@ -120,7 +120,7 @@ describe('registerLanguages', () => { mockLanguagesCommandInstance.getTargetLanguages.mockResolvedValue(targets); mockLanguagesCommandInstance.formatAllLanguages.mockReturnValue('all'); await program.parseAsync(['node', 'test', 'languages', '--source', '--target']); - expect(mockLanguagesCommandInstance.formatAllLanguages).toHaveBeenCalledWith(sources, targets); + expect(mockLanguagesCommandInstance.formatAllLanguages).toHaveBeenCalledWith(sources, targets, false); }); it('should output JSON for source languages with --format json --source', async () => { @@ -158,7 +158,7 @@ describe('registerLanguages', () => { await program.parseAsync(['node', 'test', 'languages', '--format', 'table']); }); - expect(mockLanguagesCommandInstance.formatAllLanguagesTable).toHaveBeenCalledWith(sources, targets); + expect(mockLanguagesCommandInstance.formatAllLanguagesTable).toHaveBeenCalledWith(sources, targets, false); expect(mockLanguagesCommandInstance.formatAllLanguages).not.toHaveBeenCalled(); expect(Logger.output).toHaveBeenCalledWith('all-languages-table'); expect(Logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('non-TTY')); @@ -173,7 +173,7 @@ describe('registerLanguages', () => { await program.parseAsync(['node', 'test', 'languages', '--source', '--format', 'table']); }); - expect(mockLanguagesCommandInstance.formatLanguagesTable).toHaveBeenCalledWith(sources, 'source'); + expect(mockLanguagesCommandInstance.formatLanguagesTable).toHaveBeenCalledWith(sources, 'source', false); expect(mockLanguagesCommandInstance.formatLanguages).not.toHaveBeenCalled(); }); @@ -189,12 +189,101 @@ describe('registerLanguages', () => { }); expect(mockLanguagesCommandInstance.formatAllLanguagesTable).not.toHaveBeenCalled(); - expect(mockLanguagesCommandInstance.formatAllLanguages).toHaveBeenCalledWith(sources, targets); + expect(mockLanguagesCommandInstance.formatAllLanguages).toHaveBeenCalledWith(sources, targets, false); expect(Logger.warn).toHaveBeenCalledWith(expect.stringContaining('non-TTY')); expect(Logger.output).toHaveBeenCalledWith('plain text'); }); }); + describe('--features', () => { + beforeEach(() => { + mockGetValue.mockReturnValue('fake-key'); + }); + + it('should request the feature matrix for the combined listing', async () => { + const sources = [{ language: 'en', name: 'English' }]; + const targets = [{ language: 'de', name: 'German' }]; + mockLanguagesCommandInstance.getSourceLanguages.mockResolvedValue(sources); + mockLanguagesCommandInstance.getTargetLanguages.mockResolvedValue(targets); + mockLanguagesCommandInstance.formatAllLanguages.mockReturnValue('all'); + + await program.parseAsync(['node', 'test', 'languages', '--features']); + + expect(mockLanguagesCommandInstance.formatAllLanguages).toHaveBeenCalledWith(sources, targets, true); + }); + + it('should request the feature matrix for a single-role listing', async () => { + const targets = [{ language: 'de', name: 'German' }]; + mockLanguagesCommandInstance.getTargetLanguages.mockResolvedValue(targets); + mockLanguagesCommandInstance.formatLanguages.mockReturnValue('target list'); + + await program.parseAsync(['node', 'test', 'languages', '--target', '--features']); + + expect(mockLanguagesCommandInstance.formatLanguages).toHaveBeenCalledWith(targets, 'target', true); + }); + + it('should request the feature matrix for table output', async () => { + const sources = [{ language: 'en', name: 'English' }]; + const targets = [{ language: 'de', name: 'German' }]; + mockLanguagesCommandInstance.getSourceLanguages.mockResolvedValue(sources); + mockLanguagesCommandInstance.getTargetLanguages.mockResolvedValue(targets); + + await withTTY(true, async () => { + await program.parseAsync(['node', 'test', 'languages', '--features', '--format', 'table']); + }); + + expect(mockLanguagesCommandInstance.formatAllLanguagesTable).toHaveBeenCalledWith(sources, targets, true); + }); + + it('should include features in JSON output when requested', async () => { + const targets = [ + { language: 'de', name: 'German', features: { glossary: { status: 'stable' } } }, + ]; + mockLanguagesCommandInstance.getTargetLanguages.mockResolvedValue(targets); + + await program.parseAsync(['node', 'test', 'languages', '--target', '--features', '--format', 'json']); + + expect(Logger.output).toHaveBeenCalledWith(JSON.stringify(targets, null, 2)); + }); + + it('should strip features from JSON output when not requested', async () => { + const targets = [ + { language: 'de', name: 'German', features: { glossary: { status: 'stable' } } }, + ]; + mockLanguagesCommandInstance.getTargetLanguages.mockResolvedValue(targets); + + await program.parseAsync(['node', 'test', 'languages', '--target', '--format', 'json']); + + expect(Logger.output).toHaveBeenCalledWith( + JSON.stringify([{ language: 'de', name: 'German' }], null, 2), + ); + }); + + it('should strip features from both roles of the combined JSON output', async () => { + const sources = [ + { language: 'en', name: 'English', features: { glossary: { status: 'stable' } } }, + ]; + const targets = [ + { language: 'de', name: 'German', features: { glossary: { status: 'stable' } } }, + ]; + mockLanguagesCommandInstance.getSourceLanguages.mockResolvedValue(sources); + mockLanguagesCommandInstance.getTargetLanguages.mockResolvedValue(targets); + + await program.parseAsync(['node', 'test', 'languages', '--format', 'json']); + + expect(Logger.output).toHaveBeenCalledWith( + JSON.stringify( + { + source: [{ language: 'en', name: 'English' }], + target: [{ language: 'de', name: 'German' }], + }, + null, + 2, + ), + ); + }); + }); + describe('with API key from environment', () => { it('should create client when env key is set', async () => { process.env['DEEPL_API_KEY'] = 'env-key'; @@ -216,6 +305,26 @@ describe('registerLanguages', () => { expect(createDeepLClient).not.toHaveBeenCalled(); expect(Logger.output).toHaveBeenCalledWith('local'); }); + + it('should warn that --features needs an API key', async () => { + mockLanguagesCommandInstance.getSourceLanguages.mockResolvedValue([]); + mockLanguagesCommandInstance.getTargetLanguages.mockResolvedValue([]); + mockLanguagesCommandInstance.formatAllLanguages.mockReturnValue('local'); + + await program.parseAsync(['node', 'test', 'languages', '--features']); + + expect(Logger.warn).toHaveBeenCalledWith(expect.stringContaining('--features needs an API key')); + }); + + it('should not mention --features when the flag is absent', async () => { + mockLanguagesCommandInstance.getSourceLanguages.mockResolvedValue([]); + mockLanguagesCommandInstance.getTargetLanguages.mockResolvedValue([]); + mockLanguagesCommandInstance.formatAllLanguages.mockReturnValue('local'); + + await program.parseAsync(['node', 'test', 'languages']); + + expect(Logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('--features')); + }); }); describe('error handling', () => { From ad28fe9e00282d98a4307228ed5cd99fa293d1f9 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 11:13:40 -0400 Subject: [PATCH 017/256] test(languages): cover --features end to end against the mock API The languages e2e suite ran offline only, so nothing exercised the command against a real /v3/languages response. It now spawns the mock server, whose fixture gains a features object, and asserts the per-language feature list, that --features replaces the [F] shorthand, and that the matrix reaches --format json only when requested. Column suppression stays a unit-level assertion: the row set is the whole registry, so languages the mock does not return report no features and nothing comes out uniform. --- tests/e2e/cli-languages.e2e.test.ts | 101 +++++++++++++++++++++++++++- tests/e2e/mock-deepl-server.cjs | 51 ++++++++++++-- 2 files changed, 145 insertions(+), 7 deletions(-) diff --git a/tests/e2e/cli-languages.e2e.test.ts b/tests/e2e/cli-languages.e2e.test.ts index 70fcee12..4c2e71b8 100644 --- a/tests/e2e/cli-languages.e2e.test.ts +++ b/tests/e2e/cli-languages.e2e.test.ts @@ -6,7 +6,8 @@ * Full API integration is tested separately in integration tests. */ -import { spawnSync } from 'child_process'; +import { spawn, spawnSync, ChildProcess } from 'child_process'; +import * as fs from 'fs'; import * as path from 'path'; import { createTestConfigDir, makeNodeRunCLI } from '../helpers'; @@ -103,6 +104,104 @@ describe('Languages Command E2E', () => { }); }); + describe('languages --features (against the mock API)', () => { + const featuresConfig = createTestConfigDir('e2e-languages-features'); + let mockServerProcess: ChildProcess | undefined; + let featuresRunCLI: (command: string) => string; + + function startMockServer(): Promise { + return new Promise((resolve, reject) => { + const serverScript = path.join(__dirname, 'mock-deepl-server.cjs'); + const child = spawn('node', [serverScript], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env }, + }); + + mockServerProcess = child; + let output = ''; + + child.stdout.on('data', (data: Buffer) => { + output += data.toString(); + const match = output.match(/PORT=(\d+)/); + if (match) { + resolve(parseInt(match[1]!, 10)); + } + }); + + child.on('error', reject); + child.on('exit', code => { + if (code !== null && code !== 0) { + reject(new Error(`Mock server exited with code ${code}`)); + } + }); + + setTimeout(() => reject(new Error('Mock server did not start within 15s')), 15000); + }); + } + + beforeAll(async () => { + const port = await startMockServer(); + const config = { + auth: { apiKey: 'mock-api-key-for-testing:fx' }, + api: { baseUrl: `http://127.0.0.1:${port}`, usePro: false }, + cache: { enabled: false, maxSize: 1048576, ttl: 2592000 }, + output: { format: 'text', verbose: false, color: false }, + }; + fs.writeFileSync( + path.join(featuresConfig.path, 'config.json'), + JSON.stringify(config, null, 2), + ); + featuresRunCLI = makeNodeRunCLI(featuresConfig.path, { noColor: true, timeout: 15000 }).runCLI; + }, 30000); + + afterAll(() => { + if (mockServerProcess) { + mockServerProcess.kill('SIGTERM'); + } + featuresConfig.cleanup(); + }); + + it('should list the features each language supports', () => { + const output = featuresRunCLI('languages --target --features'); + + const german = output.split('\n').find(line => line.includes('German')); + const english = output.split('\n').find(line => line.includes('English (British)')); + + expect(german).toContain('formality'); + expect(german).toContain('glossary'); + expect(english).toContain('glossary'); + expect(english).not.toContain('formality'); + }); + + // Column suppression is asserted in the unit tests: the row set here is the + // whole registry, so the languages this mock does not return report no + // features and nothing comes out uniform. + it('should drop the [F] shorthand in favour of the matrix', () => { + const output = featuresRunCLI('languages --target --features'); + + expect(output).not.toContain('[F]'); + }); + + it('should keep the [F] shorthand when features are not requested', () => { + const output = featuresRunCLI('languages --target'); + + expect(output).toContain('[F]'); + expect(output).not.toContain('tag handling'); + }); + + it('should include the matrix in JSON only when requested', () => { + const withFeatures = JSON.parse(featuresRunCLI('languages --target --features --format json')); + const without = JSON.parse(featuresRunCLI('languages --target --format json')); + + expect(withFeatures.find((l: { language: string }) => l.language === 'de').features).toEqual({ + formality: { status: 'stable' }, + glossary: { status: 'stable' }, + tag_handling: { status: 'stable' }, + }); + expect(without.find((l: { language: string }) => l.language === 'de')).not.toHaveProperty('features'); + }); + }); + describe('languages command structure', () => { it('should be registered as a command', () => { const helpOutput = runCLI('--help'); diff --git a/tests/e2e/mock-deepl-server.cjs b/tests/e2e/mock-deepl-server.cjs index 5c96f69a..16da1074 100644 --- a/tests/e2e/mock-deepl-server.cjs +++ b/tests/e2e/mock-deepl-server.cjs @@ -202,13 +202,52 @@ function handleRequest(req, res, body) { } if (method === 'GET' && url.startsWith('/v3/languages')) { + // tag_handling is uniform so it exercises the suppression path; formality + // and glossary vary so they stay as columns. + var stable = { status: 'stable' }; var languages = [ - { lang: 'en', name: 'English', usable_as_source: true, usable_as_target: true }, - { lang: 'de', name: 'German', usable_as_source: true, usable_as_target: true }, - { lang: 'fr', name: 'French', usable_as_source: true, usable_as_target: true }, - { lang: 'es', name: 'Spanish', usable_as_source: true, usable_as_target: true }, - { lang: 'en-us', name: 'English (American)', usable_as_source: false, usable_as_target: true }, - { lang: 'en-gb', name: 'English (British)', usable_as_source: false, usable_as_target: true }, + { + lang: 'en', + name: 'English', + usable_as_source: true, + usable_as_target: true, + features: { glossary: stable, tag_handling: stable }, + }, + { + lang: 'de', + name: 'German', + usable_as_source: true, + usable_as_target: true, + features: { formality: stable, glossary: stable, tag_handling: stable }, + }, + { + lang: 'fr', + name: 'French', + usable_as_source: true, + usable_as_target: true, + features: { formality: stable, glossary: stable, tag_handling: stable }, + }, + { + lang: 'es', + name: 'Spanish', + usable_as_source: true, + usable_as_target: true, + features: { formality: stable, glossary: stable, tag_handling: stable }, + }, + { + lang: 'en-us', + name: 'English (American)', + usable_as_source: false, + usable_as_target: true, + features: { glossary: stable, tag_handling: stable }, + }, + { + lang: 'en-gb', + name: 'English (British)', + usable_as_source: false, + usable_as_target: true, + features: { glossary: stable, tag_handling: stable }, + }, ]; res.writeHead(200); From ac2e9a2d6eeafccc0741c0f2e2edfe7d5cd5d4cc Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 11:18:26 -0400 Subject: [PATCH 018/256] docs(languages): document --features and the formality fix Covers the flag in API.md, README and the languages example, including the data-driven column rule, the per-listing difference it produces, and that the matrix is finer-grained than the core/regional/extended tiers. The example gains a scriptable preflight snippet using --format json. CHANGELOG records the feature under Added and the pt formality gap under Fixed. Extends examples/24-languages.sh rather than adding a script, since that example already covers the command. --- CHANGELOG.md | 4 ++++ README.md | 18 ++++++++++++++++++ docs/API.md | 30 +++++++++++++++++++++++++++++- examples/24-languages.sh | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57518f0d..01dd118f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **translate**: `--glossary` is repeatable, applying up to 5 glossaries to one request via the API's `glossary_ids` parameter. Entries are merged, so terms unique to each glossary all apply; when several glossaries define the same source term the **last** `--glossary` on the command line wins, so the order is significant and is never sorted — reordering the flags is a different request with its own cache entry. Names and UUIDs may be mixed and are resolved independently. A single `--glossary` still goes out as `glossary_id`, leaving existing commands and their cache keys untouched; the new field is appended last in the cache key for the same reason. A 6th `--glossary` exits 6 (ValidationError) before any API call. `watch` and `sync` keep their single-glossary configuration. Verified against the live API, including the two encodings the endpoints require: repeated form fields for `POST /v2/translate`, and one comma-joined value for the multipart `POST /v2/document`, which keeps only the first of several repeated fields and would otherwise silently apply just one glossary. +- **languages**: `deepl languages --features` shows which features each language supports — formality, glossary, style rules, translation memory, tag handling and auto-detection — from the `features` matrix on `GET /v3/languages`, which the CLI previously discarded. Support no longer has to be discovered by making a request and reading the error. Which features get a column is derived from the response rather than a fixed list: a feature appears when its support differs across the languages listed, and one supported by all of them is reported once as `All listed languages also support: ...` instead of being repeated on every row. That makes the columns differ between listings — `auto detection` appears under `--target`, where target-only variants lack it, but is uniform under `--source` — and means a newly reported feature shows up without a code change. Support is signalled by the API reporting a feature at all; `status` describes maturity, so anything short of generally available renders verbatim (`glossary (beta)`) rather than collapsing to `yes`. Works with `--format table` (one column per feature) and `--format json` (the raw matrix including each status, present only when `--features` is passed, so existing JSON consumers are unaffected). `--features` supersedes the `[F]` shorthand and replaces it when given. It needs an API key; without one the command warns and falls back to the registry, which carries no feature data. Note that the matrix is finer-grained than the core/regional/extended tiers: some extended languages support style rules and translation memory even though they support neither formality nor glossary. + - **cli**: `deepl correct` command (alias `c`) — spelling and grammar correction without rewording, via the Write API's `/v2/write/correct` endpoint. Supports the same input handling and workflow flags as `write` (`--check` with exit code 8, `--fix`/`--backup`, `--diff`, `--interactive`, `--output`/`--in-place`, `--format json`, `--no-cache`), but not `--style`/`--tone`, which the correct endpoint does not accept. Results are cached under a separate `correct:` namespace so corrections and rephrasings of the same text never collide. ### Changed @@ -69,6 +71,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **languages**: `deepl languages --target` marks Portuguese (`pt`) with `[F]`. Formality support is now read from `features.formality` on `GET /v3/languages` instead of a static table in the language registry. The v3 migration had assumed v3 stopped reporting formality, but the capability was only renamed — v2's `supports_formality` boolean became the presence of a `formality` key in the per-language features matrix — so the CLI was answering from an 11-entry snapshot of the final v2 response that had already drifted from the API. The snapshot and the registry's `supportsFormality` field are gone; the registry's `category` tiers are unaffected. Output is otherwise unchanged: the `[F]` set is identical apart from `pt`. + - **watch**: `--glossary` without `--from` now exits 6 before the watcher starts, instead of starting a session that fails on every single file change with a raw server message. The API rejects any translation naming a glossary without a source language ("Use of a glossary requires the source_lang parameter to be specified"), which `translate` already guarded against up front for text, files, and documents; `watch` passed `--from` straight through. A long-running command is the worst place for this, since the operator saw the failure once per edit rather than once at launch. The check runs before the glossary name is resolved, so it costs no API call. `sync` needs no equivalent: it has no `--from` at all, taking the source language from the required `source_locale` field in `.deepl-sync.yaml`, so its requests always carry one. - **voice**: A session that ends with the audio transcribed but no translation for a requested `--to` language now fails with exit code 9 and names the languages, instead of printing an empty translation line and exiting 0. Silent partial output was the worse failure: a script consuming `deepl voice` output saw success with the translation missing. Audio containing no speech transcribes to nothing and translates to nothing, which is legitimate and still exits 0, so the check only applies when a source transcript exists. Whitespace-only and tentative-but-never-concluded translations count as missing, since neither reaches the printed output. Investigated as an intermittent (~1 in 4) empty translated line; the leading hypothesis was disproved with frame-level traces of live sessions — the server sends `end_of_stream` strictly after `end_of_target_transcript`, and the client only tears the socket down on `end_of_stream`, so there is no client-side teardown race. Reconnect was ruled out too: a socket dropped after end-of-source cannot be resumed (the API returns `410 Gone`) and already exited non-zero. The empty line did not reproduce in 64 live runs across pacing, burst, multi-target, and concurrency variations, so the trigger appears to be server-side and transient — which is exactly why the client needs to detect it rather than report success. - **formats**: TOML reconstruction escapes U+2028/U+2029 (Unicode line/paragraph separators) in double-quoted values, and literal-string values gaining one fall back to double quotes. Written raw, these characters broke the entry-line scan on the *next* sync (JavaScript's `.` excludes line terminators), which re-appended the key as a duplicate and made the third sync fail to parse the file at all — first sync fine, second silently corrupting, third crashing. Found by the property-based round-trip suite. diff --git a/README.md b/README.md index fa928b15..915adff6 100644 --- a/README.md +++ b/README.md @@ -1044,6 +1044,22 @@ deepl languages --source # Show only target languages deepl languages --target +# Show which features each language supports +deepl languages --target --features +# Target Languages: +# de German — formality, glossary, style rules, translation memory, auto detection +# pt Portuguese — formality, glossary, auto detection +# en-gb English (British) — glossary, style rules, translation memory +# ... +# Extended Languages (quality_optimized only, no formality/glossary): +# th Thai — style rules, translation memory, auto detection +# ... +# +# All listed languages also support: tag handling. + +# The same matrix as columns +deepl languages --target --features --format table + # Works without API key (shows local registry data) deepl languages ``` @@ -1054,6 +1070,8 @@ deepl languages - **Regional** (7) — Target-only variants: `en-gb`, `en-us`, `es-419`, `pt-br`, `pt-pt`, `zh-hans`, `zh-hant` - **Extended** (82) — Only support `quality_optimized` model, no formality or glossary +`--features` is finer-grained than these tiers — some extended languages do support style rules and translation memory. It needs an API key, since the local registry carries no feature data. A feature only gets its own column when support differs across the languages listed; one supported by all of them is summarised on the last line instead of repeated on every row. + See [examples/24-languages.sh](./examples/24-languages.sh) for a complete example. #### Configure Defaults diff --git a/docs/API.md b/docs/API.md index 6b3af157..0f6cc15e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2362,6 +2362,7 @@ You can filter to show only source languages, only target languages, or both (de - `--source, -s` - Show only source languages - `--target` - Show only target languages +- `--features` - Show which features each language supports (requires an API key; the local registry carries no feature data) - `--format FORMAT` - Output format: `text`, `json`, `table` (default: `text`). In non-TTY output, `table` falls back to `text` with a `WARN` line on stderr. #### Examples @@ -2399,6 +2400,23 @@ deepl languages --source # Show only target languages deepl languages --target +# Show which features each language supports +deepl languages --target --features +# Target Languages: +# de German — formality, glossary, style rules, translation memory, auto detection +# pt Portuguese — formality, glossary, auto detection +# en-gb English (British) — glossary, style rules, translation memory +# ... +# Extended Languages (quality_optimized only, no formality/glossary): +# hi Hindi — auto detection +# th Thai — style rules, translation memory, auto detection +# ... +# +# All listed languages also support: tag handling. + +# The same matrix as columns +deepl languages --target --features --format table + # Works without API key (shows local registry data) deepl languages # Note: No API key configured. Showing local language registry only. @@ -2411,11 +2429,21 @@ deepl languages - Target languages that support the `--formality` parameter are marked with `[F]` (requires API key) - Language codes are left-aligned and padded for readability +**Feature matrix (`--features`):** + +- Feature support comes from `GET /v3/languages`; a feature is supported when the API reports it for that language +- Which features are shown is derived from the response, not a fixed list. A feature only appears when its support differs across the languages listed; one supported by all of them is reported once as `All listed languages also support: ...` instead of being repeated on every row +- Because of that, the columns differ between listings: `auto detection` appears under `--target` (target-only variants lack it) but is uniform under `--source` +- A feature that is not yet generally available shows its status instead of `yes`, e.g. `glossary (beta)` +- `--features` replaces the `[F]` shorthand, since formality is one of the reported features +- `--format json` includes a raw `features` object with each feature's status, but only when `--features` is passed + **Notes:** - Source and target language lists differ: 7 regional variants (en-gb, en-us, es-419, pt-br, pt-pt, zh-hans, zh-hant) are target-only - Extended languages (82 codes) only support `quality_optimized` model type and do not support formality or glossary features -- Without an API key, the command shows all languages from the local registry with a warning +- The extended tier is a coarser signal than the feature matrix: some extended languages do support style rules and translation memory even though they support neither formality nor glossary +- Without an API key, the command shows all languages from the local registry with a warning; `--features` additionally warns that it needs a key --- diff --git a/examples/24-languages.sh b/examples/24-languages.sh index 6d403275..8f1d3280 100755 --- a/examples/24-languages.sh +++ b/examples/24-languages.sh @@ -57,6 +57,37 @@ echo "6. Target languages in JSON format:" deepl languages --target --format json | head -10 echo +# Example 7: Which features each language supports +echo "7. Feature support per language" +echo " Columns only appear for features that differ between languages;" +echo " one supported by all of them is summarised on the last line." +deepl languages --target --features | head -12 +echo + +echo "8. The same matrix as columns" +deepl languages --target --features --format table | head -10 +echo + +# Example 9: Preflight a feature in a script +echo "9. Checking feature support before using a flag" +echo +cat << 'EOF' +# Bash snippet: only pass --glossary when the target supports glossaries. +supports() { # supports + deepl languages --target --features --format json \ + | jq -e --arg l "$1" --arg f "$2" \ + '.[] | select(.language == $l) | .features | has($f)' >/dev/null +} + +if supports th glossary; then + deepl translate --to th --glossary my-terms "Hello" +else + echo "Thai does not support glossaries; translating without one" + deepl translate --to th "Hello" +fi +EOF +echo + echo "=== Language listing example completed! ====" echo @@ -64,6 +95,8 @@ echo "💡 Language categories:" echo " - Core (32): Full support - formality, glossaries, all model types" echo " - Regional (7): Target-only variants (en-us, en-gb, pt-br, etc.)" echo " - Extended (82): quality_optimized only, no formality or glossaries" +echo " - --features is finer-grained than these tiers: some extended languages" +echo " do support style rules and translation memory" echo echo "📚 Language notes:" From f21ab66a223e64af0f79b97759b3bc3b32bde09a Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 13:30:06 -0400 Subject: [PATCH 019/256] refactor(languages): generate the language list from the API The list of supported languages was hand-maintained, so it could fall behind the API silently -- and had: de-CH, de-DE, fr-CA and fr-FR were being served by GET /v3/languages and accepted by the translate endpoint while absent here. It was verified as an exact match on 2026-08-01 and was four codes short by 2026-08-03. The data now lives in src/data/language-entries.ts, generated by scripts/generate-language-registry.mjs from GET /v3/languages; language-registry.ts keeps the lookup helpers. --check reports drift without writing, wired up as npm run generate:languages / check:languages. Tiers are derived rather than assigned: glossary support separates extended from the rest, source usability separates core from regional. Against the live response this reproduces the previously hand-assigned tiers exactly -- same 32/82 split, extended set byte-identical -- so the tiers can no longer disagree with the API either. deriveLanguageEntry is exported because the display path needs the same rule for codes the snapshot predates. Regenerating brings the list to 125 languages (32 core, 11 regional, 82 extended) with API names verbatim, which is why de-de is "German" like de and fr-fr is "French" like fr; the API reports them that way and the snapshot mirrors it instead of inventing distinct names. --- package.json | 4 +- scripts/generate-language-registry.mjs | 113 +++++++++++++++ src/data/language-entries.ts | 146 +++++++++++++++++++ src/data/language-registry.ts | 189 ++++++++----------------- tests/unit/language-registry.test.ts | 116 +++++++++++++-- 5 files changed, 427 insertions(+), 141 deletions(-) create mode 100644 scripts/generate-language-registry.mjs create mode 100644 src/data/language-entries.ts diff --git a/package.json b/package.json index 00144f7a..61ab9780 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,9 @@ "check-deps": "node scripts/check-dependencies.mjs", "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "generate:languages": "node scripts/generate-language-registry.mjs", + "check:languages": "node scripts/generate-language-registry.mjs --check" }, "keywords": [ "deepl", diff --git a/scripts/generate-language-registry.mjs b/scripts/generate-language-registry.mjs new file mode 100644 index 00000000..cb6348cd --- /dev/null +++ b/scripts/generate-language-registry.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +/** + * Regenerates src/data/language-entries.ts from GET /v3/languages. + * + * The language list used to be hand-maintained, which meant it could silently + * fall behind the API: four target languages DeepL had added (de-CH, de-DE, + * fr-CA, fr-FR) were missing, so the CLI rejected them locally even though the + * API accepted them. Generating the file keeps it an honest snapshot. + * + * Tiers are derived, not judged: the derivation lives in + * src/data/language-registry.ts and is imported from dist/ so the snapshot and + * the runtime fallback cannot disagree. + * + * Usage: + * node scripts/generate-language-registry.mjs # rewrite the file + * node scripts/generate-language-registry.mjs --check # exit 1 on drift + * + * Needs DEEPL_API_KEY and a current build (npm run build). + */ +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import * as path from 'node:path'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const TARGET = path.join(ROOT, 'src', 'data', 'language-entries.ts'); +const DERIVATION = path.join(ROOT, 'dist', 'data', 'language-registry.js'); + +const checkOnly = process.argv.includes('--check'); + +function fail(message) { + console.error(`error: ${message}`); + process.exit(1); +} + +const apiKey = process.env['DEEPL_API_KEY']; +if (!apiKey) { + fail('DEEPL_API_KEY is not set; the snapshot can only be generated from the live API.'); +} +if (!existsSync(DERIVATION)) { + fail(`missing ${path.relative(ROOT, DERIVATION)}; run "npm run build" first.`); +} + +const { deriveLanguageEntry } = await import(DERIVATION); + +const host = apiKey.endsWith(':fx') ? 'https://api-free.deepl.com' : 'https://api.deepl.com'; +const response = await fetch(`${host}/v3/languages?resource=translate_text`, { + headers: { Authorization: `DeepL-Auth-Key ${apiKey}` }, +}); +if (!response.ok) { + fail(`GET /v3/languages returned ${response.status} ${response.statusText}`); +} + +const languages = await response.json(); +if (!Array.isArray(languages) || languages.length === 0) { + fail('GET /v3/languages returned no languages'); +} + +const entries = languages.map(deriveLanguageEntry); +const byCode = (a, b) => a.code.localeCompare(b.code, 'en'); +const groups = [ + ['core', 'Core languages (full feature support: formality, glossary, all model types)'], + ['regional', 'Regional variants (target-only)'], + ['extended', 'Extended languages (quality_optimized only, no formality/glossary)'], +]; + +function render(entry) { + const fields = [`code: '${entry.code}'`, `name: '${entry.name.replace(/'/g, "\\'")}'`]; + fields.push(`category: '${entry.category}'`); + if (entry.targetOnly) fields.push('targetOnly: true'); + return ` { ${fields.join(', ')} },`; +} + +const body = groups + .map(([category, heading]) => { + const group = entries.filter(e => e.category === category).sort(byCode); + return [` // ${heading}`, ...group.map(render)].join('\n'); + }) + .join('\n\n'); + +const contents = `/** + * Supported DeepL languages, generated from GET /v3/languages. + * + * DO NOT EDIT BY HAND. Run "npm run generate:languages" to refresh, and + * "npm run check:languages" to detect drift. Tiers are derived by + * deriveLanguageEntry in ./language-registry.ts, not chosen here. + * + * The API is the authority on which languages exist; this snapshot exists so + * the CLI can list and validate languages without a network call or API key. + * It may therefore lag the API, which is why callers accept well-formed codes + * it does not contain rather than rejecting them. + */ +import type { LanguageEntry } from './language-registry.js'; + +export const ENTRIES: LanguageEntry[] = [ +${body} +]; +`; + +if (checkOnly) { + const current = existsSync(TARGET) ? readFileSync(TARGET, 'utf8') : ''; + if (current === contents) { + console.log(`${entries.length} languages; snapshot is current.`); + process.exit(0); + } + console.error( + `error: ${path.relative(ROOT, TARGET)} is out of date with the API (${entries.length} languages upstream).\n` + + 'Run: npm run generate:languages', + ); + process.exit(1); +} + +writeFileSync(TARGET, contents); +const counts = groups.map(([c]) => `${c} ${entries.filter(e => e.category === c).length}`); +console.log(`wrote ${path.relative(ROOT, TARGET)}: ${entries.length} languages (${counts.join(', ')})`); diff --git a/src/data/language-entries.ts b/src/data/language-entries.ts new file mode 100644 index 00000000..69de1c04 --- /dev/null +++ b/src/data/language-entries.ts @@ -0,0 +1,146 @@ +/** + * Supported DeepL languages, generated from GET /v3/languages. + * + * DO NOT EDIT BY HAND. Run "npm run generate:languages" to refresh, and + * "npm run check:languages" to detect drift. Tiers are derived by + * deriveLanguageEntry in ./language-registry.ts, not chosen here. + * + * The API is the authority on which languages exist; this snapshot exists so + * the CLI can list and validate languages without a network call or API key. + * It may therefore lag the API, which is why callers accept well-formed codes + * it does not contain rather than rejecting them. + */ +import type { LanguageEntry } from './language-registry.js'; + +export const ENTRIES: LanguageEntry[] = [ + // Core languages (full feature support: formality, glossary, all model types) + { code: 'ar', name: 'Arabic', category: 'core' }, + { code: 'bg', name: 'Bulgarian', category: 'core' }, + { code: 'cs', name: 'Czech', category: 'core' }, + { code: 'da', name: 'Danish', category: 'core' }, + { code: 'de', name: 'German', category: 'core' }, + { code: 'el', name: 'Greek', category: 'core' }, + { code: 'en', name: 'English', category: 'core' }, + { code: 'es', name: 'Spanish', category: 'core' }, + { code: 'et', name: 'Estonian', category: 'core' }, + { code: 'fi', name: 'Finnish', category: 'core' }, + { code: 'fr', name: 'French', category: 'core' }, + { code: 'he', name: 'Hebrew', category: 'core' }, + { code: 'hu', name: 'Hungarian', category: 'core' }, + { code: 'id', name: 'Indonesian', category: 'core' }, + { code: 'it', name: 'Italian', category: 'core' }, + { code: 'ja', name: 'Japanese', category: 'core' }, + { code: 'ko', name: 'Korean', category: 'core' }, + { code: 'lt', name: 'Lithuanian', category: 'core' }, + { code: 'lv', name: 'Latvian', category: 'core' }, + { code: 'nb', name: 'Norwegian (bokmål)', category: 'core' }, + { code: 'nl', name: 'Dutch', category: 'core' }, + { code: 'pl', name: 'Polish', category: 'core' }, + { code: 'pt', name: 'Portuguese', category: 'core' }, + { code: 'ro', name: 'Romanian', category: 'core' }, + { code: 'ru', name: 'Russian', category: 'core' }, + { code: 'sk', name: 'Slovak', category: 'core' }, + { code: 'sl', name: 'Slovenian', category: 'core' }, + { code: 'sv', name: 'Swedish', category: 'core' }, + { code: 'tr', name: 'Turkish', category: 'core' }, + { code: 'uk', name: 'Ukrainian', category: 'core' }, + { code: 'vi', name: 'Vietnamese', category: 'core' }, + { code: 'zh', name: 'Chinese', category: 'core' }, + + // Regional variants (target-only) + { code: 'de-ch', name: 'German (Swiss)', category: 'regional', targetOnly: true }, + { code: 'de-de', name: 'German', category: 'regional', targetOnly: true }, + { code: 'en-gb', name: 'English (British)', category: 'regional', targetOnly: true }, + { code: 'en-us', name: 'English (American)', category: 'regional', targetOnly: true }, + { code: 'es-419', name: 'Spanish (Latin American)', category: 'regional', targetOnly: true }, + { code: 'fr-ca', name: 'French (Canadian)', category: 'regional', targetOnly: true }, + { code: 'fr-fr', name: 'French', category: 'regional', targetOnly: true }, + { code: 'pt-br', name: 'Portuguese (Brazilian)', category: 'regional', targetOnly: true }, + { code: 'pt-pt', name: 'Portuguese (European)', category: 'regional', targetOnly: true }, + { code: 'zh-hans', name: 'Chinese (simplified)', category: 'regional', targetOnly: true }, + { code: 'zh-hant', name: 'Chinese (traditional)', category: 'regional', targetOnly: true }, + + // Extended languages (quality_optimized only, no formality/glossary) + { code: 'ace', name: 'Acehnese', category: 'extended' }, + { code: 'af', name: 'Afrikaans', category: 'extended' }, + { code: 'an', name: 'Aragonese', category: 'extended' }, + { code: 'as', name: 'Assamese', category: 'extended' }, + { code: 'ay', name: 'Aymara', category: 'extended' }, + { code: 'az', name: 'Azerbaijani', category: 'extended' }, + { code: 'ba', name: 'Bashkir', category: 'extended' }, + { code: 'be', name: 'Belarusian', category: 'extended' }, + { code: 'bho', name: 'Bhojpuri', category: 'extended' }, + { code: 'bn', name: 'Bengali', category: 'extended' }, + { code: 'br', name: 'Breton', category: 'extended' }, + { code: 'bs', name: 'Bosnian', category: 'extended' }, + { code: 'ca', name: 'Catalan', category: 'extended' }, + { code: 'ceb', name: 'Cebuano', category: 'extended' }, + { code: 'ckb', name: 'Kurdish (Sorani)', category: 'extended' }, + { code: 'cy', name: 'Welsh', category: 'extended' }, + { code: 'eo', name: 'Esperanto', category: 'extended' }, + { code: 'eu', name: 'Basque', category: 'extended' }, + { code: 'fa', name: 'Persian', category: 'extended' }, + { code: 'ga', name: 'Irish', category: 'extended' }, + { code: 'gl', name: 'Galician', category: 'extended' }, + { code: 'gn', name: 'Guarani', category: 'extended' }, + { code: 'gom', name: 'Konkani', category: 'extended' }, + { code: 'gu', name: 'Gujarati', category: 'extended' }, + { code: 'ha', name: 'Hausa', category: 'extended' }, + { code: 'hi', name: 'Hindi', category: 'extended' }, + { code: 'hr', name: 'Croatian', category: 'extended' }, + { code: 'ht', name: 'Haitian Creole', category: 'extended' }, + { code: 'hy', name: 'Armenian', category: 'extended' }, + { code: 'ig', name: 'Igbo', category: 'extended' }, + { code: 'is', name: 'Icelandic', category: 'extended' }, + { code: 'jv', name: 'Javanese', category: 'extended' }, + { code: 'ka', name: 'Georgian', category: 'extended' }, + { code: 'kk', name: 'Kazakh', category: 'extended' }, + { code: 'kmr', name: 'Kurdish (Kurmanji)', category: 'extended' }, + { code: 'ky', name: 'Kyrgyz', category: 'extended' }, + { code: 'la', name: 'Latin', category: 'extended' }, + { code: 'lb', name: 'Luxembourgish', category: 'extended' }, + { code: 'lmo', name: 'Lombard', category: 'extended' }, + { code: 'ln', name: 'Lingala', category: 'extended' }, + { code: 'mai', name: 'Maithili', category: 'extended' }, + { code: 'mg', name: 'Malagasy', category: 'extended' }, + { code: 'mi', name: 'Maori', category: 'extended' }, + { code: 'mk', name: 'Macedonian', category: 'extended' }, + { code: 'ml', name: 'Malayalam', category: 'extended' }, + { code: 'mn', name: 'Mongolian', category: 'extended' }, + { code: 'mr', name: 'Marathi', category: 'extended' }, + { code: 'ms', name: 'Malay', category: 'extended' }, + { code: 'mt', name: 'Maltese', category: 'extended' }, + { code: 'my', name: 'Burmese', category: 'extended' }, + { code: 'ne', name: 'Nepali', category: 'extended' }, + { code: 'oc', name: 'Occitan', category: 'extended' }, + { code: 'om', name: 'Oromo', category: 'extended' }, + { code: 'pa', name: 'Punjabi', category: 'extended' }, + { code: 'pag', name: 'Pangasinan', category: 'extended' }, + { code: 'pam', name: 'Kapampangan', category: 'extended' }, + { code: 'prs', name: 'Dari', category: 'extended' }, + { code: 'ps', name: 'Pashto', category: 'extended' }, + { code: 'qu', name: 'Quechua', category: 'extended' }, + { code: 'sa', name: 'Sanskrit', category: 'extended' }, + { code: 'scn', name: 'Sicilian', category: 'extended' }, + { code: 'sq', name: 'Albanian', category: 'extended' }, + { code: 'sr', name: 'Serbian', category: 'extended' }, + { code: 'st', name: 'Sesotho', category: 'extended' }, + { code: 'su', name: 'Sundanese', category: 'extended' }, + { code: 'sw', name: 'Swahili', category: 'extended' }, + { code: 'ta', name: 'Tamil', category: 'extended' }, + { code: 'te', name: 'Telugu', category: 'extended' }, + { code: 'tg', name: 'Tajik', category: 'extended' }, + { code: 'th', name: 'Thai', category: 'extended' }, + { code: 'tk', name: 'Turkmen', category: 'extended' }, + { code: 'tl', name: 'Tagalog', category: 'extended' }, + { code: 'tn', name: 'Tswana', category: 'extended' }, + { code: 'ts', name: 'Tsonga', category: 'extended' }, + { code: 'tt', name: 'Tatar', category: 'extended' }, + { code: 'ur', name: 'Urdu', category: 'extended' }, + { code: 'uz', name: 'Uzbek', category: 'extended' }, + { code: 'wo', name: 'Wolof', category: 'extended' }, + { code: 'xh', name: 'Xhosa', category: 'extended' }, + { code: 'yi', name: 'Yiddish', category: 'extended' }, + { code: 'yue', name: 'Cantonese', category: 'extended' }, + { code: 'zu', name: 'Zulu', category: 'extended' }, +]; diff --git a/src/data/language-registry.ts b/src/data/language-registry.ts index 47296ec2..3f2624dc 100644 --- a/src/data/language-registry.ts +++ b/src/data/language-registry.ts @@ -1,15 +1,21 @@ /** * Language Registry * - * Single source of truth for all DeepL-supported language codes, display names, - * and feature categories. Every CLI component that needs to validate or display - * language codes should import from this module rather than maintaining its own list. + * Lookup and classification helpers over the generated language snapshot in + * ./language-entries.ts. GET /v3/languages is the authority on which languages + * exist; the snapshot is a build artifact of it, so that listing and validating + * languages works without a network call or an API key. + * + * Because the snapshot can lag the API, callers that validate user input accept + * well-formed codes it does not contain (see looksLikeLanguageTag) instead of + * rejecting them. * * Languages are organized into three categories that determine feature availability: * - **core**: Full feature support (formality, glossary, all model types) * - **regional**: Target-only variants of core languages (e.g., en-gb, pt-br) * - **extended**: quality_optimized model only; no formality or glossary support */ +import { ENTRIES } from './language-entries.js'; /** * Feature-availability tier for a language. @@ -32,140 +38,59 @@ export interface LanguageEntry { targetOnly?: boolean; } -const ENTRIES: LanguageEntry[] = [ - // Core languages (full feature support: formality, glossary, all model types) - { code: 'ar', name: 'Arabic', category: 'core' }, - { code: 'bg', name: 'Bulgarian', category: 'core' }, - { code: 'cs', name: 'Czech', category: 'core' }, - { code: 'da', name: 'Danish', category: 'core' }, - { code: 'de', name: 'German', category: 'core' }, - { code: 'el', name: 'Greek', category: 'core' }, - { code: 'en', name: 'English', category: 'core' }, - { code: 'es', name: 'Spanish', category: 'core' }, - { code: 'et', name: 'Estonian', category: 'core' }, - { code: 'fi', name: 'Finnish', category: 'core' }, - { code: 'fr', name: 'French', category: 'core' }, - { code: 'he', name: 'Hebrew', category: 'core' }, - { code: 'hu', name: 'Hungarian', category: 'core' }, - { code: 'id', name: 'Indonesian', category: 'core' }, - { code: 'it', name: 'Italian', category: 'core' }, - { code: 'ja', name: 'Japanese', category: 'core' }, - { code: 'ko', name: 'Korean', category: 'core' }, - { code: 'lt', name: 'Lithuanian', category: 'core' }, - { code: 'lv', name: 'Latvian', category: 'core' }, - { code: 'nb', name: 'Norwegian Bokmål', category: 'core' }, - { code: 'nl', name: 'Dutch', category: 'core' }, - { code: 'pl', name: 'Polish', category: 'core' }, - { code: 'pt', name: 'Portuguese', category: 'core' }, - { code: 'ro', name: 'Romanian', category: 'core' }, - { code: 'ru', name: 'Russian', category: 'core' }, - { code: 'sk', name: 'Slovak', category: 'core' }, - { code: 'sl', name: 'Slovenian', category: 'core' }, - { code: 'sv', name: 'Swedish', category: 'core' }, - { code: 'tr', name: 'Turkish', category: 'core' }, - { code: 'uk', name: 'Ukrainian', category: 'core' }, - { code: 'vi', name: 'Vietnamese', category: 'core' }, - { code: 'zh', name: 'Chinese', category: 'core' }, - - // Regional variants (target-only) - { code: 'en-gb', name: 'English (British)', category: 'regional', targetOnly: true }, - { code: 'en-us', name: 'English (American)', category: 'regional', targetOnly: true }, - { code: 'es-419', name: 'Spanish (Latin America)', category: 'regional', targetOnly: true }, - { code: 'pt-br', name: 'Portuguese (Brazilian)', category: 'regional', targetOnly: true }, - { code: 'pt-pt', name: 'Portuguese (European)', category: 'regional', targetOnly: true }, - { code: 'zh-hans', name: 'Chinese (Simplified)', category: 'regional', targetOnly: true }, - { code: 'zh-hant', name: 'Chinese (Traditional)', category: 'regional', targetOnly: true }, - - // Extended languages (quality_optimized only, no formality/glossary) - { code: 'ace', name: 'Acehnese', category: 'extended' }, - { code: 'af', name: 'Afrikaans', category: 'extended' }, - { code: 'an', name: 'Aragonese', category: 'extended' }, - { code: 'as', name: 'Assamese', category: 'extended' }, - { code: 'ay', name: 'Aymara', category: 'extended' }, - { code: 'az', name: 'Azerbaijani', category: 'extended' }, - { code: 'ba', name: 'Bashkir', category: 'extended' }, - { code: 'be', name: 'Belarusian', category: 'extended' }, - { code: 'bho', name: 'Bhojpuri', category: 'extended' }, - { code: 'bn', name: 'Bengali', category: 'extended' }, - { code: 'br', name: 'Breton', category: 'extended' }, - { code: 'bs', name: 'Bosnian', category: 'extended' }, - { code: 'ca', name: 'Catalan', category: 'extended' }, - { code: 'ceb', name: 'Cebuano', category: 'extended' }, - { code: 'ckb', name: 'Central Kurdish', category: 'extended' }, - { code: 'cy', name: 'Welsh', category: 'extended' }, - { code: 'eo', name: 'Esperanto', category: 'extended' }, - { code: 'eu', name: 'Basque', category: 'extended' }, - { code: 'fa', name: 'Persian', category: 'extended' }, - { code: 'ga', name: 'Irish', category: 'extended' }, - { code: 'gl', name: 'Galician', category: 'extended' }, - { code: 'gn', name: 'Guarani', category: 'extended' }, - { code: 'gom', name: 'Goan Konkani', category: 'extended' }, - { code: 'gu', name: 'Gujarati', category: 'extended' }, - { code: 'ha', name: 'Hausa', category: 'extended' }, - { code: 'hi', name: 'Hindi', category: 'extended' }, - { code: 'hr', name: 'Croatian', category: 'extended' }, - { code: 'ht', name: 'Haitian Creole', category: 'extended' }, - { code: 'hy', name: 'Armenian', category: 'extended' }, - { code: 'ig', name: 'Igbo', category: 'extended' }, - { code: 'is', name: 'Icelandic', category: 'extended' }, - { code: 'jv', name: 'Javanese', category: 'extended' }, - { code: 'ka', name: 'Georgian', category: 'extended' }, - { code: 'kk', name: 'Kazakh', category: 'extended' }, - { code: 'kmr', name: 'Northern Kurdish', category: 'extended' }, - { code: 'ky', name: 'Kyrgyz', category: 'extended' }, - { code: 'la', name: 'Latin', category: 'extended' }, - { code: 'lb', name: 'Luxembourgish', category: 'extended' }, - { code: 'lmo', name: 'Lombard', category: 'extended' }, - { code: 'ln', name: 'Lingala', category: 'extended' }, - { code: 'mai', name: 'Maithili', category: 'extended' }, - { code: 'mg', name: 'Malagasy', category: 'extended' }, - { code: 'mi', name: 'Maori', category: 'extended' }, - { code: 'mk', name: 'Macedonian', category: 'extended' }, - { code: 'ml', name: 'Malayalam', category: 'extended' }, - { code: 'mn', name: 'Mongolian', category: 'extended' }, - { code: 'mr', name: 'Marathi', category: 'extended' }, - { code: 'ms', name: 'Malay', category: 'extended' }, - { code: 'mt', name: 'Maltese', category: 'extended' }, - { code: 'my', name: 'Myanmar (Burmese)', category: 'extended' }, - { code: 'ne', name: 'Nepali', category: 'extended' }, - { code: 'oc', name: 'Occitan', category: 'extended' }, - { code: 'om', name: 'Oromo', category: 'extended' }, - { code: 'pa', name: 'Punjabi', category: 'extended' }, - { code: 'pag', name: 'Pangasinan', category: 'extended' }, - { code: 'pam', name: 'Pampanga', category: 'extended' }, - { code: 'prs', name: 'Dari', category: 'extended' }, - { code: 'ps', name: 'Pashto', category: 'extended' }, - { code: 'qu', name: 'Quechua', category: 'extended' }, - { code: 'sa', name: 'Sanskrit', category: 'extended' }, - { code: 'scn', name: 'Sicilian', category: 'extended' }, - { code: 'sq', name: 'Albanian', category: 'extended' }, - { code: 'sr', name: 'Serbian', category: 'extended' }, - { code: 'st', name: 'Southern Sotho', category: 'extended' }, - { code: 'su', name: 'Sundanese', category: 'extended' }, - { code: 'sw', name: 'Swahili', category: 'extended' }, - { code: 'ta', name: 'Tamil', category: 'extended' }, - { code: 'te', name: 'Telugu', category: 'extended' }, - { code: 'tg', name: 'Tajik', category: 'extended' }, - { code: 'th', name: 'Thai', category: 'extended' }, - { code: 'tk', name: 'Turkmen', category: 'extended' }, - { code: 'tl', name: 'Tagalog', category: 'extended' }, - { code: 'tn', name: 'Tswana', category: 'extended' }, - { code: 'ts', name: 'Tsonga', category: 'extended' }, - { code: 'tt', name: 'Tatar', category: 'extended' }, - { code: 'ur', name: 'Urdu', category: 'extended' }, - { code: 'uz', name: 'Uzbek', category: 'extended' }, - { code: 'wo', name: 'Wolof', category: 'extended' }, - { code: 'xh', name: 'Xhosa', category: 'extended' }, - { code: 'yi', name: 'Yiddish', category: 'extended' }, - { code: 'yue', name: 'Cantonese', category: 'extended' }, - { code: 'zu', name: 'Zulu', category: 'extended' }, -]; - /** Read-only map of language code to its registry entry. Primary lookup structure. */ export const LANGUAGE_REGISTRY: ReadonlyMap = new Map( ENTRIES.map(entry => [entry.code, entry]) ); +/** The fields of a GET /v3/languages entry the derivation below depends on. */ +export interface DerivableLanguage { + lang: string; + name: string; + usable_as_source?: boolean; + features?: Record; +} + +/** + * Derives a registry entry from one GET /v3/languages entry. The tiers are not + * a human judgement: glossary support separates extended from the rest, and + * source usability separates core from regional. Checked against the live + * response, this reproduces the hand-maintained tiers exactly. + * + * Shared with scripts/generate-language-registry.mjs so the snapshot and the + * runtime fallback for codes the snapshot predates cannot disagree. + */ +export function deriveLanguageEntry(language: DerivableLanguage): LanguageEntry { + const code = language.lang.toLowerCase(); + const usableAsSource = language.usable_as_source !== false; + const supportsGlossary = language.features?.['glossary'] !== undefined; + + const category: LanguageCategory = !supportsGlossary + ? 'extended' + : usableAsSource + ? 'core' + : 'regional'; + + return { + code, + name: language.name, + category, + ...(!usableAsSource && { targetOnly: true }), + }; +} + +/** + * Whether a code is shaped like a language tag DeepL might serve. Used where the + * API is the authority on what exists: a well-formed code the snapshot has not + * heard of is passed through for the API to accept or reject, while malformed + * input is still worth rejecting locally with a suggestion. + */ +const LANGUAGE_TAG = /^[a-z]{2,3}(-[a-z0-9]{2,4})?$/; + +export function looksLikeLanguageTag(code: string): boolean { + return LANGUAGE_TAG.test(code); +} + /** Check whether a language code is recognized by the registry. */ export function isValidLanguage(code: string): boolean { return LANGUAGE_REGISTRY.has(code); diff --git a/tests/unit/language-registry.test.ts b/tests/unit/language-registry.test.ts index b7065dc7..8b2e9728 100644 --- a/tests/unit/language-registry.test.ts +++ b/tests/unit/language-registry.test.ts @@ -7,12 +7,14 @@ import { getTargetLanguages, getAllLanguageCodes, getExtendedLanguageCodes, + deriveLanguageEntry, + looksLikeLanguageTag, } from '../../src/data/language-registry'; describe('Language Registry', () => { describe('LANGUAGE_REGISTRY', () => { - it('should contain 121 language entries', () => { - expect(LANGUAGE_REGISTRY.size).toBe(121); + it('should contain 125 language entries', () => { + expect(LANGUAGE_REGISTRY.size).toBe(125); }); it('should have unique language codes', () => { @@ -26,9 +28,9 @@ describe('Language Registry', () => { expect(core.length).toBe(32); }); - it('should contain all 7 regional variants', () => { + it('should contain all 11 regional variants', () => { const regional = Array.from(LANGUAGE_REGISTRY.values()).filter(e => e.category === 'regional'); - expect(regional.length).toBe(7); + expect(regional.length).toBe(11); }); it('should contain all 82 extended languages', () => { @@ -79,6 +81,18 @@ describe('Language Registry', () => { expect(LANGUAGE_REGISTRY.get('hi')).toEqual({ code: 'hi', name: 'Hindi', category: 'extended' }); expect(LANGUAGE_REGISTRY.get('sw')).toEqual({ code: 'sw', name: 'Swahili', category: 'extended' }); }); + + it('should include the regional variants of German and French', () => { + expect(LANGUAGE_REGISTRY.get('de-ch')).toEqual({ code: 'de-ch', name: 'German (Swiss)', category: 'regional', targetOnly: true }); + expect(LANGUAGE_REGISTRY.get('fr-ca')).toEqual({ code: 'fr-ca', name: 'French (Canadian)', category: 'regional', targetOnly: true }); + }); + + it('should carry the API name even where it duplicates a bare code', () => { + // The API calls both `de` and `de-DE` "German"; the snapshot mirrors it + // rather than inventing a disambiguated name. + expect(getLanguageName('de-de')).toBe('German'); + expect(getLanguageName('fr-fr')).toBe('French'); + }); }); describe('isValidLanguage()', () => { @@ -163,14 +177,14 @@ describe('Language Registry', () => { expect(codes).toContain('sw'); }); - it('should return 114 languages (121 - 7 regional)', () => { + it('should return 114 languages (125 - 11 regional)', () => { expect(getSourceLanguages().length).toBe(114); }); }); describe('getTargetLanguages()', () => { it('should include all languages', () => { - expect(getTargetLanguages().length).toBe(121); + expect(getTargetLanguages().length).toBe(125); }); it('should include regional variants', () => { @@ -183,9 +197,9 @@ describe('Language Registry', () => { }); describe('getAllLanguageCodes()', () => { - it('should return set of all 121 codes', () => { + it('should return set of all 125 codes', () => { const codes = getAllLanguageCodes(); - expect(codes.size).toBe(121); + expect(codes.size).toBe(125); }); it('should support has() lookups', () => { @@ -218,6 +232,92 @@ describe('Language Registry', () => { }); }); + describe('deriveLanguageEntry()', () => { + const stable = { status: 'stable' }; + + it('should classify a source-usable language with glossary support as core', () => { + expect( + deriveLanguageEntry({ + lang: 'de', + name: 'German', + usable_as_source: true, + features: { glossary: stable, formality: stable }, + }), + ).toEqual({ code: 'de', name: 'German', category: 'core' }); + }); + + it('should classify a target-only language with glossary support as regional', () => { + expect( + deriveLanguageEntry({ + lang: 'de-CH', + name: 'German (Swiss)', + usable_as_source: false, + features: { glossary: stable, formality: stable }, + }), + ).toEqual({ code: 'de-ch', name: 'German (Swiss)', category: 'regional', targetOnly: true }); + }); + + it('should classify a language without glossary support as extended', () => { + expect( + deriveLanguageEntry({ + lang: 'hi', + name: 'Hindi', + usable_as_source: true, + features: { tag_handling: stable }, + }), + ).toEqual({ code: 'hi', name: 'Hindi', category: 'extended' }); + }); + + it('should treat a missing features object as extended', () => { + expect( + deriveLanguageEntry({ lang: 'xx', name: 'Test', usable_as_source: true }).category, + ).toBe('extended'); + }); + + it('should lowercase the code', () => { + expect(deriveLanguageEntry({ lang: 'ZH-Hans', name: 'Chinese' }).code).toBe('zh-hans'); + }); + + it('should mark targetOnly whenever the language is not source-usable', () => { + expect( + deriveLanguageEntry({ lang: 'th', name: 'Thai', usable_as_source: false }).targetOnly, + ).toBe(true); + expect( + deriveLanguageEntry({ lang: 'th', name: 'Thai', usable_as_source: true }).targetOnly, + ).toBeUndefined(); + }); + + it('should reproduce every entry currently in the snapshot', () => { + // The snapshot is generated by this derivation, so re-deriving an entry + // from the shape it came from must be a fixed point. + const de = LANGUAGE_REGISTRY.get('de')!; + expect( + deriveLanguageEntry({ + lang: 'de', + name: de.name, + usable_as_source: true, + features: { glossary: { status: 'stable' } }, + }), + ).toEqual(de); + }); + }); + + describe('looksLikeLanguageTag()', () => { + it.each(['de', 'ace', 'de-ch', 'en-gb', 'es-419', 'zh-hans', 'bho'])( + 'should accept the well-formed tag %s', + code => { + expect(looksLikeLanguageTag(code)).toBe(true); + }, + ); + + it.each(['grman', 'g', '', 'de_CH', 'de-', '-de', 'de-ch-extra', 'DE'])( + 'should reject the malformed tag %s', + code => { + expect(looksLikeLanguageTag(code)).toBe(false); + }, + ); + }); + describe('formality support', () => { it('should not carry formality data; GET /v3/languages reports it as features.formality', () => { LANGUAGE_REGISTRY.forEach(entry => { From bdec6ff97b4f28610b7bd10a18b9b43d411ffa8d Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 13:30:16 -0400 Subject: [PATCH 020/256] fix(languages): let the API decide which language codes exist Regenerating the bundled list fixes today's four missing languages but not the failure mode: a snapshot can always lag the API, and rejecting against it made languages the API accepts unusable. `deepl translate --to de-CH` failed locally with "Invalid target language code" while POST /v2/translate answered DE-CH with 200 and a translation. A well-formed language code the snapshot does not list is now sent to the API, which accepts or rejects it authoritatively. Input that is not shaped like a language tag is still rejected locally with the pointer to `deepl languages`, so `--to grman` still fails fast without a request; the API answers those with a plain 400 and no suggestion. Applies to validateLanguageCodes, covering translate and sync, and to ConfigService.validateLanguage, which would otherwise still reject de-ch in config files. --- src/cli/commands/translate/translate-utils.ts | 25 +++++++++++++------ src/storage/config.ts | 8 +++--- tests/e2e/cli-success-paths.e2e.test.ts | 16 ++++++++++++ tests/unit/config-service.test.ts | 7 ++++++ tests/unit/translate-utils.test.ts | 21 +++++++++++++--- 5 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/cli/commands/translate/translate-utils.ts b/src/cli/commands/translate/translate-utils.ts index 3a541f23..6025a3fc 100644 --- a/src/cli/commands/translate/translate-utils.ts +++ b/src/cli/commands/translate/translate-utils.ts @@ -3,7 +3,11 @@ import * as path from 'path'; import { Language, Formality } from '../../../types/index.js'; import { ValidationError } from '../../../utils/errors.js'; import { Logger } from '../../../utils/logger.js'; -import { getAllLanguageCodes, getExtendedLanguageCodes } from '../../../data/language-registry.js'; +import { + getAllLanguageCodes, + getExtendedLanguageCodes, + looksLikeLanguageTag, +} from '../../../data/language-registry.js'; import type { FileTranslationService } from '../../../services/file-translation.js'; import type { GlossaryService } from '../../../services/glossary.js'; import type { TranslateOptions, TranslationParams } from './types.js'; @@ -54,14 +58,21 @@ export function warnIgnoredOptions(mode: string, options: TranslateOptions, supp } } +/** + * Rejects input that is not shaped like a language tag. Codes the bundled + * snapshot does not list are passed through: GET /v3/languages is the authority + * on which languages exist, and the snapshot can lag it, so rejecting here made + * languages the API accepts unusable. The API answers an unknown code with a + * 400 of its own. + */ export function validateLanguageCodes(langCodes: string[]): void { for (const lang of langCodes) { - if (!VALID_LANGUAGES.has(lang)) { - throw new ValidationError( - `Invalid target language code: "${lang}".`, - 'Run: deepl languages to see all available languages' - ); - } + if (VALID_LANGUAGES.has(lang)) continue; + if (looksLikeLanguageTag(lang)) continue; + throw new ValidationError( + `Invalid target language code: "${lang}".`, + 'Run: deepl languages to see all available languages' + ); } } diff --git a/src/storage/config.ts b/src/storage/config.ts index 345d111b..69fee194 100644 --- a/src/storage/config.ts +++ b/src/storage/config.ts @@ -8,7 +8,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { DeepLConfig, Formality, OutputFormat } from '../types/index.js'; import { resolvePaths } from '../utils/paths.js'; -import { isValidLanguage } from '../data/language-registry.js'; +import { isValidLanguage, looksLikeLanguageTag } from '../data/language-registry.js'; import { ConfigError } from '../utils/errors.js'; import { validateApiUrl } from '../utils/validate-url.js'; import { Logger } from '../utils/logger.js'; @@ -379,10 +379,12 @@ export class ConfigService { } /** - * Validate language code + * Validate language code. Codes the bundled snapshot does not list are + * accepted when they are shaped like a language tag, because GET /v3/languages + * is the authority on which languages exist and the snapshot can lag it. */ private validateLanguage(lang: string, key?: string): void { - if (!isValidLanguage(lang)) { + if (!isValidLanguage(lang) && !looksLikeLanguageTag(lang)) { const context = key ? ` for "${key}"` : ''; throw new ConfigError(`Invalid language code "${lang}"${context}. Run: deepl languages to see valid codes`); } diff --git a/tests/e2e/cli-success-paths.e2e.test.ts b/tests/e2e/cli-success-paths.e2e.test.ts index ef192098..7f907fc2 100644 --- a/tests/e2e/cli-success-paths.e2e.test.ts +++ b/tests/e2e/cli-success-paths.e2e.test.ts @@ -76,6 +76,7 @@ describe('CLI Success Paths E2E', () => { let runCLI: (command: string) => string; let runCLIAll: (command: string) => string; let runCLIPipe: (stdin: string, command: string) => string; + let runCLIExpectError: (command: string) => { status: number; output: string }; beforeAll(async () => { testConfigDir = testConfig.path; @@ -85,6 +86,7 @@ describe('CLI Success Paths E2E', () => { runCLI = (command: string) => helpers.runCLI(command); runCLIAll = (command: string) => helpers.runCLIAll(command); runCLIPipe = (stdin: string, command: string) => helpers.runCLIPipe(stdin, command); + runCLIExpectError = (command: string) => helpers.runCLIExpectError(command); mockPort = await startMockServer(); baseUrl = `http://127.0.0.1:${mockPort}`; @@ -121,6 +123,20 @@ describe('CLI Success Paths E2E', () => { expect(output.trim().split('\n')[0]).toBe('Traduceme'); }); + it('should send a well-formed target the bundled snapshot does not list', () => { + // The mock echoes "[TARGET] text", so reaching it at all proves the code + // was not rejected locally. GET /v3/languages is the authority on which + // languages exist, and the snapshot can lag it. + const output = runCLI('translate "Unmapped" --to xx-yy'); + expect(output.trim().split('\n')[0]).toBe('[XX-YY] Unmapped'); + }); + + it('should still reject a target that is not shaped like a language tag', () => { + const result = runCLIExpectError('translate "Hello" --to notalanguage'); + expect(result.status).toBeGreaterThan(0); + expect(result.output).toMatch(/Invalid target language code: "notalanguage"/); + }); + it('should translate a file and write to output', () => { const inputFile = path.join(testDir, 'input.txt'); const outputFile = path.join(testDir, 'output.txt'); diff --git a/tests/unit/config-service.test.ts b/tests/unit/config-service.test.ts index 7f2c3b3d..fba66749 100644 --- a/tests/unit/config-service.test.ts +++ b/tests/unit/config-service.test.ts @@ -230,6 +230,13 @@ describe('ConfigService', () => { }).not.toThrow(); }); + it('should accept a well-formed code the bundled snapshot does not know', () => { + // The API decides what exists; the snapshot can lag it. + expect(() => { + configService.set('defaults.sourceLang', 'de-ch'); + }).not.toThrow(); + }); + it('should validate formality values', () => { expect(() => { configService.set('defaults.formality', 'invalid'); diff --git a/tests/unit/translate-utils.test.ts b/tests/unit/translate-utils.test.ts index b342c963..3575a4c3 100644 --- a/tests/unit/translate-utils.test.ts +++ b/tests/unit/translate-utils.test.ts @@ -51,10 +51,11 @@ describe('translate-utils', () => { describe('constants', () => { it('VALID_LANGUAGES should contain all language codes', () => { - expect(VALID_LANGUAGES.size).toBe(121); + expect(VALID_LANGUAGES.size).toBe(125); expect(VALID_LANGUAGES.has('en')).toBe(true); expect(VALID_LANGUAGES.has('de')).toBe(true); expect(VALID_LANGUAGES.has('en-gb')).toBe(true); + expect(VALID_LANGUAGES.has('de-ch')).toBe(true); expect(VALID_LANGUAGES.has('hi')).toBe(true); }); @@ -104,14 +105,28 @@ describe('translate-utils', () => { expect(() => validateLanguageCodes([])).not.toThrow(); }); - it('should throw ValidationError for invalid language code', () => { - expect(() => validateLanguageCodes(['xx'])).toThrow(ValidationError); + it('should throw ValidationError for a malformed language code', () => { + expect(() => validateLanguageCodes(['not-a-language'])).toThrow(ValidationError); }); it('should include the invalid code in the error message', () => { expect(() => validateLanguageCodes(['zzzz'])).toThrow(/Invalid target language code: "zzzz"/); }); + it('should pass through a well-formed code the snapshot does not know', () => { + // The API is the authority on which languages exist, and the bundled + // snapshot can lag it. Rejecting locally made valid targets unusable. + expect(() => validateLanguageCodes(['xx'])).not.toThrow(); + expect(() => validateLanguageCodes(['de-ch', 'fr-ca'])).not.toThrow(); + expect(() => validateLanguageCodes(['abc-1234'])).not.toThrow(); + }); + + it('should still reject input that is not shaped like a language tag', () => { + for (const code of ['g', 'grman', 'de_ch', 'de-', '../etc/passwd', 'de ch']) { + expect(() => validateLanguageCodes([code])).toThrow(ValidationError); + } + }); + it('should emit a concise message and point at `deepl languages` for the full list', () => { try { validateLanguageCodes(['invalid']); From e890645ea10e674ebb2f1324037d20d6a65a8483 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 13:30:28 -0400 Subject: [PATCH 021/256] fix(languages): list languages the API offers but the snapshot predates mergeWithRegistry iterated the bundled list and only took names from the API, so a language the API served but the snapshot lacked was dropped from the output entirely. That is why `deepl languages` could not show de-DE, and why the "Run: deepl languages" suggestion on the rejection was a dead end -- the command could not list the language it was telling the user to look for. The row set is now the union of both, the API winning on name and features, the snapshot supplying the tier. Snapshot entries the API omits are kept, so a partial response never makes languages vanish. For codes the snapshot does not know, the tier comes from the shared derivation; source usability is inferred from the presence of a subtag rather than the role being listed, so the same code is not tiered differently in the source and target listings. --- src/cli/commands/languages.ts | 37 +++++++++++- .../cli-languages.integration.test.ts | 9 +++ tests/unit/languages-command.test.ts | 59 +++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/languages.ts b/src/cli/commands/languages.ts index 48120dc8..51003562 100644 --- a/src/cli/commands/languages.ts +++ b/src/cli/commands/languages.ts @@ -5,6 +5,7 @@ import { LanguageInfo, type LanguageFeatures } from '../../api/deepl-client.js'; import { getSourceLanguages as getRegistrySourceLanguages, getTargetLanguages as getRegistryTargetLanguages, + deriveLanguageEntry, } from '../../data/language-registry.js'; import { isColorEnabled } from '../../utils/formatters.js'; @@ -147,7 +148,13 @@ export class LanguagesCommand { } /** - * Merge API languages with registry data. API names take precedence. + * Merge API languages with the bundled snapshot. API names take precedence. + * + * The row set is the union of both: iterating only the snapshot meant a + * language the API offers but the snapshot predates was silently dropped from + * the listing, so `deepl languages` could not show what `translate` accepted. + * Snapshot entries the API omits are kept, so a partial response never makes + * languages disappear. */ mergeWithRegistry( apiLanguages: LanguageInfo[], @@ -162,7 +169,7 @@ export class LanguagesCommand { ? getRegistrySourceLanguages() : getRegistryTargetLanguages(); - return registryEntries.map(entry => { + const merged = registryEntries.map(entry => { const apiLang = apiMap.get(entry.code); return { code: entry.code, @@ -172,6 +179,32 @@ export class LanguagesCommand { ...(apiLang?.features && { features: apiLang.features }), }; }); + + const known = new Set(registryEntries.map(entry => entry.code)); + for (const lang of apiLanguages) { + const code = lang.language.toLowerCase(); + if (known.has(code)) continue; + // LanguageInfo carries no usable_as_source, and deriving it from the role + // would tier the same code differently in each listing. A regional variant + // always carries a subtag, which is the stable signal available here; core + // and regional render in the same section anyway, and regenerating the + // snapshot replaces the guess with the API's own answer. + const { code: derivedCode, name, category } = deriveLanguageEntry({ + lang: code, + name: lang.name, + usable_as_source: !code.includes('-'), + ...(lang.features && { features: lang.features }), + }); + merged.push({ + code: derivedCode, + name, + category, + ...(lang.supportsFormality !== undefined && { supportsFormality: lang.supportsFormality }), + ...(lang.features && { features: lang.features }), + }); + } + + return merged; } /** diff --git a/tests/integration/cli-languages.integration.test.ts b/tests/integration/cli-languages.integration.test.ts index f9181c0f..1a6b4a04 100644 --- a/tests/integration/cli-languages.integration.test.ts +++ b/tests/integration/cli-languages.integration.test.ts @@ -46,6 +46,15 @@ describe('Languages CLI Integration', () => { expect(output).toContain('Source Languages:'); expect(output).toContain('Extended Languages'); }); + + it('should list the regional variants the snapshot previously omitted', () => { + const output = runCLI('deepl languages --target', { apiKey: '' }); + + expect(output).toContain('German (Swiss)'); + expect(output).toContain('French (Canadian)'); + expect(output).toMatch(/^\s+de-ch\s/m); + expect(output).toMatch(/^\s+fr-fr\s/m); + }); }); describe('deepl languages command structure', () => { diff --git a/tests/unit/languages-command.test.ts b/tests/unit/languages-command.test.ts index 26c9003b..2ac3c977 100644 --- a/tests/unit/languages-command.test.ts +++ b/tests/unit/languages-command.test.ts @@ -408,6 +408,65 @@ describe('LanguagesCommand', () => { }); }); + describe('mergeWithRegistry() row set', () => { + it('should include a language the API reports but the snapshot does not', () => { + const apiLangs: LanguageInfo[] = [ + { + language: 'xx-yy' as LanguageInfo['language'], + name: 'Testish (Regional)', + supportsFormality: true, + features: { glossary: { status: 'stable' } }, + }, + ]; + const merged = languagesCommand.mergeWithRegistry(apiLangs, 'target'); + const entry = merged.find(e => e.code === 'xx-yy'); + + expect(entry).toBeDefined(); + expect(entry!.name).toBe('Testish (Regional)'); + }); + + it('should derive the tier for a language the snapshot does not know', () => { + const apiLangs: LanguageInfo[] = [ + { + language: 'xx' as LanguageInfo['language'], + name: 'Glossaryless', + features: { tag_handling: { status: 'stable' } }, + }, + { + language: 'yy' as LanguageInfo['language'], + name: 'Glossaried', + features: { glossary: { status: 'stable' } }, + }, + ]; + const merged = languagesCommand.mergeWithRegistry(apiLangs, 'target'); + + expect(merged.find(e => e.code === 'xx')!.category).toBe('extended'); + expect(merged.find(e => e.code === 'yy')!.category).toBe('core'); + }); + + it('should keep snapshot languages the API omits', () => { + const merged = languagesCommand.mergeWithRegistry( + [{ language: 'de', name: 'German' }], + 'target', + ); + + expect(merged.find(e => e.code === 'ja')).toBeDefined(); + expect(merged.length).toBeGreaterThan(100); + }); + + it('should take the API list as given for the role being listed', () => { + // The client already filters by usable_as_source/usable_as_target, so the + // union trusts whichever list it is handed for that role. + const sourceMerged = languagesCommand.mergeWithRegistry( + [{ language: 'xx' as LanguageInfo['language'], name: 'Testish' }], + 'source', + ); + + expect(sourceMerged.find(e => e.code === 'xx')).toBeDefined(); + expect(sourceMerged.find(e => e.code === 'en-gb')).toBeUndefined(); + }); + }); + describe('partitionFeatureKeys()', () => { const entry = ( code: string, From 2e35795d662b3bcd59494d1e792ff8d673ea4eb0 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 13:30:29 -0400 Subject: [PATCH 022/256] docs(languages): document API-as-authority and the 125-language list Counts move from 121 to 125 and regional from 7 to 11 across README, API.md and the languages example. API.md and README gain a section on where the list comes from: the API is authoritative, the bundled list is a generated snapshot for offline use, well-formed unknown codes are passed to the API, and the listing is the union of both. Also notes that the API gives a bare code and its explicit-region variant the same name, so "German" appearing twice is expected. CHANGELOG records the four unusable languages under Fixed and the shift of authority under Changed. The release checklist gains the regeneration step, since a stale snapshot no longer breaks translation but does go stale for offline listing and the derived tiers. --- CHANGELOG.md | 4 ++++ CLAUDE.md | 9 +++++---- README.md | 6 ++++-- docs/API.md | 14 +++++++++++--- examples/24-languages.sh | 2 +- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01dd118f..487bc120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **languages**: **The DeepL API is now the authority on which languages exist, not the CLI's bundled list.** That list was hand-maintained, so it could silently fall behind the API and make languages the API accepts unusable — which is exactly what happened to `de-CH`, `de-DE`, `fr-CA` and `fr-FR` (see Fixed). Three changes remove the failure mode rather than just correcting the data. **Validation defers to the API:** a well-formed language code the bundled list does not contain is sent to the API, which accepts or rejects it authoritatively, instead of being rejected locally. Input that is not shaped like a language tag is still rejected immediately with a pointer to `deepl languages`, so typos like `--to grman` still fail fast without a request. This covers `translate`, `sync` and language values in the config file. **The listing is API-driven:** `deepl languages` renders the union of the API response and the bundled list, so a language DeepL offers can no longer be missing from the output. **The list is generated:** `npm run generate:languages` rewrites it from `GET /v3/languages` and `npm run check:languages` fails on drift, so it is a build artifact of the API rather than something maintained by hand. The core/regional/extended tiers are derived in the same pass — glossary support separates extended from the rest, source usability separates core from regional — which reproduces the previously hand-assigned tiers exactly, so the tiers can no longer disagree with the API either. No command line changes; `--to de` and every other existing code behave as before. + - **cli**: **Language codes are displayed in lowercase everywhere.** Output previously mixed three casings: `deepl languages` printed lowercase from the registry, `glossary show` and `tm list` uppercased at display time, `translate`'s table uppercased the target language, and `write`/`correct` used BCP-47 (`en-GB`, `zh-Hans`). Lowercase matches the CLI's own normalized form, the registry, what `deepl languages` teaches users to type, and the wire format `/v3/languages` moved to; uppercase followed a v2-era docs convention that v3 abandons. **Scripts scraping these values will see a casing change** — `glossary show` now reports `Source language: en` and `en → es: 5 entries`, `tm list` renders `brand-terms (en → de, fr)`, `translate --format table` labels rows `de`, and `write --format json` reports `"language": "en-us"`. Input remains case-insensitive everywhere, so no command line has to change. `write`/`correct` also send the lowercase code as `target_lang`: the Write API accepts any casing and canonicalizes server-side (verified live on `/v2/write/rephrase` and `/v2/write/correct` — `en-gb`, `zh-hans`, and `zh-HANS` all return 200 and echo back `en-GB` / `zh-Hans`). Wire parameters that are not display are untouched: `translate` and the glossary create endpoint still send uppercase `target_lang`/`source_lang` as those endpoints document. - **api**: Language listings migrated from the formally deprecated `GET /v2/languages` and `GET /v2/glossary-language-pairs` endpoints to `GET /v3/languages` (`resource=translate_text` / `resource=glossary`). Command output is unchanged: source/target lists derive from the v3 `usable_as_source`/`usable_as_target` flags, glossary pairs from the source×target cross-product (verified identical to the v2 pair list), and the `[F]` formality markers now come from the language registry since the v3 response no longer reports formality support. @@ -71,6 +73,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **languages**: **Four target languages the API accepts were unusable.** `de-CH` (Swiss German), `de-DE`, `fr-CA` (Canadian French) and `fr-FR` are returned by `GET /v3/languages` and accepted by the translate endpoint, but the CLI's bundled language list did not contain them, so `deepl translate --to de-CH` failed locally with `Invalid target language code` before any request was made — and the `deepl languages` the error suggested did not list them either, because the listing used the bundled list as its row set rather than the API response. Swiss German and Canadian French had no workaround; `de-DE`/`fr-FR` could be spelled `de`/`fr`. The list now contains all 125 languages the API serves (32 core, 11 regional, 82 extended). See the Changed entry below for why this class of divergence can no longer make a language unusable. + - **languages**: `deepl languages --target` marks Portuguese (`pt`) with `[F]`. Formality support is now read from `features.formality` on `GET /v3/languages` instead of a static table in the language registry. The v3 migration had assumed v3 stopped reporting formality, but the capability was only renamed — v2's `supports_formality` boolean became the presence of a `formality` key in the per-language features matrix — so the CLI was answering from an 11-entry snapshot of the final v2 response that had already drifted from the API. The snapshot and the registry's `supportsFormality` field are gone; the registry's `category` tiers are unaffected. Output is otherwise unchanged: the `[F]` set is identical apart from `pt`. - **watch**: `--glossary` without `--from` now exits 6 before the watcher starts, instead of starting a session that fails on every single file change with a raw server message. The API rejects any translation naming a glossary without a source language ("Use of a glossary requires the source_lang parameter to be specified"), which `translate` already guarded against up front for text, files, and documents; `watch` passed `--from` straight through. A long-running command is the worst place for this, since the operator saw the failure once per edit rather than once at launch. The check runs before the glossary name is resolved, so it costs no API call. `sync` needs no equivalent: it has no `--from` at all, taking the source language from the required `source_locale` field in `.deepl-sync.yaml`, so its requests always carry one. diff --git a/CLAUDE.md b/CLAUDE.md index d0fbecfb..3b5ed1cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,10 +77,11 @@ Use **Semantic Versioning** with **Conventional Commits**: ### When Cutting a Release -1. Move Unreleased items to `## [X.Y.Z] - YYYY-MM-DD` -2. Set the version with `npm version X.Y.Z --no-git-tag-version` (updates `package.json` and the lockfile together; the release workflow refuses to publish if the tag and `package.json` disagree) -3. Create annotated tag: `git tag -a vX.Y.Z -m "Release vX.Y.Z: "` -4. Push: `git push && git push --tags` +1. Refresh the bundled language list: `npm run generate:languages` (needs `DEEPL_API_KEY` and a current build). Commit it if it changed — `npm run check:languages` reports drift without writing. The list is a generated snapshot of `GET /v3/languages`; a stale one no longer breaks translation, since validation defers to the API, but `deepl languages` offline and the derived tiers do go stale +2. Move Unreleased items to `## [X.Y.Z] - YYYY-MM-DD` +3. Set the version with `npm version X.Y.Z --no-git-tag-version` (updates `package.json` and the lockfile together; the release workflow refuses to publish if the tag and `package.json` disagree) +4. Create annotated tag: `git tag -a vX.Y.Z -m "Release vX.Y.Z: "` +5. Push: `git push && git push --tags` ## Code Style diff --git a/README.md b/README.md index 915adff6..44207f30 100644 --- a/README.md +++ b/README.md @@ -1011,7 +1011,7 @@ deepl detect "Hola mundo" --format json #### Supported Languages -List all 121 supported languages grouped by category: +List all 125 supported languages grouped by category: ```bash # Show all supported languages (both source and target) @@ -1067,11 +1067,13 @@ deepl languages **Note:** Languages are grouped into three categories: - **Core** (32) — Full feature support including formality and glossaries -- **Regional** (7) — Target-only variants: `en-gb`, `en-us`, `es-419`, `pt-br`, `pt-pt`, `zh-hans`, `zh-hant` +- **Regional** (11) — Target-only variants: `de-ch`, `de-de`, `en-gb`, `en-us`, `es-419`, `fr-ca`, `fr-fr`, `pt-br`, `pt-pt`, `zh-hans`, `zh-hant` - **Extended** (82) — Only support `quality_optimized` model, no formality or glossary `--features` is finer-grained than these tiers — some extended languages do support style rules and translation memory. It needs an API key, since the local registry carries no feature data. A feature only gets its own column when support differs across the languages listed; one supported by all of them is summarised on the last line instead of repeated on every row. +**The API decides what exists.** `GET /v3/languages` is authoritative; the bundled list is a generated snapshot of it (`npm run generate:languages`) so that listing and validating languages works offline. Because a snapshot can lag the API, a well-formed language code it does not list is passed to the API rather than rejected locally — that is why `deepl translate --to de-CH` works even if your copy of the list predates Swiss German. Input that is not shaped like a language tag is still rejected immediately. + See [examples/24-languages.sh](./examples/24-languages.sh) for a complete example. #### Configure Defaults diff --git a/docs/API.md b/docs/API.md index 0f6cc15e..0c9f3dbd 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2354,7 +2354,7 @@ deepl languages [OPTIONS] #### Description -Display all 121 supported languages grouped by category. Core and regional languages are shown first, followed by extended languages. When an API key is configured, language names are fetched from the DeepL API; otherwise, the local language registry is used. +Display all 125 supported languages grouped by category. Core and regional languages are shown first, followed by extended languages. When an API key is configured, language names are fetched from the DeepL API; otherwise, the local language registry is used. You can filter to show only source languages, only target languages, or both (default). @@ -2438,10 +2438,18 @@ deepl languages - `--features` replaces the `[F]` shorthand, since formality is one of the reported features - `--format json` includes a raw `features` object with each feature's status, but only when `--features` is passed +**Where the language list comes from:** + +- `GET /v3/languages` is the authority on which languages exist. The CLI bundles a snapshot of it so that listing and validating languages works offline and without an API key +- The snapshot is generated, not hand-maintained (`npm run generate:languages`; `npm run check:languages` reports drift). Tiers are derived from the response — glossary support separates extended from the rest, source usability separates core from regional — so they are not a separate judgement that can disagree with the API +- Because the snapshot can lag the API, a **well-formed language code it does not list is accepted and sent to the API**, which accepts or rejects it authoritatively. Input that is not shaped like a language tag is still rejected locally, with a pointer to `deepl languages`. This applies to `translate`, `sync` and to language values in the config file +- The listing itself is the union of the API response and the snapshot, so a language DeepL offers is never hidden even if the snapshot predates it + **Notes:** -- Source and target language lists differ: 7 regional variants (en-gb, en-us, es-419, pt-br, pt-pt, zh-hans, zh-hant) are target-only +- Source and target language lists differ: 11 regional variants (de-ch, de-de, en-gb, en-us, es-419, fr-ca, fr-fr, pt-br, pt-pt, zh-hans, zh-hant) are target-only - Extended languages (82 codes) only support `quality_optimized` model type and do not support formality or glossary features +- The API reports the same display name for a bare code and its explicit-region variant — both `de` and `de-de` are "German", both `fr` and `fr-fr` are "French". The CLI mirrors the API rather than inventing distinct names; the code column distinguishes them - The extended tier is a coarser signal than the feature matrix: some extended languages do support style rules and translation memory even though they support neither formality nor glossary - Without an API key, the command shows all languages from the local registry with a warning; `--features` additionally warns that it needs a key @@ -2495,7 +2503,7 @@ echo "$LANG" # es - Requires an API key (the detection uses a translate API call) - Each detection call consumes character quota (the text is translated to produce the detection) - Very short text (single characters or words) may produce unreliable detection results -- Supports all 121 languages recognized by the DeepL API (core, regional, and extended) +- Supports all 125 languages recognized by the DeepL API (core, regional, and extended) --- diff --git a/examples/24-languages.sh b/examples/24-languages.sh index 8f1d3280..cb44e25c 100755 --- a/examples/24-languages.sh +++ b/examples/24-languages.sh @@ -93,7 +93,7 @@ echo echo "💡 Language categories:" echo " - Core (32): Full support - formality, glossaries, all model types" -echo " - Regional (7): Target-only variants (en-us, en-gb, pt-br, etc.)" +echo " - Regional (11): Target-only variants (en-us, en-gb, de-ch, fr-ca, pt-br, etc.)" echo " - Extended (82): quality_optimized only, no formality or glossaries" echo " - --features is finer-grained than these tiers: some extended languages" echo " do support style rules and translation memory" From 253ccd96911c7a3202f84c2862836ffd575cf057 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 13:40:50 -0400 Subject: [PATCH 023/256] test(languages): stop the regional-variant assertions depending on colour The row assertions anchored on leading whitespace, but the code is chalk.cyan'd, so wherever colour is forced on the line reads " \e[36mde-ch \e[39m German (Swiss)" and the anchor lands on the escape sequence instead of the code. The test passed in a NO_COLOR environment and failed in one with FORCE_COLOR set. Passes noColor so the assertion sees the text it is written against, and excludeApiKey so the graceful-degradation case reads the snapshot rather than quietly calling the live API when a key happens to be configured. The rows now assert code and name together, which also pins de-de and fr-fr carrying the API's duplicate names. --- .../cli-languages.integration.test.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/integration/cli-languages.integration.test.ts b/tests/integration/cli-languages.integration.test.ts index 1a6b4a04..a23188c1 100644 --- a/tests/integration/cli-languages.integration.test.ts +++ b/tests/integration/cli-languages.integration.test.ts @@ -48,12 +48,18 @@ describe('Languages CLI Integration', () => { }); it('should list the regional variants the snapshot previously omitted', () => { - const output = runCLI('deepl languages --target', { apiKey: '' }); - - expect(output).toContain('German (Swiss)'); - expect(output).toContain('French (Canadian)'); - expect(output).toMatch(/^\s+de-ch\s/m); - expect(output).toMatch(/^\s+fr-fr\s/m); + // noColor because the code is chalk.cyan'd, so anchoring on the row would + // otherwise trip over the escape sequence wherever colour is forced on; + // excludeApiKey to read the snapshot rather than the live API. + const output = runCLI('deepl languages --target', { + excludeApiKey: true, + noColor: true, + }); + + expect(output).toMatch(/^\s+de-ch\s+German \(Swiss\)$/m); + expect(output).toMatch(/^\s+fr-ca\s+French \(Canadian\)$/m); + expect(output).toMatch(/^\s+de-de\s+German$/m); + expect(output).toMatch(/^\s+fr-fr\s+French$/m); }); }); From c955ca27b6a8737b4274f821e16e91b02c0d3b01 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 13:41:34 -0400 Subject: [PATCH 024/256] docs(changelog): note the ten display names the API differs on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generating the language list replaced ten hand-written names with the API's own (Norwegian Bokmål -> Norwegian (bokmål), Chinese (Simplified) -> Chinese (simplified), Central Kurdish -> Kurdish (Sorani), and seven more). Worth recording because this changelog flags output that scripts may scrape, though the effect is limited to the no-API-key path: with a key the API name already won at display time, so keyed output is unchanged and offline output now agrees with it. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 487bc120..1315a929 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **languages**: **The DeepL API is now the authority on which languages exist, not the CLI's bundled list.** That list was hand-maintained, so it could silently fall behind the API and make languages the API accepts unusable — which is exactly what happened to `de-CH`, `de-DE`, `fr-CA` and `fr-FR` (see Fixed). Three changes remove the failure mode rather than just correcting the data. **Validation defers to the API:** a well-formed language code the bundled list does not contain is sent to the API, which accepts or rejects it authoritatively, instead of being rejected locally. Input that is not shaped like a language tag is still rejected immediately with a pointer to `deepl languages`, so typos like `--to grman` still fail fast without a request. This covers `translate`, `sync` and language values in the config file. **The listing is API-driven:** `deepl languages` renders the union of the API response and the bundled list, so a language DeepL offers can no longer be missing from the output. **The list is generated:** `npm run generate:languages` rewrites it from `GET /v3/languages` and `npm run check:languages` fails on drift, so it is a build artifact of the API rather than something maintained by hand. The core/regional/extended tiers are derived in the same pass — glossary support separates extended from the rest, source usability separates core from regional — which reproduces the previously hand-assigned tiers exactly, so the tiers can no longer disagree with the API either. No command line changes; `--to de` and every other existing code behave as before. +- **languages**: **Ten display names changed to match the API**, a consequence of generating the language list rather than hand-writing it: `ckb` Central Kurdish → Kurdish (Sorani), `es-419` Spanish (Latin America) → Spanish (Latin American), `gom` Goan Konkani → Konkani, `kmr` Northern Kurdish → Kurdish (Kurmanji), `my` Myanmar (Burmese) → Burmese, `nb` Norwegian Bokmål → Norwegian (bokmål), `pam` Pampanga → Kapampangan, `st` Southern Sotho → Sesotho, `zh-hans` Chinese (Simplified) → Chinese (simplified), `zh-hant` Chinese (Traditional) → Chinese (traditional). **Only offline output changes**: with an API key configured, `deepl languages` already took names from the API and was therefore already showing these, so this makes the no-API-key output consistent with the keyed output rather than changing what keyed users saw. Language codes are unaffected, so nothing that selects a language by code has to change; only output that scrapes display names. + - **cli**: **Language codes are displayed in lowercase everywhere.** Output previously mixed three casings: `deepl languages` printed lowercase from the registry, `glossary show` and `tm list` uppercased at display time, `translate`'s table uppercased the target language, and `write`/`correct` used BCP-47 (`en-GB`, `zh-Hans`). Lowercase matches the CLI's own normalized form, the registry, what `deepl languages` teaches users to type, and the wire format `/v3/languages` moved to; uppercase followed a v2-era docs convention that v3 abandons. **Scripts scraping these values will see a casing change** — `glossary show` now reports `Source language: en` and `en → es: 5 entries`, `tm list` renders `brand-terms (en → de, fr)`, `translate --format table` labels rows `de`, and `write --format json` reports `"language": "en-us"`. Input remains case-insensitive everywhere, so no command line has to change. `write`/`correct` also send the lowercase code as `target_lang`: the Write API accepts any casing and canonicalizes server-side (verified live on `/v2/write/rephrase` and `/v2/write/correct` — `en-gb`, `zh-hans`, and `zh-HANS` all return 200 and echo back `en-GB` / `zh-Hans`). Wire parameters that are not display are untouched: `translate` and the glossary create endpoint still send uppercase `target_lang`/`source_lang` as those endpoints document. - **api**: Language listings migrated from the formally deprecated `GET /v2/languages` and `GET /v2/glossary-language-pairs` endpoints to `GET /v3/languages` (`resource=translate_text` / `resource=glossary`). Command output is unchanged: source/target lists derive from the v3 `usable_as_source`/`usable_as_target` flags, glossary pairs from the source×target cross-product (verified identical to the v2 pair list), and the `[F]` formality markers now come from the language registry since the v3 response no longer reports formality support. From b69494a6e9eafb13ff970d83f150ca90b1e05185 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 14:07:10 -0400 Subject: [PATCH 025/256] feat(translate): preflight a named glossary against the requested pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translation memories already refused a name that did not cover the requested language pair, locally and before any request. Glossaries did not: the request went out and the API answered "No dictionary found for language pair EN-DE in glossary ", naming a UUID the user never typed. resolveGlossaryId now takes the same optional `expected` pair that resolveTranslationMemoryId does and reports what the glossary actually covers. It costs no extra request, because resolving a name already fetches the glossary list. Matching is per dictionary, so a glossary holding en→es and de→fr is not read as covering en→fr, and every target of a multi-target translation must be covered. Two paths deliberately skip the check: a UUID is trusted and left to the API, matching translation-memory behaviour and leaving an escape hatch if the check is ever wrong; and a glossary the API reports with no dictionaries says nothing about coverage, so it is not rejected on no evidence. The pair is threaded from the text/file path via applySharedTmAndGlossary and from the document path, both of which already require --from with --glossary. Existing call-site assertions now pin the pair rather than just the name. --- CHANGELOG.md | 2 + docs/API.md | 14 +- .../translate/document-translation-handler.ts | 5 +- src/cli/commands/translate/translate-utils.ts | 8 +- .../translate/translation-options-factory.ts | 11 +- src/services/glossary.ts | 43 +++++- tests/unit/file-translation-handler.test.ts | 2 +- tests/unit/glossary-service.test.ts | 126 ++++++++++++++++++ tests/unit/translate-command.test.ts | 12 +- tests/unit/translate-utils.test.ts | 18 ++- .../unit/translation-options-factory.test.ts | 17 ++- 11 files changed, 239 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1315a929..85edea4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **translate**: **A glossary referenced by name is checked against the requested language pair before any translation request.** Previously only translation memories did this; a glossary whose dictionaries did not cover the pair reached the API and came back as `No dictionary found for language pair EN-DE in glossary `, naming a UUID the user never typed. It now fails locally, exit 7, naming what the glossary actually covers: `Glossary "my-terms" does not support the requested language pair` / `Glossary covers en→es; requested en→de.` This costs no extra request, because the glossary list is already fetched to resolve the name. Matching is per dictionary, so a multilingual glossary holding en→es and de→fr is not treated as covering en→fr, and when translating to several targets at once every one of them must be covered. Two deliberate exemptions: a glossary passed as a **UUID** is trusted and left to the API, matching how translation-memory resolution already behaves and giving an escape hatch if the check is ever wrong; and a glossary the API reports with no dictionaries is left alone, since that says nothing about coverage. + - **languages**: **The DeepL API is now the authority on which languages exist, not the CLI's bundled list.** That list was hand-maintained, so it could silently fall behind the API and make languages the API accepts unusable — which is exactly what happened to `de-CH`, `de-DE`, `fr-CA` and `fr-FR` (see Fixed). Three changes remove the failure mode rather than just correcting the data. **Validation defers to the API:** a well-formed language code the bundled list does not contain is sent to the API, which accepts or rejects it authoritatively, instead of being rejected locally. Input that is not shaped like a language tag is still rejected immediately with a pointer to `deepl languages`, so typos like `--to grman` still fail fast without a request. This covers `translate`, `sync` and language values in the config file. **The listing is API-driven:** `deepl languages` renders the union of the API response and the bundled list, so a language DeepL offers can no longer be missing from the output. **The list is generated:** `npm run generate:languages` rewrites it from `GET /v3/languages` and `npm run check:languages` fails on drift, so it is a build artifact of the API rather than something maintained by hand. The core/regional/extended tiers are derived in the same pass — glossary support separates extended from the rest, source usability separates core from regional — which reproduces the previously hand-assigned tiers exactly, so the tiers can no longer disagree with the API either. No command line changes; `--to de` and every other existing code behave as before. - **languages**: **Ten display names changed to match the API**, a consequence of generating the language list rather than hand-writing it: `ckb` Central Kurdish → Kurdish (Sorani), `es-419` Spanish (Latin America) → Spanish (Latin American), `gom` Goan Konkani → Konkani, `kmr` Northern Kurdish → Kurdish (Kurmanji), `my` Myanmar (Burmese) → Burmese, `nb` Norwegian Bokmål → Norwegian (bokmål), `pam` Pampanga → Kapampangan, `st` Southern Sotho → Sesotho, `zh-hans` Chinese (Simplified) → Chinese (simplified), `zh-hant` Chinese (Traditional) → Chinese (traditional). **Only offline output changes**: with an API key configured, `deepl languages` already took names from the API and was therefore already showing these, so this makes the no-API-key output consistent with the keyed output rather than changing what keyed users saw. Language codes are unaffected, so nothing that selects a language by code has to change; only output that scrapes display names. diff --git a/docs/API.md b/docs/API.md index 0c9f3dbd..fa2d82f0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -552,7 +552,7 @@ deepl translate README.md --from en --to fr --glossary abc-123-def-456 --output **Multiple glossaries on one request:** -Repeat `--glossary` to apply up to 5 glossaries to a single request. Their entries are merged, so terms unique to each glossary all apply. When more than one glossary defines the same source term, the **last** `--glossary` on the command line wins — order is significant, and reordering the flags produces a different translation (and a separate cache entry). Names and UUIDs can be mixed; each value is resolved independently. A 6th `--glossary` exits 6 (ValidationError), and an unresolvable name exits 7 (ConfigError) without sending a translation request. +Repeat `--glossary` to apply up to 5 glossaries to a single request. Their entries are merged, so terms unique to each glossary all apply. When more than one glossary defines the same source term, the **last** `--glossary` on the command line wins — order is significant, and reordering the flags produces a different translation (and a separate cache entry). Names and UUIDs can be mixed; each value is resolved independently. A 6th `--glossary` exits 6 (ValidationError). A name that cannot be resolved — unknown, ambiguous, or covering a different language pair than the one requested — exits 7 (ConfigError) without sending a translation request. ```bash # Shared base terminology, overridden by project-specific terms @@ -587,7 +587,17 @@ deepl translate "Welcome to our product." --from en --to de \ **Multi-target file translation with glossary / TM:** -Both `--glossary` and `--translation-memory` apply to multi-target file translation (e.g. `--to en,fr,es`) and in that mode `--from` is required. Glossary name resolution works transparently across all target languages. Translation memory name resolution, however, requires a single TM that covers every requested target language pair — because each TM in DeepL is scoped to one source→target pair, using a TM name with differing multi-targets surfaces a `ConfigError` (exit 7). For multi-target TM use, pass the TM UUID directly. +Both `--glossary` and `--translation-memory` apply to multi-target file translation (e.g. `--to en,fr,es`) and in that mode `--from` is required. Resolving either **by name** checks that the resource covers every requested language pair before any translation request goes out, and exits 7 (`ConfigError`) naming what it does cover if not: + +```bash +deepl translate "hello" --from en --to de --glossary "EN-ES Test Glossary" +# Error: Glossary "EN-ES Test Glossary" does not support the requested language pair +# Suggestion: Glossary covers en→es; requested en→de. +``` + +A multilingual glossary satisfies this when it holds a dictionary for each requested pair; matching is per dictionary, so a glossary holding en→es and de→fr does not count as covering en→fr. Each translation memory in DeepL is scoped to one source→target pair, so a TM name with differing multi-targets cannot satisfy it at all — pass the TM UUID for multi-target TM use. + +Passing a **UUID** skips the check and lets the API decide, which is the escape hatch if the check is ever wrong: the API answers with `No dictionary found for language pair EN-DE in glossary `. ```bash # Glossary across multiple targets (name resolution works for all targets) diff --git a/src/cli/commands/translate/document-translation-handler.ts b/src/cli/commands/translate/document-translation-handler.ts index 70116a36..fa6be7b0 100644 --- a/src/cli/commands/translate/document-translation-handler.ts +++ b/src/cli/commands/translate/document-translation-handler.ts @@ -1,6 +1,7 @@ import ora from 'ora'; import { Logger } from '../../../utils/logger.js'; import { ValidationError } from '../../../utils/errors.js'; +import type { Language } from '../../../types/index.js'; import type { DocumentTranslationOptions } from '../../../types/api.js'; import type { HandlerContext, TranslateOptions } from './types.js'; import { warnIgnoredOptions, validateLanguageCodes } from './translate-utils.js'; @@ -34,7 +35,9 @@ export class DocumentTranslationHandler { const translationOptions = buildBaseTranslationOptions(options); - await applyGlossarySelection(translationOptions, options, this.ctx.glossaryService); + await applyGlossarySelection(translationOptions, options, this.ctx.glossaryService, [ + options.to as Language, + ]); if (options.outputFormat) { translationOptions.outputFormat = options.outputFormat; diff --git a/src/cli/commands/translate/translate-utils.ts b/src/cli/commands/translate/translate-utils.ts index 6025a3fc..654b05ef 100644 --- a/src/cli/commands/translate/translate-utils.ts +++ b/src/cli/commands/translate/translate-utils.ts @@ -134,8 +134,12 @@ export function buildTranslationOptions(options: TranslateOptions): TranslationP return result; } -export async function resolveGlossaryId(glossaryService: GlossaryService, nameOrId: string): Promise { - return glossaryService.resolveGlossaryId(nameOrId); +export async function resolveGlossaryId( + glossaryService: GlossaryService, + nameOrId: string, + expected?: { from: Language; targets: Language[] }, +): Promise { + return glossaryService.resolveGlossaryId(nameOrId, expected); } export function isFilePath(input: string, cachedStats: fs.Stats | null | undefined, fileTranslationService: FileTranslationService): boolean { diff --git a/src/cli/commands/translate/translation-options-factory.ts b/src/cli/commands/translate/translation-options-factory.ts index 4e353166..1e59800e 100644 --- a/src/cli/commands/translate/translation-options-factory.ts +++ b/src/cli/commands/translate/translation-options-factory.ts @@ -44,16 +44,23 @@ export async function applyGlossarySelection< base: T, options: TranslateOptions, glossaryService: GlossaryService, + targets?: Language[], ): Promise { if (!options.glossary || options.glossary.length === 0) { return; } + // --glossary already requires --from, so the pair is always known here. + const expected = + options.from && targets && targets.length > 0 + ? { from: options.from as Language, targets } + : undefined; + // Resolved sequentially so the service's resolution cache is populated // before the next name-or-ID lookup needs the glossary list. const ids: string[] = []; for (const nameOrId of options.glossary) { - ids.push(await resolveGlossaryId(glossaryService, nameOrId)); + ids.push(await resolveGlossaryId(glossaryService, nameOrId, expected)); } const [only] = ids; @@ -104,7 +111,7 @@ export async function applySharedTmAndGlossary< options: TranslateOptions, deps: SharedTmAndGlossaryDeps, ): Promise { - await applyGlossarySelection(base, options, deps.glossaryService); + await applyGlossarySelection(base, options, deps.glossaryService, deps.targets); if (options.translationMemory) { const cache = deps.tmCache ?? new Map(); diff --git a/src/services/glossary.ts b/src/services/glossary.ts index 98a1c244..135f8bae 100644 --- a/src/services/glossary.ts +++ b/src/services/glossary.ts @@ -160,12 +160,26 @@ export class GlossaryService { /** * Resolve a glossary name or ID to a glossary ID. * If the input is a UUID, returns it directly. Otherwise looks up by name. + * + * When `expected` is given, the resolved glossary's dictionaries must cover + * that language pair, which turns "No dictionary found for language pair + * EN-DE" from the API into a local error naming what the glossary does cover. + * Matching is per dictionary, so a glossary holding en→es and de→fr is not + * read as also covering en→fr. Free on this path because the list is already + * fetched to resolve the name; the UUID path trusts the caller and skips the + * check, as translation-memory resolution does. */ - async resolveGlossaryId(nameOrId: string): Promise { + async resolveGlossaryId( + nameOrId: string, + expected?: { from: Language; targets: Language[] }, + ): Promise { if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(nameOrId)) { return nameOrId; } - const cached = this.resolutionCache.get(nameOrId); + const cacheKey = expected + ? `${nameOrId}|${expected.from.toLowerCase()}|${expected.targets.map(t => t.toLowerCase()).join(',')}` + : nameOrId; + const cached = this.resolutionCache.get(cacheKey); if (cached !== undefined) { Logger.verbose(`[verbose] Glossary cache hit: "${nameOrId}" -> ${cached}`); return cached; @@ -183,7 +197,30 @@ export class GlossaryService { if (!match) { throw new ConfigError(`Glossary "${sanitizeForError(nameOrId)}" not found`); } - this.resolutionCache.set(nameOrId, match.glossary_id); + + // An empty dictionary list says nothing about coverage, so leave the + // judgement to the API rather than rejecting on no evidence. + if (expected && match.dictionaries.length > 0) { + const from = expected.from.toLowerCase(); + const covered = (target: string): boolean => + match.dictionaries.some( + d => + d.source_lang.toLowerCase() === from && + d.target_lang.toLowerCase() === target.toLowerCase(), + ); + const missing = expected.targets.filter(target => !covered(target)); + if (missing.length > 0) { + const pairs = match.dictionaries + .map(d => `${d.source_lang.toLowerCase()}→${d.target_lang.toLowerCase()}`) + .join(', '); + throw new ConfigError( + `Glossary "${sanitizeForError(nameOrId)}" does not support the requested language pair`, + `Glossary covers ${pairs}; requested ${from}→${missing.map(t => t.toLowerCase()).join(',')}.`, + ); + } + } + + this.resolutionCache.set(cacheKey, match.glossary_id); Logger.verbose(`[verbose] Resolved glossary "${nameOrId}" -> ${match.glossary_id}`); return match.glossary_id; } diff --git a/tests/unit/file-translation-handler.test.ts b/tests/unit/file-translation-handler.test.ts index b222a2db..9b9e393e 100644 --- a/tests/unit/file-translation-handler.test.ts +++ b/tests/unit/file-translation-handler.test.ts @@ -197,7 +197,7 @@ describe('FileTranslationHandler', () => { })); expect(mocks.glossaryService.resolveGlossaryId).toHaveBeenCalledTimes(1); - expect(mocks.glossaryService.resolveGlossaryId).toHaveBeenCalledWith('my-glossary'); + expect(mocks.glossaryService.resolveGlossaryId).toHaveBeenCalledWith('my-glossary', { from: 'en', targets: ['de', 'fr'] }); const call = mocks.fileTranslationService.translateFileToMultiple.mock.calls[0]!; expect(call[2]).toEqual(expect.objectContaining({ glossaryId: 'glossary-abc-123' })); }); diff --git a/tests/unit/glossary-service.test.ts b/tests/unit/glossary-service.test.ts index 4b488b1f..73ad70ac 100644 --- a/tests/unit/glossary-service.test.ts +++ b/tests/unit/glossary-service.test.ts @@ -341,6 +341,132 @@ describe('GlossaryService', () => { expect(mockDeepLClient.listGlossaries).not.toHaveBeenCalled(); }); + describe('language-pair preflight', () => { + const listing = (dictionaries: Array<{ source_lang: string; target_lang: string }>) => [ + { + glossary_id: 'found-glossary-id', + name: 'tech-terms', + source_lang: dictionaries[0]!.source_lang, + target_langs: dictionaries.map(d => d.target_lang), + dictionaries: dictionaries.map(d => ({ ...d, entry_count: 1 })), + creation_time: '2024-01-01T00:00:00Z', + }, + ]; + + it('should resolve when a dictionary covers the requested pair', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue( + listing([{ source_lang: 'en', target_lang: 'es' }]) as never, + ); + + await expect( + glossaryService.resolveGlossaryId('tech-terms', { from: 'en', targets: ['es'] }), + ).resolves.toBe('found-glossary-id'); + }); + + it('should reject when no dictionary covers the requested target', async () => { + expect.assertions(2); + mockDeepLClient.listGlossaries.mockResolvedValue( + listing([{ source_lang: 'en', target_lang: 'es' }]) as never, + ); + + try { + await glossaryService.resolveGlossaryId('tech-terms', { from: 'en', targets: ['de'] }); + } catch (error) { + expect((error as Error).message).toContain('does not support the requested language pair'); + expect((error as ConfigError).suggestion).toContain('en→es'); + } + }); + + it('should reject when the source language does not match', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue( + listing([{ source_lang: 'en', target_lang: 'es' }]) as never, + ); + + await expect( + glossaryService.resolveGlossaryId('tech-terms', { from: 'de', targets: ['es'] }), + ).rejects.toThrow('does not support the requested language pair'); + }); + + it('should require every requested target to be covered', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue( + listing([ + { source_lang: 'en', target_lang: 'es' }, + { source_lang: 'en', target_lang: 'fr' }, + ]) as never, + ); + + await expect( + glossaryService.resolveGlossaryId('tech-terms', { from: 'en', targets: ['es', 'fr'] }), + ).resolves.toBe('found-glossary-id'); + await expect( + glossaryService.resolveGlossaryId('tech-terms', { from: 'en', targets: ['es', 'de'] }), + ).rejects.toThrow('does not support the requested language pair'); + }); + + it('should match per dictionary rather than across derived source and target lists', async () => { + // en→es and de→fr must not be read as also covering en→fr. + mockDeepLClient.listGlossaries.mockResolvedValue( + listing([ + { source_lang: 'en', target_lang: 'es' }, + { source_lang: 'de', target_lang: 'fr' }, + ]) as never, + ); + + await expect( + glossaryService.resolveGlossaryId('tech-terms', { from: 'en', targets: ['fr'] }), + ).rejects.toThrow('does not support the requested language pair'); + }); + + it('should compare languages case-insensitively', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue( + listing([{ source_lang: 'EN', target_lang: 'ES' }]) as never, + ); + + await expect( + glossaryService.resolveGlossaryId('tech-terms', { from: 'en', targets: ['es'] }), + ).resolves.toBe('found-glossary-id'); + }); + + it('should skip the check for a UUID, trusting the caller', async () => { + const uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + + await expect( + glossaryService.resolveGlossaryId(uuid, { from: 'en', targets: ['de'] }), + ).resolves.toBe(uuid); + expect(mockDeepLClient.listGlossaries).not.toHaveBeenCalled(); + }); + + it('should skip the check when the glossary reports no dictionaries', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue([ + { + glossary_id: 'empty-glossary-id', + name: 'tech-terms', + source_lang: 'en', + target_langs: [], + dictionaries: [], + creation_time: '2024-01-01T00:00:00Z', + }, + ] as never); + + await expect( + glossaryService.resolveGlossaryId('tech-terms', { from: 'en', targets: ['de'] }), + ).resolves.toBe('empty-glossary-id'); + }); + + it('should not let a cached resolution skip the check for a different pair', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue( + listing([{ source_lang: 'en', target_lang: 'es' }]) as never, + ); + + await expect( + glossaryService.resolveGlossaryId('tech-terms', { from: 'en', targets: ['es'] }), + ).resolves.toBe('found-glossary-id'); + await expect( + glossaryService.resolveGlossaryId('tech-terms', { from: 'en', targets: ['de'] }), + ).rejects.toThrow('does not support the requested language pair'); + }); + }); + it('should emit a verbose log with resolved glossary name -> UUID after name lookup', async () => { mockDeepLClient.listGlossaries.mockResolvedValue([ { diff --git a/tests/unit/translate-command.test.ts b/tests/unit/translate-command.test.ts index 34622332..0c9a6c73 100644 --- a/tests/unit/translate-command.test.ts +++ b/tests/unit/translate-command.test.ts @@ -1193,7 +1193,7 @@ describe('TranslateCommand', () => { }); expect(result).toBe('Hallo Welt'); - expect(mockGlossaryService.resolveGlossaryId).toHaveBeenCalledWith('my-glossary'); + expect(mockGlossaryService.resolveGlossaryId).toHaveBeenCalledWith('my-glossary', { from: 'en', targets: ['de'] }); expect(mockTranslationService.translate).toHaveBeenCalledWith( 'Hello world', { targetLang: 'de', sourceLang: 'en', glossaryId: 'glossary-123' }, @@ -1216,7 +1216,7 @@ describe('TranslateCommand', () => { }); expect(result).toBe('Bonjour le monde'); - expect(mockGlossaryService.resolveGlossaryId).toHaveBeenCalledWith('01234567-89ab-cdef-0123-456789abcdef'); + expect(mockGlossaryService.resolveGlossaryId).toHaveBeenCalledWith('01234567-89ab-cdef-0123-456789abcdef', { from: 'en', targets: ['fr'] }); expect(mockTranslationService.translate).toHaveBeenCalledWith( 'Hello world', { targetLang: 'fr', sourceLang: 'en', glossaryId: '01234567-89ab-cdef-0123-456789abcdef' }, @@ -1255,7 +1255,7 @@ describe('TranslateCommand', () => { }) ).rejects.toThrow('Glossary "non-existent" not found'); - expect(mockGlossaryService.resolveGlossaryId).toHaveBeenCalledWith('non-existent'); + expect(mockGlossaryService.resolveGlossaryId).toHaveBeenCalledWith('non-existent', { from: 'en', targets: ['de'] }); }); it('should translate to multiple languages with glossary', async () => { @@ -1273,7 +1273,7 @@ describe('TranslateCommand', () => { }); expect(result).toBe('[de] Hallo\n[fr] Bonjour'); - expect(mockGlossaryService.resolveGlossaryId).toHaveBeenCalledWith('tech-terms'); + expect(mockGlossaryService.resolveGlossaryId).toHaveBeenCalledWith('tech-terms', { from: 'en', targets: ['de', 'fr'] }); expect(mockTranslationService.translateToMultiple).toHaveBeenCalledWith( 'Hello', ['de', 'fr'], @@ -1298,7 +1298,7 @@ describe('TranslateCommand', () => { }); expect(result).toBe('Sehr geehrte Damen und Herren'); - expect(mockGlossaryService.resolveGlossaryId).toHaveBeenCalledWith('business-glossary'); + expect(mockGlossaryService.resolveGlossaryId).toHaveBeenCalledWith('business-glossary', { from: 'en', targets: ['de'] }); expect(mockTranslationService.translate).toHaveBeenCalledWith( 'Dear Sir or Madam', { @@ -3325,7 +3325,7 @@ describe('TranslateCommand', () => { glossary: ['my-glossary'], }); - expect(mockGlossaryService.resolveGlossaryId).toHaveBeenCalledWith('my-glossary'); + expect(mockGlossaryService.resolveGlossaryId).toHaveBeenCalledWith('my-glossary', { from: 'en', targets: ['es'] }); expect(mockTranslationService.translate).toHaveBeenCalledWith( 'Hello', expect.objectContaining({ glossaryId: 'glossary-file-123' }), diff --git a/tests/unit/translate-utils.test.ts b/tests/unit/translate-utils.test.ts index 3575a4c3..bb97d42b 100644 --- a/tests/unit/translate-utils.test.ts +++ b/tests/unit/translate-utils.test.ts @@ -589,7 +589,23 @@ describe('translate-utils', () => { const result = await resolveGlossaryId(mockGlossaryService, 'my-glossary'); expect(result).toBe('glossary-123'); - expect(mockGlossaryService.resolveGlossaryId).toHaveBeenCalledWith('my-glossary'); + expect(mockGlossaryService.resolveGlossaryId).toHaveBeenCalledWith('my-glossary', undefined); + }); + + it('should forward the expected language pair for the preflight check', async () => { + const mockGlossaryService = { + resolveGlossaryId: jest.fn().mockResolvedValue('glossary-123'), + } as unknown as GlossaryService; + + await resolveGlossaryId(mockGlossaryService, 'my-glossary', { + from: 'en', + targets: ['de'], + }); + + expect(mockGlossaryService.resolveGlossaryId).toHaveBeenCalledWith('my-glossary', { + from: 'en', + targets: ['de'], + }); }); it('should pass through errors from glossaryService', async () => { diff --git a/tests/unit/translation-options-factory.test.ts b/tests/unit/translation-options-factory.test.ts index c0f3dad7..c8bd1f5c 100644 --- a/tests/unit/translation-options-factory.test.ts +++ b/tests/unit/translation-options-factory.test.ts @@ -88,7 +88,22 @@ describe('translation-options-factory', () => { targets: ['de'], }); expect(base['glossaryId']).toBe('glos-123'); - expect(glossarySvc.resolveGlossaryId).toHaveBeenCalledWith('my-glossary'); + // No `from` in these options, so there is no pair to preflight against. + expect(glossarySvc.resolveGlossaryId).toHaveBeenCalledWith('my-glossary', undefined); + }); + + it('passes the requested language pair when from and targets are known', async () => { + glossarySvc.resolveGlossaryId.mockResolvedValue('glos-123'); + const base: Record = { targetLang: 'de' }; + await applySharedTmAndGlossary(base, { to: 'de,fr', from: 'en', glossary: ['my-glossary'] }, { + glossaryService: glossarySvc, + translationService: translationSvc, + targets: ['de', 'fr'], + }); + expect(glossarySvc.resolveGlossaryId).toHaveBeenCalledWith('my-glossary', { + from: 'en', + targets: ['de', 'fr'], + }); }); it('skips glossary resolution when options.glossary is absent', async () => { From 011ae192d2c7bbfd59f28995d252143c79f89b05 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 14:17:03 -0400 Subject: [PATCH 026/256] refactor(write): generate the Write target languages from the API The Write API's 14 target languages were hand-maintained in two places -- the WRITE_LANGUAGES array and a separate WriteLanguage union in types/api.ts -- either of which could fall behind GET /v3/languages?resource=write the way the translation list silently went four codes stale. The generator now emits both language lists, and check:languages names which one drifted rather than reporting a misleading translate_text count. The type is derived from the generated list via `as const`, so a language added upstream widens it on regenerate instead of needing a second hand edit. No behaviour change: the generated list is byte-identical to the hand-written one, and write/correct still reject an unknown code locally while enumerating every valid option. That strictness is deliberate and not what translate does -- at 14 of 125 languages, naming the options beats a round trip. The style/tone support table in API.md stays hand-maintained on purpose: it records what the API accepts, and resource=write omits writing_style for en even though --style --lang en works. Noted there so it is not "corrected" against the metadata later. --- CHANGELOG.md | 2 + CLAUDE.md | 2 +- docs/API.md | 8 +++ scripts/generate-language-registry.mjs | 75 +++++++++++++++++++++----- src/cli/commands/register-write.ts | 9 +++- src/data/language-entries.ts | 29 ++++++++++ src/types/api.ts | 22 +++----- tests/unit/language-registry.test.ts | 32 +++++++++++ tests/unit/register-write.test.ts | 30 +++++++++++ 9 files changed, 180 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85edea4f..8da717e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **translate**: **A glossary referenced by name is checked against the requested language pair before any translation request.** Previously only translation memories did this; a glossary whose dictionaries did not cover the pair reached the API and came back as `No dictionary found for language pair EN-DE in glossary `, naming a UUID the user never typed. It now fails locally, exit 7, naming what the glossary actually covers: `Glossary "my-terms" does not support the requested language pair` / `Glossary covers en→es; requested en→de.` This costs no extra request, because the glossary list is already fetched to resolve the name. Matching is per dictionary, so a multilingual glossary holding en→es and de→fr is not treated as covering en→fr, and when translating to several targets at once every one of them must be covered. Two deliberate exemptions: a glossary passed as a **UUID** is trusted and left to the API, matching how translation-memory resolution already behaves and giving an escape hatch if the check is ever wrong; and a glossary the API reports with no dictionaries is left alone, since that says nothing about coverage. +- **write**: **The Write API's 14 target languages are generated rather than hand-maintained**, closing the last hand-kept language list. The list and the `WriteLanguage` type were two separate hand-edited copies of the same set, either of which could fall behind `GET /v3/languages?resource=write` — the same way the translation list silently went four codes stale. `npm run generate:languages` now emits both language lists and `npm run check:languages` reports which one drifted; the type is derived from the generated list, so a language added upstream widens it on regenerate instead of needing a second edit. No behaviour change: the generated list is byte-identical to what was there, and `write`/`correct` still **reject** a code outside it locally while naming every valid option, deliberately unlike `translate --to`, which passes well-formed unknown codes to the API. At 14 of 125 languages an enumerated error beats a round trip. Note the documented style/tone support table is unchanged and still maintained by hand, because it records what the API accepts rather than what its metadata claims: `resource=write` omits `writing_style` for `en`, but `--style` with `--lang en` works. + - **languages**: **The DeepL API is now the authority on which languages exist, not the CLI's bundled list.** That list was hand-maintained, so it could silently fall behind the API and make languages the API accepts unusable — which is exactly what happened to `de-CH`, `de-DE`, `fr-CA` and `fr-FR` (see Fixed). Three changes remove the failure mode rather than just correcting the data. **Validation defers to the API:** a well-formed language code the bundled list does not contain is sent to the API, which accepts or rejects it authoritatively, instead of being rejected locally. Input that is not shaped like a language tag is still rejected immediately with a pointer to `deepl languages`, so typos like `--to grman` still fail fast without a request. This covers `translate`, `sync` and language values in the config file. **The listing is API-driven:** `deepl languages` renders the union of the API response and the bundled list, so a language DeepL offers can no longer be missing from the output. **The list is generated:** `npm run generate:languages` rewrites it from `GET /v3/languages` and `npm run check:languages` fails on drift, so it is a build artifact of the API rather than something maintained by hand. The core/regional/extended tiers are derived in the same pass — glossary support separates extended from the rest, source usability separates core from regional — which reproduces the previously hand-assigned tiers exactly, so the tiers can no longer disagree with the API either. No command line changes; `--to de` and every other existing code behave as before. - **languages**: **Ten display names changed to match the API**, a consequence of generating the language list rather than hand-writing it: `ckb` Central Kurdish → Kurdish (Sorani), `es-419` Spanish (Latin America) → Spanish (Latin American), `gom` Goan Konkani → Konkani, `kmr` Northern Kurdish → Kurdish (Kurmanji), `my` Myanmar (Burmese) → Burmese, `nb` Norwegian Bokmål → Norwegian (bokmål), `pam` Pampanga → Kapampangan, `st` Southern Sotho → Sesotho, `zh-hans` Chinese (Simplified) → Chinese (simplified), `zh-hant` Chinese (Traditional) → Chinese (traditional). **Only offline output changes**: with an API key configured, `deepl languages` already took names from the API and was therefore already showing these, so this makes the no-API-key output consistent with the keyed output rather than changing what keyed users saw. Language codes are unaffected, so nothing that selects a language by code has to change; only output that scrapes display names. diff --git a/CLAUDE.md b/CLAUDE.md index 3b5ed1cc..122bfc0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ Use **Semantic Versioning** with **Conventional Commits**: ### When Cutting a Release -1. Refresh the bundled language list: `npm run generate:languages` (needs `DEEPL_API_KEY` and a current build). Commit it if it changed — `npm run check:languages` reports drift without writing. The list is a generated snapshot of `GET /v3/languages`; a stale one no longer breaks translation, since validation defers to the API, but `deepl languages` offline and the derived tiers do go stale +1. Refresh the bundled language lists: `npm run generate:languages` (needs `DEEPL_API_KEY` and a current build). Commit if changed — `npm run check:languages` reports which list drifted without writing. Both come from `GET /v3/languages`: the translation snapshot (`resource=translate_text`) and the Write target list (`resource=write`). A stale translation snapshot no longer breaks translation, since validation defers to the API, but `deepl languages` offline and the derived tiers do go stale. **A stale Write list does break `write`/`correct`**, which reject unknown codes locally — so this step matters most for that list 2. Move Unreleased items to `## [X.Y.Z] - YYYY-MM-DD` 3. Set the version with `npm version X.Y.Z --no-git-tag-version` (updates `package.json` and the lockfile together; the release workflow refuses to publish if the tag and `package.json` disagree) 4. Create annotated tag: `git tag -a vX.Y.Z -m "Release vX.Y.Z: "` diff --git a/docs/API.md b/docs/API.md index fa2d82f0..21240ffa 100644 --- a/docs/API.md +++ b/docs/API.md @@ -754,6 +754,14 @@ Enhance text quality with AI-powered grammar checking, style improvement, and to When `--style` or `--tone` is set for a target language that does not support it, the server returns a 4xx; the CLI converts that response into a `ValidationError` (exit code 6) that names the unsupported combination and points back to this table. +This table is maintained by hand and reflects what the API **accepts**, which is not always what its metadata reports: `GET /v3/languages?resource=write` omits `writing_style` for `en`, yet `--style` with `--lang en` succeeds and returns what `--lang en-us` returns. Do not narrow this table to the metadata without re-checking behaviour. + +**Where the target-language list comes from:** + +The 14 languages are generated from `GET /v3/languages?resource=write` into `src/data/language-entries.ts` by `npm run generate:languages`, alongside the translation list, and `npm run check:languages` reports drift in either. The `WriteLanguage` type is derived from that same list, so a language added upstream widens it on regenerate rather than needing a second hand edit. + +Unlike `translate --to`, which passes a well-formed unknown code to the API, `write` and `correct` **reject** a code outside this list locally (exit 6) and name every valid option. The set is small enough to enumerate, so that beats a round trip — the reasoning that makes permissiveness right for translation does not transfer to 14 of 125 languages. + **Output Options:** - `--alternatives, -a` - Show all improvement alternatives diff --git a/scripts/generate-language-registry.mjs b/scripts/generate-language-registry.mjs index cb6348cd..ed1780a5 100644 --- a/scripts/generate-language-registry.mjs +++ b/scripts/generate-language-registry.mjs @@ -42,19 +42,32 @@ if (!existsSync(DERIVATION)) { const { deriveLanguageEntry } = await import(DERIVATION); const host = apiKey.endsWith(':fx') ? 'https://api-free.deepl.com' : 'https://api.deepl.com'; -const response = await fetch(`${host}/v3/languages?resource=translate_text`, { - headers: { Authorization: `DeepL-Auth-Key ${apiKey}` }, -}); -if (!response.ok) { - fail(`GET /v3/languages returned ${response.status} ${response.statusText}`); -} -const languages = await response.json(); -if (!Array.isArray(languages) || languages.length === 0) { - fail('GET /v3/languages returned no languages'); +async function fetchResource(resource) { + const response = await fetch(`${host}/v3/languages?resource=${resource}`, { + headers: { Authorization: `DeepL-Auth-Key ${apiKey}` }, + }); + if (!response.ok) { + fail(`GET /v3/languages?resource=${resource} returned ${response.status} ${response.statusText}`); + } + const languages = await response.json(); + if (!Array.isArray(languages) || languages.length === 0) { + fail(`GET /v3/languages?resource=${resource} returned no languages`); + } + return languages; } +const languages = await fetchResource('translate_text'); +const writeLanguages = await fetchResource('write'); + const entries = languages.map(deriveLanguageEntry); +// The write endpoints take a target language only, so the list is filtered by +// that role rather than run through deriveLanguageEntry -- write has no notion +// of the core/regional/extended tiers. +const writeTargets = writeLanguages + .filter(language => language.usable_as_target) + .map(language => language.lang.toLowerCase()) + .sort((a, b) => a.localeCompare(b, 'en')); const byCode = (a, b) => a.code.localeCompare(b.code, 'en'); const groups = [ ['core', 'Core languages (full feature support: formality, glossary, all model types)'], @@ -93,16 +106,51 @@ import type { LanguageEntry } from './language-registry.js'; export const ENTRIES: LanguageEntry[] = [ ${body} ]; + +/** + * Target languages the Write API accepts, from resource=write. + * + * Unlike translation, \`write\` and \`correct\` reject a code outside this list + * locally rather than deferring to the API: the supported set is small enough + * to enumerate in the error, so naming the valid options beats a round trip. + * That makes keeping this generated the thing that stops it going stale. + * + * \`as const\` is load-bearing -- the WriteLanguage union in src/types/api.ts is + * derived from it, so adding a language upstream widens the type on regenerate + * instead of needing a second hand edit. + */ +export const WRITE_TARGET_LANGUAGES = [ +${writeTargets.map(code => ` '${code}',`).join('\n')} +] as const; `; if (checkOnly) { const current = existsSync(TARGET) ? readFileSync(TARGET, 'utf8') : ''; if (current === contents) { - console.log(`${entries.length} languages; snapshot is current.`); + console.log( + `${entries.length} languages, ${writeTargets.length} write targets; snapshot is current.`, + ); process.exit(0); } + // Name which list moved: the two are generated from different resources, and + // "N languages upstream" is misleading when it is the write list that drifted. + const codesIn = (source, open, close) => { + const start = source.indexOf(open); + if (start === -1) return ''; + const from = start + open.length; + const end = source.indexOf(close, from); + return (source.slice(from, end === -1 ? undefined : end).match(/'[a-z0-9-]+'/g) ?? []).join(','); + }; + const blocks = [ + ['translate_text', `${entries.length}`, 'export const ENTRIES', '\n];'], + ['write', `${writeTargets.length}`, 'export const WRITE_TARGET_LANGUAGES', '] as const;'], + ]; + const drifted = blocks + .filter(([, , open, close]) => codesIn(current, open, close) !== codesIn(contents, open, close)) + .map(([name, count]) => `${name} (${count} upstream)`); + const detail = drifted.length > 0 ? drifted.join(', ') : 'formatting only'; console.error( - `error: ${path.relative(ROOT, TARGET)} is out of date with the API (${entries.length} languages upstream).\n` + + `error: ${path.relative(ROOT, TARGET)} is out of date with the API -- ${detail}.\n` + 'Run: npm run generate:languages', ); process.exit(1); @@ -110,4 +158,7 @@ if (checkOnly) { writeFileSync(TARGET, contents); const counts = groups.map(([c]) => `${c} ${entries.filter(e => e.category === c).length}`); -console.log(`wrote ${path.relative(ROOT, TARGET)}: ${entries.length} languages (${counts.join(', ')})`); +console.log( + `wrote ${path.relative(ROOT, TARGET)}: ${entries.length} languages (${counts.join(', ')}), ` + + `${writeTargets.length} write targets`, +); diff --git a/src/cli/commands/register-write.ts b/src/cli/commands/register-write.ts index e0adb500..dba9f177 100644 --- a/src/cli/commands/register-write.ts +++ b/src/cli/commands/register-write.ts @@ -3,13 +3,20 @@ import { existsSync } from 'fs'; import { atomicWriteFile } from '../../utils/atomic-write.js'; import chalk from 'chalk'; import type { WriteLanguage, WritingStyle, WriteTone } from '../../types/index.js'; +import { WRITE_TARGET_LANGUAGES } from '../../data/language-entries.js'; import { Logger } from '../../utils/logger.js'; import { ExitCode } from '../../utils/exit-codes.js'; import { isNoInput } from '../../utils/confirm.js'; import { ValidationError } from '../../utils/errors.js'; import { createWriteCommand, type ServiceDeps } from './service-factory.js'; -export const WRITE_LANGUAGES = ['de', 'en', 'en-gb', 'en-us', 'es', 'fr', 'it', 'ja', 'ko', 'pt', 'pt-br', 'pt-pt', 'zh', 'zh-hans'] as const; +/** + * Generated from GET /v3/languages?resource=write, so it cannot drift from what + * the API accepts. Unlike `translate --to`, a code outside this list is rejected + * locally rather than deferred to the API: at 14 entries the error can name every + * valid option, which beats a round trip. + */ +export const WRITE_LANGUAGES = WRITE_TARGET_LANGUAGES; /** * Codes are accepted in any casing and normalized to lowercase, matching * `translate --to` and the codes `deepl languages` prints. The Write API diff --git a/src/data/language-entries.ts b/src/data/language-entries.ts index 69de1c04..08092444 100644 --- a/src/data/language-entries.ts +++ b/src/data/language-entries.ts @@ -144,3 +144,32 @@ export const ENTRIES: LanguageEntry[] = [ { code: 'yue', name: 'Cantonese', category: 'extended' }, { code: 'zu', name: 'Zulu', category: 'extended' }, ]; + +/** + * Target languages the Write API accepts, from resource=write. + * + * Unlike translation, `write` and `correct` reject a code outside this list + * locally rather than deferring to the API: the supported set is small enough + * to enumerate in the error, so naming the valid options beats a round trip. + * That makes keeping this generated the thing that stops it going stale. + * + * `as const` is load-bearing -- the WriteLanguage union in src/types/api.ts is + * derived from it, so adding a language upstream widens the type on regenerate + * instead of needing a second hand edit. + */ +export const WRITE_TARGET_LANGUAGES = [ + 'de', + 'en', + 'en-gb', + 'en-us', + 'es', + 'fr', + 'it', + 'ja', + 'ko', + 'pt', + 'pt-br', + 'pt-pt', + 'zh', + 'zh-hans', +] as const; diff --git a/src/types/api.ts b/src/types/api.ts index fbdf6eb1..5aae2431 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -3,6 +3,7 @@ */ import { Language, Formality } from './common'; +import { WRITE_TARGET_LANGUAGES } from '../data/language-entries.js'; export type ModelType = | 'quality_optimized' @@ -45,21 +46,12 @@ export interface TranslationMemory { target_languages: string[]; } -export type WriteLanguage = - | 'de' - | 'en' - | 'en-gb' - | 'en-us' - | 'es' - | 'fr' - | 'it' - | 'ja' - | 'ko' - | 'pt' - | 'pt-br' - | 'pt-pt' - | 'zh' - | 'zh-hans'; +/** + * Target languages the Write API accepts. Derived from the generated snapshot + * so that adding a language upstream widens this on `npm run generate:languages` + * rather than needing a second, hand-kept copy of the same list. + */ +export type WriteLanguage = (typeof WRITE_TARGET_LANGUAGES)[number]; export type WritingStyle = | 'default' diff --git a/tests/unit/language-registry.test.ts b/tests/unit/language-registry.test.ts index 8b2e9728..77f220d5 100644 --- a/tests/unit/language-registry.test.ts +++ b/tests/unit/language-registry.test.ts @@ -10,6 +10,7 @@ import { deriveLanguageEntry, looksLikeLanguageTag, } from '../../src/data/language-registry'; +import { WRITE_TARGET_LANGUAGES } from '../../src/data/language-entries'; describe('Language Registry', () => { describe('LANGUAGE_REGISTRY', () => { @@ -232,6 +233,37 @@ describe('Language Registry', () => { }); }); + describe('WRITE_TARGET_LANGUAGES', () => { + it('should be non-empty', () => { + expect(WRITE_TARGET_LANGUAGES.length).toBeGreaterThan(0); + }); + + it('should be lowercase and sorted, matching how the generator emits it', () => { + const codes = [...WRITE_TARGET_LANGUAGES]; + expect(codes).toEqual(codes.map(c => c.toLowerCase())); + expect(codes).toEqual([...codes].sort((a, b) => a.localeCompare(b, 'en'))); + }); + + it('should have no duplicates', () => { + expect(new Set(WRITE_TARGET_LANGUAGES).size).toBe(WRITE_TARGET_LANGUAGES.length); + }); + + it('should only contain languages the translate snapshot also knows', () => { + // Both lists come from the same GET /v3/languages, so a write target the + // main snapshot has never heard of means one of them was generated stale. + for (const code of WRITE_TARGET_LANGUAGES) { + expect(isValidLanguage(code)).toBe(true); + } + }); + + it('should be a subset of the target languages', () => { + const targets = new Set(getTargetLanguages().map(e => e.code)); + for (const code of WRITE_TARGET_LANGUAGES) { + expect(targets.has(code)).toBe(true); + } + }); + }); + describe('deriveLanguageEntry()', () => { const stable = { status: 'stable' }; diff --git a/tests/unit/register-write.test.ts b/tests/unit/register-write.test.ts index 4a1367f9..95b3f9b0 100644 --- a/tests/unit/register-write.test.ts +++ b/tests/unit/register-write.test.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; +import { WRITE_TARGET_LANGUAGES } from '../../src/data/language-entries'; jest.mock('chalk', () => { const passthrough = (s: string) => s; @@ -195,6 +196,35 @@ describe('registerWrite', () => { ); }); + it('should reject a well-formed code the Write API does not support', async () => { + // Deliberately stricter than `translate --to`, which passes well-formed + // unknown codes to the API: the Write set is small enough to enumerate, + // so naming the valid options beats a round trip. + await program.parseAsync(['node', 'test', 'write', 'Hello', '--lang', 'hi']); + expect(handleError).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('Invalid language code') }), + ); + expect(mockWriteCommand.improve).not.toHaveBeenCalled(); + }); + + it('should enumerate every supported language in the rejection', async () => { + await program.parseAsync(['node', 'test', 'write', 'Hello', '--lang', 'xx']); + const error = handleError.mock.calls[0]?.[0] as Error; + for (const code of WRITE_TARGET_LANGUAGES) { + expect(error.message).toContain(code); + } + }); + + it('should accept every language in the generated list', async () => { + for (const code of WRITE_TARGET_LANGUAGES) { + jest.clearAllMocks(); + mockCreateWriteCommand.mockResolvedValue(mockWriteCommand); + mockWriteCommand.improve.mockResolvedValue('ok'); + await program.parseAsync(['node', 'test', 'write', 'Hello', '--lang', code]); + expect(handleError).not.toHaveBeenCalled(); + } + }); + it.each(['ja', 'ko', 'zh', 'zh-hans'])('should accept new target language %s', async (lang) => { mockCreateWriteCommand.mockResolvedValue(mockWriteCommand); mockWriteCommand.improve.mockResolvedValue('ok'); From 2211352b11130ad07b6c4b33fb0e44e4a4fa1742 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 14:31:43 -0400 Subject: [PATCH 027/256] feat(translate): pin tag_handling_version to v2 instead of the server default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version was sent only when --tag-handling-version was passed, so every other tag-handling request inherited the API's default. That default is documented as moving from v1 to v2, which would have shifted output with no CLI change to attribute it to — and would have gone undetected by the cache, since a request omitting the version hashes identically either side of the flip. One resolver feeds both the wire parameter and cache key field 10, so they cannot disagree. An explicitly named version always wins, including v1; requests without tag handling send nothing and keep their keys. --- CHANGELOG.md | 2 ++ README.md | 4 +-- docs/API.md | 2 +- src/api/translation-client.ts | 6 ++-- src/services/translation.ts | 8 +++++- src/utils/tag-handling-version.ts | 38 +++++++++++++++++++++++++ tests/unit/deepl-client.test.ts | 37 ++++++++++++++++++++++++ tests/unit/tag-handling-version.test.ts | 35 +++++++++++++++++++++++ tests/unit/translation-service.test.ts | 33 +++++++++++++++++++++ 9 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 src/utils/tag-handling-version.ts create mode 100644 tests/unit/tag-handling-version.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8da717e0..79543c8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **translate**: **`--tag-handling` requests now pin `tag_handling_version=v2`** instead of letting the API pick. The CLI previously sent the parameter only when `--tag-handling-version` was passed, so everyone else inherited the server default — which DeepL documents as moving from v1 (deprecation-bound) to v2 at an unannounced date. That flip would have changed tag-handling output with no CLI change to point at, and worse, would have gone unnoticed by the cache: a request that omits the version hashes identically before and after, so cached v1 output would have kept being served after the API started returning v2. Adopting v2 now makes that one output shift deliberate and dated, and DeepL's own docs recommend v2 for structure handling. **`--tag-handling xml`/`html` output may differ from previous releases**; pass `--tag-handling-version v1` to keep the old behaviour, which is still honoured and always wins over the default. Requests without `--tag-handling` send no version and keep their existing cache keys; tag-handling entries cached by earlier versions now miss rather than being served stale, so the first such translation after upgrading is refetched. + - **translate**: **A glossary referenced by name is checked against the requested language pair before any translation request.** Previously only translation memories did this; a glossary whose dictionaries did not cover the pair reached the API and came back as `No dictionary found for language pair EN-DE in glossary `, naming a UUID the user never typed. It now fails locally, exit 7, naming what the glossary actually covers: `Glossary "my-terms" does not support the requested language pair` / `Glossary covers en→es; requested en→de.` This costs no extra request, because the glossary list is already fetched to resolve the name. Matching is per dictionary, so a multilingual glossary holding en→es and de→fr is not treated as covering en→fr, and when translating to several targets at once every one of them must be covered. Two deliberate exemptions: a glossary passed as a **UUID** is trusted and left to the API, matching how translation-memory resolution already behaves and giving an escape hatch if the check is ever wrong; and a glossary the API reports with no dictionaries is left alone, since that says nothing about coverage. - **write**: **The Write API's 14 target languages are generated rather than hand-maintained**, closing the last hand-kept language list. The list and the `WriteLanguage` type were two separate hand-edited copies of the same set, either of which could fall behind `GET /v3/languages?resource=write` — the same way the translation list silently went four codes stale. `npm run generate:languages` now emits both language lists and `npm run check:languages` reports which one drifted; the type is derived from the generated list, so a language added upstream widens it on regenerate instead of needing a second edit. No behaviour change: the generated list is byte-identical to what was there, and `write`/`correct` still **reject** a code outside it locally while naming every valid option, deliberately unlike `translate --to`, which passes well-formed unknown codes to the API. At 14 of 125 languages an enumerated error beats a round trip. Note the documented style/tone support table is unchanged and still maintained by hand, because it records what the API accepts rather than what its metadata claims: `resource=write` omits `writing_style` for `en`, but `--style` with `--lang en` works. diff --git a/README.md b/README.md index 44207f30..1e1b61ea 100644 --- a/README.md +++ b/README.md @@ -541,8 +541,8 @@ deepl translate "Cost analysis" --to es,fr,de --format table --show-billed-chara # Preview what would be translated without making API calls (file/directory mode) deepl translate ./docs --to es --dry-run -# Specify tag handling version (v2 improves structure handling, requires --tag-handling) -deepl translate page.html --to es --tag-handling html --tag-handling-version v2 +# Tag handling pins v2 (better structure handling); opt back into v1 explicitly +deepl translate page.html --to es --tag-handling html --tag-handling-version v1 # Advanced XML/HTML tag handling (requires --tag-handling xml) # Control automatic XML structure detection diff --git a/docs/API.md b/docs/API.md index 21240ffa..f4992bac 100644 --- a/docs/API.md +++ b/docs/API.md @@ -250,7 +250,7 @@ Translate text directly, from stdin, from files, or entire directories. Supports - `--splitting-tags TAGS` - Comma-separated XML tags that split sentences (requires `--tag-handling xml`) - `--non-splitting-tags TAGS` - Comma-separated XML tags that should not be used to split sentences (requires `--tag-handling xml`) - `--ignore-tags TAGS` - Comma-separated XML tags with content to ignore (requires `--tag-handling xml`) -- `--tag-handling-version VERSION` - Tag handling version: `v1`, `v2`. v2 improves XML/HTML structure handling (requires `--tag-handling`) +- `--tag-handling-version VERSION` - Tag handling version: `v1`, `v2`. v2 improves XML/HTML structure handling (requires `--tag-handling`). **Defaults to `v2`**, sent explicitly on every `--tag-handling` request rather than left to the API's own default, which is documented as moving from v1 to v2 at some point — pinning keeps output from shifting on DeepL's timetable. Pass `--tag-handling-version v1` for the older behaviour, which DeepL documents as heading for deprecation - `--glossary NAME-OR-ID` - Use glossary by name or ID for consistent terminology. Repeatable, up to 5 per request; when several glossaries define the same source term, the last one given wins. Passing a 6th exits 6 (ValidationError). - `--translation-memory NAME-OR-UUID` - Use translation memory by name or UUID (forces `quality_optimized` model). Requires `--from` because TMs are pinned to a specific source→target language pair. Invalid use exits 6 (ValidationError); unresolvable/misconfigured TM exits 7 (ConfigError). - `--tm-threshold N` - Minimum match score 0–100 (default 75, requires `--translation-memory`). Invalid use exits 6 (ValidationError); unresolvable/misconfigured TM exits 7 (ConfigError). diff --git a/src/api/translation-client.ts b/src/api/translation-client.ts index 67246e4f..0588491f 100644 --- a/src/api/translation-client.ts +++ b/src/api/translation-client.ts @@ -3,6 +3,7 @@ import { TranslationOptions, Language, TranslationMemory } from '../types/index. import { NetworkError } from '../utils/errors.js'; import { normalizeFormality } from '../utils/formality.js'; import { resolveGlossaryWireParams } from '../utils/glossary-params.js'; +import { resolveTagHandlingVersion } from '../utils/tag-handling-version.js'; import { Logger } from '../utils/logger.js'; // DeepL's /v3/translation_memories endpoint paginates via `page` (0-indexed) and @@ -391,8 +392,9 @@ export class TranslationClient extends HttpClient { params['style_id'] = options.styleId; } - if (options.tagHandlingVersion) { - params['tag_handling_version'] = options.tagHandlingVersion; + const tagHandlingVersion = resolveTagHandlingVersion(options); + if (tagHandlingVersion) { + params['tag_handling_version'] = tagHandlingVersion; } diff --git a/src/services/translation.ts b/src/services/translation.ts index af8be411..70f7e905 100644 --- a/src/services/translation.ts +++ b/src/services/translation.ts @@ -13,6 +13,7 @@ import { mapWithConcurrency, MULTI_TARGET_CONCURRENCY } from '../utils/concurren import { ValidationError } from '../utils/errors.js'; import { errorMessage } from '../utils/error-message.js'; import { resolveGlossaryWireParams } from '../utils/glossary-params.js'; +import { resolveTagHandlingVersion } from '../utils/tag-handling-version.js'; import { preserveCodeBlocks, preserveVariables, restorePlaceholders } from '../utils/text-preservation.js'; export { MULTI_TARGET_CONCURRENCY }; @@ -396,6 +397,11 @@ export class TranslationService { * not using them stay unchanged. `glossaryIds` is hashed in the caller's * order rather than sorted, because reordering the list changes which * glossary wins a conflicting term and therefore the translation itself. + * + * `tagHandlingVersion` is resolved rather than read straight off the options, + * so it holds the version the request will actually carry. Leaving it unset + * would let the key stay stable across a change of the API's own default, + * serving entries the API would no longer produce. */ private generateCacheKey(text: string, options: TranslationOptions): string { // Keyed on the parameter the request will actually carry, so the two ways of @@ -414,7 +420,7 @@ export class TranslationService { modelType: options.modelType, // 7. Model type affects output quality splitSentences: options.splitSentences, // 8. Sentence splitting behavior tagHandling: options.tagHandling, // 9. HTML/XML processing - tagHandlingVersion: options.tagHandlingVersion, // 10. Tag handling version + tagHandlingVersion: resolveTagHandlingVersion(options), // 10. Tag handling version customInstructions: options.customInstructions, // 11. Custom instructions styleId: options.styleId, // 12. Style rules glossaryIds: glossary && 'glossary_ids' in glossary ? glossary.glossary_ids : undefined, // 13. Multi-glossary selection (order-significant) diff --git a/src/utils/tag-handling-version.ts b/src/utils/tag-handling-version.ts new file mode 100644 index 00000000..4b60d089 --- /dev/null +++ b/src/utils/tag-handling-version.ts @@ -0,0 +1,38 @@ +/** + * The tag handling version the CLI pins when a tag-handling request does not + * name one. v2 is the version the API will eventually default to; v1, today's + * server-side default, is documented as heading for deprecation. + */ +export const DEFAULT_TAG_HANDLING_VERSION = 'v2'; + +export interface TagHandlingSelection { + tagHandling?: 'xml' | 'html'; + tagHandlingVersion?: 'v1' | 'v2'; +} + +/** + * Pick the `tag_handling_version` to send for a tag-handling request. + * + * The version is always sent explicitly once tag handling is on, rather than + * left to the server default. That default is scheduled to move from v1 to v2, + * which would change translation output on DeepL's timetable instead of ours — + * and would silently invalidate cached entries, because a request that omits + * the version hashes the same before and after the flip. + * + * A version the caller named always wins, including v1, so pinning stays an + * escape hatch rather than a lock-in. Requests without tag handling carry no + * version and keep the cache keys they had. + */ +export function resolveTagHandlingVersion( + selection: TagHandlingSelection, +): 'v1' | 'v2' | undefined { + if (selection.tagHandlingVersion) { + return selection.tagHandlingVersion; + } + + if (selection.tagHandling) { + return DEFAULT_TAG_HANDLING_VERSION; + } + + return undefined; +} diff --git a/tests/unit/deepl-client.test.ts b/tests/unit/deepl-client.test.ts index 044a0fb5..4d28befa 100644 --- a/tests/unit/deepl-client.test.ts +++ b/tests/unit/deepl-client.test.ts @@ -2146,6 +2146,43 @@ describe('DeepLClient', () => { expect(scope.isDone()).toBe(true); }); + + it('should pin tag_handling_version to v2 when tag_handling is set without a version', async () => { + const scope = nock(baseUrl) + .post('/v2/translate', (body: string) => { + const params = new URLSearchParams(body); + return params.get('tag_handling_version') === 'v2'; + }) + .reply(200, { + translations: [{ text: '

Hola

', detected_source_language: 'EN' }], + }); + + await client.translate('

Hello

', { + targetLang: 'es', + tagHandling: 'html', + }); + + expect(scope.isDone()).toBe(true); + }); + + it('should send an explicit v1 rather than the pinned default', async () => { + const scope = nock(baseUrl) + .post('/v2/translate', (body: string) => { + const params = new URLSearchParams(body); + return params.get('tag_handling_version') === 'v1'; + }) + .reply(200, { + translations: [{ text: '

Hola

', detected_source_language: 'EN' }], + }); + + await client.translate('

Hello

', { + targetLang: 'es', + tagHandling: 'xml', + tagHandlingVersion: 'v1', + }); + + expect(scope.isDone()).toBe(true); + }); }); describe('Admin API', () => { diff --git a/tests/unit/tag-handling-version.test.ts b/tests/unit/tag-handling-version.test.ts new file mode 100644 index 00000000..fe5a79b3 --- /dev/null +++ b/tests/unit/tag-handling-version.test.ts @@ -0,0 +1,35 @@ +/** + * Tests for tag_handling_version pinning + */ + +import { + resolveTagHandlingVersion, + DEFAULT_TAG_HANDLING_VERSION, +} from '../../src/utils/tag-handling-version.js'; + +describe('resolveTagHandlingVersion', () => { + it('should pin v2 as the default', () => { + expect(DEFAULT_TAG_HANDLING_VERSION).toBe('v2'); + }); + + it('should return undefined when tag handling is off', () => { + expect(resolveTagHandlingVersion({})).toBeUndefined(); + }); + + it('should pin the default version when tag handling is on without a version', () => { + expect(resolveTagHandlingVersion({ tagHandling: 'xml' })).toBe('v2'); + expect(resolveTagHandlingVersion({ tagHandling: 'html' })).toBe('v2'); + }); + + it('should honour an explicit v1 over the pinned default', () => { + expect(resolveTagHandlingVersion({ tagHandling: 'xml', tagHandlingVersion: 'v1' })).toBe('v1'); + }); + + it('should honour an explicit v2', () => { + expect(resolveTagHandlingVersion({ tagHandling: 'html', tagHandlingVersion: 'v2' })).toBe('v2'); + }); + + it('should keep an explicit version that arrived without tag handling', () => { + expect(resolveTagHandlingVersion({ tagHandlingVersion: 'v1' })).toBe('v1'); + }); +}); diff --git a/tests/unit/translation-service.test.ts b/tests/unit/translation-service.test.ts index c80340bd..3d334989 100644 --- a/tests/unit/translation-service.test.ts +++ b/tests/unit/translation-service.test.ts @@ -1207,6 +1207,39 @@ describe('TranslationService', () => { }); }); + describe('tag handling version cache keys', () => { + const keysFor = async ( + optionSets: Array>, + ): Promise => { + mockCacheService.get.mockReturnValue(null); + mockDeepLClient.translate.mockResolvedValue({ text: 'Hola' }); + mockCacheService.set.mockClear(); + + for (const options of optionSets) { + await translationService.translate('Hello', { targetLang: 'es', ...options } as any); + } + + return mockCacheService.set.mock.calls.map((call) => call[0]); + }; + + /** Pinning makes these the same request, so they must share an entry. */ + it('should key a bare tag-handling request the same as an explicit v2', async () => { + const [pinned, explicit] = await keysFor([ + { tagHandling: 'xml' }, + { tagHandling: 'xml', tagHandlingVersion: 'v2' }, + ]); + expect(pinned).toBe(explicit); + }); + + it('should separate an explicit v1 from the pinned default', async () => { + const [v1, pinned] = await keysFor([ + { tagHandling: 'xml', tagHandlingVersion: 'v1' }, + { tagHandling: 'xml' }, + ]); + expect(v1).not.toBe(pinned); + }); + }); + it('should use cached result when options are provided in different order', async () => { // Set up cache to return a hit for the second call let callCount = 0; From 7bbd58b1254b085de84402eb84a0f702b000477c Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 17:08:41 -0400 Subject: [PATCH 028/256] fix(glossary): treat a base-language glossary as covering regional targets The language-pair preflight compared the requested target verbatim against the glossary's dictionaries, but dictionaries only ever name base languages while --to accepts en-us, en-gb, pt-br and the rest. A de->en glossary therefore failed locally for --to en-us, which the API accepts, making glossaries unusable for the regional variants DeepL steers users towards. Both sides now compare on their base language, so the check stays a nicer error for pairs a glossary genuinely lacks without blocking valid work. --- src/data/language-registry.ts | 10 +++++++++ src/services/glossary.ts | 11 +++++++-- tests/unit/glossary-service.test.ts | 35 +++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/data/language-registry.ts b/src/data/language-registry.ts index 3f2624dc..f5c5eb16 100644 --- a/src/data/language-registry.ts +++ b/src/data/language-registry.ts @@ -91,6 +91,16 @@ export function looksLikeLanguageTag(code: string): boolean { return LANGUAGE_TAG.test(code); } +/** + * The base language of a code, dropping any regional subtag: `en-us` -> `en`. + * Used where one side of a comparison carries variants the other cannot, such as + * glossary dictionaries, which only ever name base languages while `--to` + * accepts `en-us`, `pt-br` and the rest. + */ +export function baseLanguage(code: string): string { + return code.toLowerCase().split('-')[0] ?? code.toLowerCase(); +} + /** Check whether a language code is recognized by the registry. */ export function isValidLanguage(code: string): boolean { return LANGUAGE_REGISTRY.has(code); diff --git a/src/services/glossary.ts b/src/services/glossary.ts index 135f8bae..9b7e5575 100644 --- a/src/services/glossary.ts +++ b/src/services/glossary.ts @@ -6,6 +6,7 @@ import { DeepLClient } from '../api/deepl-client.js'; import { GlossaryInfo, GlossaryLanguagePair, Language, isMultilingual } from '../types/index.js'; import { Logger } from '../utils/logger.js'; +import { baseLanguage } from '../data/language-registry.js'; import { ValidationError, ConfigError } from '../utils/errors.js'; function sanitizeForError(input: string): string { @@ -168,6 +169,12 @@ export class GlossaryService { * read as also covering en→fr. Free on this path because the list is already * fetched to resolve the name; the UUID path trusts the caller and skips the * check, as translation-memory resolution does. + * + * Both sides are compared on their base language, because dictionaries only + * ever name base languages while `--to` accepts regional variants: a de→en + * glossary has to count as covering de→en-us, which the API accepts. That + * makes the check deliberately permissive at the edges — a pair it lets + * through is still the API's to reject, which is the cheaper mistake. */ async resolveGlossaryId( nameOrId: string, @@ -205,8 +212,8 @@ export class GlossaryService { const covered = (target: string): boolean => match.dictionaries.some( d => - d.source_lang.toLowerCase() === from && - d.target_lang.toLowerCase() === target.toLowerCase(), + baseLanguage(d.source_lang) === baseLanguage(from) && + baseLanguage(d.target_lang) === baseLanguage(target), ); const missing = expected.targets.filter(target => !covered(target)); if (missing.length > 0) { diff --git a/tests/unit/glossary-service.test.ts b/tests/unit/glossary-service.test.ts index 73ad70ac..37783236 100644 --- a/tests/unit/glossary-service.test.ts +++ b/tests/unit/glossary-service.test.ts @@ -417,6 +417,41 @@ describe('GlossaryService', () => { ).rejects.toThrow('does not support the requested language pair'); }); + it('should treat a base-language dictionary as covering its regional variants', async () => { + // Glossary dictionaries carry base codes, while --to accepts regional + // variants, so de→en has to cover de→en-us or glossaries become + // unusable for the variants DeepL steers users towards. + mockDeepLClient.listGlossaries.mockResolvedValue( + listing([{ source_lang: 'de', target_lang: 'en' }]) as never, + ); + + for (const target of ['en-us', 'en-gb'] as const) { + await expect( + glossaryService.resolveGlossaryId('tech-terms', { from: 'de', targets: [target] }), + ).resolves.toBe('found-glossary-id'); + } + }); + + it('should treat a regional --from as covered by a base-language dictionary', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue( + listing([{ source_lang: 'pt', target_lang: 'de' }]) as never, + ); + + await expect( + glossaryService.resolveGlossaryId('tech-terms', { from: 'pt-br', targets: ['de'] }), + ).resolves.toBe('found-glossary-id'); + }); + + it('should still reject a pair no dictionary covers even across regions', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue( + listing([{ source_lang: 'de', target_lang: 'en' }]) as never, + ); + + await expect( + glossaryService.resolveGlossaryId('tech-terms', { from: 'de', targets: ['pt-br'] }), + ).rejects.toThrow('does not support the requested language pair'); + }); + it('should compare languages case-insensitively', async () => { mockDeepLClient.listGlossaries.mockResolvedValue( listing([{ source_lang: 'EN', target_lang: 'ES' }]) as never, From 5245ee5e053078ac1948d921ec414b7bd4f3f47e Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 17:08:50 -0400 Subject: [PATCH 029/256] fix(translate): honour --tag-handling-version outside text mode buildTranslationOptions mapped tagHandling but not tagHandlingVersion, so only the text handler carried the flag. That was harmless while no version was sent, but since the CLI pins v2 whenever tag handling is on, a file or directory translation asked for v1 silently got v2 -- with no warning, because single-file mode does not run warnIgnoredOptions. The flag is now mapped in the shared base mapping every handler uses, and its validation moves with it so both paths reject the same input. --- src/cli/commands/translate/translate-utils.ts | 20 ++++++++++++++++++ tests/unit/translate-utils.test.ts | 21 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/cli/commands/translate/translate-utils.ts b/src/cli/commands/translate/translate-utils.ts index 654b05ef..b387c420 100644 --- a/src/cli/commands/translate/translate-utils.ts +++ b/src/cli/commands/translate/translate-utils.ts @@ -117,6 +117,24 @@ export function validateXmlTags(tags: string[], paramName: string): void { } } +/** + * Validate `--tag-handling-version` and return it. Shared so every handler maps + * the flag: since the CLI pins v2 whenever tag handling is on, a handler that + * dropped the flag would silently send v2 to a caller who asked for v1. + */ +export function validateTagHandlingVersion( + options: TranslateOptions, +): TranslationParams['tagHandlingVersion'] { + if (!options.tagHandlingVersion) return undefined; + if (!options.tagHandling) { + throw new ValidationError('--tag-handling-version requires --tag-handling to be set (xml or html)'); + } + if (options.tagHandlingVersion !== 'v1' && options.tagHandlingVersion !== 'v2') { + throw new ValidationError('--tag-handling-version must be "v1" or "v2"'); + } + return options.tagHandlingVersion; +} + export function buildTranslationOptions(options: TranslateOptions): TranslationParams { const result: TranslationParams = { targetLang: options.to as Language, @@ -127,6 +145,8 @@ export function buildTranslationOptions(options: TranslateOptions): TranslationP if (options.context) result.context = options.context; if (options.splitSentences) result.splitSentences = options.splitSentences as TranslationParams['splitSentences']; if (options.tagHandling) result.tagHandling = options.tagHandling as TranslationParams['tagHandling']; + const tagHandlingVersion = validateTagHandlingVersion(options); + if (tagHandlingVersion) result.tagHandlingVersion = tagHandlingVersion; if (options.modelType) result.modelType = options.modelType as TranslationParams['modelType']; if (options.preserveFormatting !== undefined) result.preserveFormatting = options.preserveFormatting; if (options.showBilledCharacters) result.showBilledCharacters = true; diff --git a/tests/unit/translate-utils.test.ts b/tests/unit/translate-utils.test.ts index bb97d42b..72458fbf 100644 --- a/tests/unit/translate-utils.test.ts +++ b/tests/unit/translate-utils.test.ts @@ -430,6 +430,27 @@ describe('translate-utils', () => { expect(result.modelType).toBe('quality_optimized'); }); + it('should map tagHandlingVersion so file and directory mode honour it too', () => { + const result = buildTranslationOptions({ + to: 'de', + tagHandling: 'html', + tagHandlingVersion: 'v1', + }); + expect(result.tagHandlingVersion).toBe('v1'); + }); + + it('should reject tagHandlingVersion without tagHandling', () => { + expect(() => buildTranslationOptions({ to: 'de', tagHandlingVersion: 'v2' })).toThrow( + '--tag-handling-version requires --tag-handling', + ); + }); + + it('should reject a tagHandlingVersion that is neither v1 nor v2', () => { + expect(() => + buildTranslationOptions({ to: 'de', tagHandling: 'xml', tagHandlingVersion: 'v3' }), + ).toThrow('--tag-handling-version must be "v1" or "v2"'); + }); + it('should map preserveFormatting when explicitly set', () => { const result = buildTranslationOptions({ to: 'de', preserveFormatting: true }); expect(result.preserveFormatting).toBe(true); From 34b947b8070f6516241c0047dc5ec89aaf3bc776 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 17:09:01 -0400 Subject: [PATCH 030/256] fix(glossary): resolve the source language from config before requiring --from TranslationService merges defaults.sourceLang, so a glossary request supplies source_lang whether or not --from was typed. Rejecting on a missing flag alone broke watch sessions and translations that had been working from config. The guard now resolves the effective source language onto `from`, which also gives the document path -- which merges no defaults of its own -- and the glossary preflight the pair they need. Two related holes close with it: - `--glossary` was tested for bare truthiness, and `[]` is truthy, so a caller passing an empty selection got a spurious error - watch --dry-run returned before the guard, reporting a command as runnable that fails on the first file change Watch also passes the pair to resolveGlossaryId now, so a glossary that does not cover it fails at launch rather than once per file change. The text handler's duplicate tag-handling-version block goes with the shared mapping. --- src/cli/commands/register-watch.ts | 11 ++++ .../translate/document-translation-handler.ts | 12 ++--- .../translate/file-translation-handler.ts | 23 ++++---- .../translate/text-translation-handler.ts | 34 ++++-------- src/cli/commands/watch.ts | 15 ++++-- src/utils/glossary-params.ts | 38 ++++++++++++++ tests/unit/glossary-params.test.ts | 52 +++++++++++++++++++ tests/unit/watch-command.test.ts | 7 ++- 8 files changed, 146 insertions(+), 46 deletions(-) diff --git a/src/cli/commands/register-watch.ts b/src/cli/commands/register-watch.ts index 5c0b08b5..406a50f0 100644 --- a/src/cli/commands/register-watch.ts +++ b/src/cli/commands/register-watch.ts @@ -4,6 +4,7 @@ import chalk from 'chalk'; import { Logger } from '../../utils/logger.js'; import { ValidationError } from '../../utils/errors.js'; import { createWatchCommand, type ServiceDeps } from './service-factory.js'; +import { applyGlossarySourceLang, hasGlossarySelection } from '../../utils/glossary-params.js'; export function registerWatch( program: Command, @@ -67,6 +68,16 @@ Examples: } } + // Resolved before --dry-run so a well-formed-looking command is not + // reported as runnable when it would fail on the first file change. + if (hasGlossarySelection(options)) { + applyGlossarySourceLang( + options, + deps.getConfigService().getValue('defaults.sourceLang'), + 'Example: deepl watch ./docs --from en --to es --glossary my-glossary', + ); + } + if (options.dryRun) { const targetLangs = options.to.split(',').map(l => l.trim()).filter(l => l.length > 0); const isDirectory = fs.existsSync(watchPath) && fs.statSync(watchPath).isDirectory(); diff --git a/src/cli/commands/translate/document-translation-handler.ts b/src/cli/commands/translate/document-translation-handler.ts index fa6be7b0..c14fe062 100644 --- a/src/cli/commands/translate/document-translation-handler.ts +++ b/src/cli/commands/translate/document-translation-handler.ts @@ -6,6 +6,7 @@ import type { DocumentTranslationOptions } from '../../../types/api.js'; import type { HandlerContext, TranslateOptions } from './types.js'; import { warnIgnoredOptions, validateLanguageCodes } from './translate-utils.js'; import { buildBaseTranslationOptions, applyGlossarySelection } from './translation-options-factory.js'; +import { applyGlossarySourceLang } from '../../../utils/glossary-params.js'; export class DocumentTranslationHandler { constructor(public ctx: HandlerContext) {} @@ -24,12 +25,11 @@ export class DocumentTranslationHandler { // The API rejects a document glossary without source_lang: "source_lang has // to be specified in order to use a glossary." - if (options.glossary && options.glossary.length > 0 && !options.from) { - throw new ValidationError( - 'Source language (--from) is required when using a glossary', - 'Example: deepl translate --from en --to es --glossary my-glossary report.pdf --output report.es.pdf' - ); - } + applyGlossarySourceLang( + options, + this.ctx.config.getValue('defaults.sourceLang'), + 'Example: deepl translate --from en --to es --glossary my-glossary report.pdf --output report.es.pdf' + ); const outputPath = options.output!; diff --git a/src/cli/commands/translate/file-translation-handler.ts b/src/cli/commands/translate/file-translation-handler.ts index e12aae17..64d4a885 100644 --- a/src/cli/commands/translate/file-translation-handler.ts +++ b/src/cli/commands/translate/file-translation-handler.ts @@ -14,6 +14,7 @@ import { SAFE_TEXT_SIZE_LIMIT, } from './translate-utils.js'; import { buildBaseTranslationOptions, applySharedTmAndGlossary } from './translation-options-factory.js'; +import { applyGlossarySourceLang } from '../../../utils/glossary-params.js'; import type { DocumentTranslationHandler } from './document-translation-handler.js'; export class FileTranslationHandler { @@ -36,12 +37,11 @@ export class FileTranslationHandler { const validTargetLangs = targetLangs as Language[]; - if (options.glossary && !options.from) { - throw new ValidationError( - 'Source language (--from) is required when using a glossary', - 'Example: deepl translate --from en --to en,fr,es --glossary my-glossary file.txt' - ); - } + applyGlossarySourceLang( + options, + this.ctx.config.getValue('defaults.sourceLang'), + 'Example: deepl translate --from en --to en,fr,es --glossary my-glossary file.txt' + ); if (options.translationMemory) { if (!options.from) { @@ -123,12 +123,11 @@ export class FileTranslationHandler { async translateTextFile(filePath: string, options: TranslateOptions): Promise { validateLanguageCodes([options.to]); - if (options.glossary && !options.from) { - throw new ValidationError( - 'Source language (--from) is required when using a glossary', - 'Example: deepl translate --from en --to es --glossary my-glossary file.txt' - ); - } + applyGlossarySourceLang( + options, + this.ctx.config.getValue('defaults.sourceLang'), + 'Example: deepl translate --from en --to es --glossary my-glossary file.txt' + ); if (options.translationMemory) { if (!options.from) { diff --git a/src/cli/commands/translate/text-translation-handler.ts b/src/cli/commands/translate/text-translation-handler.ts index 0c36e9c1..d2cf8f61 100644 --- a/src/cli/commands/translate/text-translation-handler.ts +++ b/src/cli/commands/translate/text-translation-handler.ts @@ -12,6 +12,7 @@ import { MAX_CUSTOM_INSTRUCTION_CHARS, } from './translate-utils.js'; import { buildBaseTranslationOptions, applySharedTmAndGlossary } from './translation-options-factory.js'; +import { applyGlossarySourceLang } from '../../../utils/glossary-params.js'; export class TextTranslationHandler { constructor(public ctx: HandlerContext) {} @@ -44,12 +45,11 @@ export class TextTranslationHandler { validateLanguageCodes([options.to]); validateExtendedLanguageConstraints(options.to, options); - if (options.glossary && !options.from) { - throw new ValidationError( - 'Source language (--from) is required when using a glossary', - 'Example: deepl translate --from en --to es --glossary my-glossary "Hello"' - ); - } + applyGlossarySourceLang( + options, + this.ctx.config.getValue('defaults.sourceLang'), + 'Example: deepl translate --from en --to es --glossary my-glossary "Hello"' + ); if (options.translationMemory) { if (!options.from) { @@ -127,17 +127,6 @@ export class TextTranslationHandler { translationOptions.ignoreTags = tags; } - if (options.tagHandlingVersion) { - if (!options.tagHandling) { - throw new ValidationError('--tag-handling-version requires --tag-handling to be set (xml or html)'); - } - if (options.tagHandlingVersion !== 'v1' && options.tagHandlingVersion !== 'v2') { - throw new ValidationError('--tag-handling-version must be "v1" or "v2"'); - } - translationOptions.tagHandlingVersion = options.tagHandlingVersion; - } - - const result = await this.ctx.translationService.translate( text, translationOptions, @@ -181,12 +170,11 @@ export class TextTranslationHandler { validateLanguageCodes(targetLangs); validateExtendedLanguageConstraints(options.to, options); - if (options.glossary && !options.from) { - throw new ValidationError( - 'Source language (--from) is required when using a glossary', - 'Example: deepl translate --from en --to es --glossary my-glossary "Hello"' - ); - } + applyGlossarySourceLang( + options, + this.ctx.config.getValue('defaults.sourceLang'), + 'Example: deepl translate --from en --to es --glossary my-glossary "Hello"' + ); if (options.translationMemory) { if (!options.from) { diff --git a/src/cli/commands/watch.ts b/src/cli/commands/watch.ts index dc37d1c5..e3c03cfa 100644 --- a/src/cli/commands/watch.ts +++ b/src/cli/commands/watch.ts @@ -40,8 +40,11 @@ export class WatchCommand { this.glossaryService = glossaryService; } - private async resolveGlossaryId(nameOrId: string): Promise { - return this.glossaryService.resolveGlossaryId(nameOrId); + private async resolveGlossaryId( + nameOrId: string, + expected?: { from: Language; targets: Language[] }, + ): Promise { + return this.glossaryService.resolveGlossaryId(nameOrId, expected); } /** @@ -104,10 +107,14 @@ export class WatchCommand { Logger.info(chalk.gray(`Git-staged files: ${stagedFiles.size}`)); } - // Resolve glossary ID if provided + // Resolve glossary ID if provided. The pair is known at launch, so the + // coverage check happens here rather than once per file change. let glossaryId: string | undefined; if (options.glossary) { - glossaryId = await this.resolveGlossaryId(options.glossary); + glossaryId = await this.resolveGlossaryId( + options.glossary, + options.from ? { from: options.from as Language, targets: targetLangs } : undefined, + ); } // Determine output directory diff --git a/src/utils/glossary-params.ts b/src/utils/glossary-params.ts index ff86e062..1046dec1 100644 --- a/src/utils/glossary-params.ts +++ b/src/utils/glossary-params.ts @@ -8,6 +8,44 @@ export interface GlossarySelection { glossaryIds?: string[]; } +/** A command's `--glossary`/`--from` pair, however that command spells them. */ +export interface GlossarySourceLangSelection { + glossary?: string | string[]; + from?: string; +} + +/** Whether `--glossary` names anything; `[]` is truthy but selects nothing. */ +export function hasGlossarySelection(selection: GlossarySourceLangSelection): boolean { + const { glossary } = selection; + return Array.isArray(glossary) ? glossary.length > 0 : !!glossary; +} + +/** + * Settle the source language a glossary request will carry, filling `from` from + * the configured default when the flag is absent. + * + * The API rejects a glossary without `source_lang`, but `--from` is not the only + * way one is supplied: `TranslationService` merges `defaults.sourceLang`, so + * rejecting on a missing flag alone broke sessions that had been working from + * config. Resolving it onto `from` instead means every path sees the same + * answer, including the document path, which merges no defaults of its own, and + * the glossary preflight, which needs the pair to check coverage. + */ +export function applyGlossarySourceLang( + selection: GlossarySourceLangSelection, + configuredSourceLang: string | undefined, + example: string, +): void { + if (!hasGlossarySelection(selection) || selection.from) { + return; + } + if (configuredSourceLang) { + selection.from = configuredSourceLang.toLowerCase(); + return; + } + throw new ValidationError('Source language (--from) is required when using a glossary', example); +} + export type GlossaryWireParams = | { glossary_id: string } | { glossary_ids: string[] }; diff --git a/tests/unit/glossary-params.test.ts b/tests/unit/glossary-params.test.ts index 5b7532d8..c2852b40 100644 --- a/tests/unit/glossary-params.test.ts +++ b/tests/unit/glossary-params.test.ts @@ -5,6 +5,8 @@ import { resolveGlossaryWireParams, encodeGlossaryIdsForMultipart, + applyGlossarySourceLang, + hasGlossarySelection, MAX_GLOSSARIES_PER_REQUEST, } from '../../src/utils/glossary-params.js'; import { ValidationError } from '../../src/utils/errors.js'; @@ -86,3 +88,53 @@ describe('encodeGlossaryIdsForMultipart', () => { expect(encodeGlossaryIdsForMultipart([B, A])).toBe(`${B},${A}`); }); }); + +describe('hasGlossarySelection', () => { + it('should treat an empty array as selecting nothing, unlike bare truthiness', () => { + expect(hasGlossarySelection({ glossary: [] })).toBe(false); + }); + + it('should recognize a string and a non-empty array', () => { + expect(hasGlossarySelection({ glossary: 'terms' })).toBe(true); + expect(hasGlossarySelection({ glossary: ['terms'] })).toBe(true); + }); +}); + +describe('applyGlossarySourceLang', () => { + const example = 'Example: deepl translate --from en --to es --glossary g "Hello"'; + + it('should leave an explicit --from alone', () => { + const options = { glossary: ['terms'], from: 'de' }; + applyGlossarySourceLang(options, 'en', example); + expect(options.from).toBe('de'); + }); + + it('should fall back to the configured source language', () => { + // The request carries source_lang either way, so rejecting on a missing + // flag alone broke sessions that had been working from config. + const options: { glossary: string[]; from?: string } = { glossary: ['terms'] }; + applyGlossarySourceLang(options, 'EN', example); + expect(options.from).toBe('en'); + }); + + it('should throw only when neither the flag nor the config supplies one', () => { + expect(() => applyGlossarySourceLang({ glossary: ['terms'] }, undefined, example)).toThrow( + ValidationError, + ); + expect(() => applyGlossarySourceLang({ glossary: ['terms'] }, undefined, example)).toThrow( + 'Source language (--from) is required when using a glossary', + ); + }); + + it('should do nothing when no glossary is selected', () => { + const options: { glossary?: string[]; from?: string } = { glossary: [] }; + expect(() => applyGlossarySourceLang(options, undefined, example)).not.toThrow(); + expect(options.from).toBeUndefined(); + }); + + it('should accept a single-string glossary, as watch and sync spell it', () => { + const options: { glossary: string; from?: string } = { glossary: 'terms' }; + applyGlossarySourceLang(options, 'en', example); + expect(options.from).toBe('en'); + }); +}); diff --git a/tests/unit/watch-command.test.ts b/tests/unit/watch-command.test.ts index 61c83693..c68068a5 100644 --- a/tests/unit/watch-command.test.ts +++ b/tests/unit/watch-command.test.ts @@ -465,7 +465,12 @@ describe('WatchCommand', () => { // Expected: the watcher is stubbed to throw once reached } - expect(mockGlossaryService.resolveGlossaryId).toHaveBeenCalledWith('my-glossary'); + // The pair goes with it so coverage is checked at launch, not once per + // file change. + expect(mockGlossaryService.resolveGlossaryId).toHaveBeenCalledWith('my-glossary', { + from: 'en', + targets: ['es'], + }); }); }); From 9dff6328eb93fad3f1da19db783c355356bc7516 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 17:16:18 -0400 Subject: [PATCH 031/256] fix(services): key the translation cache on every parameter that changes the text The cache key carried tagHandling and the glossary selection but not the translation memory, the XML tag parameters, or preserveFormatting -- so a plain request and the same request with --translation-memory, a different --ignore-tags, or --preserve-formatting collided and the cache served the wrong translation while reporting cached: true. preserveFormatting was excluded on the grounds that it does not affect output, but preserve_formatting suppresses the sentence-boundary punctuation and case correction, which shows up in the text. --- src/services/translation.ts | 15 +++++- tests/unit/translation-service.test.ts | 71 ++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/services/translation.ts b/src/services/translation.ts index 70f7e905..d9f8e0d3 100644 --- a/src/services/translation.ts +++ b/src/services/translation.ts @@ -402,6 +402,13 @@ export class TranslationService { * so it holds the version the request will actually carry. Leaving it unset * would let the key stay stable across a change of the API's own default, * serving entries the API would no longer produce. + * + * Every parameter that changes the returned text has to appear here or the + * cache serves the wrong translation: a plain request and the same request + * with a translation memory, different --ignore-tags, or --preserve-formatting + * are different requests. `preserveFormatting` is included because + * preserve_formatting suppresses the sentence-boundary punctuation and case + * correction, which shows up in the text. */ private generateCacheKey(text: string, options: TranslationOptions): string { // Keyed on the parameter the request will actually carry, so the two ways of @@ -424,7 +431,13 @@ export class TranslationService { customInstructions: options.customInstructions, // 11. Custom instructions styleId: options.styleId, // 12. Style rules glossaryIds: glossary && 'glossary_ids' in glossary ? glossary.glossary_ids : undefined, // 13. Multi-glossary selection (order-significant) - // Note: preserveFormatting doesn't affect translation output, so not cached + translationMemoryId: options.translationMemoryId, // 14. Memory consulted for matches + translationMemoryThreshold: options.translationMemoryThreshold, // 15. Which matches it reuses + ignoreTags: options.ignoreTags, // 16. Tags left untranslated + splittingTags: options.splittingTags, // 17. Tags that split sentences + nonSplittingTags: options.nonSplittingTags, // 18. Tags that do not + outlineDetection: options.outlineDetection, // 19. XML structure inference + preserveFormatting: options.preserveFormatting, // 20. Suppresses boundary correction }; // Generate SHA-256 hash of the stable representation diff --git a/tests/unit/translation-service.test.ts b/tests/unit/translation-service.test.ts index 3d334989..615bedde 100644 --- a/tests/unit/translation-service.test.ts +++ b/tests/unit/translation-service.test.ts @@ -1240,6 +1240,77 @@ describe('TranslationService', () => { }); }); + describe('cache keys for parameters that change the translation', () => { + const keysFor = async ( + optionSets: Array>, + ): Promise => { + mockCacheService.get.mockReturnValue(null); + mockDeepLClient.translate.mockResolvedValue({ text: 'Hola' }); + mockCacheService.set.mockClear(); + + for (const options of optionSets) { + await translationService.translate('Hello', { targetLang: 'es', ...options } as any); + } + + return mockCacheService.set.mock.calls.map((call) => call[0]); + }; + + it('should separate a translation-memory request from one without', async () => { + const [none, withTm] = await keysFor([{}, { translationMemoryId: 'tm-1' }]); + expect(none).not.toBe(withTm); + }); + + it('should separate two translation memories', async () => { + const [one, two] = await keysFor([ + { translationMemoryId: 'tm-1' }, + { translationMemoryId: 'tm-2' }, + ]); + expect(one).not.toBe(two); + }); + + it('should separate two match thresholds for the same memory', async () => { + const [low, high] = await keysFor([ + { translationMemoryId: 'tm-1', translationMemoryThreshold: 20 }, + { translationMemoryId: 'tm-1', translationMemoryThreshold: 80 }, + ]); + expect(low).not.toBe(high); + }); + + it.each([ + ['ignoreTags', ['b'], ['i']], + ['splittingTags', ['p'], ['div']], + ['nonSplittingTags', ['br'], ['span']], + ])('should separate two values of %s', async (field, first, second) => { + const [a, b] = await keysFor([ + { tagHandling: 'xml', [field]: first }, + { tagHandling: 'xml', [field]: second }, + ]); + expect(a).not.toBe(b); + }); + + it('should separate outlineDetection=false from the default', async () => { + const [off, on] = await keysFor([ + { tagHandling: 'xml', outlineDetection: false }, + { tagHandling: 'xml' }, + ]); + expect(off).not.toBe(on); + }); + + /** preserve_formatting suppresses sentence-boundary correction, so it shows up in the text. */ + it('should separate the two preserveFormatting values', async () => { + const [off, on] = await keysFor([ + { preserveFormatting: false }, + { preserveFormatting: true }, + ]); + expect(off).not.toBe(on); + }); + + it('should leave keys unchanged for requests using none of them', async () => { + const [plain, alsoPlain] = await keysFor([{}, {}]); + expect(plain).toBe(alsoPlain); + }); + }); + it('should use cached result when options are provided in different order', async () => { // Set up cache to return a hit for the second call let callCount = 0; From 2b22d12e916bf4ef77ea84b50da8ac3ad2637968 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 17:16:34 -0400 Subject: [PATCH 032/256] refactor(types)!: derive the Language union from the generated snapshot The union was a fourth hand-maintained copy of the language list and had already fallen four codes behind the snapshot it is supposed to describe: de-ch, de-de, fr-ca and fr-fr -- the very codes the /v3/languages migration was written to add. Neither generate:languages nor check:languages touched it, and it is published, so `deepl config set defaults.targetLangs de-de` succeeded at runtime while DeepLConfig could not type the config the CLI had just written. ENTRIES is now generated `as const satisfies readonly LanguageEntry[]` and Language derives from its codes, the same way WriteLanguage already derived from WRITE_TARGET_LANGUAGES. Regenerating the snapshot widens both. The registry's own lookups keep the interface rather than 125 literal types, and the suite's tier counts are compared against the snapshot instead of written out, so the documented release step no longer turns the suite red. --- src/data/language-entries.ts | 8 ++- src/data/language-registry.ts | 9 +++- src/types/common.ts | 34 ++++++------ tests/unit/language-registry.test.ts | 77 +++++++++++++++++++++------- 4 files changed, 88 insertions(+), 40 deletions(-) diff --git a/src/data/language-entries.ts b/src/data/language-entries.ts index 08092444..60db1bea 100644 --- a/src/data/language-entries.ts +++ b/src/data/language-entries.ts @@ -9,10 +9,14 @@ * the CLI can list and validate languages without a network call or API key. * It may therefore lag the API, which is why callers accept well-formed codes * it does not contain rather than rejecting them. + * + * `as const` is load-bearing: the Language union in src/types/common.ts is + * derived from these codes, so a language added upstream widens the type on + * regenerate instead of needing a second hand-kept copy of the same list. */ import type { LanguageEntry } from './language-registry.js'; -export const ENTRIES: LanguageEntry[] = [ +export const ENTRIES = [ // Core languages (full feature support: formality, glossary, all model types) { code: 'ar', name: 'Arabic', category: 'core' }, { code: 'bg', name: 'Bulgarian', category: 'core' }, @@ -143,7 +147,7 @@ export const ENTRIES: LanguageEntry[] = [ { code: 'yi', name: 'Yiddish', category: 'extended' }, { code: 'yue', name: 'Cantonese', category: 'extended' }, { code: 'zu', name: 'Zulu', category: 'extended' }, -]; +] as const satisfies readonly LanguageEntry[]; /** * Target languages the Write API accepts, from resource=write. diff --git a/src/data/language-registry.ts b/src/data/language-registry.ts index f5c5eb16..5c09de67 100644 --- a/src/data/language-registry.ts +++ b/src/data/language-registry.ts @@ -15,7 +15,7 @@ * - **regional**: Target-only variants of core languages (e.g., en-gb, pt-br) * - **extended**: quality_optimized model only; no formality or glossary support */ -import { ENTRIES } from './language-entries.js'; +import { ENTRIES as GENERATED_ENTRIES } from './language-entries.js'; /** * Feature-availability tier for a language. @@ -38,6 +38,13 @@ export interface LanguageEntry { targetOnly?: boolean; } +/** + * The snapshot as plain entries. It is generated `as const` so the `Language` + * union can be derived from its codes; the lookups below want the interface, not + * 125 individual literal types. + */ +const ENTRIES: readonly LanguageEntry[] = GENERATED_ENTRIES; + /** Read-only map of language code to its registry entry. Primary lookup structure. */ export const LANGUAGE_REGISTRY: ReadonlyMap = new Map( ENTRIES.map(entry => [entry.code, entry]) diff --git a/src/types/common.ts b/src/types/common.ts index 261e8689..ffe3f10f 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -2,24 +2,22 @@ * Common types used throughout the application */ -export type Language = - // Core languages (full feature support: formality, glossaries, all model types) - | 'ar' | 'bg' | 'cs' | 'da' | 'de' | 'el' | 'en' | 'es' | 'et' | 'fi' - | 'fr' | 'he' | 'hu' | 'id' | 'it' | 'ja' | 'ko' | 'lt' | 'lv' | 'nb' - | 'nl' | 'pl' | 'pt' | 'ro' | 'ru' | 'sk' | 'sl' | 'sv' | 'tr' | 'uk' - | 'vi' | 'zh' - // Target-only regional variants - | 'en-gb' | 'en-us' | 'es-419' | 'pt-br' | 'pt-pt' | 'zh-hans' | 'zh-hant' - // Extended languages (quality_optimized only, no formality/glossary support) - | 'ace' | 'af' | 'an' | 'as' | 'ay' | 'az' | 'ba' | 'be' | 'bho' | 'bn' - | 'br' | 'bs' | 'ca' | 'ceb' | 'ckb' | 'cy' | 'eo' | 'eu' | 'fa' | 'ga' - | 'gl' | 'gn' | 'gom' | 'gu' | 'ha' | 'hi' | 'hr' | 'ht' | 'hy' | 'ig' - | 'is' | 'jv' | 'ka' | 'kk' | 'kmr' | 'ky' | 'la' | 'lb' | 'lmo' | 'ln' - | 'mai' | 'mg' | 'mi' | 'mk' | 'ml' | 'mn' | 'mr' | 'ms' | 'mt' | 'my' - | 'ne' | 'oc' | 'om' | 'pa' | 'pag' | 'pam' | 'prs' | 'ps' | 'qu' | 'sa' - | 'scn' | 'sq' | 'sr' | 'st' | 'su' | 'sw' | 'ta' | 'te' | 'tg' | 'th' - | 'tk' | 'tl' | 'tn' | 'ts' | 'tt' | 'ur' | 'uz' | 'wo' | 'xh' | 'yi' - | 'yue' | 'zu'; +import { ENTRIES } from '../data/language-entries.js'; + +/** + * Every language code the bundled snapshot lists, which is generated from + * GET /v3/languages. + * + * Derived rather than hand-maintained: as a written-out union this was a fourth + * copy of the same list and had already fallen four codes behind the snapshot + * (de-ch, de-de, fr-ca, fr-fr), so the published typings could not describe a + * config the CLI itself accepts. Regenerating the snapshot now widens this too. + * + * The API remains the authority on which languages exist -- runtime validation + * accepts well-formed codes the snapshot predates, so this union is the set the + * CLI can name offline, not the set that works. + */ +export type Language = (typeof ENTRIES)[number]['code']; export type Formality = | 'default' diff --git a/tests/unit/language-registry.test.ts b/tests/unit/language-registry.test.ts index 77f220d5..6cf30469 100644 --- a/tests/unit/language-registry.test.ts +++ b/tests/unit/language-registry.test.ts @@ -10,12 +10,22 @@ import { deriveLanguageEntry, looksLikeLanguageTag, } from '../../src/data/language-registry'; -import { WRITE_TARGET_LANGUAGES } from '../../src/data/language-entries'; +import { ENTRIES, WRITE_TARGET_LANGUAGES } from '../../src/data/language-entries'; +import type { Language } from '../../src/types/common'; + +/** + * Counts are compared against the snapshot rather than written out, so + * regenerating it -- the documented release step -- does not turn this suite red + * for a change it is supposed to accept. Drift from the API is checked by + * "npm run check:languages", which is where that belongs. + */ +const TIERS = ['core', 'regional', 'extended'] as const; +const entriesIn = (category: string) => ENTRIES.filter(e => e.category === category); describe('Language Registry', () => { describe('LANGUAGE_REGISTRY', () => { - it('should contain 125 language entries', () => { - expect(LANGUAGE_REGISTRY.size).toBe(125); + it('should contain one entry per snapshot language', () => { + expect(LANGUAGE_REGISTRY.size).toBe(ENTRIES.length); }); it('should have unique language codes', () => { @@ -24,19 +34,27 @@ describe('Language Registry', () => { expect(unique.size).toBe(codes.length); }); - it('should contain all 32 core languages', () => { - const core = Array.from(LANGUAGE_REGISTRY.values()).filter(e => e.category === 'core'); - expect(core.length).toBe(32); + it.each(TIERS)('should contain every %s language from the snapshot', category => { + const inRegistry = Array.from(LANGUAGE_REGISTRY.values()).filter( + e => e.category === category, + ); + expect(inRegistry.length).toBe(entriesIn(category).length); + expect(inRegistry.length).toBeGreaterThan(0); }); - it('should contain all 11 regional variants', () => { - const regional = Array.from(LANGUAGE_REGISTRY.values()).filter(e => e.category === 'regional'); - expect(regional.length).toBe(11); + it('should place every entry in exactly one known tier', () => { + expect(TIERS.map(entriesIn).reduce((sum, group) => sum + group.length, 0)).toBe( + ENTRIES.length, + ); }); - it('should contain all 82 extended languages', () => { - const extended = Array.from(LANGUAGE_REGISTRY.values()).filter(e => e.category === 'extended'); - expect(extended.length).toBe(82); + /** + * Mirrors the generator's floor: the tiers come from the features matrix, so + * a matrix that stopped reporting `glossary` would retier every language as + * extended and make --formality and --glossary unusable everywhere. + */ + it('should keep a plausible number of core languages', () => { + expect(entriesIn('core').length).toBeGreaterThanOrEqual(20); }); it('should mark regional variants as targetOnly', () => { @@ -178,14 +196,16 @@ describe('Language Registry', () => { expect(codes).toContain('sw'); }); - it('should return 114 languages (125 - 11 regional)', () => { - expect(getSourceLanguages().length).toBe(114); + it('should return every language that is not target-only', () => { + const targetOnly = ENTRIES.filter(e => 'targetOnly' in e && e.targetOnly).length; + expect(getSourceLanguages().length).toBe(ENTRIES.length - targetOnly); + expect(targetOnly).toBeGreaterThan(0); }); }); describe('getTargetLanguages()', () => { it('should include all languages', () => { - expect(getTargetLanguages().length).toBe(125); + expect(getTargetLanguages().length).toBe(ENTRIES.length); }); it('should include regional variants', () => { @@ -198,9 +218,9 @@ describe('Language Registry', () => { }); describe('getAllLanguageCodes()', () => { - it('should return set of all 125 codes', () => { + it('should return a set of every snapshot code', () => { const codes = getAllLanguageCodes(); - expect(codes.size).toBe(125); + expect(codes.size).toBe(ENTRIES.length); }); it('should support has() lookups', () => { @@ -213,9 +233,9 @@ describe('Language Registry', () => { }); describe('getExtendedLanguageCodes()', () => { - it('should return set of 82 extended codes', () => { + it('should return a set of every extended code', () => { const codes = getExtendedLanguageCodes(); - expect(codes.size).toBe(82); + expect(codes.size).toBe(entriesIn('extended').length); }); it('should only contain extended language codes', () => { @@ -334,6 +354,25 @@ describe('Language Registry', () => { }); }); + describe('Language union', () => { + /** + * Compile-time, not runtime: these assignments fail to build if the union + * goes back to being hand-written and falls behind the snapshot again, which + * is exactly how de-ch, de-de, fr-ca and fr-fr came to be missing from it. + */ + it('should cover the regional variants a hand-written union had missed', () => { + const codes: Language[] = ['de-ch', 'de-de', 'fr-ca', 'fr-fr']; + codes.forEach(code => expect(isValidLanguage(code)).toBe(true)); + }); + + it('should still exclude a code the snapshot does not list', () => { + // @ts-expect-error 'zz' is not a snapshot language; widening Language to + // string would make this directive unused and fail the build. + const unknown: Language = 'zz'; + expect(isValidLanguage(unknown)).toBe(false); + }); + }); + describe('looksLikeLanguageTag()', () => { it.each(['de', 'ace', 'de-ch', 'en-gb', 'es-419', 'zh-hans', 'bho'])( 'should accept the well-formed tag %s', From 3ce520b49afa66a85882bcb970ca81f1f77bd762 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 17:16:34 -0400 Subject: [PATCH 033/256] fix(scripts): guard the language generator against silent bad output Three ways the generator could write a snapshot that breaks the CLI without saying anything: - an empty write list collapses the WriteLanguage union to never, so every --lang is rejected while the error names no valid option at all - tiers come solely from features.glossary, so a matrix that stopped reporting it would retier all 125 languages as extended and make --formality and --glossary unusable everywhere - --check compared quoted lowercase codes only, so the ten renamed display names of c955ca2 would have been reported as "formatting only" Also fetches both resources together instead of failing fast on the first, so a key that cannot read resource=write no longer blocks regenerating the translation list it can read, and reports both failures at once. The rendering is exported so the snapshot can be re-rendered from the data it already holds when only the template changes. --- scripts/generate-language-registry.mjs | 228 ++++++++++++++++--------- 1 file changed, 143 insertions(+), 85 deletions(-) diff --git a/scripts/generate-language-registry.mjs b/scripts/generate-language-registry.mjs index ed1780a5..55314b4d 100644 --- a/scripts/generate-language-registry.mjs +++ b/scripts/generate-language-registry.mjs @@ -24,72 +24,47 @@ const ROOT = path.resolve(import.meta.dirname, '..'); const TARGET = path.join(ROOT, 'src', 'data', 'language-entries.ts'); const DERIVATION = path.join(ROOT, 'dist', 'data', 'language-registry.js'); -const checkOnly = process.argv.includes('--check'); +/** + * A tier count this far below the live shape means the derivation stopped + * working rather than that DeepL dropped languages -- most likely the features + * matrix stopped reporting `glossary`, which would silently retier all 125 + * languages as extended and make --formality and --glossary unusable + * everywhere. Cheap floor, catches the whole failure class. + */ +const MIN_CORE_LANGUAGES = 20; + +const GROUPS = [ + ['core', 'Core languages (full feature support: formality, glossary, all model types)'], + ['regional', 'Regional variants (target-only)'], + ['extended', 'Extended languages (quality_optimized only, no formality/glossary)'], +]; function fail(message) { console.error(`error: ${message}`); process.exit(1); } -const apiKey = process.env['DEEPL_API_KEY']; -if (!apiKey) { - fail('DEEPL_API_KEY is not set; the snapshot can only be generated from the live API.'); -} -if (!existsSync(DERIVATION)) { - fail(`missing ${path.relative(ROOT, DERIVATION)}; run "npm run build" first.`); -} - -const { deriveLanguageEntry } = await import(DERIVATION); - -const host = apiKey.endsWith(':fx') ? 'https://api-free.deepl.com' : 'https://api.deepl.com'; - -async function fetchResource(resource) { - const response = await fetch(`${host}/v3/languages?resource=${resource}`, { - headers: { Authorization: `DeepL-Auth-Key ${apiKey}` }, - }); - if (!response.ok) { - fail(`GET /v3/languages?resource=${resource} returned ${response.status} ${response.statusText}`); - } - const languages = await response.json(); - if (!Array.isArray(languages) || languages.length === 0) { - fail(`GET /v3/languages?resource=${resource} returned no languages`); - } - return languages; -} - -const languages = await fetchResource('translate_text'); -const writeLanguages = await fetchResource('write'); - -const entries = languages.map(deriveLanguageEntry); -// The write endpoints take a target language only, so the list is filtered by -// that role rather than run through deriveLanguageEntry -- write has no notion -// of the core/regional/extended tiers. -const writeTargets = writeLanguages - .filter(language => language.usable_as_target) - .map(language => language.lang.toLowerCase()) - .sort((a, b) => a.localeCompare(b, 'en')); const byCode = (a, b) => a.code.localeCompare(b.code, 'en'); -const groups = [ - ['core', 'Core languages (full feature support: formality, glossary, all model types)'], - ['regional', 'Regional variants (target-only)'], - ['extended', 'Extended languages (quality_optimized only, no formality/glossary)'], -]; -function render(entry) { +function renderEntry(entry) { const fields = [`code: '${entry.code}'`, `name: '${entry.name.replace(/'/g, "\\'")}'`]; fields.push(`category: '${entry.category}'`); if (entry.targetOnly) fields.push('targetOnly: true'); return ` { ${fields.join(', ')} },`; } -const body = groups - .map(([category, heading]) => { +/** + * Renders the whole file. Exported so the snapshot can be re-rendered from the + * data it already holds -- a formatting or type change to the template does not + * need a live API call to apply. + */ +export function renderRegistry(entries, writeTargets) { + const body = GROUPS.map(([category, heading]) => { const group = entries.filter(e => e.category === category).sort(byCode); - return [` // ${heading}`, ...group.map(render)].join('\n'); - }) - .join('\n\n'); + return [` // ${heading}`, ...group.map(renderEntry)].join('\n'); + }).join('\n\n'); -const contents = `/** + return `/** * Supported DeepL languages, generated from GET /v3/languages. * * DO NOT EDIT BY HAND. Run "npm run generate:languages" to refresh, and @@ -100,12 +75,16 @@ const contents = `/** * the CLI can list and validate languages without a network call or API key. * It may therefore lag the API, which is why callers accept well-formed codes * it does not contain rather than rejecting them. + * + * \`as const\` is load-bearing: the Language union in src/types/common.ts is + * derived from these codes, so a language added upstream widens the type on + * regenerate instead of needing a second hand-kept copy of the same list. */ import type { LanguageEntry } from './language-registry.js'; -export const ENTRIES: LanguageEntry[] = [ +export const ENTRIES = [ ${body} -]; +] as const satisfies readonly LanguageEntry[]; /** * Target languages the Write API accepts, from resource=write. @@ -123,42 +102,121 @@ export const WRITE_TARGET_LANGUAGES = [ ${writeTargets.map(code => ` '${code}',`).join('\n')} ] as const; `; +} + +async function main() { + const checkOnly = process.argv.includes('--check'); + + const apiKey = process.env['DEEPL_API_KEY']; + if (!apiKey) { + fail('DEEPL_API_KEY is not set; the snapshot can only be generated from the live API.'); + } + if (!existsSync(DERIVATION)) { + fail(`missing ${path.relative(ROOT, DERIVATION)}; run "npm run build" first.`); + } + + const { deriveLanguageEntry } = await import(DERIVATION); + + const host = apiKey.endsWith(':fx') ? 'https://api-free.deepl.com' : 'https://api.deepl.com'; + + async function fetchResource(resource) { + const response = await fetch(`${host}/v3/languages?resource=${resource}`, { + headers: { Authorization: `DeepL-Auth-Key ${apiKey}` }, + }); + if (!response.ok) { + return { + resource, + error: `GET /v3/languages?resource=${resource} returned ${response.status} ${response.statusText}`, + }; + } + const languages = await response.json(); + if (!Array.isArray(languages) || languages.length === 0) { + return { resource, error: `GET /v3/languages?resource=${resource} returned no languages` }; + } + return { resource, languages }; + } + + // Fetched together and reported together: failing fast on the first resource + // meant a key that cannot read resource=write blocked regenerating the + // translation list too, which it can read perfectly well. + const [translateResult, writeResult] = await Promise.all([ + fetchResource('translate_text'), + fetchResource('write'), + ]); + const errors = [translateResult, writeResult].filter(r => r.error).map(r => r.error); + if (errors.length > 0) { + fail(errors.join('\n ')); + } + + const entries = translateResult.languages.map(deriveLanguageEntry); + // The write endpoints take a target language only, so the list is filtered by + // that role rather than run through deriveLanguageEntry -- write has no notion + // of the core/regional/extended tiers. + const writeTargets = writeResult.languages + .filter(language => language.usable_as_target) + .map(language => language.lang.toLowerCase()) + .sort((a, b) => a.localeCompare(b, 'en')); + + const coreCount = entries.filter(e => e.category === 'core').length; + if (coreCount < MIN_CORE_LANGUAGES) { + fail( + `only ${coreCount} core languages derived (expected at least ${MIN_CORE_LANGUAGES}); ` + + 'the features matrix probably stopped reporting "glossary". Refusing to write a ' + + 'snapshot that would retier every language as extended.', + ); + } + // An empty write list would collapse the WriteLanguage union to never, so + // every --lang would be rejected while naming no valid option at all. + if (writeTargets.length === 0) { + fail('no write target languages reported (expected usable_as_target on resource=write)'); + } -if (checkOnly) { - const current = existsSync(TARGET) ? readFileSync(TARGET, 'utf8') : ''; - if (current === contents) { - console.log( - `${entries.length} languages, ${writeTargets.length} write targets; snapshot is current.`, + const contents = renderRegistry(entries, writeTargets); + + if (checkOnly) { + const current = existsSync(TARGET) ? readFileSync(TARGET, 'utf8') : ''; + if (current === contents) { + console.log( + `${entries.length} languages, ${writeTargets.length} write targets; snapshot is current.`, + ); + process.exit(0); + } + // Name which list moved: the two are generated from different resources, and + // "N languages upstream" is misleading when it is the write list that drifted. + // Compared on the whole block rather than the codes alone, so a renamed + // display name is not reported as "formatting only". + const blockIn = (source, open, close) => { + const start = source.indexOf(open); + if (start === -1) return ''; + const from = start + open.length; + const end = source.indexOf(close, from); + return source.slice(from, end === -1 ? undefined : end); + }; + const blocks = [ + ['translate_text', `${entries.length}`, 'export const ENTRIES', '\n] as const satisfies'], + ['write', `${writeTargets.length}`, 'export const WRITE_TARGET_LANGUAGES', '] as const;'], + ]; + const drifted = blocks + .filter(([, , open, close]) => blockIn(current, open, close) !== blockIn(contents, open, close)) + .map(([name, count]) => `${name} (${count} upstream)`); + const detail = drifted.length > 0 ? drifted.join(', ') : 'file header or formatting'; + console.error( + `error: ${path.relative(ROOT, TARGET)} is out of date with the API -- ${detail}.\n` + + 'Run: npm run generate:languages', ); - process.exit(0); + process.exit(1); } - // Name which list moved: the two are generated from different resources, and - // "N languages upstream" is misleading when it is the write list that drifted. - const codesIn = (source, open, close) => { - const start = source.indexOf(open); - if (start === -1) return ''; - const from = start + open.length; - const end = source.indexOf(close, from); - return (source.slice(from, end === -1 ? undefined : end).match(/'[a-z0-9-]+'/g) ?? []).join(','); - }; - const blocks = [ - ['translate_text', `${entries.length}`, 'export const ENTRIES', '\n];'], - ['write', `${writeTargets.length}`, 'export const WRITE_TARGET_LANGUAGES', '] as const;'], - ]; - const drifted = blocks - .filter(([, , open, close]) => codesIn(current, open, close) !== codesIn(contents, open, close)) - .map(([name, count]) => `${name} (${count} upstream)`); - const detail = drifted.length > 0 ? drifted.join(', ') : 'formatting only'; - console.error( - `error: ${path.relative(ROOT, TARGET)} is out of date with the API -- ${detail}.\n` + - 'Run: npm run generate:languages', + + writeFileSync(TARGET, contents); + const counts = GROUPS.map(([c]) => `${c} ${entries.filter(e => e.category === c).length}`); + console.log( + `wrote ${path.relative(ROOT, TARGET)}: ${entries.length} languages (${counts.join(', ')}), ` + + `${writeTargets.length} write targets`, ); - process.exit(1); } -writeFileSync(TARGET, contents); -const counts = groups.map(([c]) => `${c} ${entries.filter(e => e.category === c).length}`); -console.log( - `wrote ${path.relative(ROOT, TARGET)}: ${entries.length} languages (${counts.join(', ')}), ` + - `${writeTargets.length} write targets`, -); +// Importable for re-rendering without touching the network; only the CLI entry +// point fetches. +if (process.argv[1] === import.meta.filename) { + await main(); +} From 7798e9b8027911d50dca221d895f364e0f5115e0 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 17:24:18 -0400 Subject: [PATCH 034/256] fix(languages): stop --features claiming knowledge it does not have The listing keeps snapshot entries the API response omitted, so those rows had no feature data -- and every one of them was rendered as the positive claim "none". With a response covering a handful of languages, --features reported over a hundred languages as supporting nothing. Those rows also made every feature look non-uniform, so features shared by all the described languages became columns of repeated values instead of the single footer note the feature was built around. A language the response did not describe now reads as unknown, only described languages decide what varies, and the shared-feature note says "languages with reported features" when some rows have none, so it does not speak for them. Two smaller lies with it: - a feature reported without a `status` rendered as literal "undefined"; the enum is open and an absent status still means the feature is there - supportsFormality was asserted false for a language whose features the response never mentioned, turning on the "[F] = supports formality" legend with no [F] anywhere to explain it Also fetches /v3/languages once per client: both roles are filtered out of the same payload, so `deepl languages` was making the identical full-list request twice. --- src/api/translation-client.ts | 40 ++++++++++++--- src/cli/commands/languages.ts | 73 +++++++++++++++++++++------ tests/e2e/cli-languages.e2e.test.ts | 16 +++++- tests/unit/languages-command.test.ts | 28 +++++++++- tests/unit/translation-client.test.ts | 19 ++++++- 5 files changed, 148 insertions(+), 28 deletions(-) diff --git a/src/api/translation-client.ts b/src/api/translation-client.ts index 0588491f..38a363bf 100644 --- a/src/api/translation-client.ts +++ b/src/api/translation-client.ts @@ -288,15 +288,35 @@ export class TranslationClient extends HttpClient { * here to preserve the per-type contract. Formality support comes from the * per-language features matrix, which is what v2's supports_formality became. */ + /** + * The raw translate_text language list, fetched at most once per client. + * + * Both roles are filtered out of one payload, and the request does not vary by + * role, so `deepl languages` -- which asks for both -- was making the same + * full-list request twice. A failed fetch is not retained, so the next caller + * retries. + */ + private translateLanguages?: Promise; + + private fetchTranslateLanguages(): Promise { + if (!this.translateLanguages) { + this.translateLanguages = this.makeRequest( + 'GET', + '/v3/languages', + { resource: 'translate_text' } + ).catch((error: unknown) => { + delete this.translateLanguages; + throw error; + }); + } + return this.translateLanguages; + } + async getSupportedLanguages( type: 'source' | 'target' ): Promise { try { - const response = await this.makeRequest( - 'GET', - '/v3/languages', - { resource: 'translate_text' } - ); + const response = await this.fetchTranslateLanguages(); return response .filter((lang) => (type === 'source' ? lang.usable_as_source : lang.usable_as_target)) @@ -305,9 +325,13 @@ export class TranslationClient extends HttpClient { return { language: code, name: lang.name, - ...(type === 'target' && { - supportsFormality: lang.features?.['formality'] !== undefined, - }), + // Only claimed when the response actually described this language's + // features: asserting false for a language it said nothing about + // turned the [F] legend on with no [F] anywhere to explain it. + ...(type === 'target' && + lang.features && { + supportsFormality: lang.features['formality'] !== undefined, + }), ...(lang.features && { features: lang.features }), }; }); diff --git a/src/cli/commands/languages.ts b/src/cli/commands/languages.ts index 51003562..2a20079a 100644 --- a/src/cli/commands/languages.ts +++ b/src/cli/commands/languages.ts @@ -46,15 +46,33 @@ function featureLabel(key: string): string { ); } +/** Cell text for a language the response carried no feature data for at all. */ +const UNKNOWN_CELL = '?'; + +/** + * Whether the response described this language's features at all. An empty + * matrix is data -- it says the language supports none of them -- while a + * missing one means the language never appeared in the response. + */ +function hasFeatureData(entry: LanguageDisplayEntry): boolean { + return entry.features !== undefined; +} + /** * Cell text for one feature on one language. A feature is supported when the * API reports the key at all; `status` describes maturity, so anything other - * than `stable` is shown verbatim rather than collapsed to yes. + * than `stable` is shown verbatim rather than collapsed to yes. `status` is an + * open enum and may be absent, which still means the feature is there. + * + * A language the response omitted entirely reads as unknown rather than + * unsupported: the listing keeps snapshot entries the API did not mention, and + * claiming they support nothing would be inventing an answer. */ function featureCell(entry: LanguageDisplayEntry, key: string): string { + if (!hasFeatureData(entry)) return UNKNOWN_CELL; const feature = entry.features?.[key]; if (!feature) return '—'; - return feature.status === 'stable' ? 'yes' : feature.status; + return !feature.status || feature.status === 'stable' ? 'yes' : feature.status; } function sortFeatureKeys(keys: string[]): string[] { @@ -78,18 +96,22 @@ export function partitionFeatureKeys(entries: LanguageDisplayEntry[]): { columns: string[]; uniform: Array<{ key: string; cell: string }>; } { - if (entries.length === 0) return { columns: [], uniform: [] }; + // Only languages the response described can say whether a feature varies; + // including the rest made every feature look non-uniform, so a feature all of + // them share became a column of repeated values instead of one footer note. + const described = entries.filter(hasFeatureData); + if (described.length === 0) return { columns: [], uniform: [] }; const allKeys = new Set(); - for (const entry of entries) { + for (const entry of described) { for (const key of Object.keys(entry.features ?? {})) allKeys.add(key); } const columns: string[] = []; const uniform: Array<{ key: string; cell: string }> = []; for (const key of allKeys) { - const first = featureCell(entries[0]!, key); - if (entries.some(entry => featureCell(entry, key) !== first)) { + const first = featureCell(described[0]!, key); + if (described.some(entry => featureCell(entry, key) !== first)) { columns.push(key); } else { uniform.push({ key, cell: first }); @@ -105,11 +127,18 @@ export function partitionFeatureKeys(entries: LanguageDisplayEntry[]): { } function hasAnyFeatures(entries: LanguageDisplayEntry[]): boolean { - return entries.some(entry => Object.keys(entry.features ?? {}).length > 0); + return entries.some(hasFeatureData); } -/** Lowercased feature list for prose contexts, e.g. `glossary, style rules`. */ +/** + * Lowercased feature list for prose contexts, e.g. `glossary, style rules`. + * Empty when there is nothing per-language to say: with no discriminating + * features the footer note carries the answer, and annotating every row `none` + * would contradict it. + */ function featureList(entry: LanguageDisplayEntry, keys: string[]): string { + if (!hasFeatureData(entry)) return 'no feature data'; + if (keys.length === 0) return ''; const supported = keys .filter(key => featureCell(entry, key) !== '—') .map(key => { @@ -120,8 +149,16 @@ function featureList(entry: LanguageDisplayEntry, keys: string[]): string { return supported.length > 0 ? supported.join(', ') : 'none'; } -function uniformNote(uniform: Array<{ key: string; cell: string }>): string | undefined { - const supported = uniform.filter(u => u.cell !== '—'); +/** + * The one-line summary for features every language shares. Scoped to the + * languages the response described when some rows carry no data, since those + * rows are listed too and the note must not speak for them. + */ +function uniformNote( + uniform: Array<{ key: string; cell: string }>, + entries: LanguageDisplayEntry[], +): string | undefined { + const supported = uniform.filter(u => u.cell !== '—' && u.cell !== UNKNOWN_CELL); if (supported.length === 0) return undefined; const list = supported .map(u => { @@ -129,7 +166,10 @@ function uniformNote(uniform: Array<{ key: string; cell: string }>): string | un return u.cell === 'yes' ? label : `${label} (${u.cell})`; }) .join(', '); - return `All listed languages also support: ${list}.`; + const subject = entries.every(hasFeatureData) + ? 'All listed languages' + : 'All languages with reported features'; + return `${subject} also support: ${list}.`; } export class LanguagesCommand { @@ -263,8 +303,11 @@ export class LanguagesCommand { const { columns, uniform } = renderFeatures ? partitionFeatureKeys(entries) : { columns: [], uniform: [] }; - const suffix = (entry: LanguageDisplayEntry): string => - renderFeatures ? chalk.gray(` — ${featureList(entry, columns)}`) : ''; + const suffix = (entry: LanguageDisplayEntry): string => { + if (!renderFeatures) return ''; + const list = featureList(entry, columns); + return list ? chalk.gray(` — ${list}`) : ''; + }; coreAndRegional.forEach(entry => { const code = entry.code.padEnd(maxCodeLength + 2); @@ -286,7 +329,7 @@ export class LanguagesCommand { lines.push(chalk.gray(' [F] = supports formality parameter')); } - const note = renderFeatures ? uniformNote(uniform) : undefined; + const note = renderFeatures ? uniformNote(uniform, entries) : undefined; if (note) { lines.push(''); lines.push(chalk.gray(` ${note}`)); @@ -366,7 +409,7 @@ export class LanguagesCommand { table.push(row); } - const note = renderFeatures ? uniformNote(uniform) : undefined; + const note = renderFeatures ? uniformNote(uniform, entries) : undefined; return `${header}:\n${table.toString()}${note ? `\n${note}` : ''}`; } diff --git a/tests/e2e/cli-languages.e2e.test.ts b/tests/e2e/cli-languages.e2e.test.ts index 4c2e71b8..8283a661 100644 --- a/tests/e2e/cli-languages.e2e.test.ts +++ b/tests/e2e/cli-languages.e2e.test.ts @@ -167,10 +167,22 @@ describe('Languages Command E2E', () => { const german = output.split('\n').find(line => line.includes('German')); const english = output.split('\n').find(line => line.includes('English (British)')); + // formality is what varies across the languages the mock describes, so it + // is the per-row annotation; glossary is shared by all of them and is + // reported once at the end instead of on every row. expect(german).toContain('formality'); - expect(german).toContain('glossary'); - expect(english).toContain('glossary'); expect(english).not.toContain('formality'); + expect(output).toContain('glossary'); + }); + + it('should scope the shared-feature note to the languages it has data for', () => { + const output = featuresRunCLI('languages --target --features'); + + // The listing also carries snapshot languages the mock never returned, so + // the note must not speak for them. + expect(output).toContain('All languages with reported features also support'); + const zulu = output.split('\n').find(line => line.includes('Zulu')); + expect(zulu).toContain('no feature data'); }); // Column suppression is asserted in the unit tests: the row set here is the diff --git a/tests/unit/languages-command.test.ts b/tests/unit/languages-command.test.ts index 2ac3c977..e3115584 100644 --- a/tests/unit/languages-command.test.ts +++ b/tests/unit/languages-command.test.ts @@ -651,9 +651,33 @@ describe('LanguagesCommand', () => { }, ]; const formatted = languagesCommand.formatLanguages(apiLangs, 'target', true); - const de = formatted.split('\n').find(l => l.includes('German')); - expect(de).toContain('glossary'); + // Reported once for the whole listing rather than per row: the languages + // the response omitted carry no feature data, so they cannot make glossary + // look like a discriminating column. + expect(formatted).toContain('glossary'); + }); + + it('should report a language the response omitted as unknown, not as supporting nothing', () => { + const apiLangs: LanguageInfo[] = [ + { + language: 'de', + name: 'German', + supportsFormality: true, + features: { glossary: { status: 'stable' }, formality: { status: 'stable' } }, + }, + { + language: 'pt', + name: 'Portuguese', + supportsFormality: true, + features: { glossary: { status: 'stable' } }, + }, + ]; + const formatted = languagesCommand.formatLanguages(apiLangs, 'target', true); + const zulu = formatted.split('\n').find(l => l.includes('Zulu')); + + expect(zulu).toContain('no feature data'); + expect(zulu).not.toContain('none'); }); it('should add a column per discriminating feature in table output', () => { diff --git a/tests/unit/translation-client.test.ts b/tests/unit/translation-client.test.ts index dd2d5334..52f5cedc 100644 --- a/tests/unit/translation-client.test.ts +++ b/tests/unit/translation-client.test.ts @@ -503,8 +503,25 @@ describe('TranslationClient', () => { const result = await client.getSupportedLanguages('target'); + // Formality support is left unstated rather than denied: a response that + // described no features is not evidence that formality is unavailable, and + // claiming false turned on the [F] legend with no [F] to explain. expect(result[0]).not.toHaveProperty('features'); - expect(result[0]!.supportsFormality).toBe(false); + expect(result[0]!.supportsFormality).toBeUndefined(); + }); + + it('should fetch the language list once for both roles', async () => { + mockAxiosInstance.request.mockResolvedValue({ + data: [{ lang: 'de', name: 'German', usable_as_source: true, usable_as_target: true }], + status: 200, + headers: {}, + }); + + await client.getSupportedLanguages('source'); + await client.getSupportedLanguages('target'); + + // The request does not vary by role -- both are filtered out of one payload. + expect(mockAxiosInstance.request).toHaveBeenCalledTimes(1); }); }); From 49756349388251a837e0992ee9583eccb6373017 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 17:34:39 -0400 Subject: [PATCH 035/256] fix(voice): keep partial transcripts and match echoed languages case-insensitively Failing a session that ends with a target untranslated was the right call, but two things made it worse than the silence it replaced. Target updates were matched against the exact requested spelling, and the requested set uses casings like zh-HANS and en-GB. A server echoing another canonicalization had its translation dropped by the existing `if (target)` guard, and the new check then reported that target as missing -- turning a cosmetic mismatch into a failed session. Lookups are keyed lowercase now. A single missing target also discarded the source transcript and every successful translation, after the audio had been transcribed and billed. The failure now carries what did arrive, and the command prints it to stderr before exiting non-zero, so nothing has to be re-streamed to see it. --- src/cli/commands/voice.ts | 28 ++++++++++++ src/services/voice-stream-session.ts | 37 ++++++++++++---- tests/unit/voice-stream-session.test.ts | 58 ++++++++++++++++++++++++- 3 files changed, 114 insertions(+), 9 deletions(-) diff --git a/src/cli/commands/voice.ts b/src/cli/commands/voice.ts index 26246faf..f8cdfeca 100644 --- a/src/cli/commands/voice.ts +++ b/src/cli/commands/voice.ts @@ -17,6 +17,8 @@ import type { VoiceSourceMediaContentType, } from '../../types/index.js'; import { ValidationError } from '../../utils/errors.js'; +import { Logger } from '../../utils/logger.js'; +import { VoicePartialResultError } from '../../services/voice-stream-session.js'; const VALID_VOICE_TARGET_LANGS: ReadonlySet = new Set([ 'ar','bg','cs','da','de','el','en','en-GB','en-US','es','et','fi','fr', @@ -83,6 +85,9 @@ export class VoiceCommand { } return this.formatResult(result, options.format); + } catch (error) { + this.reportPartialResult(error, translateOptions.targetLangs.length, isTTY); + throw error; } finally { process.removeListener('SIGINT', sigintHandler); } @@ -108,6 +113,9 @@ export class VoiceCommand { } return this.formatResult(result, options.format); + } catch (error) { + this.reportPartialResult(error, translateOptions.targetLangs.length, isTTY); + throw error; } finally { process.removeListener('SIGINT', sigintHandler); } @@ -252,6 +260,26 @@ export class VoiceCommand { readline.moveCursor(process.stdout, 0, -(lineCount - 1)); } + /** + * Print what a failed session did produce. The audio is transcribed and billed + * before the missing translation is noticed, so discarding the transcripts + * would make the user re-stream and pay again to see them. Written to stderr so + * a partial result is never mistaken for the command's output. + */ + private reportPartialResult(error: unknown, targetCount: number, isTTY: boolean): void { + if (!(error instanceof VoicePartialResultError)) { + return; + } + if (isTTY) { + this.clearTTYDisplay(targetCount); + } + const salvaged = this.formatResult(error.result); + if (salvaged.trim() !== '') { + Logger.warn(chalk.yellow('Partial result before the session failed:')); + Logger.warn(salvaged); + } + } + private formatResult(result: VoiceSessionResult, format?: string): string { if (format === 'json') { return formatVoiceJson(result); diff --git a/src/services/voice-stream-session.ts b/src/services/voice-stream-session.ts index 7d3f7786..bb09a3e4 100644 --- a/src/services/voice-stream-session.ts +++ b/src/services/voice-stream-session.ts @@ -22,6 +22,21 @@ import type { const DEFAULT_MAX_RECONNECT_ATTEMPTS = 3; +/** + * A session that ended with at least one target untranslated, carrying whatever + * did arrive. The audio has been transcribed and billed by this point, so the + * partial transcripts travel with the failure instead of being discarded. + */ +export class VoicePartialResultError extends VoiceError { + constructor( + message: string, + suggestion: string, + readonly result: VoiceSessionResult, + ) { + super(message, suggestion); + } +} + export class VoiceStreamSession { private readonly client: VoiceClient; private readonly session: VoiceSessionResponse; @@ -61,7 +76,10 @@ export class VoiceStreamSession { for (const lang of options.targetLangs) { const transcript: VoiceTranscript = { lang, text: '', segments: [] }; - this.targetTranscripts.set(lang, transcript); + // Keyed lowercase because the requested spellings (zh-HANS, en-GB) are not + // the only canonicalization the server might echo, and an unmatched update + // is dropped silently -- which then reads as a missing translation. + this.targetTranscripts.set(lang.toLowerCase(), transcript); this.textParts.set(transcript, []); } } @@ -118,7 +136,7 @@ export class VoiceStreamSession { this.callbacks?.onSourceTranscript?.(update); }, onTargetTranscript: (update: VoiceTargetTranscriptUpdate) => { - const target = this.targetTranscripts.get(update.language); + const target = this.targetTranscripts.get(update.language.toLowerCase()); if (target) { this.accumulateTranscript(target, update.concluded); } @@ -137,23 +155,26 @@ export class VoiceStreamSession { this.closeInput(); this.finalizeTranscripts(); + const result: VoiceSessionResult = { + sessionId: this.session.session_id, + source: this.sourceTranscript, + targets: Array.from(this.targetTranscripts.values()), + }; + const untranslated = this.untranslatedTargets(); if (untranslated.length > 0) { this.fail( reject, - new VoiceError( + new VoicePartialResultError( `Voice session ended without a translation for: ${untranslated.join(', ')}.`, 'The audio was transcribed but the server sent no translated text. Retry the request.', + result, ), ); return; } - resolve({ - sessionId: this.session.session_id, - source: this.sourceTranscript, - targets: Array.from(this.targetTranscripts.values()), - }); + resolve(result); }, onError: (error) => { this.callbacks?.onError?.(error); diff --git a/tests/unit/voice-stream-session.test.ts b/tests/unit/voice-stream-session.test.ts index cf451a5f..1e4b9cd4 100644 --- a/tests/unit/voice-stream-session.test.ts +++ b/tests/unit/voice-stream-session.test.ts @@ -5,7 +5,10 @@ * and transcript accumulation directly on the extracted class. */ -import { VoiceStreamSession } from '../../src/services/voice-stream-session.js'; +import { + VoiceStreamSession, + VoicePartialResultError, +} from '../../src/services/voice-stream-session.js'; import { VoiceClient } from '../../src/api/voice-client.js'; import { VoiceError } from '../../src/utils/errors.js'; import type { @@ -13,6 +16,7 @@ import type { VoiceSessionResult, VoiceTranslateOptions, VoiceStreamCallbacks, + VoiceTargetLanguage, } from '../../src/types/voice.js'; import { createMockVoiceClient } from '../helpers/mock-factories'; @@ -793,6 +797,58 @@ describe('VoiceStreamSession', () => { expect(result.targets[0]!.text).toBe(''); }); + it('should match the echoed language regardless of casing', async () => { + // The requested set spells variants zh-HANS and en-GB; a server echoing + // another canonicalization used to have its translation dropped on the + // floor and then reported as missing. + const result = await runWithFrames( + (callbacks) => { + callbacks.onSourceTranscript?.({ + concluded: [{ text: 'Hello', language: 'en', start_time: 0, end_time: 1 }], + tentative: [], + }); + callbacks.onTargetTranscript?.({ + // Cast because the union only spells the requested casing; the wire + // is not bound by it, which is the whole hazard here. + language: 'zh-Hans' as VoiceTargetLanguage, + concluded: [{ text: '你好', start_time: 0, end_time: 1 }], + tentative: [], + }); + }, + { targetLangs: ['zh-HANS'], chunkInterval: 0 }, + ); + + expect(result.targets[0]!.text).toBe('你好'); + expect(result.targets[0]!.lang).toBe('zh-HANS'); + }); + + it('should carry the salvaged transcripts on the error', async () => { + expect.assertions(3); + try { + await runWithFrames( + (callbacks) => { + callbacks.onSourceTranscript?.({ + concluded: [{ text: 'Hello', language: 'en', start_time: 0, end_time: 1 }], + tentative: [], + }); + callbacks.onTargetTranscript?.({ + language: 'fr', + concluded: [{ text: 'Bonjour', start_time: 0, end_time: 1 }], + tentative: [], + }); + }, + { targetLangs: ['fr', 'de'], chunkInterval: 0 }, + ); + } catch (error) { + // The audio is billed either way, so what did arrive must not be thrown + // away with the failure. + const partial = (error as VoicePartialResultError).result; + expect(partial.source.text).toBe('Hello'); + expect(partial.targets.find(t => t.lang === 'fr')?.text).toBe('Bonjour'); + expect(partial.targets.find(t => t.lang === 'de')?.text).toBe(''); + } + }); + it('should close the input generator when rejecting', async () => { const tracked = trackedChunks(); From 8c699a9473103b6c5fe288f896202214e57abf06 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 17:34:39 -0400 Subject: [PATCH 036/256] fix(usage): stop reporting API-key usage as the account total Duration-billed products fell back to `apiKeyUnitCount` for the account column because live responses omit `unit_count` for them -- so `deepl usage` printed the same number twice, as though the key accounted for all voice usage on the account. The account-wide figure the response does carry, `account_unit_count`, was neither typed nor parsed. It is parsed now, and when no account-wide figure is present the row says "(API key)" rather than inventing a total. --- src/api/translation-client.ts | 26 +++++++++++++++---------- src/cli/commands/usage.ts | 33 ++++++++++++++++++++++++-------- tests/unit/usage-command.test.ts | 24 ++++++++++++++++++++++- 3 files changed, 64 insertions(+), 19 deletions(-) diff --git a/src/api/translation-client.ts b/src/api/translation-client.ts index 38a363bf..1d031cb9 100644 --- a/src/api/translation-client.ts +++ b/src/api/translation-client.ts @@ -38,6 +38,7 @@ interface DeepLUsageResponse { character_count: number; api_key_character_count: number; unit_count?: number; + account_unit_count?: number; api_key_unit_count?: number; billing_unit?: string; }>; @@ -74,6 +75,12 @@ export interface ProductUsage { characterCount: number; apiKeyCharacterCount: number; unitCount?: number; + /** + * Account-wide units. What live responses actually carry for duration-billed + * products, where `unit_count` is absent -- so leaving it unparsed made the + * account total unreportable. + */ + accountUnitCount?: number; apiKeyUnitCount?: number; billingUnit?: string; } @@ -232,6 +239,7 @@ export class TranslationClient extends HttpClient { characterCount: p.character_count, apiKeyCharacterCount: p.api_key_character_count, ...(p.unit_count !== undefined && { unitCount: p.unit_count }), + ...(p.account_unit_count !== undefined && { accountUnitCount: p.account_unit_count }), ...(p.api_key_unit_count !== undefined && { apiKeyUnitCount: p.api_key_unit_count }), ...(p.billing_unit && { billingUnit: p.billing_unit }), })); @@ -299,16 +307,14 @@ export class TranslationClient extends HttpClient { private translateLanguages?: Promise; private fetchTranslateLanguages(): Promise { - if (!this.translateLanguages) { - this.translateLanguages = this.makeRequest( - 'GET', - '/v3/languages', - { resource: 'translate_text' } - ).catch((error: unknown) => { - delete this.translateLanguages; - throw error; - }); - } + this.translateLanguages ??= this.makeRequest( + 'GET', + '/v3/languages', + { resource: 'translate_text' } + ).catch((error: unknown) => { + delete this.translateLanguages; + throw error; + }); return this.translateLanguages; } diff --git a/src/cli/commands/usage.ts b/src/cli/commands/usage.ts index dba29db0..53127626 100644 --- a/src/cli/commands/usage.ts +++ b/src/cli/commands/usage.ts @@ -21,12 +21,25 @@ function isDurationBilled(product: ProductUsage): boolean { return product.billingUnit !== undefined && DURATION_BILLING_UNITS.has(product.billingUnit); } -/** Duration-billed usage in milliseconds: total and API-key-scoped amounts. */ -function productDurationsMs(product: ProductUsage): { used: number; apiKeyUsed: number } { +/** + * Duration-billed usage in milliseconds: the account-wide amount where the + * response carries one, and the API-key-scoped amount. + * + * `accountUsed` stays undefined rather than falling back to the API-key figure: + * live responses omit `unit_count` for these products, so the fallback printed + * the key's own usage in the account column and the two were always equal. + */ +function productDurationsMs(product: ProductUsage): { + accountUsed: number | undefined; + apiKeyUsed: number; +} { const scale = product.billingUnit === 'minutes' ? 60_000 : 1; - const used = product.unitCount ?? product.apiKeyUnitCount ?? product.characterCount; + const account = product.unitCount ?? product.accountUnitCount; const apiKeyUsed = product.apiKeyUnitCount ?? product.apiKeyCharacterCount; - return { used: used * scale, apiKeyUsed: apiKeyUsed * scale }; + return { + accountUsed: account === undefined ? undefined : account * scale, + apiKeyUsed: apiKeyUsed * scale, + }; } export class UsageCommand { @@ -109,8 +122,12 @@ export class UsageCommand { for (const product of usage.products) { const name = productDisplayName(product.productType); if (isDurationBilled(product)) { - const { used, apiKeyUsed } = productDurationsMs(product); - lines.push(` ${name}: ${this.formatMilliseconds(used)} (API key: ${this.formatMilliseconds(apiKeyUsed)})`); + const { accountUsed, apiKeyUsed } = productDurationsMs(product); + lines.push( + accountUsed === undefined + ? ` ${name}: ${this.formatMilliseconds(apiKeyUsed)} (API key)` + : ` ${name}: ${this.formatMilliseconds(accountUsed)} (API key: ${this.formatMilliseconds(apiKeyUsed)})`, + ); } else if (product.unitCount !== undefined) { const apiKeyPart = product.apiKeyUnitCount !== undefined ? ` (API key: ${formatNumber(product.apiKeyUnitCount)} units)` @@ -198,10 +215,10 @@ export class UsageCommand { for (const product of usage.products) { const name = productDisplayName(product.productType); if (isDurationBilled(product)) { - const { used, apiKeyUsed } = productDurationsMs(product); + const { accountUsed, apiKeyUsed } = productDurationsMs(product); productTable.push([ name, - this.formatMilliseconds(used), + accountUsed === undefined ? '—' : this.formatMilliseconds(accountUsed), this.formatMilliseconds(apiKeyUsed), ]); } else if (product.unitCount !== undefined) { diff --git a/tests/unit/usage-command.test.ts b/tests/unit/usage-command.test.ts index 02cec009..59887458 100644 --- a/tests/unit/usage-command.test.ts +++ b/tests/unit/usage-command.test.ts @@ -340,10 +340,32 @@ describe('UsageCommand', () => { ], }); - expect(formatted).toContain('speech_to_text: 24h 23m 0s (API key: 24h 23m 0s)'); + // No account-wide figure in the response, so only the API key's own usage + // is reported. Printing it in both columns implied an account total the + // response never carried. + expect(formatted).toContain('speech_to_text: 24h 23m 0s (API key)'); expect(formatted).not.toContain('speech_to_text: 0 characters'); }); + it('should report the account-wide duration when the response carries one', () => { + const formatted = usageCommand.formatUsage({ + characterCount: 0, + characterLimit: 20000000, + products: [ + { + productType: 'speechToText', + characterCount: 0, + apiKeyCharacterCount: 0, + accountUnitCount: 6000, + apiKeyUnitCount: 1463, + billingUnit: 'minutes', + }, + ], + }); + + expect(formatted).toContain('speech_to_text: 100h 0m 0s (API key: 24h 23m 0s)'); + }); + it('should prefer unitCount over apiKeyUnitCount for minutes-billed totals', () => { const formatted = usageCommand.formatUsage({ characterCount: 0, From a7bb0447414dcb72651dc0337286160deb77c095 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 17:34:47 -0400 Subject: [PATCH 037/256] fix(translate): stop repeating a doomed request and say when a code is deferred Since validation defers unknown-but-well-formed codes to the API, a two-letter typo now reaches it -- and a directory translation asked the same rejected question once per batch, spending a round trip each time to be told the same thing. An unsupported target_lang or source_lang is a property of the request, not of one batch, so the remaining batches fail without being sent. Errors specific to a batch still let the run continue. Local validation also used to be the thing that pointed at `deepl languages`, and deferring left the user with a bare "target_lang not supported" from the server. A code the bundled snapshot does not list now says so up front, before anything is sent or billed. --- src/cli/commands/translate/translate-utils.ts | 11 ++++- src/services/batch-translation.ts | 17 +++++++ src/utils/unrecoverable-request-error.ts | 21 ++++++++ tests/unit/services/batch-translation.test.ts | 48 +++++++++++++++++++ tests/unit/translate-utils.test.ts | 18 +++++++ 5 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 src/utils/unrecoverable-request-error.ts diff --git a/src/cli/commands/translate/translate-utils.ts b/src/cli/commands/translate/translate-utils.ts index b387c420..a24544ef 100644 --- a/src/cli/commands/translate/translate-utils.ts +++ b/src/cli/commands/translate/translate-utils.ts @@ -68,7 +68,16 @@ export function warnIgnoredOptions(mode: string, options: TranslateOptions, supp export function validateLanguageCodes(langCodes: string[]): void { for (const lang of langCodes) { if (VALID_LANGUAGES.has(lang)) continue; - if (looksLikeLanguageTag(lang)) continue; + if (looksLikeLanguageTag(lang)) { + // Said up front, before anything is sent or billed. Deferring to the API + // means a two-letter typo now comes back as a bare "target_lang not + // supported" from the server, which no longer points anywhere useful. + Logger.warn( + `Note: "${lang}" is not in the bundled language list; deferring to the API.\n` + + ' Run: deepl languages to see the languages this build knows about.' + ); + continue; + } throw new ValidationError( `Invalid target language code: "${lang}".`, 'Run: deepl languages to see all available languages' diff --git a/src/services/batch-translation.ts b/src/services/batch-translation.ts index 7dcbc535..b4a087f2 100644 --- a/src/services/batch-translation.ts +++ b/src/services/batch-translation.ts @@ -15,6 +15,7 @@ import { TranslationOptions } from '../types/index.js'; import { safeReadFile } from '../utils/safe-read-file.js'; import { Logger } from '../utils/logger.js'; import { ValidationError } from '../utils/errors.js'; +import { isUnrecoverableRequestError } from '../utils/unrecoverable-request-error.js'; import { errorMessage } from '../utils/error-message.js'; interface BatchOptions { @@ -220,6 +221,10 @@ export class BatchTranslationService { let completed = startCompleted; let currentBatch: FileEntry[] = []; let currentBytes = 0; + // Set once the API rejects the request itself (an unsupported target_lang, + // say). Every remaining batch would be told the same thing, so they are + // failed without spending the round trips. + let requestRejected: unknown; const flushBatch = async (): Promise => { const batch = currentBatch; @@ -229,6 +234,15 @@ export class BatchTranslationService { return; } + if (requestRejected !== undefined) { + for (const entry of batch) { + failed.push({ file: entry.file, error: errorMessage(requestRejected) }); + completed++; + onProgress?.({ completed, total: totalFiles, current: entry.file }); + } + return; + } + if (batchOptions.abortSignal?.aborted) { for (const entry of batch) { completed++; @@ -275,6 +289,9 @@ export class BatchTranslationService { onProgress?.({ completed, total: totalFiles, current: entry.file }); } } catch (error) { + if (isUnrecoverableRequestError(error)) { + requestRejected = error; + } Logger.error(`Batch translation failed: ${errorMessage(error)}`); for (const entry of batch) { failed.push({ diff --git a/src/utils/unrecoverable-request-error.ts b/src/utils/unrecoverable-request-error.ts new file mode 100644 index 00000000..5f7efb9d --- /dev/null +++ b/src/utils/unrecoverable-request-error.ts @@ -0,0 +1,21 @@ +import { errorMessage } from './error-message.js'; + +/** + * Whether an error means the request itself is wrong, not that this particular + * item failed. + * + * A rejected `target_lang` is the same rejection for every file and every target + * in the run, so retrying it per batch buys nothing: it just spends another round + * trip -- and bills the items that do succeed -- to be told the same thing again. + * Language validation defers to the API on codes the bundled snapshot predates, + * which is what makes this reachable from a plain typo. + */ +export function isUnrecoverableRequestError(error: unknown): boolean { + const message = errorMessage(error).toLowerCase(); + return ( + message.includes("value for 'target_lang' not supported") || + message.includes("value for 'source_lang' not supported") || + message.includes('target_lang not supported') || + message.includes('source_lang not supported') + ); +} diff --git a/tests/unit/services/batch-translation.test.ts b/tests/unit/services/batch-translation.test.ts index d86cd5cc..bd3ebea3 100644 --- a/tests/unit/services/batch-translation.test.ts +++ b/tests/unit/services/batch-translation.test.ts @@ -502,6 +502,54 @@ describe('BatchTranslationService', () => { expect(secondCallTexts).toHaveLength(2); }); + it('should stop requesting once the API rejects the target language', async () => { + const files: string[] = []; + for (let i = 0; i < 52; i++) { + const f = path.join(testDir, `reject${i}.txt`); + fs.writeFileSync(f, `Text ${i}`); + files.push(f); + } + + mockTranslationService.translateBatch.mockRejectedValue( + new Error("API error: Value for 'target_lang' not supported."), + ); + + const result = await batchServiceWithTranslation.translateFiles( + files, + { targetLang: 'ex' as never }, + { outputDir: testDir }, + ); + + // The same rejection applies to every batch, so it is asked once rather + // than once per batch. + expect(mockTranslationService.translateBatch).toHaveBeenCalledTimes(1); + expect(result.failed).toHaveLength(52); + expect(result.successful).toHaveLength(0); + }); + + it('should keep going when a batch fails for a reason specific to it', async () => { + const files: string[] = []; + for (let i = 0; i < 52; i++) { + const f = path.join(testDir, `partial${i}.txt`); + fs.writeFileSync(f, `Text ${i}`); + files.push(f); + } + + mockTranslationService.translateBatch + .mockRejectedValueOnce(new Error('Too many requests')) + .mockImplementation(async (texts) => texts.map(t => ({ text: `translated: ${t}` }))); + + const result = await batchServiceWithTranslation.translateFiles( + files, + { targetLang: 'es' }, + { outputDir: testDir }, + ); + + expect(mockTranslationService.translateBatch).toHaveBeenCalledTimes(2); + expect(result.successful).toHaveLength(2); + expect(result.failed).toHaveLength(50); + }); + it('should split batches when cumulative bytes exceed MAX_TEXT_BYTES', async () => { // Create two files that together exceed MAX_TEXT_BYTES const halfSize = Math.floor(MAX_TEXT_BYTES / 2) + 100; diff --git a/tests/unit/translate-utils.test.ts b/tests/unit/translate-utils.test.ts index 72458fbf..7288d64a 100644 --- a/tests/unit/translate-utils.test.ts +++ b/tests/unit/translate-utils.test.ts @@ -121,6 +121,24 @@ describe('translate-utils', () => { expect(() => validateLanguageCodes(['abc-1234'])).not.toThrow(); }); + it('should warn that an unknown code is being deferred to the API', () => { + mockedLoggerWarn.mockClear(); + validateLanguageCodes(['ex']); + + // Said before anything is sent: the API answers a typo with a bare + // "target_lang not supported" that points nowhere. + const warning = mockedLoggerWarn.mock.calls.map(call => String(call[0])).join('\n'); + expect(warning).toContain('"ex" is not in the bundled language list'); + expect(warning).toContain('deepl languages'); + }); + + it('should not warn about a code the snapshot lists', () => { + mockedLoggerWarn.mockClear(); + validateLanguageCodes(['de', 'en-gb']); + + expect(mockedLoggerWarn).not.toHaveBeenCalled(); + }); + it('should still reject input that is not shaped like a language tag', () => { for (const code of ['g', 'grman', 'de_ch', 'de-', '../etc/passwd', 'de ch']) { expect(() => validateLanguageCodes([code])).toThrow(ValidationError); From cd7005aed4e92a37614bcd53ddbef8d8256ed4a4 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 18:00:08 -0400 Subject: [PATCH 038/256] fix(write): defer an unknown Write language to the API instead of rejecting it The Write list is checked locally because it is small enough for the error to name every option -- but it is still a generated snapshot, and nothing in CI regenerates it. A language DeepL added was therefore unreachable until someone ran generate:languages, with no flag or env var to get past it. A code shaped like a language tag now goes to the API with a warning that still names the bundled set, so the user gets more information than the old hard failure gave, not less. Malformed input is still rejected locally. The generated file is also added to .prettierignore: check:languages compares raw text, so `npm run format` would have reported permanent drift. --- .prettierignore | 4 ++++ src/cli/commands/register-write.ts | 24 ++++++++++++++++++------ tests/unit/register-write.test.ts | 30 +++++++++++++++++------------- 3 files changed, 39 insertions(+), 19 deletions(-) diff --git a/.prettierignore b/.prettierignore index aa7de81f..f1d49a79 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,7 @@ dist coverage *.log .DS_Store + +# Generated from GET /v3/languages; check:languages compares the raw text, so +# reformatting it would report permanent drift. +src/data/language-entries.ts diff --git a/src/cli/commands/register-write.ts b/src/cli/commands/register-write.ts index dba9f177..62ad3ad2 100644 --- a/src/cli/commands/register-write.ts +++ b/src/cli/commands/register-write.ts @@ -4,6 +4,7 @@ import { atomicWriteFile } from '../../utils/atomic-write.js'; import chalk from 'chalk'; import type { WriteLanguage, WritingStyle, WriteTone } from '../../types/index.js'; import { WRITE_TARGET_LANGUAGES } from '../../data/language-entries.js'; +import { looksLikeLanguageTag } from '../../data/language-registry.js'; import { Logger } from '../../utils/logger.js'; import { ExitCode } from '../../utils/exit-codes.js'; import { isNoInput } from '../../utils/confirm.js'; @@ -11,10 +12,11 @@ import { ValidationError } from '../../utils/errors.js'; import { createWriteCommand, type ServiceDeps } from './service-factory.js'; /** - * Generated from GET /v3/languages?resource=write, so it cannot drift from what - * the API accepts. Unlike `translate --to`, a code outside this list is rejected - * locally rather than deferred to the API: at 14 entries the error can name every - * valid option, which beats a round trip. + * Generated from GET /v3/languages?resource=write. Small enough that an error can + * name every option, which is why it is checked locally at all -- but it is still + * a snapshot, so an unrecognized code that is shaped like a language tag is + * deferred to the API with a warning rather than rejected. Rejecting outright + * made a language DeepL had added unreachable until someone regenerated the file. */ export const WRITE_LANGUAGES = WRITE_TARGET_LANGUAGES; /** @@ -84,10 +86,20 @@ export function createWriteAction( if (options.lang) { const canonical = WRITE_LANGUAGE_BY_LOWERCASE.get(options.lang.toLowerCase()); - if (!canonical) { + if (canonical) { + options.lang = canonical; + } else if (looksLikeLanguageTag(options.lang.toLowerCase())) { + // The bundled list is a snapshot and can lag the API, and nothing in CI + // regenerates it, so a language DeepL has added must not be + // unreachable: a well-formed code goes to the API to accept or reject. + Logger.warn( + `Note: "${options.lang}" is not in the bundled Write language list; deferring to the API.\n` + + ` Bundled options: ${WRITE_LANGUAGES.join(', ')}` + ); + options.lang = options.lang.toLowerCase(); + } else { throw new ValidationError(`Invalid language code: ${options.lang}. Valid options: ${WRITE_LANGUAGES.join(', ')}`); } - options.lang = canonical; } if (options.style && !(WRITE_STYLES as readonly string[]).includes(options.style)) { diff --git a/tests/unit/register-write.test.ts b/tests/unit/register-write.test.ts index 95b3f9b0..f8fbccaf 100644 --- a/tests/unit/register-write.test.ts +++ b/tests/unit/register-write.test.ts @@ -189,26 +189,30 @@ describe('registerWrite', () => { }); describe('validation', () => { - it('should reject invalid language code', async () => { - await program.parseAsync(['node', 'test', 'write', 'Hello', '--lang', 'xx']); + it('should reject a code that is not shaped like a language tag', async () => { + await program.parseAsync(['node', 'test', 'write', 'Hello', '--lang', 'de_ch']); expect(handleError).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining('Invalid language code') }), ); }); - it('should reject a well-formed code the Write API does not support', async () => { - // Deliberately stricter than `translate --to`, which passes well-formed - // unknown codes to the API: the Write set is small enough to enumerate, - // so naming the valid options beats a round trip. - await program.parseAsync(['node', 'test', 'write', 'Hello', '--lang', 'hi']); - expect(handleError).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining('Invalid language code') }), - ); - expect(mockWriteCommand.improve).not.toHaveBeenCalled(); + it('should defer a well-formed code the bundled list does not have', async () => { + // The bundled list is a snapshot and nothing in CI regenerates it, so + // rejecting outright made a language DeepL had added unreachable. The + // request goes through with a warning that still names the bundled set. + await program.parseAsync(['node', 'test', 'write', 'Hello', '--lang', 'xx']); + + expect(handleError).not.toHaveBeenCalled(); + expect(mockWriteCommand.improve).toHaveBeenCalled(); + const warning = (Logger.warn as jest.Mock).mock.calls.map(c => String(c[0])).join('\n'); + expect(warning).toContain('not in the bundled Write language list'); + for (const code of WRITE_TARGET_LANGUAGES) { + expect(warning).toContain(code); + } }); - it('should enumerate every supported language in the rejection', async () => { - await program.parseAsync(['node', 'test', 'write', 'Hello', '--lang', 'xx']); + it('should enumerate every supported language when rejecting malformed input', async () => { + await program.parseAsync(['node', 'test', 'write', 'Hello', '--lang', 'nope_nope']); const error = handleError.mock.calls[0]?.[0] as Error; for (const code of WRITE_TARGET_LANGUAGES) { expect(error.message).toContain(code); From 8449319257854e8b026390a6be38c26bd2d8c262 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 18:00:08 -0400 Subject: [PATCH 039/256] fix(glossary): dedupe IDs, check coverage in sync, and validate before dry-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six smaller defects around the multi-glossary work: - naming one glossary twice (or a name plus its own UUID) sent glossary_ids with a duplicate: it flipped the wire parameter away from glossary_id, minted a third cache key for an identical request, and spent two of the five slots - sync resolved its glossary without the language pair, so a glossary that does not cover it failed once per file instead of at startup, as the sibling translation-memory resolution already avoided - translate --dry-run reported a glossary command as runnable that the real run rejects for a missing --from - the document path never ran the extended-tier constraint check, so a glossary with an extended target uploaded the file and let the API refuse it, while the text path said so locally - `glossary info` printed raw dictionary languages under a normalized summary, so one glossary could show "EN → DE" beneath "Source language: en" - a coverage error listed every dictionary of a multilingual glossary on one line The glossary pair matrix also compares languages after normalization, so casing differing between the two role lists can no longer emit a self-pair. --- src/cli/commands/glossary.ts | 4 +++- src/cli/commands/register-translate.ts | 17 ++++++++++++++++- .../translate/document-translation-handler.ts | 10 +++++++++- .../translate/translation-options-factory.ts | 10 +++++++++- src/services/glossary.ts | 15 ++++++++++++--- src/sync/sync-service.ts | 11 ++++++++++- tests/e2e/cli-multi-glossary.e2e.test.ts | 17 ++++++++++++++--- tests/unit/sync/sync-service.test.ts | 7 ++++++- 8 files changed, 79 insertions(+), 12 deletions(-) diff --git a/src/cli/commands/glossary.ts b/src/cli/commands/glossary.ts index 1490c502..fcf41191 100644 --- a/src/cli/commands/glossary.ts +++ b/src/cli/commands/glossary.ts @@ -251,7 +251,9 @@ export class GlossaryCommand { if (multilingual) { lines.push('\nLanguage pairs:'); glossary.dictionaries.forEach(dict => { - lines.push(` ${dict.source_lang} → ${dict.target_lang}: ${dict.entry_count} entries`); + // Lowercased like the summary above: normalizeGlossaryInfo only touches + // the top-level fields, so raw dictionaries printed EN -> DE under en. + lines.push(` ${dict.source_lang.toLowerCase()} → ${dict.target_lang.toLowerCase()}: ${dict.entry_count} entries`); }); } diff --git a/src/cli/commands/register-translate.ts b/src/cli/commands/register-translate.ts index e6210757..21f3e4b8 100644 --- a/src/cli/commands/register-translate.ts +++ b/src/cli/commands/register-translate.ts @@ -4,7 +4,11 @@ import chalk from 'chalk'; import { Logger } from '../../utils/logger.js'; import { ValidationError } from '../../utils/errors.js'; import { createTranslateCommand, type ServiceDeps } from './service-factory.js'; -import { MAX_GLOSSARIES_PER_REQUEST } from '../../utils/glossary-params.js'; +import { + MAX_GLOSSARIES_PER_REQUEST, + applyGlossarySourceLang, + hasGlossarySelection, +} from '../../utils/glossary-params.js'; export function registerTranslate( program: Command, @@ -166,6 +170,17 @@ Examples: ); } + // Checked before --dry-run reports the command as runnable. Glossary name + // resolution still needs the API and stays out of dry-run, but the + // requirement that a glossary carry a source language does not. + if (hasGlossarySelection(options)) { + applyGlossarySourceLang( + options, + deps.getConfigService().getValue('defaults.sourceLang'), + 'Example: deepl translate --from en --to es --glossary my-glossary "Hello"', + ); + } + if (options.dryRun) { const targetLangs = options.to!.split(',').map(l => l.trim()); const lines: string[] = [ diff --git a/src/cli/commands/translate/document-translation-handler.ts b/src/cli/commands/translate/document-translation-handler.ts index c14fe062..0a3f0502 100644 --- a/src/cli/commands/translate/document-translation-handler.ts +++ b/src/cli/commands/translate/document-translation-handler.ts @@ -4,7 +4,11 @@ import { ValidationError } from '../../../utils/errors.js'; import type { Language } from '../../../types/index.js'; import type { DocumentTranslationOptions } from '../../../types/api.js'; import type { HandlerContext, TranslateOptions } from './types.js'; -import { warnIgnoredOptions, validateLanguageCodes } from './translate-utils.js'; +import { + warnIgnoredOptions, + validateLanguageCodes, + validateExtendedLanguageConstraints, +} from './translate-utils.js'; import { buildBaseTranslationOptions, applyGlossarySelection } from './translation-options-factory.js'; import { applyGlossarySourceLang } from '../../../utils/glossary-params.js'; @@ -22,6 +26,10 @@ export class DocumentTranslationHandler { warnIgnoredOptions('document', options, supported); validateLanguageCodes([options.to]); + // Documents support glossaries now, which makes the extended-tier constraint + // reachable here: without this the upload happens and the API rejects it, + // while the text path says so locally. + validateExtendedLanguageConstraints(options.to, options); // The API rejects a document glossary without source_lang: "source_lang has // to be specified in order to use a glossary." diff --git a/src/cli/commands/translate/translation-options-factory.ts b/src/cli/commands/translate/translation-options-factory.ts index 1e59800e..5cd483f9 100644 --- a/src/cli/commands/translate/translation-options-factory.ts +++ b/src/cli/commands/translate/translation-options-factory.ts @@ -58,9 +58,17 @@ export async function applyGlossarySelection< // Resolved sequentially so the service's resolution cache is populated // before the next name-or-ID lookup needs the glossary list. + // + // Deduplicated after resolution, because a name and its own UUID resolve to + // the same glossary: naming one twice would otherwise flip the wire parameter + // from glossary_id to glossary_ids, mint a third cache key for an identical + // request, and spend two of the five slots the API allows. const ids: string[] = []; for (const nameOrId of options.glossary) { - ids.push(await resolveGlossaryId(glossaryService, nameOrId, expected)); + const id = await resolveGlossaryId(glossaryService, nameOrId, expected); + if (!ids.includes(id)) { + ids.push(id); + } } const [only] = ids; diff --git a/src/services/glossary.ts b/src/services/glossary.ts index 9b7e5575..ff93a3e5 100644 --- a/src/services/glossary.ts +++ b/src/services/glossary.ts @@ -21,6 +21,9 @@ function hasSuspiciousChars(name: string): boolean { const LIST_CACHE_TTL_MS = 60_000; +/** Language pairs to name in a coverage error before summarizing the rest. */ +const MAX_PAIRS_IN_SUGGESTION = 8; + /** * Characters the glossary TSV wire format reserves as column and row * separators. A term containing one of them cannot survive the round trip: @@ -217,9 +220,15 @@ export class GlossaryService { ); const missing = expected.targets.filter(target => !covered(target)); if (missing.length > 0) { - const pairs = match.dictionaries - .map(d => `${d.source_lang.toLowerCase()}→${d.target_lang.toLowerCase()}`) - .join(', '); + const allPairs = match.dictionaries.map( + d => `${d.source_lang.toLowerCase()}→${d.target_lang.toLowerCase()}`, + ); + // A multilingual glossary can hold dozens of dictionaries, and the whole + // cross-product on one line stops being a suggestion. + const pairs = + allPairs.length > MAX_PAIRS_IN_SUGGESTION + ? `${allPairs.slice(0, MAX_PAIRS_IN_SUGGESTION).join(', ')} and ${allPairs.length - MAX_PAIRS_IN_SUGGESTION} more` + : allPairs.join(', '); throw new ConfigError( `Glossary "${sanitizeForError(nameOrId)}" does not support the requested language pair`, `Glossary covers ${pairs}; requested ${from}→${missing.map(t => t.toLowerCase()).join(',')}.`, diff --git a/src/sync/sync-service.ts b/src/sync/sync-service.ts index edfe3d6c..b08ea1a1 100644 --- a/src/sync/sync-service.ts +++ b/src/sync/sync-service.ts @@ -191,7 +191,16 @@ export class SyncService { let resolvedGlossaryId: string | undefined; if (config.translation?.glossary && config.translation.glossary !== 'auto' && !options?.dryRun) { - resolvedGlossaryId = await this.glossaryService.resolveGlossaryId(config.translation.glossary); + // The pair is known from the config, so a glossary that does not cover it + // fails here rather than once per file, as translation-memory resolution + // below already does. + const glossaryLocales = options?.localeFilter?.length + ? config.target_locales.filter(l => options.localeFilter!.includes(l)) + : config.target_locales; + resolvedGlossaryId = await this.glossaryService.resolveGlossaryId( + config.translation.glossary, + { from: config.source_locale as Language, targets: glossaryLocales as Language[] }, + ); } let resolvedTmId: string | undefined; diff --git a/tests/e2e/cli-multi-glossary.e2e.test.ts b/tests/e2e/cli-multi-glossary.e2e.test.ts index 42fa7f62..8ae6e131 100644 --- a/tests/e2e/cli-multi-glossary.e2e.test.ts +++ b/tests/e2e/cli-multi-glossary.e2e.test.ts @@ -44,7 +44,7 @@ describe('translate --glossary repetition E2E', () => { it('should not reject exactly five glossaries during flag validation', () => { const result = runCLIExpectError( - 'translate "Hello" --to de --dry-run --glossary a --glossary b --glossary c ' + + 'translate "Hello" --from en --to de --dry-run --glossary a --glossary b --glossary c ' + '--glossary d --glossary e', ); @@ -56,7 +56,7 @@ describe('translate --glossary repetition E2E', () => { describe('dry run', () => { it('should list every requested glossary in order', () => { const output = runCLI( - 'translate "Hello" --to de --dry-run --glossary base-terms --glossary project-overrides', + 'translate "Hello" --from en --to de --dry-run --glossary base-terms --glossary project-overrides', { noColor: true }, ); @@ -64,7 +64,7 @@ describe('translate --glossary repetition E2E', () => { }); it('should keep the singular label for one glossary', () => { - const output = runCLI('translate "Hello" --to de --dry-run --glossary base-terms', { + const output = runCLI('translate "Hello" --from en --to de --dry-run --glossary base-terms', { noColor: true, }); @@ -77,6 +77,17 @@ describe('translate --glossary repetition E2E', () => { expect(output).not.toMatch(/Glossar/i); }); + + it('should not report a glossary command as runnable when --from is missing', () => { + // Dry run is where people check a command is well-formed, so it has to + // apply the same requirement the real run does. + const result = runCLIExpectError('translate "Hello" --to de --dry-run --glossary base-terms', { + excludeApiKey: true, + }); + + expect(result.status).toBeGreaterThan(0); + expect(result.output).toMatch(/Source language \(--from\) is required/i); + }); }); describe('argument handling', () => { diff --git a/tests/unit/sync/sync-service.test.ts b/tests/unit/sync/sync-service.test.ts index 7df0fc40..40699b4f 100644 --- a/tests/unit/sync/sync-service.test.ts +++ b/tests/unit/sync/sync-service.test.ts @@ -2424,7 +2424,12 @@ describe('SyncService', () => { translation: { glossary: 'my-glossary' }, })); - expect(mockGlossary.resolveGlossaryId).toHaveBeenCalledWith('my-glossary'); + // With the configured pair, so a glossary that does not cover it fails here + // rather than once per file. + expect(mockGlossary.resolveGlossaryId).toHaveBeenCalledWith('my-glossary', { + from: 'en', + targets: ['de'], + }); }); it('should not resolve glossary during dryRun', async () => { From 110a76717d55b8680f18aab6352d141c09858753 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 18:00:37 -0400 Subject: [PATCH 040/256] fix(config): normalize language codes and warn about ones the snapshot lacks `config set defaults.sourceLang DE` was rejected while `translate --from DE` works, because the translate paths lowercase the flag first and the fallback pattern is lowercase-only -- so the one casing that is certainly valid was the one class of input still refused. Values are lowercased before validating and stored normalized. A code the bundled snapshot does not list is still accepted, since the snapshot can lag the API, but it now warns at the point of entry: a typo written to config otherwise failed on every later command with nothing pointing back at the config value that caused it. Also documents the repeatable --glossary in the flag table, adds the required --from to the last example that omitted it, and gives the api.ts import of ./common the extension its neighbour already had. --- docs/API.md | 2 +- examples/24-languages.sh | 2 +- src/storage/config.ts | 31 ++++++++++++++++++++++++++++++- src/types/api.ts | 2 +- 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/docs/API.md b/docs/API.md index f4992bac..78a80023 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1005,7 +1005,7 @@ deepl voice [options] | `--to ` | `-t` | Target language(s), comma-separated, max 5 (required) | - | | `--from ` | `-f` | Source language (auto-detect if not specified) | auto | | `--formality ` | | Formality level: `default`, `formal`, `more`, `informal`, `less`, `prefer_more`, `prefer_less` | `default` | -| `--glossary ` | | Use glossary by name or ID | - | +| `--glossary ` | | Use glossary by name or ID (requires `--from`; repeatable, max 5, last wins on conflicts) | - | | `--content-type ` | | Audio content type (auto-detected from file extension) | auto | | `--chunk-size ` | | Audio chunk size in bytes | `6400` | | `--chunk-interval ` | | Interval between audio chunks in milliseconds | `200` | diff --git a/examples/24-languages.sh b/examples/24-languages.sh index cb44e25c..3446d608 100755 --- a/examples/24-languages.sh +++ b/examples/24-languages.sh @@ -80,7 +80,7 @@ supports() { # supports } if supports th glossary; then - deepl translate --to th --glossary my-terms "Hello" + deepl translate --from en --to th --glossary my-terms "Hello" else echo "Thai does not support glossaries; translating without one" deepl translate --to th "Hello" diff --git a/src/storage/config.ts b/src/storage/config.ts index 69fee194..b4d979e1 100644 --- a/src/storage/config.ts +++ b/src/storage/config.ts @@ -45,6 +45,21 @@ const DEFAULT_CACHE_SIZE = 1024 * 1024 * 1024; // 1GB const DEFAULT_CACHE_TTL = 30 * 24 * 60 * 60; // 30 days in seconds const DEFAULT_DEBOUNCE_MS = 500; +/** + * Language values are stored lowercase, matching what `deepl languages` prints + * and what every translate path normalizes its flags to, so a config written as + * `DE` does not read back as a code the registry cannot look up. + */ +function normalizeLanguageValue(path: string, value: unknown): unknown { + if (path === 'defaults.sourceLang' && typeof value === 'string') { + return value.toLowerCase(); + } + if (path === 'defaults.targetLangs' && Array.isArray(value)) { + return value.map(lang => (typeof lang === 'string' ? lang.toLowerCase() : lang)); + } + return value; +} + export class ConfigService { private config: DeepLConfig; private configPath: string; @@ -71,6 +86,7 @@ export class ConfigService { const keys = key.split('.'); this.validatePath(keys, value); + value = normalizeLanguageValue(keys.join('.'), value); let current: Record = this.config as unknown as Record; for (let i = 0; i < keys.length - 1; i++) { @@ -384,10 +400,23 @@ export class ConfigService { * is the authority on which languages exist and the snapshot can lag it. */ private validateLanguage(lang: string, key?: string): void { - if (!isValidLanguage(lang) && !looksLikeLanguageTag(lang)) { + // Lowercased first: every translate path lowercases the flag before using it, + // so `--from DE` works while `config set defaults.sourceLang DE` was rejected + // by a lowercase-only pattern -- the one casing that is certainly valid. + const normalized = typeof lang === 'string' ? lang.toLowerCase() : lang; + if (!isValidLanguage(normalized) && !looksLikeLanguageTag(normalized)) { const context = key ? ` for "${key}"` : ''; throw new ConfigError(`Invalid language code "${lang}"${context}. Run: deepl languages to see valid codes`); } + if (!isValidLanguage(normalized)) { + // Stored anyway, because the snapshot can lag the API -- but a typo written + // to config fails on every later command with nothing pointing back here. + const context = key ? ` for "${key}"` : ''; + Logger.warn( + `Note: "${lang}"${context} is not in the bundled language list; it will be sent to the API as-is.\n` + + ' Run: deepl languages to see the languages this build knows about.' + ); + } } /** diff --git a/src/types/api.ts b/src/types/api.ts index 5aae2431..0dde7419 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -2,7 +2,7 @@ * API-related type definitions */ -import { Language, Formality } from './common'; +import { Language, Formality } from './common.js'; import { WRITE_TARGET_LANGUAGES } from '../data/language-entries.js'; export type ModelType = From 2e925c3a5defa0c7ec0e0ebd2f99655e75ab2a2f Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 18:00:38 -0400 Subject: [PATCH 041/256] fix(api): compare glossary pair languages after normalization The v3 cross-product skipped the identity pair on raw casing, so a language spelled differently across the two role lists would emit a self-pair the API does not offer. --- src/api/glossary-client.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/api/glossary-client.ts b/src/api/glossary-client.ts index fc7efbfd..e65eb2b6 100644 --- a/src/api/glossary-client.ts +++ b/src/api/glossary-client.ts @@ -33,13 +33,14 @@ export class GlossaryClient extends HttpClient { const pairs: GlossaryLanguagePair[] = []; for (const source of sources) { for (const target of targets) { - if (source.lang === target.lang) { + // Compared after normalization: raw casing differing between the two role + // lists would otherwise emit a self-pair the API does not offer. + const sourceLang = this.normalizeLanguage(source.lang); + const targetLang = this.normalizeLanguage(target.lang); + if (sourceLang === targetLang) { continue; } - pairs.push({ - sourceLang: this.normalizeLanguage(source.lang), - targetLang: this.normalizeLanguage(target.lang), - }); + pairs.push({ sourceLang, targetLang }); } } return pairs; From f3e9af90b18bbd1bb4ffad7af7e4b226f6c5c6d2 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 18:04:11 -0400 Subject: [PATCH 042/256] docs(changelog): record the review fixes and correct claims they invalidated Adds Fixed entries for the cache-key omissions, the per-batch retry of a rejected language, the voice usage figure, the glossary minors, the config-only source language, the --features overclaiming, config code normalization, the tag-handling version dropped outside text mode, and the generator guards; plus a Changed entry for the derived Language union. Five existing Unreleased claims were no longer true and are corrected in place rather than contradicted further down: - write no longer rejects every unknown code locally - the multipart glossary_ids encoding was described as live-verified; the live document endpoint answers any unresolvable glossary the same way regardless of encoding, so only parameter recognition was confirmed there - the glossary preflight compares base languages, so regional targets work - the shared-feature note is scoped when some languages have no data - formality comes from the v3 features matrix, not the registry --- CHANGELOG.md | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79543c8a..7a3025bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **translate**: `--glossary` now applies to document translation (PDF, DOCX, PPTX, XLSX, images, and text-based files routed to the document API). It was previously accepted and then silently discarded with a "document mode does not support --glossary" warning, even though `POST /v2/document` supports glossaries. Repeating the flag works here too, with the same last-one-wins precedence. `--from` is required, because the API rejects a document glossary without a source language; `--translation-memory` remains unsupported for documents. Note that glossary matching is context-dependent for documents exactly as it is for text — a term applied in one sentence may be left alone in another. -- **translate**: `--glossary` is repeatable, applying up to 5 glossaries to one request via the API's `glossary_ids` parameter. Entries are merged, so terms unique to each glossary all apply; when several glossaries define the same source term the **last** `--glossary` on the command line wins, so the order is significant and is never sorted — reordering the flags is a different request with its own cache entry. Names and UUIDs may be mixed and are resolved independently. A single `--glossary` still goes out as `glossary_id`, leaving existing commands and their cache keys untouched; the new field is appended last in the cache key for the same reason. A 6th `--glossary` exits 6 (ValidationError) before any API call. `watch` and `sync` keep their single-glossary configuration. Verified against the live API, including the two encodings the endpoints require: repeated form fields for `POST /v2/translate`, and one comma-joined value for the multipart `POST /v2/document`, which keeps only the first of several repeated fields and would otherwise silently apply just one glossary. +- **translate**: `--glossary` is repeatable, applying up to 5 glossaries to one request via the API's `glossary_ids` parameter. Entries are merged, so terms unique to each glossary all apply; when several glossaries define the same source term the **last** `--glossary` on the command line wins, so the order is significant and is never sorted — reordering the flags is a different request with its own cache entry. Names and UUIDs may be mixed and are resolved independently. A single `--glossary` still goes out as `glossary_id`, leaving existing commands and their cache keys untouched; the new field is appended last in the cache key for the same reason. A 6th `--glossary` exits 6 (ValidationError) before any API call. `watch` and `sync` keep their single-glossary configuration. Verified against the live API: `POST /v2/translate` accepts `glossary_ids` as repeated form fields and resolves them in order, rejects a sixth with `A maximum of 5 glossaries can be specified per request.`, and rejects `glossary_id` and `glossary_ids` together with `Specify either glossary_id or glossary_ids, not both.` -- which is why the CLI collapses a single glossary to `glossary_id` rather than sending both. The multipart `POST /v2/document` takes one comma-joined value, since multipart does not parse repeated fields as a list; that endpoint answers any unresolvable glossary with `glossary_ids is not valid` regardless of encoding, so it was confirmed only to the extent that it recognises the parameter, and the list semantics there rest on the API documentation rather than on a live round trip. -- **languages**: `deepl languages --features` shows which features each language supports — formality, glossary, style rules, translation memory, tag handling and auto-detection — from the `features` matrix on `GET /v3/languages`, which the CLI previously discarded. Support no longer has to be discovered by making a request and reading the error. Which features get a column is derived from the response rather than a fixed list: a feature appears when its support differs across the languages listed, and one supported by all of them is reported once as `All listed languages also support: ...` instead of being repeated on every row. That makes the columns differ between listings — `auto detection` appears under `--target`, where target-only variants lack it, but is uniform under `--source` — and means a newly reported feature shows up without a code change. Support is signalled by the API reporting a feature at all; `status` describes maturity, so anything short of generally available renders verbatim (`glossary (beta)`) rather than collapsing to `yes`. Works with `--format table` (one column per feature) and `--format json` (the raw matrix including each status, present only when `--features` is passed, so existing JSON consumers are unaffected). `--features` supersedes the `[F]` shorthand and replaces it when given. It needs an API key; without one the command warns and falls back to the registry, which carries no feature data. Note that the matrix is finer-grained than the core/regional/extended tiers: some extended languages support style rules and translation memory even though they support neither formality nor glossary. +- **languages**: `deepl languages --features` shows which features each language supports — formality, glossary, style rules, translation memory, tag handling and auto-detection — from the `features` matrix on `GET /v3/languages`, which the CLI previously discarded. Support no longer has to be discovered by making a request and reading the error. Which features get a column is derived from the response rather than a fixed list: a feature appears when its support differs across the languages listed, and one supported by all of them is reported once as `All listed languages also support: ...` instead of being repeated on every row (`All languages with reported features also support: ...` when the listing also carries languages the response did not describe, since the note must not speak for those). A language the response omitted reads as `no feature data` rather than as supporting nothing, and does not count towards whether a feature varies. That makes the columns differ between listings — `auto detection` appears under `--target`, where target-only variants lack it, but is uniform under `--source` — and means a newly reported feature shows up without a code change. Support is signalled by the API reporting a feature at all; `status` describes maturity, so anything short of generally available renders verbatim (`glossary (beta)`) rather than collapsing to `yes`. Works with `--format table` (one column per feature) and `--format json` (the raw matrix including each status, present only when `--features` is passed, so existing JSON consumers are unaffected). `--features` supersedes the `[F]` shorthand and replaces it when given. It needs an API key; without one the command warns and falls back to the registry, which carries no feature data. Note that the matrix is finer-grained than the core/regional/extended tiers: some extended languages support style rules and translation memory even though they support neither formality nor glossary. - **cli**: `deepl correct` command (alias `c`) — spelling and grammar correction without rewording, via the Write API's `/v2/write/correct` endpoint. Supports the same input handling and workflow flags as `write` (`--check` with exit code 8, `--fix`/`--backup`, `--diff`, `--interactive`, `--output`/`--in-place`, `--format json`, `--no-cache`), but not `--style`/`--tone`, which the correct endpoint does not accept. Results are cached under a separate `correct:` namespace so corrections and rephrasings of the same text never collide. @@ -21,16 +21,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **translate**: **`--tag-handling` requests now pin `tag_handling_version=v2`** instead of letting the API pick. The CLI previously sent the parameter only when `--tag-handling-version` was passed, so everyone else inherited the server default — which DeepL documents as moving from v1 (deprecation-bound) to v2 at an unannounced date. That flip would have changed tag-handling output with no CLI change to point at, and worse, would have gone unnoticed by the cache: a request that omits the version hashes identically before and after, so cached v1 output would have kept being served after the API started returning v2. Adopting v2 now makes that one output shift deliberate and dated, and DeepL's own docs recommend v2 for structure handling. **`--tag-handling xml`/`html` output may differ from previous releases**; pass `--tag-handling-version v1` to keep the old behaviour, which is still honoured and always wins over the default. Requests without `--tag-handling` send no version and keep their existing cache keys; tag-handling entries cached by earlier versions now miss rather than being served stale, so the first such translation after upgrading is refetched. -- **translate**: **A glossary referenced by name is checked against the requested language pair before any translation request.** Previously only translation memories did this; a glossary whose dictionaries did not cover the pair reached the API and came back as `No dictionary found for language pair EN-DE in glossary `, naming a UUID the user never typed. It now fails locally, exit 7, naming what the glossary actually covers: `Glossary "my-terms" does not support the requested language pair` / `Glossary covers en→es; requested en→de.` This costs no extra request, because the glossary list is already fetched to resolve the name. Matching is per dictionary, so a multilingual glossary holding en→es and de→fr is not treated as covering en→fr, and when translating to several targets at once every one of them must be covered. Two deliberate exemptions: a glossary passed as a **UUID** is trusted and left to the API, matching how translation-memory resolution already behaves and giving an escape hatch if the check is ever wrong; and a glossary the API reports with no dictionaries is left alone, since that says nothing about coverage. +- **translate**: **A glossary referenced by name is checked against the requested language pair before any translation request.** Previously only translation memories did this; a glossary whose dictionaries did not cover the pair reached the API and came back as `No dictionary found for language pair EN-DE in glossary `, naming a UUID the user never typed. It now fails locally, exit 7, naming what the glossary actually covers: `Glossary "my-terms" does not support the requested language pair` / `Glossary covers en→es; requested en→de.` This costs no extra request, because the glossary list is already fetched to resolve the name. Matching is per dictionary, so a multilingual glossary holding en→es and de→fr is not treated as covering en→fr, and when translating to several targets at once every one of them must be covered. Both sides are compared on their **base** language, because glossary dictionaries only ever name base languages while `--to` accepts regional variants: a de→en glossary covers `--to en-us`, which the API accepts, and demanding an exact match would have made glossaries unusable for every regional target — `en-us`, `en-gb`, `pt-br`, `pt-pt`, `zh-hans`, `es-419`, `fr-ca` — including the ones DeepL steers users towards over bare `en`/`pt`. The check is therefore deliberately permissive at the edges: a pair it lets through is still the API's to reject, which is much the cheaper mistake. Two deliberate exemptions: a glossary passed as a **UUID** is trusted and left to the API, matching how translation-memory resolution already behaves and giving an escape hatch if the check is ever wrong; and a glossary the API reports with no dictionaries is left alone, since that says nothing about coverage. -- **write**: **The Write API's 14 target languages are generated rather than hand-maintained**, closing the last hand-kept language list. The list and the `WriteLanguage` type were two separate hand-edited copies of the same set, either of which could fall behind `GET /v3/languages?resource=write` — the same way the translation list silently went four codes stale. `npm run generate:languages` now emits both language lists and `npm run check:languages` reports which one drifted; the type is derived from the generated list, so a language added upstream widens it on regenerate instead of needing a second edit. No behaviour change: the generated list is byte-identical to what was there, and `write`/`correct` still **reject** a code outside it locally while naming every valid option, deliberately unlike `translate --to`, which passes well-formed unknown codes to the API. At 14 of 125 languages an enumerated error beats a round trip. Note the documented style/tone support table is unchanged and still maintained by hand, because it records what the API accepts rather than what its metadata claims: `resource=write` omits `writing_style` for `en`, but `--style` with `--lang en` works. +- **write**: **The Write API's 14 target languages are generated rather than hand-maintained**, closing the last hand-kept language list. The list and the `WriteLanguage` type were two separate hand-edited copies of the same set, either of which could fall behind `GET /v3/languages?resource=write` — the same way the translation list silently went four codes stale. `npm run generate:languages` now emits both language lists and `npm run check:languages` reports which one drifted; the type is derived from the generated list, so a language added upstream widens it on regenerate instead of needing a second edit. The generated list is byte-identical to what was there. `write`/`correct` still check the code locally and name every valid option, because at 14 of 125 languages an enumerated error beats a round trip -- but a code that is *shaped* like a language tag and simply is not in the snapshot is now sent to the API with that list as a warning, rather than refused. Nothing in CI regenerates the snapshot, so refusing outright made a language DeepL had added unreachable until someone ran the generator, with no flag to get past it. Malformed input is still rejected locally. Note the documented style/tone support table is unchanged and still maintained by hand, because it records what the API accepts rather than what its metadata claims: `resource=write` omits `writing_style` for `en`, but `--style` with `--lang en` works. - **languages**: **The DeepL API is now the authority on which languages exist, not the CLI's bundled list.** That list was hand-maintained, so it could silently fall behind the API and make languages the API accepts unusable — which is exactly what happened to `de-CH`, `de-DE`, `fr-CA` and `fr-FR` (see Fixed). Three changes remove the failure mode rather than just correcting the data. **Validation defers to the API:** a well-formed language code the bundled list does not contain is sent to the API, which accepts or rejects it authoritatively, instead of being rejected locally. Input that is not shaped like a language tag is still rejected immediately with a pointer to `deepl languages`, so typos like `--to grman` still fail fast without a request. This covers `translate`, `sync` and language values in the config file. **The listing is API-driven:** `deepl languages` renders the union of the API response and the bundled list, so a language DeepL offers can no longer be missing from the output. **The list is generated:** `npm run generate:languages` rewrites it from `GET /v3/languages` and `npm run check:languages` fails on drift, so it is a build artifact of the API rather than something maintained by hand. The core/regional/extended tiers are derived in the same pass — glossary support separates extended from the rest, source usability separates core from regional — which reproduces the previously hand-assigned tiers exactly, so the tiers can no longer disagree with the API either. No command line changes; `--to de` and every other existing code behave as before. +- **types**: **The published `Language` union is derived from the generated language snapshot** instead of being written out by hand. It was a fourth copy of the same list and had already fallen four codes behind the snapshot it describes — `de-ch`, `de-de`, `fr-ca`, `fr-fr`, the very codes the /v3 migration added — so `const lang: Language = 'de-de'` was a compile error in the published typings while `deepl config set defaults.targetLangs de-de` succeeded at runtime and wrote a config `DeepLConfig` could not type. Neither `generate:languages` nor `check:languages` touched it. `ENTRIES` is now generated `as const satisfies readonly LanguageEntry[]` and the union derives from its codes, exactly as `WriteLanguage` already derived from `WRITE_TARGET_LANGUAGES`, so regenerating the snapshot widens both. The union only ever gains codes here, so nothing that compiled before stops compiling; runtime validation still defers to the API, which means the union describes what the CLI can name offline rather than what works. + - **languages**: **Ten display names changed to match the API**, a consequence of generating the language list rather than hand-writing it: `ckb` Central Kurdish → Kurdish (Sorani), `es-419` Spanish (Latin America) → Spanish (Latin American), `gom` Goan Konkani → Konkani, `kmr` Northern Kurdish → Kurdish (Kurmanji), `my` Myanmar (Burmese) → Burmese, `nb` Norwegian Bokmål → Norwegian (bokmål), `pam` Pampanga → Kapampangan, `st` Southern Sotho → Sesotho, `zh-hans` Chinese (Simplified) → Chinese (simplified), `zh-hant` Chinese (Traditional) → Chinese (traditional). **Only offline output changes**: with an API key configured, `deepl languages` already took names from the API and was therefore already showing these, so this makes the no-API-key output consistent with the keyed output rather than changing what keyed users saw. Language codes are unaffected, so nothing that selects a language by code has to change; only output that scrapes display names. - **cli**: **Language codes are displayed in lowercase everywhere.** Output previously mixed three casings: `deepl languages` printed lowercase from the registry, `glossary show` and `tm list` uppercased at display time, `translate`'s table uppercased the target language, and `write`/`correct` used BCP-47 (`en-GB`, `zh-Hans`). Lowercase matches the CLI's own normalized form, the registry, what `deepl languages` teaches users to type, and the wire format `/v3/languages` moved to; uppercase followed a v2-era docs convention that v3 abandons. **Scripts scraping these values will see a casing change** — `glossary show` now reports `Source language: en` and `en → es: 5 entries`, `tm list` renders `brand-terms (en → de, fr)`, `translate --format table` labels rows `de`, and `write --format json` reports `"language": "en-us"`. Input remains case-insensitive everywhere, so no command line has to change. `write`/`correct` also send the lowercase code as `target_lang`: the Write API accepts any casing and canonicalizes server-side (verified live on `/v2/write/rephrase` and `/v2/write/correct` — `en-gb`, `zh-hans`, and `zh-HANS` all return 200 and echo back `en-GB` / `zh-Hans`). Wire parameters that are not display are untouched: `translate` and the glossary create endpoint still send uppercase `target_lang`/`source_lang` as those endpoints document. -- **api**: Language listings migrated from the formally deprecated `GET /v2/languages` and `GET /v2/glossary-language-pairs` endpoints to `GET /v3/languages` (`resource=translate_text` / `resource=glossary`). Command output is unchanged: source/target lists derive from the v3 `usable_as_source`/`usable_as_target` flags, glossary pairs from the source×target cross-product (verified identical to the v2 pair list), and the `[F]` formality markers now come from the language registry since the v3 response no longer reports formality support. +- **api**: Language listings migrated from the formally deprecated `GET /v2/languages` and `GET /v2/glossary-language-pairs` endpoints to `GET /v3/languages` (`resource=translate_text` / `resource=glossary`). Command output is unchanged: source/target lists derive from the v3 `usable_as_source`/`usable_as_target` flags, glossary pairs from the source×target cross-product (verified identical to the v2 pair list), and the `[F]` formality markers come from the per-language `features` matrix, which is what v2's `supports_formality` boolean became (see the Fixed entry on `pt`). A language whose features the response does not describe is left unmarked rather than marked unsupported, so the `[F]` legend cannot appear with no `[F]` beneath it. ### Removed @@ -81,12 +83,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **cache**: **The translation cache keyed on too little and could serve the wrong text.** `translationMemoryId`, `translationMemoryThreshold`, `--ignore-tags`, `--splitting-tags`, `--non-splitting-tags`, `--outline-detection` and `--preserve-formatting` were absent from the key, so `deepl translate "Hello" --to de` and the same command with `--translation-memory my-tm` collided: the second returned the cached non-TM translation, never consulted the memory, and reported `cached: true`. Likewise two runs differing only in `--ignore-tags` returned each other's output. `preserveFormatting` had been excluded on the grounds that it does not affect output, but `preserve_formatting` suppresses the sentence-boundary punctuation and case correction, which shows up in the text. **Entries cached by earlier versions for requests using any of these options now miss rather than being served wrongly**, so the first such translation after upgrading is refetched. + +- **translate**: **A rejected language no longer costs one API round trip per batch.** Because validation defers well-formed unknown codes to the API, a two-letter typo reaches it — and a directory translation asked the same rejected question once per batch, so `deepl translate ./docs --to ex` on 200 files made 200 failing requests to be told the same thing 200 times. An unsupported `target_lang` or `source_lang` is a property of the request, not of one batch, so the remaining batches now fail without being sent; errors specific to a batch (a rate limit, say) still let the run continue. Relatedly, local validation used to be what pointed at `deepl languages`, and deferring left users with a bare `Value for 'target_lang' not supported.` from the server: a code the bundled snapshot does not list now says so up front, before anything is sent or billed. + +- **usage**: **Voice usage reported the API key's own consumption as the account total.** Duration-billed products fell back to `apiKeyUnitCount` for the account figure because live `GET /v2/usage` responses omit `unit_count` for them, so `deepl usage` printed the same number in both columns — an account with 100 minutes used and 10 on this key showed 10 for both. The account-wide field the response does carry, `account_unit_count`, was neither typed nor parsed; it is now, and where no account-wide figure exists the row reads `(API key)` instead of inventing a total. This is the only place voice usage appears since the Speech-to-Text section was removed, which is why the wrong number mattered. + +- **glossary**: Six smaller defects around the multi-glossary work. Naming one glossary twice — or a name plus its own UUID — sent a duplicate in `glossary_ids`, which flipped the wire parameter away from `glossary_id`, minted a third cache key for an identical request, and spent two of the five slots the API allows; IDs are deduplicated after resolution. `sync` resolved its glossary without the language pair, so a glossary that did not cover it failed once per file instead of at startup, unlike the sibling translation-memory resolution. `translate --dry-run` reported a glossary command as runnable that the real run rejects for a missing `--from`, and `watch --dry-run` did the same. The document path never ran the extended-tier constraint check, newly reachable now that documents accept glossaries, so it uploaded the file and let the API refuse it while the text path said so locally. `glossary info` printed raw dictionary languages beneath a normalized summary, so one glossary could show `EN → DE` under `Source language: en`. And a coverage error listed every dictionary of a multilingual glossary on a single line. + +- **glossary**: `--glossary` with a source language set only in config no longer fails. `TranslationService` merges `defaults.sourceLang`, so the request carries `source_lang` whether or not `--from` was typed, but the guard tested the flag alone — so `deepl config set defaults.sourceLang en` followed by `deepl watch ./docs --to es --glossary my-terms` broke a session that had been working. The effective source language is now resolved onto the request, which also gives the document path (which merges no defaults of its own) and the glossary preflight the pair they need. An empty `--glossary` selection is no longer treated as a glossary either: `[]` is truthy, so a caller passing one got a spurious "Source language (--from) is required". + +- **languages**: `--features` no longer claims knowledge it does not have. The listing keeps snapshot entries the API response omitted, and every one of them was rendered as the positive claim `none` — with a response covering a handful of languages, `--features` reported over a hundred as supporting nothing. Those rows also made every feature look non-uniform, so features shared by all the described languages became columns of repeated values instead of the single footer note the flag was built around. A feature reported without a `status` rendered as the literal string `undefined` (the enum is open, and an absent status still means the feature is there), and `supportsFormality` was asserted `false` for languages whose features the response never mentioned, turning on the `[F]` legend with no `[F]` anywhere to explain it. `deepl languages` also makes one `GET /v3/languages` request instead of two identical ones — both roles are filtered out of the same payload. + +- **config**: `deepl config set defaults.sourceLang DE` was rejected while `deepl translate --from DE` works. Every translate path lowercases the flag before validating, but the config validator matched a lowercase-only pattern, so the one casing that is certainly valid was the one class of input still refused. Language values are lowercased before validation and stored normalized. A code the bundled snapshot does not list is still accepted, since the snapshot can lag the API, but now warns at the point of entry — a typo written to config otherwise failed on every later command with nothing pointing back at the config value responsible. + +- **translate**: `--tag-handling-version` is honoured for files and directories, not only for text. The shared option mapping carried `--tag-handling` but not the version, so with the CLI now pinning v2 whenever tag handling is on, `deepl translate page.html --tag-handling html --tag-handling-version v1` silently sent v2 — and single-file mode emits no ignored-option warning to say so. The flag is mapped in the shared mapping every handler uses, and its validation moved with it. + +- **scripts**: `npm run generate:languages` refuses to write a snapshot that would break the CLI. An empty write list would collapse the `WriteLanguage` union to `never`, rejecting every `--lang` while naming no valid option; a features matrix that stopped reporting `glossary` would retier all 125 languages as extended and make `--formality` and `--glossary` unusable everywhere. Both now fail the run instead. `--check` compares whole blocks rather than quoted codes, so a renamed display name is reported as real drift instead of "formatting only", and the generated file is in `.prettierignore` so `npm run format` cannot make that check fail permanently. Both resources are fetched together and their failures reported together, so a key that cannot read `resource=write` no longer blocks regenerating the translation list it can read. + - **languages**: **Four target languages the API accepts were unusable.** `de-CH` (Swiss German), `de-DE`, `fr-CA` (Canadian French) and `fr-FR` are returned by `GET /v3/languages` and accepted by the translate endpoint, but the CLI's bundled language list did not contain them, so `deepl translate --to de-CH` failed locally with `Invalid target language code` before any request was made — and the `deepl languages` the error suggested did not list them either, because the listing used the bundled list as its row set rather than the API response. Swiss German and Canadian French had no workaround; `de-DE`/`fr-FR` could be spelled `de`/`fr`. The list now contains all 125 languages the API serves (32 core, 11 regional, 82 extended). See the Changed entry below for why this class of divergence can no longer make a language unusable. - **languages**: `deepl languages --target` marks Portuguese (`pt`) with `[F]`. Formality support is now read from `features.formality` on `GET /v3/languages` instead of a static table in the language registry. The v3 migration had assumed v3 stopped reporting formality, but the capability was only renamed — v2's `supports_formality` boolean became the presence of a `formality` key in the per-language features matrix — so the CLI was answering from an 11-entry snapshot of the final v2 response that had already drifted from the API. The snapshot and the registry's `supportsFormality` field are gone; the registry's `category` tiers are unaffected. Output is otherwise unchanged: the `[F]` set is identical apart from `pt`. - **watch**: `--glossary` without `--from` now exits 6 before the watcher starts, instead of starting a session that fails on every single file change with a raw server message. The API rejects any translation naming a glossary without a source language ("Use of a glossary requires the source_lang parameter to be specified"), which `translate` already guarded against up front for text, files, and documents; `watch` passed `--from` straight through. A long-running command is the worst place for this, since the operator saw the failure once per edit rather than once at launch. The check runs before the glossary name is resolved, so it costs no API call. `sync` needs no equivalent: it has no `--from` at all, taking the source language from the required `source_locale` field in `.deepl-sync.yaml`, so its requests always carry one. -- **voice**: A session that ends with the audio transcribed but no translation for a requested `--to` language now fails with exit code 9 and names the languages, instead of printing an empty translation line and exiting 0. Silent partial output was the worse failure: a script consuming `deepl voice` output saw success with the translation missing. Audio containing no speech transcribes to nothing and translates to nothing, which is legitimate and still exits 0, so the check only applies when a source transcript exists. Whitespace-only and tentative-but-never-concluded translations count as missing, since neither reaches the printed output. Investigated as an intermittent (~1 in 4) empty translated line; the leading hypothesis was disproved with frame-level traces of live sessions — the server sends `end_of_stream` strictly after `end_of_target_transcript`, and the client only tears the socket down on `end_of_stream`, so there is no client-side teardown race. Reconnect was ruled out too: a socket dropped after end-of-source cannot be resumed (the API returns `410 Gone`) and already exited non-zero. The empty line did not reproduce in 64 live runs across pacing, burst, multi-target, and concurrency variations, so the trigger appears to be server-side and transient — which is exactly why the client needs to detect it rather than report success. +- **voice**: A session that ends with the audio transcribed but no translation for a requested `--to` language now fails with exit code 9 and names the languages, instead of printing an empty translation line and exiting 0. The failure **carries the transcripts that did arrive**, which the command prints to stderr before exiting: the audio is transcribed and billed before the missing translation is noticed, so with `--to de,fr,es,it,ja` one dropped target must not throw away the source transcript and four good translations and make the user re-stream to see them. Target updates are also matched **case-insensitively**, because the requested spellings include `zh-HANS` and `en-GB`: a server echoing another canonicalization had its translation silently dropped by the existing unmatched-update guard, and this check would then have reported that target as missing — turning a cosmetic mismatch into a failed session. Silent partial output was the worse failure: a script consuming `deepl voice` output saw success with the translation missing. Audio containing no speech transcribes to nothing and translates to nothing, which is legitimate and still exits 0, so the check only applies when a source transcript exists. Whitespace-only and tentative-but-never-concluded translations count as missing, since neither reaches the printed output. Investigated as an intermittent (~1 in 4) empty translated line; the leading hypothesis was disproved with frame-level traces of live sessions — the server sends `end_of_stream` strictly after `end_of_target_transcript`, and the client only tears the socket down on `end_of_stream`, so there is no client-side teardown race. Reconnect was ruled out too: a socket dropped after end-of-source cannot be resumed (the API returns `410 Gone`) and already exited non-zero. The empty line did not reproduce in 64 live runs across pacing, burst, multi-target, and concurrency variations, so the trigger appears to be server-side and transient — which is exactly why the client needs to detect it rather than report success. - **formats**: TOML reconstruction escapes U+2028/U+2029 (Unicode line/paragraph separators) in double-quoted values, and literal-string values gaining one fall back to double quotes. Written raw, these characters broke the entry-line scan on the *next* sync (JavaScript's `.` excludes line terminators), which re-appended the key as a duplicate and made the third sync fail to parse the file at all — first sync fine, second silently corrupting, third crashing. Found by the property-based round-trip suite. - **formats**: `.properties` reconstruction escapes leading spaces in values (`\ `), which the value parser otherwise strips on the next read — a translation beginning with a space silently lost it on every subsequent sync. Leading tabs, trailing spaces, and newlines were already escaped correctly. Found by the property-based round-trip suite. - **sync**: `--auto-commit` now recognises its own translation output rather than only the files a given run wrote, which fixes two related problems. A translation left on disk by an earlier refused run was classed as an unrelated modification, so auto-commit refused forever and the user had to commit it by hand; it is now committed once the genuinely unrelated changes are dealt with. And in `--watch` mode, where the same path runs once per trigger, a trigger that translated nothing skipped the checks entirely and reported success while a commit was still owed. Staging is also driven by what is actually dirty, so a rewrite that produced identical bytes no longer attempts an empty commit. Ownership is derived from the lockfile's tracked source files, with each file matched to its own bucket so one bucket's `target_path_pattern` cannot claim another's output. From 8c26e1e3b14b4f78ee40a336c6e7b17866dd467a Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 18:10:36 -0400 Subject: [PATCH 043/256] fix(languages): list the bundled snapshot in --format json without an API key The JSON path answered {"source":[],"target":[]} while text and table printed all 125 languages from the same snapshot, so the documented offline listing worked in two formats out of three. JSON falls back to the snapshot as the others do. --- CHANGELOG.md | 2 ++ src/cli/commands/register-languages.ts | 37 +++++++++++++++++++++----- tests/e2e/cli-languages.e2e.test.ts | 27 +++++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a3025bf..96cc0824 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **languages**: `--features` no longer claims knowledge it does not have. The listing keeps snapshot entries the API response omitted, and every one of them was rendered as the positive claim `none` — with a response covering a handful of languages, `--features` reported over a hundred as supporting nothing. Those rows also made every feature look non-uniform, so features shared by all the described languages became columns of repeated values instead of the single footer note the flag was built around. A feature reported without a `status` rendered as the literal string `undefined` (the enum is open, and an absent status still means the feature is there), and `supportsFormality` was asserted `false` for languages whose features the response never mentioned, turning on the `[F]` legend with no `[F]` anywhere to explain it. `deepl languages` also makes one `GET /v3/languages` request instead of two identical ones — both roles are filtered out of the same payload. +- **languages**: `deepl languages --format json` with no API key answered `{"source":[],"target":[]}` while the text and table formats printed all 125 from the bundled snapshot. Both read the same snapshot, so the JSON path now falls back to it too — listing languages works offline in every format. + - **config**: `deepl config set defaults.sourceLang DE` was rejected while `deepl translate --from DE` works. Every translate path lowercases the flag before validating, but the config validator matched a lowercase-only pattern, so the one casing that is certainly valid was the one class of input still refused. Language values are lowercased before validation and stored normalized. A code the bundled snapshot does not list is still accepted, since the snapshot can lag the API, but now warns at the point of entry — a typo written to config otherwise failed on every later command with nothing pointing back at the config value responsible. - **translate**: `--tag-handling-version` is honoured for files and directories, not only for text. The shared option mapping carried `--tag-handling` but not the version, so with the CLI now pinning v2 whenever tag handling is on, `deepl translate page.html --tag-handling html --tag-handling-version v1` silently sent v2 — and single-file mode emits no ignored-option warning to say so. The flag is mapped in the shared mapping every handler uses, and its validation moved with it. diff --git a/src/cli/commands/register-languages.ts b/src/cli/commands/register-languages.ts index 87c171fb..4679b081 100644 --- a/src/cli/commands/register-languages.ts +++ b/src/cli/commands/register-languages.ts @@ -18,6 +18,23 @@ function forJson(languages: LanguageInfo[], includeFeatures: boolean): unknown[] }); } +/** + * The bundled snapshot in LanguageInfo shape, for output with no API key. + * + * Without this the JSON path answered `{"source":[],"target":[]}` while the text + * path printed all 125 from the same snapshot -- listing languages works offline + * either way, so the two formats have no business disagreeing. + */ +function registryAsLanguageInfo( + command: { getRegistryLanguages: (type: 'source' | 'target') => Array<{ code: string; name: string }> }, + type: 'source' | 'target', +): LanguageInfo[] { + return command.getRegistryLanguages(type).map(entry => ({ + language: entry.code as LanguageInfo['language'], + name: entry.name, + })); +} + export function registerLanguages( program: Command, deps: { @@ -66,16 +83,24 @@ Examples: const languagesCommand = await createLanguagesCommand(client); if (options.format === 'json') { + const listFor = async (type: 'source' | 'target'): Promise => { + const fromApi = + type === 'source' + ? await languagesCommand.getSourceLanguages() + : await languagesCommand.getTargetLanguages(); + return fromApi.length === 0 && !hasApiKey + ? registryAsLanguageInfo(languagesCommand, type) + : fromApi; + }; + if (options.source && !options.target) { - const sourceLanguages = await languagesCommand.getSourceLanguages(); - Logger.output(JSON.stringify(forJson(sourceLanguages, showFeatures), null, 2)); + Logger.output(JSON.stringify(forJson(await listFor('source'), showFeatures), null, 2)); } else if (options.target && !options.source) { - const targetLanguages = await languagesCommand.getTargetLanguages(); - Logger.output(JSON.stringify(forJson(targetLanguages, showFeatures), null, 2)); + Logger.output(JSON.stringify(forJson(await listFor('target'), showFeatures), null, 2)); } else { const [sourceLanguages, targetLanguages] = await Promise.all([ - languagesCommand.getSourceLanguages(), - languagesCommand.getTargetLanguages(), + listFor('source'), + listFor('target'), ]); Logger.output(JSON.stringify({ source: forJson(sourceLanguages, showFeatures), diff --git a/tests/e2e/cli-languages.e2e.test.ts b/tests/e2e/cli-languages.e2e.test.ts index 8283a661..4d2ec491 100644 --- a/tests/e2e/cli-languages.e2e.test.ts +++ b/tests/e2e/cli-languages.e2e.test.ts @@ -102,6 +102,33 @@ describe('Languages Command E2E', () => { const combined = result.stdout + result.stderr; expect(combined).toMatch(/no api key|local.*registry/i); }); + + it('should list the same languages in --format json as in text output', () => { + // Both formats read the same bundled snapshot, so answering + // {"source":[],"target":[]} while the text output printed 125 was the JSON + // path simply not falling back. + const result = runCLIWithEnv('languages --format json', { DEEPL_API_KEY: '' }); + + expect(result.status).toBe(0); + const parsed = JSON.parse(result.stdout) as { + source: Array<{ language: string; name: string }>; + target: Array<{ language: string; name: string }>; + }; + expect(parsed.source.length).toBeGreaterThan(100); + expect(parsed.target.length).toBeGreaterThan(parsed.source.length); + expect(parsed.source).toEqual( + expect.arrayContaining([{ language: 'de', name: 'German' }]), + ); + expect(parsed.target.map(entry => entry.language)).toContain('en-gb'); + }); + + it('should list target-only languages in --format json --target', () => { + const result = runCLIWithEnv('languages --target --format json', { DEEPL_API_KEY: '' }); + + expect(result.status).toBe(0); + const parsed = JSON.parse(result.stdout) as Array<{ language: string }>; + expect(parsed.map(entry => entry.language)).toContain('pt-br'); + }); }); describe('languages --features (against the mock API)', () => { From 66f5e55c8315d0820a81ce70cc73bf0f36bfe246 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 18:21:10 -0400 Subject: [PATCH 044/256] docs(comments): describe current behaviour instead of how it got there Comments across the language, glossary, voice, cache, usage and generator code narrated the development process -- what the code used to do, which bug a check exists because of, what an earlier version got wrong. They now state the behaviour and the constraint behind it. Two corrections came with the pass: the generated Write-language doc still said `write`/`correct` reject an unknown code locally, which is no longer what the code does, and the same text lives in the generator template, so the snapshot was re-rendered from it and verified byte-identical against the live API. --- scripts/generate-language-registry.mjs | 52 ++++++++----------- src/api/glossary-client.ts | 4 +- src/api/translation-client.ts | 20 +++---- src/cli/commands/glossary.ts | 4 +- src/cli/commands/languages.ts | 18 +++---- src/cli/commands/register-languages.ts | 7 +-- src/cli/commands/register-translate.ts | 6 +-- src/cli/commands/register-watch.ts | 4 +- src/cli/commands/register-write.ts | 13 +++-- .../translate/document-translation-handler.ts | 5 +- src/cli/commands/translate/translate-utils.ts | 14 +++-- .../translate/translation-options-factory.ts | 6 +-- src/cli/commands/voice.ts | 6 +-- src/data/language-entries.ts | 13 +++-- src/data/language-registry.ts | 15 +++--- src/services/batch-translation.ts | 4 +- src/services/glossary.ts | 12 ++--- src/services/translation.ts | 12 ++--- src/services/voice-stream-session.ts | 10 ++-- src/storage/config.ts | 12 ++--- src/sync/sync-service.ts | 4 +- src/types/common.ts | 16 +++--- src/utils/glossary-params.ts | 11 ++-- src/utils/unrecoverable-request-error.ts | 7 ++- tests/e2e/cli-languages.e2e.test.ts | 7 ++- tests/unit/glossary-params.test.ts | 4 +- tests/unit/language-registry.test.ts | 3 +- tests/unit/register-write.test.ts | 5 +- tests/unit/translate-utils.test.ts | 8 +-- tests/unit/translation-client.test.ts | 2 +- tests/unit/voice-stream-session.test.ts | 5 +- 31 files changed, 139 insertions(+), 170 deletions(-) diff --git a/scripts/generate-language-registry.mjs b/scripts/generate-language-registry.mjs index 55314b4d..19233669 100644 --- a/scripts/generate-language-registry.mjs +++ b/scripts/generate-language-registry.mjs @@ -1,11 +1,7 @@ #!/usr/bin/env node /** - * Regenerates src/data/language-entries.ts from GET /v3/languages. - * - * The language list used to be hand-maintained, which meant it could silently - * fall behind the API: four target languages DeepL had added (de-CH, de-DE, - * fr-CA, fr-FR) were missing, so the CLI rejected them locally even though the - * API accepted them. Generating the file keeps it an honest snapshot. + * Regenerates src/data/language-entries.ts from GET /v3/languages, keeping the + * bundled snapshot a build artifact of the API rather than a hand-kept list. * * Tiers are derived, not judged: the derivation lives in * src/data/language-registry.ts and is imported from dist/ so the snapshot and @@ -25,11 +21,10 @@ const TARGET = path.join(ROOT, 'src', 'data', 'language-entries.ts'); const DERIVATION = path.join(ROOT, 'dist', 'data', 'language-registry.js'); /** - * A tier count this far below the live shape means the derivation stopped - * working rather than that DeepL dropped languages -- most likely the features - * matrix stopped reporting `glossary`, which would silently retier all 125 - * languages as extended and make --formality and --glossary unusable - * everywhere. Cheap floor, catches the whole failure class. + * A core count below this means the derivation is broken rather than that DeepL + * dropped languages -- most likely the features matrix no longer reports + * `glossary`, which would tier every language as extended and make --formality + * and --glossary unusable. Cheap floor over the whole failure class. */ const MIN_CORE_LANGUAGES = 20; @@ -54,9 +49,8 @@ function renderEntry(entry) { } /** - * Renders the whole file. Exported so the snapshot can be re-rendered from the - * data it already holds -- a formatting or type change to the template does not - * need a live API call to apply. + * Renders the whole file. Exported so the snapshot can also be re-rendered from + * the data it already holds, without a live API call. */ export function renderRegistry(entries, writeTargets) { const body = GROUPS.map(([category, heading]) => { @@ -78,7 +72,7 @@ export function renderRegistry(entries, writeTargets) { * * \`as const\` is load-bearing: the Language union in src/types/common.ts is * derived from these codes, so a language added upstream widens the type on - * regenerate instead of needing a second hand-kept copy of the same list. + * regenerate. */ import type { LanguageEntry } from './language-registry.js'; @@ -89,14 +83,13 @@ ${body} /** * Target languages the Write API accepts, from resource=write. * - * Unlike translation, \`write\` and \`correct\` reject a code outside this list - * locally rather than deferring to the API: the supported set is small enough - * to enumerate in the error, so naming the valid options beats a round trip. - * That makes keeping this generated the thing that stops it going stale. + * \`write\` and \`correct\` check a code against this list locally, because the + * set is small enough for the error to name every option. A code shaped like a + * language tag but absent from the list still goes to the API, with the list as + * a warning, so a language added upstream is usable before a regenerate. * * \`as const\` is load-bearing -- the WriteLanguage union in src/types/api.ts is - * derived from it, so adding a language upstream widens the type on regenerate - * instead of needing a second hand edit. + * derived from it, so a language added upstream widens the type on regenerate. */ export const WRITE_TARGET_LANGUAGES = [ ${writeTargets.map(code => ` '${code}',`).join('\n')} @@ -136,9 +129,8 @@ async function main() { return { resource, languages }; } - // Fetched together and reported together: failing fast on the first resource - // meant a key that cannot read resource=write blocked regenerating the - // translation list too, which it can read perfectly well. + // Fetched and reported together, so a key that cannot read one resource still + // regenerates from the other and both failures surface at once. const [translateResult, writeResult] = await Promise.all([ fetchResource('translate_text'), fetchResource('write'), @@ -165,8 +157,8 @@ async function main() { 'snapshot that would retier every language as extended.', ); } - // An empty write list would collapse the WriteLanguage union to never, so - // every --lang would be rejected while naming no valid option at all. + // An empty write list collapses the WriteLanguage union to never, which would + // reject every --lang while naming no valid option at all. if (writeTargets.length === 0) { fail('no write target languages reported (expected usable_as_target on resource=write)'); } @@ -181,10 +173,10 @@ async function main() { ); process.exit(0); } - // Name which list moved: the two are generated from different resources, and - // "N languages upstream" is misleading when it is the write list that drifted. - // Compared on the whole block rather than the codes alone, so a renamed - // display name is not reported as "formatting only". + // Name which list moved: the two come from different resources, so "N + // languages upstream" is misleading when it is the write list that drifted. + // Whole blocks are compared, not just the codes, so a renamed display name + // is reported as real drift. const blockIn = (source, open, close) => { const start = source.indexOf(open); if (start === -1) return ''; diff --git a/src/api/glossary-client.ts b/src/api/glossary-client.ts index e65eb2b6..2203e6ca 100644 --- a/src/api/glossary-client.ts +++ b/src/api/glossary-client.ts @@ -33,8 +33,8 @@ export class GlossaryClient extends HttpClient { const pairs: GlossaryLanguagePair[] = []; for (const source of sources) { for (const target of targets) { - // Compared after normalization: raw casing differing between the two role - // lists would otherwise emit a self-pair the API does not offer. + // Compared after normalization, so casing differing between the two role + // lists cannot emit a self-pair the API does not offer. const sourceLang = this.normalizeLanguage(source.lang); const targetLang = this.normalizeLanguage(target.lang); if (sourceLang === targetLang) { diff --git a/src/api/translation-client.ts b/src/api/translation-client.ts index 1d031cb9..a2e4a4f9 100644 --- a/src/api/translation-client.ts +++ b/src/api/translation-client.ts @@ -76,9 +76,8 @@ export interface ProductUsage { apiKeyCharacterCount: number; unitCount?: number; /** - * Account-wide units. What live responses actually carry for duration-billed - * products, where `unit_count` is absent -- so leaving it unparsed made the - * account total unreportable. + * Account-wide units, which is what duration-billed products report instead of + * `unit_count`. */ accountUnitCount?: number; apiKeyUnitCount?: number; @@ -297,12 +296,10 @@ export class TranslationClient extends HttpClient { * per-language features matrix, which is what v2's supports_formality became. */ /** - * The raw translate_text language list, fetched at most once per client. - * - * Both roles are filtered out of one payload, and the request does not vary by - * role, so `deepl languages` -- which asks for both -- was making the same - * full-list request twice. A failed fetch is not retained, so the next caller - * retries. + * The raw translate_text language list, fetched at most once per client. The + * request does not vary by role and both roles are filtered out of the one + * payload, so a caller asking for both costs a single request. A failed fetch + * is not retained, leaving the next caller free to retry. */ private translateLanguages?: Promise; @@ -331,9 +328,8 @@ export class TranslationClient extends HttpClient { return { language: code, name: lang.name, - // Only claimed when the response actually described this language's - // features: asserting false for a language it said nothing about - // turned the [F] legend on with no [F] anywhere to explain it. + // Only claimed when the response described this language's features; + // silence about a language is not evidence that formality is absent. ...(type === 'target' && lang.features && { supportsFormality: lang.features['formality'] !== undefined, diff --git a/src/cli/commands/glossary.ts b/src/cli/commands/glossary.ts index fcf41191..d0e6a158 100644 --- a/src/cli/commands/glossary.ts +++ b/src/cli/commands/glossary.ts @@ -251,8 +251,8 @@ export class GlossaryCommand { if (multilingual) { lines.push('\nLanguage pairs:'); glossary.dictionaries.forEach(dict => { - // Lowercased like the summary above: normalizeGlossaryInfo only touches - // the top-level fields, so raw dictionaries printed EN -> DE under en. + // Lowercased like the summary above; normalizeGlossaryInfo only touches + // the top-level fields. lines.push(` ${dict.source_lang.toLowerCase()} → ${dict.target_lang.toLowerCase()}: ${dict.entry_count} entries`); }); } diff --git a/src/cli/commands/languages.ts b/src/cli/commands/languages.ts index 2a20079a..7cab4644 100644 --- a/src/cli/commands/languages.ts +++ b/src/cli/commands/languages.ts @@ -52,7 +52,7 @@ const UNKNOWN_CELL = '?'; /** * Whether the response described this language's features at all. An empty * matrix is data -- it says the language supports none of them -- while a - * missing one means the language never appeared in the response. + * missing one means the language did not appear in the response. */ function hasFeatureData(entry: LanguageDisplayEntry): boolean { return entry.features !== undefined; @@ -64,9 +64,9 @@ function hasFeatureData(entry: LanguageDisplayEntry): boolean { * than `stable` is shown verbatim rather than collapsed to yes. `status` is an * open enum and may be absent, which still means the feature is there. * - * A language the response omitted entirely reads as unknown rather than - * unsupported: the listing keeps snapshot entries the API did not mention, and - * claiming they support nothing would be inventing an answer. + * A language the response omitted reads as unknown rather than unsupported: + * the listing includes snapshot entries the API did not mention, and claiming + * they support nothing would be inventing an answer. */ function featureCell(entry: LanguageDisplayEntry, key: string): string { if (!hasFeatureData(entry)) return UNKNOWN_CELL; @@ -96,9 +96,9 @@ export function partitionFeatureKeys(entries: LanguageDisplayEntry[]): { columns: string[]; uniform: Array<{ key: string; cell: string }>; } { - // Only languages the response described can say whether a feature varies; - // including the rest made every feature look non-uniform, so a feature all of - // them share became a column of repeated values instead of one footer note. + // Only languages the response described can say whether a feature varies. + // Counting the rest would make every feature look non-uniform, turning one + // shared by all of them into a column of repeated values. const described = entries.filter(hasFeatureData); if (described.length === 0) return { columns: [], uniform: [] }; @@ -133,7 +133,7 @@ function hasAnyFeatures(entries: LanguageDisplayEntry[]): boolean { /** * Lowercased feature list for prose contexts, e.g. `glossary, style rules`. * Empty when there is nothing per-language to say: with no discriminating - * features the footer note carries the answer, and annotating every row `none` + * features the footer note carries the answer, so annotating each row `none` * would contradict it. */ function featureList(entry: LanguageDisplayEntry, keys: string[]): string { @@ -152,7 +152,7 @@ function featureList(entry: LanguageDisplayEntry, keys: string[]): string { /** * The one-line summary for features every language shares. Scoped to the * languages the response described when some rows carry no data, since those - * rows are listed too and the note must not speak for them. + * rows are listed too and the note cannot speak for them. */ function uniformNote( uniform: Array<{ key: string; cell: string }>, diff --git a/src/cli/commands/register-languages.ts b/src/cli/commands/register-languages.ts index 4679b081..3cc0243f 100644 --- a/src/cli/commands/register-languages.ts +++ b/src/cli/commands/register-languages.ts @@ -19,11 +19,8 @@ function forJson(languages: LanguageInfo[], includeFeatures: boolean): unknown[] } /** - * The bundled snapshot in LanguageInfo shape, for output with no API key. - * - * Without this the JSON path answered `{"source":[],"target":[]}` while the text - * path printed all 125 from the same snapshot -- listing languages works offline - * either way, so the two formats have no business disagreeing. + * The bundled snapshot in LanguageInfo shape, for output with no API key, so that + * JSON lists the same languages the text and table formats read from it. */ function registryAsLanguageInfo( command: { getRegistryLanguages: (type: 'source' | 'target') => Array<{ code: string; name: string }> }, diff --git a/src/cli/commands/register-translate.ts b/src/cli/commands/register-translate.ts index 21f3e4b8..ae76845b 100644 --- a/src/cli/commands/register-translate.ts +++ b/src/cli/commands/register-translate.ts @@ -170,9 +170,9 @@ Examples: ); } - // Checked before --dry-run reports the command as runnable. Glossary name - // resolution still needs the API and stays out of dry-run, but the - // requirement that a glossary carry a source language does not. + // Checked before --dry-run reports the command as runnable. Resolving a + // glossary name needs the API and stays out of dry-run; the requirement + // that a glossary carry a source language does not. if (hasGlossarySelection(options)) { applyGlossarySourceLang( options, diff --git a/src/cli/commands/register-watch.ts b/src/cli/commands/register-watch.ts index 406a50f0..a78fa3c3 100644 --- a/src/cli/commands/register-watch.ts +++ b/src/cli/commands/register-watch.ts @@ -68,8 +68,8 @@ Examples: } } - // Resolved before --dry-run so a well-formed-looking command is not - // reported as runnable when it would fail on the first file change. + // Resolved before --dry-run, which otherwise reports a command as + // runnable when it would fail on the first file change. if (hasGlossarySelection(options)) { applyGlossarySourceLang( options, diff --git a/src/cli/commands/register-write.ts b/src/cli/commands/register-write.ts index 62ad3ad2..5d573627 100644 --- a/src/cli/commands/register-write.ts +++ b/src/cli/commands/register-write.ts @@ -13,10 +13,10 @@ import { createWriteCommand, type ServiceDeps } from './service-factory.js'; /** * Generated from GET /v3/languages?resource=write. Small enough that an error can - * name every option, which is why it is checked locally at all -- but it is still - * a snapshot, so an unrecognized code that is shaped like a language tag is - * deferred to the API with a warning rather than rejected. Rejecting outright - * made a language DeepL had added unreachable until someone regenerated the file. + * name every option, which is why it is checked locally at all. It is still a + * snapshot, so a code shaped like a language tag but absent from it goes to the + * API with a warning rather than being rejected -- otherwise a language DeepL + * adds is unreachable until the file is regenerated. */ export const WRITE_LANGUAGES = WRITE_TARGET_LANGUAGES; /** @@ -89,9 +89,8 @@ export function createWriteAction( if (canonical) { options.lang = canonical; } else if (looksLikeLanguageTag(options.lang.toLowerCase())) { - // The bundled list is a snapshot and can lag the API, and nothing in CI - // regenerates it, so a language DeepL has added must not be - // unreachable: a well-formed code goes to the API to accept or reject. + // The snapshot can lag the API, so a well-formed code it does not list + // is the API's to accept or reject. Logger.warn( `Note: "${options.lang}" is not in the bundled Write language list; deferring to the API.\n` + ` Bundled options: ${WRITE_LANGUAGES.join(', ')}` diff --git a/src/cli/commands/translate/document-translation-handler.ts b/src/cli/commands/translate/document-translation-handler.ts index 0a3f0502..ae80286b 100644 --- a/src/cli/commands/translate/document-translation-handler.ts +++ b/src/cli/commands/translate/document-translation-handler.ts @@ -26,9 +26,8 @@ export class DocumentTranslationHandler { warnIgnoredOptions('document', options, supported); validateLanguageCodes([options.to]); - // Documents support glossaries now, which makes the extended-tier constraint - // reachable here: without this the upload happens and the API rejects it, - // while the text path says so locally. + // Documents accept glossaries, so the extended-tier constraint applies here + // too: checked before the upload rather than left to the API. validateExtendedLanguageConstraints(options.to, options); // The API rejects a document glossary without source_lang: "source_lang has diff --git a/src/cli/commands/translate/translate-utils.ts b/src/cli/commands/translate/translate-utils.ts index a24544ef..4e448d0b 100644 --- a/src/cli/commands/translate/translate-utils.ts +++ b/src/cli/commands/translate/translate-utils.ts @@ -61,17 +61,15 @@ export function warnIgnoredOptions(mode: string, options: TranslateOptions, supp /** * Rejects input that is not shaped like a language tag. Codes the bundled * snapshot does not list are passed through: GET /v3/languages is the authority - * on which languages exist, and the snapshot can lag it, so rejecting here made - * languages the API accepts unusable. The API answers an unknown code with a - * 400 of its own. + * on which languages exist and the snapshot can lag it, so an unknown code is + * the API's to accept or reject with a 400 of its own. */ export function validateLanguageCodes(langCodes: string[]): void { for (const lang of langCodes) { if (VALID_LANGUAGES.has(lang)) continue; if (looksLikeLanguageTag(lang)) { - // Said up front, before anything is sent or billed. Deferring to the API - // means a two-letter typo now comes back as a bare "target_lang not - // supported" from the server, which no longer points anywhere useful. + // Said up front, before anything is sent or billed: the API answers an + // unknown code with a bare "target_lang not supported" that points nowhere. Logger.warn( `Note: "${lang}" is not in the bundled language list; deferring to the API.\n` + ' Run: deepl languages to see the languages this build knows about.' @@ -128,8 +126,8 @@ export function validateXmlTags(tags: string[], paramName: string): void { /** * Validate `--tag-handling-version` and return it. Shared so every handler maps - * the flag: since the CLI pins v2 whenever tag handling is on, a handler that - * dropped the flag would silently send v2 to a caller who asked for v1. + * the flag: the CLI pins v2 whenever tag handling is on, so a handler that + * dropped the flag would send v2 to a caller who asked for v1. */ export function validateTagHandlingVersion( options: TranslateOptions, diff --git a/src/cli/commands/translate/translation-options-factory.ts b/src/cli/commands/translate/translation-options-factory.ts index 5cd483f9..21958f48 100644 --- a/src/cli/commands/translate/translation-options-factory.ts +++ b/src/cli/commands/translate/translation-options-factory.ts @@ -60,9 +60,9 @@ export async function applyGlossarySelection< // before the next name-or-ID lookup needs the glossary list. // // Deduplicated after resolution, because a name and its own UUID resolve to - // the same glossary: naming one twice would otherwise flip the wire parameter - // from glossary_id to glossary_ids, mint a third cache key for an identical - // request, and spend two of the five slots the API allows. + // the same glossary: a duplicate would flip the wire parameter from + // glossary_id to glossary_ids, key an identical request differently, and + // spend two of the five slots the API allows. const ids: string[] = []; for (const nameOrId of options.glossary) { const id = await resolveGlossaryId(glossaryService, nameOrId, expected); diff --git a/src/cli/commands/voice.ts b/src/cli/commands/voice.ts index f8cdfeca..75b54f72 100644 --- a/src/cli/commands/voice.ts +++ b/src/cli/commands/voice.ts @@ -262,9 +262,9 @@ export class VoiceCommand { /** * Print what a failed session did produce. The audio is transcribed and billed - * before the missing translation is noticed, so discarding the transcripts - * would make the user re-stream and pay again to see them. Written to stderr so - * a partial result is never mistaken for the command's output. + * before a missing translation is noticed, so discarding the transcripts would + * cost another stream to see them. Written to stderr, so a partial result is + * never mistaken for the command's output. */ private reportPartialResult(error: unknown, targetCount: number, isTTY: boolean): void { if (!(error instanceof VoicePartialResultError)) { diff --git a/src/data/language-entries.ts b/src/data/language-entries.ts index 60db1bea..dd544e29 100644 --- a/src/data/language-entries.ts +++ b/src/data/language-entries.ts @@ -12,7 +12,7 @@ * * `as const` is load-bearing: the Language union in src/types/common.ts is * derived from these codes, so a language added upstream widens the type on - * regenerate instead of needing a second hand-kept copy of the same list. + * regenerate. */ import type { LanguageEntry } from './language-registry.js'; @@ -152,14 +152,13 @@ export const ENTRIES = [ /** * Target languages the Write API accepts, from resource=write. * - * Unlike translation, `write` and `correct` reject a code outside this list - * locally rather than deferring to the API: the supported set is small enough - * to enumerate in the error, so naming the valid options beats a round trip. - * That makes keeping this generated the thing that stops it going stale. + * `write` and `correct` check a code against this list locally, because the + * set is small enough for the error to name every option. A code shaped like a + * language tag but absent from the list still goes to the API, with the list as + * a warning, so a language added upstream is usable before a regenerate. * * `as const` is load-bearing -- the WriteLanguage union in src/types/api.ts is - * derived from it, so adding a language upstream widens the type on regenerate - * instead of needing a second hand edit. + * derived from it, so a language added upstream widens the type on regenerate. */ export const WRITE_TARGET_LANGUAGES = [ 'de', diff --git a/src/data/language-registry.ts b/src/data/language-registry.ts index 5c09de67..c1f7b081 100644 --- a/src/data/language-registry.ts +++ b/src/data/language-registry.ts @@ -40,8 +40,8 @@ export interface LanguageEntry { /** * The snapshot as plain entries. It is generated `as const` so the `Language` - * union can be derived from its codes; the lookups below want the interface, not - * 125 individual literal types. + * union can derive from its codes; the lookups below want the interface rather + * than one literal type per language. */ const ENTRIES: readonly LanguageEntry[] = GENERATED_ENTRIES; @@ -61,11 +61,10 @@ export interface DerivableLanguage { /** * Derives a registry entry from one GET /v3/languages entry. The tiers are not * a human judgement: glossary support separates extended from the rest, and - * source usability separates core from regional. Checked against the live - * response, this reproduces the hand-maintained tiers exactly. + * source usability separates core from regional. * * Shared with scripts/generate-language-registry.mjs so the snapshot and the - * runtime fallback for codes the snapshot predates cannot disagree. + * runtime fallback for codes it does not list cannot disagree. */ export function deriveLanguageEntry(language: DerivableLanguage): LanguageEntry { const code = language.lang.toLowerCase(); @@ -100,9 +99,9 @@ export function looksLikeLanguageTag(code: string): boolean { /** * The base language of a code, dropping any regional subtag: `en-us` -> `en`. - * Used where one side of a comparison carries variants the other cannot, such as - * glossary dictionaries, which only ever name base languages while `--to` - * accepts `en-us`, `pt-br` and the rest. + * For comparisons where one side carries variants the other cannot -- glossary + * dictionaries name base languages only, while `--to` accepts `en-us`, `pt-br` + * and the rest. */ export function baseLanguage(code: string): string { return code.toLowerCase().split('-')[0] ?? code.toLowerCase(); diff --git a/src/services/batch-translation.ts b/src/services/batch-translation.ts index b4a087f2..9f1080e7 100644 --- a/src/services/batch-translation.ts +++ b/src/services/batch-translation.ts @@ -222,8 +222,8 @@ export class BatchTranslationService { let currentBatch: FileEntry[] = []; let currentBytes = 0; // Set once the API rejects the request itself (an unsupported target_lang, - // say). Every remaining batch would be told the same thing, so they are - // failed without spending the round trips. + // say). Every remaining batch would draw the same rejection, so they fail + // without spending the round trips. let requestRejected: unknown; const flushBatch = async (): Promise => { diff --git a/src/services/glossary.ts b/src/services/glossary.ts index ff93a3e5..d1831626 100644 --- a/src/services/glossary.ts +++ b/src/services/glossary.ts @@ -173,11 +173,11 @@ export class GlossaryService { * fetched to resolve the name; the UUID path trusts the caller and skips the * check, as translation-memory resolution does. * - * Both sides are compared on their base language, because dictionaries only - * ever name base languages while `--to` accepts regional variants: a de→en - * glossary has to count as covering de→en-us, which the API accepts. That - * makes the check deliberately permissive at the edges — a pair it lets - * through is still the API's to reject, which is the cheaper mistake. + * Both sides are compared on their base language, because dictionaries name + * base languages only while `--to` accepts regional variants: a de→en glossary + * covers de→en-us, which the API accepts. That makes the check deliberately + * permissive at the edges — a pair it lets through is still the API's to + * reject, which is the cheaper mistake. */ async resolveGlossaryId( nameOrId: string, @@ -223,7 +223,7 @@ export class GlossaryService { const allPairs = match.dictionaries.map( d => `${d.source_lang.toLowerCase()}→${d.target_lang.toLowerCase()}`, ); - // A multilingual glossary can hold dozens of dictionaries, and the whole + // A multilingual glossary can hold dozens of dictionaries; the whole // cross-product on one line stops being a suggestion. const pairs = allPairs.length > MAX_PAIRS_IN_SUGGESTION diff --git a/src/services/translation.ts b/src/services/translation.ts index d9f8e0d3..1c6448eb 100644 --- a/src/services/translation.ts +++ b/src/services/translation.ts @@ -403,12 +403,12 @@ export class TranslationService { * would let the key stay stable across a change of the API's own default, * serving entries the API would no longer produce. * - * Every parameter that changes the returned text has to appear here or the - * cache serves the wrong translation: a plain request and the same request - * with a translation memory, different --ignore-tags, or --preserve-formatting - * are different requests. `preserveFormatting` is included because - * preserve_formatting suppresses the sentence-boundary punctuation and case - * correction, which shows up in the text. + * Every parameter that changes the returned text belongs here, or the cache + * serves the wrong translation: a plain request and the same request with a + * translation memory, different --ignore-tags, or --preserve-formatting are + * different requests. `preserveFormatting` counts because preserve_formatting + * suppresses the sentence-boundary punctuation and case correction, which + * shows up in the text. */ private generateCacheKey(text: string, options: TranslationOptions): string { // Keyed on the parameter the request will actually carry, so the two ways of diff --git a/src/services/voice-stream-session.ts b/src/services/voice-stream-session.ts index bb09a3e4..28c943c6 100644 --- a/src/services/voice-stream-session.ts +++ b/src/services/voice-stream-session.ts @@ -24,8 +24,8 @@ const DEFAULT_MAX_RECONNECT_ATTEMPTS = 3; /** * A session that ended with at least one target untranslated, carrying whatever - * did arrive. The audio has been transcribed and billed by this point, so the - * partial transcripts travel with the failure instead of being discarded. + * did arrive. The audio is transcribed and billed by this point, so the partial + * transcripts travel with the failure rather than being discarded. */ export class VoicePartialResultError extends VoiceError { constructor( @@ -76,9 +76,9 @@ export class VoiceStreamSession { for (const lang of options.targetLangs) { const transcript: VoiceTranscript = { lang, text: '', segments: [] }; - // Keyed lowercase because the requested spellings (zh-HANS, en-GB) are not - // the only canonicalization the server might echo, and an unmatched update - // is dropped silently -- which then reads as a missing translation. + // Keyed lowercase: the requested spellings (zh-HANS, en-GB) are not the + // only canonicalization the server may echo, and an update that matches no + // target is dropped, which reads as a missing translation. this.targetTranscripts.set(lang.toLowerCase(), transcript); this.textParts.set(transcript, []); } diff --git a/src/storage/config.ts b/src/storage/config.ts index b4d979e1..0f0b0e5b 100644 --- a/src/storage/config.ts +++ b/src/storage/config.ts @@ -47,7 +47,7 @@ const DEFAULT_DEBOUNCE_MS = 500; /** * Language values are stored lowercase, matching what `deepl languages` prints - * and what every translate path normalizes its flags to, so a config written as + * and what the translate paths normalize their flags to, so a config written as * `DE` does not read back as a code the registry cannot look up. */ function normalizeLanguageValue(path: string, value: unknown): unknown { @@ -400,17 +400,17 @@ export class ConfigService { * is the authority on which languages exist and the snapshot can lag it. */ private validateLanguage(lang: string, key?: string): void { - // Lowercased first: every translate path lowercases the flag before using it, - // so `--from DE` works while `config set defaults.sourceLang DE` was rejected - // by a lowercase-only pattern -- the one casing that is certainly valid. + // Lowercased first: the translate paths lowercase their flags before use, and + // the tag pattern below is lowercase-only, so `DE` is as valid as `de` here. const normalized = typeof lang === 'string' ? lang.toLowerCase() : lang; if (!isValidLanguage(normalized) && !looksLikeLanguageTag(normalized)) { const context = key ? ` for "${key}"` : ''; throw new ConfigError(`Invalid language code "${lang}"${context}. Run: deepl languages to see valid codes`); } if (!isValidLanguage(normalized)) { - // Stored anyway, because the snapshot can lag the API -- but a typo written - // to config fails on every later command with nothing pointing back here. + // Stored anyway, since the snapshot can lag the API -- but flagged here, + // because a typo in config otherwise surfaces on every later command with + // nothing pointing back at the value responsible. const context = key ? ` for "${key}"` : ''; Logger.warn( `Note: "${lang}"${context} is not in the bundled language list; it will be sent to the API as-is.\n` + diff --git a/src/sync/sync-service.ts b/src/sync/sync-service.ts index b08ea1a1..088d223a 100644 --- a/src/sync/sync-service.ts +++ b/src/sync/sync-service.ts @@ -192,8 +192,8 @@ export class SyncService { let resolvedGlossaryId: string | undefined; if (config.translation?.glossary && config.translation.glossary !== 'auto' && !options?.dryRun) { // The pair is known from the config, so a glossary that does not cover it - // fails here rather than once per file, as translation-memory resolution - // below already does. + // fails here rather than once per file, as with the translation memory + // below. const glossaryLocales = options?.localeFilter?.length ? config.target_locales.filter(l => options.localeFilter!.includes(l)) : config.target_locales; diff --git a/src/types/common.ts b/src/types/common.ts index ffe3f10f..069ec28d 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -5,17 +5,13 @@ import { ENTRIES } from '../data/language-entries.js'; /** - * Every language code the bundled snapshot lists, which is generated from - * GET /v3/languages. + * Every language code in the bundled snapshot, which is generated from + * GET /v3/languages. Derived from the snapshot so the two cannot disagree: + * regenerating it widens this union. * - * Derived rather than hand-maintained: as a written-out union this was a fourth - * copy of the same list and had already fallen four codes behind the snapshot - * (de-ch, de-de, fr-ca, fr-fr), so the published typings could not describe a - * config the CLI itself accepts. Regenerating the snapshot now widens this too. - * - * The API remains the authority on which languages exist -- runtime validation - * accepts well-formed codes the snapshot predates, so this union is the set the - * CLI can name offline, not the set that works. + * The API is the authority on which languages exist, and runtime validation + * accepts well-formed codes the snapshot does not list, so this is the set the + * CLI can name offline rather than the set that works. */ export type Language = (typeof ENTRIES)[number]['code']; diff --git a/src/utils/glossary-params.ts b/src/utils/glossary-params.ts index 1046dec1..c03f0f41 100644 --- a/src/utils/glossary-params.ts +++ b/src/utils/glossary-params.ts @@ -24,12 +24,11 @@ export function hasGlossarySelection(selection: GlossarySourceLangSelection): bo * Settle the source language a glossary request will carry, filling `from` from * the configured default when the flag is absent. * - * The API rejects a glossary without `source_lang`, but `--from` is not the only - * way one is supplied: `TranslationService` merges `defaults.sourceLang`, so - * rejecting on a missing flag alone broke sessions that had been working from - * config. Resolving it onto `from` instead means every path sees the same - * answer, including the document path, which merges no defaults of its own, and - * the glossary preflight, which needs the pair to check coverage. + * The API rejects a glossary without `source_lang`, and `--from` is not the only + * way one is supplied: `TranslationService` merges `defaults.sourceLang`. + * Resolving the effective value onto `from` gives every path the same answer, + * including the document path, which merges no defaults of its own, and the + * glossary preflight, which needs the pair to check coverage. */ export function applyGlossarySourceLang( selection: GlossarySourceLangSelection, diff --git a/src/utils/unrecoverable-request-error.ts b/src/utils/unrecoverable-request-error.ts index 5f7efb9d..cffe1856 100644 --- a/src/utils/unrecoverable-request-error.ts +++ b/src/utils/unrecoverable-request-error.ts @@ -5,10 +5,9 @@ import { errorMessage } from './error-message.js'; * item failed. * * A rejected `target_lang` is the same rejection for every file and every target - * in the run, so retrying it per batch buys nothing: it just spends another round - * trip -- and bills the items that do succeed -- to be told the same thing again. - * Language validation defers to the API on codes the bundled snapshot predates, - * which is what makes this reachable from a plain typo. + * in the run, so retrying it per batch buys nothing but another round trip. + * Language validation defers to the API on codes the bundled snapshot does not + * list, which is what makes this reachable from a plain typo. */ export function isUnrecoverableRequestError(error: unknown): boolean { const message = errorMessage(error).toLowerCase(); diff --git a/tests/e2e/cli-languages.e2e.test.ts b/tests/e2e/cli-languages.e2e.test.ts index 4d2ec491..473b2e8a 100644 --- a/tests/e2e/cli-languages.e2e.test.ts +++ b/tests/e2e/cli-languages.e2e.test.ts @@ -104,9 +104,8 @@ describe('Languages Command E2E', () => { }); it('should list the same languages in --format json as in text output', () => { - // Both formats read the same bundled snapshot, so answering - // {"source":[],"target":[]} while the text output printed 125 was the JSON - // path simply not falling back. + // Both formats read the same bundled snapshot, so both list the same + // languages when there is no API key. const result = runCLIWithEnv('languages --format json', { DEEPL_API_KEY: '' }); expect(result.status).toBe(0); @@ -196,7 +195,7 @@ describe('Languages Command E2E', () => { // formality is what varies across the languages the mock describes, so it // is the per-row annotation; glossary is shared by all of them and is - // reported once at the end instead of on every row. + // reported once at the end rather than on every row. expect(german).toContain('formality'); expect(english).not.toContain('formality'); expect(output).toContain('glossary'); diff --git a/tests/unit/glossary-params.test.ts b/tests/unit/glossary-params.test.ts index c2852b40..12a12acc 100644 --- a/tests/unit/glossary-params.test.ts +++ b/tests/unit/glossary-params.test.ts @@ -110,8 +110,8 @@ describe('applyGlossarySourceLang', () => { }); it('should fall back to the configured source language', () => { - // The request carries source_lang either way, so rejecting on a missing - // flag alone broke sessions that had been working from config. + // The request carries source_lang either way, so a missing flag is not on + // its own a reason to reject. const options: { glossary: string[]; from?: string } = { glossary: ['terms'] }; applyGlossarySourceLang(options, 'EN', example); expect(options.from).toBe('en'); diff --git a/tests/unit/language-registry.test.ts b/tests/unit/language-registry.test.ts index 6cf30469..6e13e1fc 100644 --- a/tests/unit/language-registry.test.ts +++ b/tests/unit/language-registry.test.ts @@ -357,8 +357,7 @@ describe('Language Registry', () => { describe('Language union', () => { /** * Compile-time, not runtime: these assignments fail to build if the union - * goes back to being hand-written and falls behind the snapshot again, which - * is exactly how de-ch, de-de, fr-ca and fr-fr came to be missing from it. + * stops deriving from the snapshot and falls behind it. */ it('should cover the regional variants a hand-written union had missed', () => { const codes: Language[] = ['de-ch', 'de-de', 'fr-ca', 'fr-fr']; diff --git a/tests/unit/register-write.test.ts b/tests/unit/register-write.test.ts index f8fbccaf..7b92a7c2 100644 --- a/tests/unit/register-write.test.ts +++ b/tests/unit/register-write.test.ts @@ -197,9 +197,8 @@ describe('registerWrite', () => { }); it('should defer a well-formed code the bundled list does not have', async () => { - // The bundled list is a snapshot and nothing in CI regenerates it, so - // rejecting outright made a language DeepL had added unreachable. The - // request goes through with a warning that still names the bundled set. + // The bundled list is a snapshot, so a well-formed code it does not list + // goes through with a warning that still names the bundled set. await program.parseAsync(['node', 'test', 'write', 'Hello', '--lang', 'xx']); expect(handleError).not.toHaveBeenCalled(); diff --git a/tests/unit/translate-utils.test.ts b/tests/unit/translate-utils.test.ts index 7288d64a..bb62fd7f 100644 --- a/tests/unit/translate-utils.test.ts +++ b/tests/unit/translate-utils.test.ts @@ -114,8 +114,8 @@ describe('translate-utils', () => { }); it('should pass through a well-formed code the snapshot does not know', () => { - // The API is the authority on which languages exist, and the bundled - // snapshot can lag it. Rejecting locally made valid targets unusable. + // The API is the authority on which languages exist and the bundled + // snapshot can lag it, so a well-formed code is the API's to judge. expect(() => validateLanguageCodes(['xx'])).not.toThrow(); expect(() => validateLanguageCodes(['de-ch', 'fr-ca'])).not.toThrow(); expect(() => validateLanguageCodes(['abc-1234'])).not.toThrow(); @@ -125,8 +125,8 @@ describe('translate-utils', () => { mockedLoggerWarn.mockClear(); validateLanguageCodes(['ex']); - // Said before anything is sent: the API answers a typo with a bare - // "target_lang not supported" that points nowhere. + // Said before anything is sent, since the API answers an unknown code with + // a bare "target_lang not supported" that points nowhere. const warning = mockedLoggerWarn.mock.calls.map(call => String(call[0])).join('\n'); expect(warning).toContain('"ex" is not in the bundled language list'); expect(warning).toContain('deepl languages'); diff --git a/tests/unit/translation-client.test.ts b/tests/unit/translation-client.test.ts index 52f5cedc..9fad3ab8 100644 --- a/tests/unit/translation-client.test.ts +++ b/tests/unit/translation-client.test.ts @@ -505,7 +505,7 @@ describe('TranslationClient', () => { // Formality support is left unstated rather than denied: a response that // described no features is not evidence that formality is unavailable, and - // claiming false turned on the [F] legend with no [F] to explain. + // claiming false would turn on the [F] legend with no [F] to explain. expect(result[0]).not.toHaveProperty('features'); expect(result[0]!.supportsFormality).toBeUndefined(); }); diff --git a/tests/unit/voice-stream-session.test.ts b/tests/unit/voice-stream-session.test.ts index 1e4b9cd4..a3bb4f61 100644 --- a/tests/unit/voice-stream-session.test.ts +++ b/tests/unit/voice-stream-session.test.ts @@ -798,9 +798,8 @@ describe('VoiceStreamSession', () => { }); it('should match the echoed language regardless of casing', async () => { - // The requested set spells variants zh-HANS and en-GB; a server echoing - // another canonicalization used to have its translation dropped on the - // floor and then reported as missing. + // The requested set spells variants zh-HANS and en-GB, so a server echoing + // another canonicalization must still match its requested target. const result = await runWithFrames( (callbacks) => { callbacks.onSourceTranscript?.({ From 469f8beb2a172be69bf3468e70b95ba2e3302dee Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 18:30:44 -0400 Subject: [PATCH 045/256] fix(package)!: give the ESM entry point resolvable specifiers `import '@deepl/cli'` failed with ERR_UNSUPPORTED_DIR_IMPORT: the package is `type: module`, but src/index.ts re-exported the directory './types' and src/types/config.ts imported './common' without an extension. Node resolves neither, so the programmatic API could not be loaded at all, and `nodenext` consumers saw no exported types -- `import type { Language }` was an error whatever the union held. The CLI itself was unaffected, which is why nothing caught it: `bin` points at dist/cli/index.js, whose module graph uses full specifiers throughout. The manifest suite now imports the built entry in a real Node ESM process (jest's CJS transform cannot) and fails on any extensionless relative specifier in the emitted entry chain. Verified by reverting the fix: 3 of the new assertions fail. --- CHANGELOG.md | 2 + src/index.ts | 2 +- src/types/config.ts | 2 +- tests/unit/package-manifest.test.ts | 60 +++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96cc0824..0b6f052e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **package**: **`import '@deepl/cli'` threw instead of loading.** The package is ESM, so Node requires a full specifier for every relative import, but the entry point re-exported `'./types'` — a directory — which fails with `ERR_UNSUPPORTED_DIR_IMPORT`. The whole programmatic surface was therefore unreachable, and the published typings resolved to nothing for a `nodenext` consumer, so `import type { Language } from '@deepl/cli'` was an error regardless of what the union contained. `deepl --help` never exercised this, because the `bin` entry has its own module graph. Both remaining directory specifiers now carry `/index.js`, and the manifest suite imports the built entry in a real Node ESM process and rejects any extensionless relative specifier in the emitted entry chain. + - **cache**: **The translation cache keyed on too little and could serve the wrong text.** `translationMemoryId`, `translationMemoryThreshold`, `--ignore-tags`, `--splitting-tags`, `--non-splitting-tags`, `--outline-detection` and `--preserve-formatting` were absent from the key, so `deepl translate "Hello" --to de` and the same command with `--translation-memory my-tm` collided: the second returned the cached non-TM translation, never consulted the memory, and reported `cached: true`. Likewise two runs differing only in `--ignore-tags` returned each other's output. `preserveFormatting` had been excluded on the grounds that it does not affect output, but `preserve_formatting` suppresses the sentence-boundary punctuation and case correction, which shows up in the text. **Entries cached by earlier versions for requests using any of these options now miss rather than being served wrongly**, so the first such translation after upgrading is refetched. - **translate**: **A rejected language no longer costs one API round trip per batch.** Because validation defers well-formed unknown codes to the API, a two-letter typo reaches it — and a directory translation asked the same rejected question once per batch, so `deepl translate ./docs --to ex` on 200 files made 200 failing requests to be told the same thing 200 times. An unsupported `target_lang` or `source_lang` is a property of the request, not of one batch, so the remaining batches now fail without being sent; errors specific to a batch (a rate limit, say) still let the run continue. Relatedly, local validation used to be what pointed at `deepl languages`, and deferring left users with a bare `Value for 'target_lang' not supported.` from the server: a code the bundled snapshot does not list now says so up front, before anything is sent or billed. diff --git a/src/index.ts b/src/index.ts index 844c81b8..63622f90 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,4 +5,4 @@ * For CLI usage, see src/cli/index.ts */ -export * from './types'; +export * from './types/index.js'; diff --git a/src/types/config.ts b/src/types/config.ts index c8c9e0dd..9a009471 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -2,7 +2,7 @@ * Configuration type definitions */ -import { Language, Formality, OutputFormat } from './common'; +import { Language, Formality, OutputFormat } from './common.js'; export interface DeepLConfig { auth: { diff --git a/tests/unit/package-manifest.test.ts b/tests/unit/package-manifest.test.ts index 21c43541..f8077b9d 100644 --- a/tests/unit/package-manifest.test.ts +++ b/tests/unit/package-manifest.test.ts @@ -6,11 +6,13 @@ * `npm pack`. */ +import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; interface PackageManifest { name: string; + main?: string; version: string; publishConfig?: { access?: string }; repository: { type: string; url: string }; @@ -115,4 +117,62 @@ describe('package.json manifest', () => { expect(pkg.homepage).toBe('https://github.com/DeepL/deepl-cli#readme'); }); }); + + describe('programmatic entry point', () => { + /** + * The package is ESM (`type: module`), so Node needs a full specifier for + * every relative import it resolves. A directory specifier anywhere on the + * path from `main` throws ERR_UNSUPPORTED_DIR_IMPORT on `import '@deepl/cli'` + * -- which `deepl --help` never exercises, because the bin has its own entry. + */ + /** Imported in a real Node ESM process; jest's CJS transform cannot load it. */ + const importEntry = (): { status: number; stdout: string; stderr: string } => { + const entry = path.join(__dirname, '..', '..', pkg.main ?? 'dist/index.js'); + const result = spawnSync( + 'node', + [ + '--input-type=module', + '-e', + `const m = await import(${JSON.stringify(entry)});` + + 'process.stdout.write(Object.keys(m).sort().join(","));', + ], + { encoding: 'utf-8' }, + ); + return { + status: result.status ?? 1, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + }; + }; + + it('should be importable', () => { + const result = importEntry(); + + expect(result.stderr).not.toMatch(/ERR_UNSUPPORTED_DIR_IMPORT|ERR_MODULE_NOT_FOUND/); + expect(result.status).toBe(0); + }); + + it('should expose the type barrel through the entry point', () => { + const exported = importEntry().stdout.split(','); + + expect(exported).toContain('isMultilingual'); + expect(exported).toContain('normalizeGlossaryInfo'); + }); + + it('should declare no directory specifiers in the emitted entry chain', () => { + const distDir = path.join(__dirname, '..', '..', 'dist'); + const emitted = ['index.js', 'index.d.ts', path.join('types', 'index.js')] + .map(file => path.join(distDir, file)) + .filter(file => fs.existsSync(file)); + + expect(emitted.length).toBeGreaterThan(0); + for (const file of emitted) { + const source = fs.readFileSync(file, 'utf-8'); + const specifiers = [...source.matchAll(/from\s+'(\.[^']*)'/g)].map(match => match[1]!); + for (const specifier of specifiers) { + expect(specifier).toMatch(/\.js$/); + } + } + }); + }); }); From d5e43f1a5e48374d3e50a907a5116e7be0e59679 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 19:10:13 -0400 Subject: [PATCH 046/256] fix(scripts)!: validate the API response before writing it into TypeScript The generator interpolated `lang` and `name` from GET /v3/languages straight into a single-quoted TypeScript literal. `lang` was not escaped at all and `name` escaped only quotes, so a response field ending in a backslash -- or containing `' }] as const;` -- could close the literal and append arbitrary code to src/data/language-entries.ts, which the next build compiles and the test suite imports. The existing guards check the shape of the response, never the content of a field. Codes must now match the language-tag pattern, display names a conservative letter/mark/digit/punctuation set, and categories one of the three tiers; everything is quoted with escaping as well. Validation runs before grouping, because grouping filters by category and would otherwise drop an entry with an unrecognized one before it was ever checked. The main guard also compares argv[1] through realpathSync: Node resolves the ESM entry to its real path, so under a symlinked checkout both npm scripts exited 0 without doing anything -- including the release step that exists to stop the Write list going stale. --- scripts/generate-language-registry.mjs | 78 ++++++++++-- tests/unit/generate-language-registry.test.ts | 114 ++++++++++++++++++ 2 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 tests/unit/generate-language-registry.test.ts diff --git a/scripts/generate-language-registry.mjs b/scripts/generate-language-registry.mjs index 19233669..15596bac 100644 --- a/scripts/generate-language-registry.mjs +++ b/scripts/generate-language-registry.mjs @@ -13,7 +13,7 @@ * * Needs DEEPL_API_KEY and a current build (npm run build). */ -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { readFileSync, writeFileSync, existsSync, realpathSync } from 'node:fs'; import * as path from 'node:path'; const ROOT = path.resolve(import.meta.dirname, '..'); @@ -41,9 +41,46 @@ function fail(message) { const byCode = (a, b) => a.code.localeCompare(b.code, 'en'); +const LANGUAGE_CODE = /^[a-z]{2,3}(-[a-z0-9]{2,4})?$/; +/** Letters, marks, digits and the punctuation DeepL's display names actually use. */ +const DISPLAY_NAME = /^[\p{L}\p{M}\p{N} ()'’.,-]{1,60}$/u; + +/** + * Quote a value as a single-quoted TypeScript string literal, escaping what + * would otherwise end the literal or the line. + */ +function quote(value) { + const escaped = String(value) + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); + return `'${escaped}'`; +} + +/** + * Rejects a response field that has no business being in a language list. + * The output of this script is TypeScript that the next build compiles and the + * test suite imports, so a response field is untrusted input to a code + * generator: it is validated, not merely escaped. + */ +export function assertRenderable(entry) { + if (typeof entry.code !== 'string' || !LANGUAGE_CODE.test(entry.code)) { + throw new Error(`refusing to write language code ${JSON.stringify(entry.code)}: not shaped like a language tag`); + } + if (typeof entry.name !== 'string' || !DISPLAY_NAME.test(entry.name)) { + throw new Error(`refusing to write display name ${JSON.stringify(entry.name)} for ${entry.code}`); + } + if (!['core', 'regional', 'extended'].includes(entry.category)) { + throw new Error(`unexpected category ${JSON.stringify(entry.category)} for ${entry.code}`); + } +} + function renderEntry(entry) { - const fields = [`code: '${entry.code}'`, `name: '${entry.name.replace(/'/g, "\\'")}'`]; - fields.push(`category: '${entry.category}'`); + const fields = [`code: ${quote(entry.code)}`, `name: ${quote(entry.name)}`]; + fields.push(`category: ${quote(entry.category)}`); if (entry.targetOnly) fields.push('targetOnly: true'); return ` { ${fields.join(', ')} },`; } @@ -53,6 +90,10 @@ function renderEntry(entry) { * the data it already holds, without a live API call. */ export function renderRegistry(entries, writeTargets) { + // Validated before grouping: grouping filters by category, so an entry with an + // unrecognized one would be dropped from the output without ever being checked. + entries.forEach(assertRenderable); + const body = GROUPS.map(([category, heading]) => { const group = entries.filter(e => e.category === category).sort(byCode); return [` // ${heading}`, ...group.map(renderEntry)].join('\n'); @@ -92,7 +133,7 @@ ${body} * derived from it, so a language added upstream widens the type on regenerate. */ export const WRITE_TARGET_LANGUAGES = [ -${writeTargets.map(code => ` '${code}',`).join('\n')} +${writeTargets.map(code => ` ${quote(code)},`).join('\n')} ] as const; `; } @@ -162,8 +203,18 @@ async function main() { if (writeTargets.length === 0) { fail('no write target languages reported (expected usable_as_target on resource=write)'); } + for (const code of writeTargets) { + if (!LANGUAGE_CODE.test(code)) { + fail(`refusing to write Write language code ${JSON.stringify(code)}: not shaped like a language tag`); + } + } - const contents = renderRegistry(entries, writeTargets); + let contents; + try { + contents = renderRegistry(entries, writeTargets); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } if (checkOnly) { const current = existsSync(TARGET) ? readFileSync(TARGET, 'utf8') : ''; @@ -208,7 +259,20 @@ async function main() { } // Importable for re-rendering without touching the network; only the CLI entry -// point fetches. -if (process.argv[1] === import.meta.filename) { +// point fetches. argv[1] is compared through realpathSync because Node resolves +// the ESM entry to its real path, so a symlinked checkout (or an npm-linked +// package) would otherwise make both npm scripts silent no-ops. +const invokedPath = process.argv[1]; +const invokedDirectly = + invokedPath !== undefined && + (() => { + try { + return realpathSync(invokedPath) === import.meta.filename; + } catch { + return invokedPath === import.meta.filename; + } + })(); + +if (invokedDirectly) { await main(); } diff --git a/tests/unit/generate-language-registry.test.ts b/tests/unit/generate-language-registry.test.ts new file mode 100644 index 00000000..923f009e --- /dev/null +++ b/tests/unit/generate-language-registry.test.ts @@ -0,0 +1,114 @@ +/** + * Tests for the language-snapshot generator's rendering contract. + * + * The script's output is TypeScript that the next build compiles and the test + * suite imports, so `GET /v3/languages` is untrusted input to a code generator. + * + * Exercised in a spawned Node process: the script is ESM and jest's CJS + * transform cannot load it. + */ + +import { spawnSync } from 'child_process'; +import * as path from 'path'; + +const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'generate-language-registry.mjs'); + +/** Renders `entries`/`writeTargets` and reports either the output or the rejection. */ +function render( + entries: Array>, + writeTargets: string[] = ['de'], +): { ok: boolean; output: string } { + const source = ` + const gen = await import(${JSON.stringify(SCRIPT)}); + try { + const out = gen.renderRegistry(${JSON.stringify(entries)}, ${JSON.stringify(writeTargets)}); + process.stdout.write('OK\\n' + out); + } catch (error) { + process.stdout.write('ERR\\n' + error.message); + } + `; + const result = spawnSync('node', ['--input-type=module', '-e', source], { encoding: 'utf-8' }); + const stdout = result.stdout ?? ''; + return { ok: stdout.startsWith('OK'), output: stdout.slice(stdout.indexOf('\n') + 1) }; +} + +describe('generate-language-registry', () => { + describe('benign responses', () => { + it('should render an entry as a single-quoted literal', () => { + const { ok, output } = render([{ code: 'de', name: 'German', category: 'core' }]); + + expect(ok).toBe(true); + expect(output).toContain("{ code: 'de', name: 'German', category: 'core' },"); + expect(output).toContain(" 'de',"); + }); + + it('should mark a target-only entry', () => { + const { ok, output } = render( + [{ code: 'en-gb', name: 'English (British)', category: 'regional', targetOnly: true }], + ['en-gb'], + ); + + expect(ok).toBe(true); + expect(output).toContain('targetOnly: true'); + }); + + it('should accept the punctuation real display names use', () => { + for (const name of ['Norwegian (bokmål)', 'Kurdish (Sorani)', 'Chinese (simplified)']) { + expect(render([{ code: 'nb', name, category: 'core' }]).ok).toBe(true); + } + }); + }); + + describe('hostile responses', () => { + it('should reject a language code that could terminate the literal', () => { + const { ok, output } = render([ + { + code: "x' }] as const; eval('boom'); const z = [{ code: 'y", + name: 'X', + category: 'core', + }, + ]); + + expect(ok).toBe(false); + expect(output).toMatch(/not shaped like a language tag/); + }); + + it('should reject a display name carrying a quote or comment marker', () => { + const { ok, output } = render([{ code: 'de', name: "Ger'; eval('x'); //", category: 'core' }]); + + expect(ok).toBe(false); + expect(output).toMatch(/refusing to write display name/); + }); + + it.each([['newline', 'German\nExtra'], ['backslash', 'German\\']])( + 'should reject a display name containing a %s', + (_label, name) => { + expect(render([{ code: 'de', name, category: 'core' }]).ok).toBe(false); + }, + ); + + it('should reject a category outside the three tiers', () => { + const { ok, output } = render([{ code: 'de', name: 'German', category: "core'; eval('x')" }]); + + expect(ok).toBe(false); + expect(output).toMatch(/unexpected category/); + }); + + it('should reject a non-string code or name', () => { + expect(render([{ code: 42, name: 'X', category: 'core' }]).ok).toBe(false); + expect(render([{ code: 'de', name: 42, category: 'core' }]).ok).toBe(false); + }); + + it('should reject a Write target that is not a language tag', () => { + const { ok } = render([{ code: 'de', name: 'German', category: 'core' }], [ + "de' ], evil = [", + ]); + + // The Write list is validated in main(); rendering it quotes the value so + // it cannot escape the literal even when reached directly. + expect(ok).toBe(true); + expect(render([{ code: 'de', name: 'German', category: 'core' }], ["de' ], evil = ["]).output) + .toContain("'de\\' ], evil = ['"); + }); + }); +}); From 559a89a127c1b5202414360cc705507daa3f602a Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 19:10:29 -0400 Subject: [PATCH 047/256] fix(cache): retire the translation entries the new key derivation orphans Adding preserveFormatting to the cache key changes every translation key, not only the keys of requests using the newly-hashed parameters: the service merges `defaults.preserveFormatting`, which defaults to true, so the field is always materialized. The design note and the CHANGELOG both claimed otherwise. Rather than walk the correctness fix back, the schema version is bumped so those rows are dropped on first open instead of lingering unreachable until their 30-day TTL, counting towards the size budget and `cache stats` in the meantime. The purge is scoped to the `translation:` namespace -- write and correct entries key the same way they always did and are still read in place, as the v2 notes promise. --- src/services/translation.ts | 8 +++++--- src/storage/cache.ts | 17 ++++++++++++---- tests/unit/cache-service.test.ts | 35 +++++++++++++++++++++++++------- 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/services/translation.ts b/src/services/translation.ts index 1c6448eb..e8edbd87 100644 --- a/src/services/translation.ts +++ b/src/services/translation.ts @@ -393,9 +393,11 @@ export class TranslationService { * intentional — the API receives the un-normalized bytes, so the cache keys * on exactly what is sent. * - * New fields are appended after the existing ones so that keys for requests - * not using them stay unchanged. `glossaryIds` is hashed in the caller's - * order rather than sorted, because reordering the list changes which + * New fields are appended after the existing ones, so a field left `undefined` + * does not perturb the key -- `JSON.stringify` omits it. `preserveFormatting` + * is the exception: the service merges a config default for it, so it is always + * materialized and every key reflects it. `glossaryIds` is hashed in the + * caller's order rather than sorted, because reordering the list changes which * glossary wins a conflicting term and therefore the translation itself. * * `tagHandlingVersion` is resolved rather than read straight off the options, diff --git a/src/storage/cache.ts b/src/storage/cache.ts index dd6ef871..72894162 100644 --- a/src/storage/cache.ts +++ b/src/storage/cache.ts @@ -66,11 +66,13 @@ function isCorruptionError(error: unknown): boolean { * Current on-disk schema version for the SQLite cache. Stamped into * `PRAGMA user_version` on fresh DBs and checked on every open. Bumping * this number means "future callers will read a DB laid out differently." - * Pre-versioned databases (created before this field existed) report - * `user_version = 0` and are upgrade-stamped in place without data - * migration — the schema is backward-compatible. + * + * Version 2 marks a change in how translation cache keys are computed. Opening + * an older DB drops the `translation:` rows -- no reader can reach them again -- + * and leaves every other namespace in place, since their keys are unchanged. The + * table layout is identical in all versions, so nothing is migrated. */ -const CACHE_SCHEMA_VERSION = 1; +const CACHE_SCHEMA_VERSION = 2; export class CacheService { private static instance: CacheService | null = null; @@ -243,6 +245,13 @@ export class CacheService { `); if (userVersion < CACHE_SCHEMA_VERSION) { + // Only the translation namespace: its key derivation changed, so those rows + // address entries no reader can reach again, and leaving them would let + // them occupy the size budget and `cache stats` until their TTL expires. + // Every other namespace (write, correct) keys the same way it always did + // and is read in place. A fresh DB has no rows, so this is a no-op on + // first open. + this.db.exec("DELETE FROM cache WHERE key LIKE 'translation:%'"); this.db.exec(`PRAGMA user_version = ${CACHE_SCHEMA_VERSION}`); } } diff --git a/tests/unit/cache-service.test.ts b/tests/unit/cache-service.test.ts index cd63d4fd..015b48b7 100644 --- a/tests/unit/cache-service.test.ts +++ b/tests/unit/cache-service.test.ts @@ -76,16 +76,16 @@ describe('CacheService', () => { expect(result).toEqual({ journal_mode: 'wal' }); }); - it('should stamp user_version = 1 on a fresh database', () => { + it('should stamp the current schema version on a fresh database', () => { const db = (cacheService as any).db; - const result = db.prepare('PRAGMA user_version').get(); - expect(result).toEqual({ user_version: 1 }); + const result = db.prepare('PRAGMA user_version').get() as { user_version: number }; + expect(result.user_version).toBe(2); }); it('should upgrade-stamp a pre-versioned (user_version=0) database in place', () => { - // Simulate a DB created before schema versioning: stamp 0, close, - // reopen via a new CacheService, verify it got stamped to 1 and - // existing data survived. + // Simulate a DB created before schema versioning: stamp 0, close, reopen + // via a new CacheService, verify it got stamped and non-translation data + // survived. const db = (cacheService as any).db; db.exec('PRAGMA user_version = 0'); cacheService.set('preexisting', { text: 'survives' }); @@ -94,13 +94,34 @@ describe('CacheService', () => { const reopened = new CacheService({ dbPath: testCachePath }); try { const reopenedDb = (reopened as any).db; - expect(reopenedDb.prepare('PRAGMA user_version').get()).toEqual({ user_version: 1 }); + expect( + (reopenedDb.prepare('PRAGMA user_version').get() as { user_version: number }) + .user_version, + ).toBe(2); expect(reopened.get('preexisting')).toEqual({ text: 'survives' }); } finally { reopened.close(); } }); + it('should drop only translation rows when upgrading an older database', () => { + // Translation keys are derived differently from version 2 on, so those rows + // are unreachable; every other namespace keys the same way and is kept. + const db = (cacheService as any).db; + db.exec('PRAGMA user_version = 1'); + cacheService.set('translation:oldhash', { text: 'unreachable' }); + cacheService.set('write:samehash', { text: 'still reachable' }); + cacheService.close(); + + const reopened = new CacheService({ dbPath: testCachePath }); + try { + expect(reopened.get('translation:oldhash')).toBeNull(); + expect(reopened.get('write:samehash')).toEqual({ text: 'still reachable' }); + } finally { + reopened.close(); + } + }); + it('should back up a corrupted database rather than unlinking it', () => { // Write a non-SQLite file to the cache path. The constructor's // openDatabase catch should rename it aside and recreate. From a464fd9ec9e61fa1705d9acc3cc8e2fa9a7e9777 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 19:10:29 -0400 Subject: [PATCH 048/256] fix(translate): extend the doomed-request abort past plain-text batches The short-circuit only covered .txt/.md, the files that were already grouped into batches. Everything else -- .json, .yaml, .html, .srt, .xliff -- goes through the per-file path, which still spent one round trip per file to collect the same rejection, so `translate ./locales --to xx` over 200 JSON files made 200 failing requests. That path now stops the same way, and the files it never sent are reported as skipped rather than as failures carrying another batch's error text. The classifier also gates on the error class before matching the message. A 5xx interpolates the upstream body into a NetworkError, so a gateway error quoting `target_lang` would have aborted a run that was otherwise succeeding. A refused key and an exhausted quota now abort too: they are as identical across items as a bad language code. --- src/services/batch-translation.ts | 31 ++++++++++++++--- src/utils/unrecoverable-request-error.ts | 33 +++++++++++++++---- tests/unit/services/batch-translation.test.ts | 24 ++++++++++++-- 3 files changed, 75 insertions(+), 13 deletions(-) diff --git a/src/services/batch-translation.ts b/src/services/batch-translation.ts index 9f1080e7..554f15a3 100644 --- a/src/services/batch-translation.ts +++ b/src/services/batch-translation.ts @@ -144,12 +144,18 @@ export class BatchTranslationService { ); result.successful.push(...batchResult.successful); result.failed.push(...batchResult.failed); - completed += batchResult.successful.length + batchResult.failed.length; + result.skipped.push(...batchResult.skipped); + completed += + batchResult.successful.length + batchResult.failed.length + batchResult.skipped.length; } // Per-file translation for structured/other files if (perFileFiles.length > 0) { const limit = pLimit(this.concurrency); + // Same reasoning as the batched path: an unsupported target_lang is a + // property of the request, so the files still queued would each spend a + // round trip to be told the same thing. + let requestRejected: unknown; const tasks = perFileFiles.map(file => limit(async () => { @@ -160,6 +166,13 @@ export class BatchTranslationService { return; } + if (requestRejected !== undefined) { + result.skipped.push({ file, reason: errorMessage(requestRejected) }); + completed++; + batchOptions.onProgress?.({ completed, total: totalFiles, current: file }); + return; + } + try { const outputPath = this.generateOutputPath( file, @@ -178,6 +191,9 @@ export class BatchTranslationService { completed++; batchOptions.onProgress?.({ completed, total: totalFiles, current: file }); } catch (error) { + if (isUnrecoverableRequestError(error)) { + requestRejected = error; + } result.failed.push({ file, error: errorMessage(error), @@ -207,9 +223,14 @@ export class BatchTranslationService { totalFiles: number, startCompleted: number, onProgress?: (progress: ProgressInfo) => void, - ): Promise<{ successful: Array<{ file: string; outputPath: string }>; failed: Array<{ file: string; error: string }> }> { + ): Promise<{ + successful: Array<{ file: string; outputPath: string }>; + failed: Array<{ file: string; error: string }>; + skipped: Array<{ file: string; reason: string }>; + }> { const successful: Array<{ file: string; outputPath: string }> = []; const failed: Array<{ file: string; error: string }> = []; + const skipped: Array<{ file: string; reason: string }> = []; interface FileEntry { file: string; @@ -235,8 +256,10 @@ export class BatchTranslationService { } if (requestRejected !== undefined) { + // Never sent, so reported as skipped rather than as individual failures + // carrying another batch's error. for (const entry of batch) { - failed.push({ file: entry.file, error: errorMessage(requestRejected) }); + skipped.push({ file: entry.file, reason: errorMessage(requestRejected) }); completed++; onProgress?.({ completed, total: totalFiles, current: entry.file }); } @@ -346,7 +369,7 @@ export class BatchTranslationService { } await flushBatch(); - return { successful, failed }; + return { successful, failed, skipped }; } /** diff --git a/src/utils/unrecoverable-request-error.ts b/src/utils/unrecoverable-request-error.ts index cffe1856..7d229f3c 100644 --- a/src/utils/unrecoverable-request-error.ts +++ b/src/utils/unrecoverable-request-error.ts @@ -1,5 +1,17 @@ +import { AuthError, QuotaError, ValidationError } from './errors.js'; import { errorMessage } from './error-message.js'; +/** + * Rejections that describe the request rather than one item, and so will be + * identical for everything still queued. + */ +const REJECTED_REQUEST_PATTERNS = [ + "value for 'target_lang' not supported", + "value for 'source_lang' not supported", + 'target_lang not supported', + 'source_lang not supported', +]; + /** * Whether an error means the request itself is wrong, not that this particular * item failed. @@ -7,14 +19,21 @@ import { errorMessage } from './error-message.js'; * A rejected `target_lang` is the same rejection for every file and every target * in the run, so retrying it per batch buys nothing but another round trip. * Language validation defers to the API on codes the bundled snapshot does not - * list, which is what makes this reachable from a plain typo. + * list, which is what makes this reachable from a plain typo. A refused key or + * an exhausted quota are the same for every item too. + * + * The error class is checked before the message: 4xx rejections arrive as + * ValidationError, while a 5xx interpolates the upstream body into a + * NetworkError -- so a transient gateway error quoting the same phrase must not + * abort a run that would otherwise mostly succeed. */ export function isUnrecoverableRequestError(error: unknown): boolean { + if (error instanceof AuthError || error instanceof QuotaError) { + return true; + } + if (!(error instanceof ValidationError)) { + return false; + } const message = errorMessage(error).toLowerCase(); - return ( - message.includes("value for 'target_lang' not supported") || - message.includes("value for 'source_lang' not supported") || - message.includes('target_lang not supported') || - message.includes('source_lang not supported') - ); + return REJECTED_REQUEST_PATTERNS.some(pattern => message.includes(pattern)); } diff --git a/tests/unit/services/batch-translation.test.ts b/tests/unit/services/batch-translation.test.ts index bd3ebea3..2e7cf0df 100644 --- a/tests/unit/services/batch-translation.test.ts +++ b/tests/unit/services/batch-translation.test.ts @@ -11,6 +11,8 @@ import { TranslationService, MAX_TEXT_BYTES } from '../../../src/services/transl import pLimit from 'p-limit'; import fg from 'fast-glob'; import { createMockFileTranslationService, createMockTranslationService } from '../../helpers/mock-factories'; +import { NetworkError, ValidationError } from '../../../src/utils/errors'; +import { isUnrecoverableRequestError } from '../../../src/utils/unrecoverable-request-error'; // Mock ESM modules jest.mock('p-limit'); @@ -511,7 +513,7 @@ describe('BatchTranslationService', () => { } mockTranslationService.translateBatch.mockRejectedValue( - new Error("API error: Value for 'target_lang' not supported."), + new ValidationError("API error: Value for 'target_lang' not supported."), ); const result = await batchServiceWithTranslation.translateFiles( @@ -523,8 +525,26 @@ describe('BatchTranslationService', () => { // The same rejection applies to every batch, so it is asked once rather // than once per batch. expect(mockTranslationService.translateBatch).toHaveBeenCalledTimes(1); - expect(result.failed).toHaveLength(52); expect(result.successful).toHaveLength(0); + // The first batch genuinely failed; the rest were never sent, so they are + // reported as skipped rather than as failures carrying another batch's error. + expect(result.failed).toHaveLength(50); + expect(result.skipped).toHaveLength(2); + expect(result.skipped[0]!.reason).toMatch(/target_lang/); + }); + + it('should keep going when a 5xx quotes the same phrase as a rejected language', () => { + // A gateway error interpolates the upstream body, so the message alone + // cannot distinguish it from a genuine rejection. + const transient = new NetworkError( + "Server error (502): upstream unavailable, value for 'target_lang' not supported by shard", + ); + expect(isUnrecoverableRequestError(transient)).toBe(false); + expect( + isUnrecoverableRequestError( + new ValidationError("API error: Value for 'target_lang' not supported."), + ), + ).toBe(true); }); it('should keep going when a batch fails for a reason specific to it', async () => { From fa5a6c49558de3c559735fcb0a4d1291b8e52144 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 19:10:56 -0400 Subject: [PATCH 049/256] fix(voice): keep the salvaged transcripts visible, and match casing in the display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three holes in the partial-result path: - the salvage went through Logger.warn, which --quiet suppresses, while the TTY display was erased regardless -- so `voice … -q` on a TTY wiped the rendered transcripts and printed nothing in their place, for audio already billed. It goes through Logger.error now, which quiet mode does not touch, and the display is only cleared once there is something to print instead - --format json was ignored, so a consumer capturing stderr got prose - the live display still keyed target state by the requested spelling, the exact defect the session-level fix addressed: with `--to zh-HANS`, a `zh-Hans` echo left the row blank for the whole session Also corrects the voice `--glossary` row in docs/API.md, which had picked up translate's semantics: voice takes a single glossary, is not repeatable, and has no --from requirement. --- docs/API.md | 2 +- src/cli/commands/voice.ts | 43 +++++++++++++++++++++++++++++---------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/docs/API.md b/docs/API.md index 78a80023..078dabff 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1005,7 +1005,7 @@ deepl voice [options] | `--to ` | `-t` | Target language(s), comma-separated, max 5 (required) | - | | `--from ` | `-f` | Source language (auto-detect if not specified) | auto | | `--formality ` | | Formality level: `default`, `formal`, `more`, `informal`, `less`, `prefer_more`, `prefer_less` | `default` | -| `--glossary ` | | Use glossary by name or ID (requires `--from`; repeatable, max 5, last wins on conflicts) | - | +| `--glossary ` | | Use glossary by name or ID (single glossary; not repeatable) | - | | `--content-type ` | | Audio content type (auto-detected from file extension) | auto | | `--chunk-size ` | | Audio chunk size in bytes | `6400` | | `--chunk-interval ` | | Interval between audio chunks in milliseconds | `200` | diff --git a/src/cli/commands/voice.ts b/src/cli/commands/voice.ts index 75b54f72..a62566d5 100644 --- a/src/cli/commands/voice.ts +++ b/src/cli/commands/voice.ts @@ -86,7 +86,12 @@ export class VoiceCommand { return this.formatResult(result, options.format); } catch (error) { - this.reportPartialResult(error, translateOptions.targetLangs.length, isTTY); + this.reportPartialResult( + error, + translateOptions.targetLangs.length, + isTTY, + options.format, + ); throw error; } finally { process.removeListener('SIGINT', sigintHandler); @@ -114,7 +119,12 @@ export class VoiceCommand { return this.formatResult(result, options.format); } catch (error) { - this.reportPartialResult(error, translateOptions.targetLangs.length, isTTY); + this.reportPartialResult( + error, + translateOptions.targetLangs.length, + isTTY, + options.format, + ); throw error; } finally { process.removeListener('SIGINT', sigintHandler); @@ -170,10 +180,12 @@ export class VoiceCommand { private createTTYCallbacks(targetLangs: VoiceTargetLanguage[], maxReconnectAttempts?: number): VoiceStreamCallbacks { const state: Record = {}; - // Initialize state for source + each target + // Initialize state for source + each target. Keyed lowercase for the same + // reason the session is: the server may echo a different canonicalization of + // a requested code, and an update matching no key renders nothing. state['source'] = { concluded: '', tentative: '' }; for (const lang of targetLangs) { - state[lang] = { concluded: '', tentative: '' }; + state[lang.toLowerCase()] = { concluded: '', tentative: '' }; } const lineCount = 1 + targetLangs.length; // source + targets @@ -232,7 +244,7 @@ export class VoiceCommand { scheduleRender(); }, onTargetTranscript: (update) => { - const tgt = state[update.language]; + const tgt = state[update.language.toLowerCase()]; if (!tgt) return; const concludedText = update.concluded.map((s) => s.text).join(' '); if (concludedText) { @@ -266,18 +278,27 @@ export class VoiceCommand { * cost another stream to see them. Written to stderr, so a partial result is * never mistaken for the command's output. */ - private reportPartialResult(error: unknown, targetCount: number, isTTY: boolean): void { + private reportPartialResult( + error: unknown, + targetCount: number, + isTTY: boolean, + format?: string, + ): void { if (!(error instanceof VoicePartialResultError)) { return; } + const salvaged = this.formatResult(error.result, format); + if (salvaged.trim() === '') { + return; + } if (isTTY) { this.clearTTYDisplay(targetCount); } - const salvaged = this.formatResult(error.result); - if (salvaged.trim() !== '') { - Logger.warn(chalk.yellow('Partial result before the session failed:')); - Logger.warn(salvaged); - } + // Logger.error, not warn: warnings are suppressed under --quiet, and erasing + // the live display without reprinting would leave the user with nothing for + // audio that has already been transcribed and billed. + Logger.error(chalk.yellow('Partial result before the session failed:')); + Logger.error(salvaged); } private formatResult(result: VoiceSessionResult, format?: string): string { From 2fe564c5ac1e2578220c46006a54b1082808b51b Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 19:10:56 -0400 Subject: [PATCH 050/256] fix(languages): stop --features contradicting its own footer A language the response described but which supports none of the discriminating features rendered as `none`, two lines above a footer crediting it with the features every described language shares. Only a language credited with nothing at all now reads `none`; the rest defer to the footer. Two more display defects with it: `--features` disabled the Formality column without always replacing it, so the flag could show strictly less than the plain listing, and the `?` cell for an undescribed language had no legend even though the text mode and the `[F]` marker both explain themselves. The `[F]` legend also keyed on `supportsFormality !== undefined`, so a language whose features were described without formality turned the legend on with nothing under it. --- src/cli/commands/languages.ts | 26 +++++++++++++++++++++----- tests/unit/languages-command.test.ts | 17 ++++++++++++++++- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/cli/commands/languages.ts b/src/cli/commands/languages.ts index 7cab4644..a283540a 100644 --- a/src/cli/commands/languages.ts +++ b/src/cli/commands/languages.ts @@ -146,7 +146,11 @@ function featureList(entry: LanguageDisplayEntry, keys: string[]): string { const label = featureLabel(key).toLowerCase(); return cell === 'yes' ? label : `${label} (${cell})`; }); - return supported.length > 0 ? supported.join(', ') : 'none'; + if (supported.length > 0) return supported.join(', '); + // Supports none of the columns, but the footer may still be crediting it with + // the features every described language shares; "none" would contradict that. + // Only a language supporting nothing at all is reported as supporting nothing. + return Object.keys(entry.features ?? {}).length === 0 ? 'none' : ''; } /** @@ -284,9 +288,11 @@ export class LanguagesCommand { const lines: string[] = []; const header = type === 'source' ? 'Source Languages:' : 'Target Languages:'; const renderFeatures = showFeatures && hasAnyFeatures(entries); - // Formality is one of the feature columns, so the [F] shorthand would say it twice. + // Formality is one of the feature columns, so the [F] shorthand would say it + // twice. `=== true` because a language the response did not describe carries + // no answer, and a legend with no [F] beneath it reads as "none support it". const showFormality = - !renderFeatures && type === 'target' && entries.some(e => e.supportsFormality !== undefined); + !renderFeatures && type === 'target' && entries.some(e => e.supportsFormality === true); lines.push(chalk.bold(header)); @@ -376,8 +382,13 @@ export class LanguagesCommand { const { columns, uniform } = renderFeatures ? partitionFeatureKeys(entries) : { columns: [], uniform: [] }; + // Formality is one of the feature columns, so the dedicated column would say + // it twice -- unless no feature discriminates, in which case dropping it + // would make --features show strictly less than the plain listing. const showFormality = - !renderFeatures && type === 'target' && entries.some(e => e.supportsFormality !== undefined); + (!renderFeatures || columns.length === 0) && + type === 'target' && + entries.some(e => e.supportsFormality === true); const head = ['Code', 'Name', 'Category']; const colWidths = [10, renderFeatures ? 24 : showFormality ? 30 : 36, 12]; @@ -409,8 +420,13 @@ export class LanguagesCommand { table.push(row); } + const notes: string[] = []; + if (renderFeatures && columns.length > 0 && entries.some(e => !hasFeatureData(e))) { + notes.push(`${UNKNOWN_CELL} = the API response did not describe this language`); + } const note = renderFeatures ? uniformNote(uniform, entries) : undefined; - return `${header}:\n${table.toString()}${note ? `\n${note}` : ''}`; + if (note) notes.push(note); + return `${header}:\n${table.toString()}${notes.length > 0 ? `\n${notes.join('\n')}` : ''}`; } /** Format both source and target language tables joined by a blank line. */ diff --git a/tests/unit/languages-command.test.ts b/tests/unit/languages-command.test.ts index e3115584..de2eb358 100644 --- a/tests/unit/languages-command.test.ts +++ b/tests/unit/languages-command.test.ts @@ -584,10 +584,25 @@ describe('LanguagesCommand', () => { expect(formatted).toContain('All listed languages also support: tag handling.'); }); - it('should mark a language with no supported features', () => { + it('should not claim a language supports nothing when the footer credits it', () => { const formatted = languagesCommand.formatDisplayEntries(displayEntries, 'target', true); const hi = formatted.split('\n').find(l => l.includes('Hindi')); + // Hindi supports none of the discriminating columns but does support the + // uniform one, which the footer reports -- "none" would contradict it. + expect(hi).not.toContain('none'); + expect(formatted).toContain('All listed languages also support: tag handling.'); + }); + + it('should mark a language the response credits with no features at all', () => { + const entries: LanguageDisplayEntry[] = [ + { code: 'de', name: 'German', category: 'core', features: { glossary: { status: 'stable' } } }, + { code: 'hi', name: 'Hindi', category: 'extended', features: {} }, + ]; + + const formatted = languagesCommand.formatDisplayEntries(entries, 'target', true); + const hi = formatted.split('\n').find(l => l.includes('Hindi')); + expect(hi).toContain('none'); }); From fa721dd3b94d48fb8430a41d18f2164413334966 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 19:11:22 -0400 Subject: [PATCH 051/256] fix: five defects the second review pass turned up - **glossary**: deduplication kept a repeat's first position, which inverted the documented last-wins precedence -- `--glossary base --glossary override --glossary base` handed conflicts to `override` although the user put `base` last. A repeat now keeps its last position. - **sync**: the new startup coverage check required the top-level glossary to cover every target locale, including ones with their own `locale_overrides..glossary`. That glossary never translates those locales, so a documented configuration failed before any file was touched. - **config**: normalization was wired into `set()` only, so a file written or edited with uppercase codes kept them, and `TranslationService` merges `defaults.sourceLang` verbatim -- `DE` in config and `--from de` keyed two cache entries for one request. The load path normalizes too. The unknown-code note is also scoped to the write path; shared with the loader it fired on every invocation, including `deepl --version`. - **translate**: document mode warned that `--model-type` and `--tag-handling*` would be ignored and then rejected the command over them, because the shared validation still saw them. They are dropped after the warning, so the mode ignores what it says it ignores. - **usage**: a duration-billed product could still render a character count as hours by scaling it, and a `null` count was reported as a real zero. Character counts are read only for `milliseconds` billing, where they carry the duration; non-finite values are treated as absent. --- .../translate/document-translation-handler.ts | 5 +++ .../translate/translation-options-factory.ts | 10 ++++- src/cli/commands/usage.ts | 41 ++++++++++++------- src/storage/config.ts | 34 ++++++++++----- src/sync/sync-service.ts | 17 ++++++-- tests/unit/usage-command.test.ts | 38 +++++++++++++++++ 6 files changed, 116 insertions(+), 29 deletions(-) diff --git a/src/cli/commands/translate/document-translation-handler.ts b/src/cli/commands/translate/document-translation-handler.ts index ae80286b..17209a79 100644 --- a/src/cli/commands/translate/document-translation-handler.ts +++ b/src/cli/commands/translate/document-translation-handler.ts @@ -24,6 +24,11 @@ export class DocumentTranslationHandler { const supported = new Set(['from', 'formality', 'glossary', 'outputFormat', 'enableMinification']); warnIgnoredOptions('document', options, supported); + // Actually ignored, not just announced: leaving them set let shared + // validation reject a command over a flag this mode has just said it would + // disregard. + const { modelType: _model, tagHandling: _tags, tagHandlingVersion: _version, ...rest } = options; + options = rest; validateLanguageCodes([options.to]); // Documents accept glossaries, so the extended-tier constraint applies here diff --git a/src/cli/commands/translate/translation-options-factory.ts b/src/cli/commands/translate/translation-options-factory.ts index 21958f48..09abfc43 100644 --- a/src/cli/commands/translate/translation-options-factory.ts +++ b/src/cli/commands/translate/translation-options-factory.ts @@ -63,12 +63,18 @@ export async function applyGlossarySelection< // the same glossary: a duplicate would flip the wire parameter from // glossary_id to glossary_ids, key an identical request differently, and // spend two of the five slots the API allows. + // + // A repeat keeps its LAST position, because the last glossary to define a term + // is the one the API applies -- dropping the later occurrence would hand the + // conflict to a glossary the user had deliberately overridden. const ids: string[] = []; for (const nameOrId of options.glossary) { const id = await resolveGlossaryId(glossaryService, nameOrId, expected); - if (!ids.includes(id)) { - ids.push(id); + const existing = ids.indexOf(id); + if (existing !== -1) { + ids.splice(existing, 1); } + ids.push(id); } const [only] = ids; diff --git a/src/cli/commands/usage.ts b/src/cli/commands/usage.ts index 53127626..870721b2 100644 --- a/src/cli/commands/usage.ts +++ b/src/cli/commands/usage.ts @@ -26,19 +26,28 @@ function isDurationBilled(product: ProductUsage): boolean { * response carries one, and the API-key-scoped amount. * * `accountUsed` stays undefined rather than falling back to the API-key figure: - * live responses omit `unit_count` for these products, so the fallback printed - * the key's own usage in the account column and the two were always equal. + * live responses omit `unit_count` for these products, so a fallback would print + * the key's own usage in the account column and the two would always be equal. + * + * A `milliseconds`-billed product reports its duration in the character-count + * fields, which is why those are read at all -- but only there. Under `minutes` + * billing they are character counts, and scaling one by 60,000 invents hours of + * usage that never happened. */ function productDurationsMs(product: ProductUsage): { accountUsed: number | undefined; - apiKeyUsed: number; + apiKeyUsed: number | undefined; } { - const scale = product.billingUnit === 'minutes' ? 60_000 : 1; - const account = product.unitCount ?? product.accountUnitCount; - const apiKeyUsed = product.apiKeyUnitCount ?? product.apiKeyCharacterCount; + const perMinute = product.billingUnit === 'minutes'; + const scale = perMinute ? 60_000 : 1; + const duration = (value: number | null | undefined): number | undefined => + typeof value === 'number' && Number.isFinite(value) ? value * scale : undefined; + const accountFallback = perMinute ? undefined : duration(product.characterCount); + const apiKeyFallback = perMinute ? undefined : duration(product.apiKeyCharacterCount); return { - accountUsed: account === undefined ? undefined : account * scale, - apiKeyUsed: apiKeyUsed * scale, + accountUsed: + duration(product.unitCount) ?? duration(product.accountUnitCount) ?? accountFallback, + apiKeyUsed: duration(product.apiKeyUnitCount) ?? apiKeyFallback, }; } @@ -123,11 +132,15 @@ export class UsageCommand { const name = productDisplayName(product.productType); if (isDurationBilled(product)) { const { accountUsed, apiKeyUsed } = productDurationsMs(product); - lines.push( - accountUsed === undefined - ? ` ${name}: ${this.formatMilliseconds(apiKeyUsed)} (API key)` - : ` ${name}: ${this.formatMilliseconds(accountUsed)} (API key: ${this.formatMilliseconds(apiKeyUsed)})`, - ); + const account = accountUsed === undefined ? undefined : this.formatMilliseconds(accountUsed); + const apiKey = apiKeyUsed === undefined ? undefined : this.formatMilliseconds(apiKeyUsed); + if (account !== undefined && apiKey !== undefined) { + lines.push(` ${name}: ${account} (API key: ${apiKey})`); + } else if (apiKey !== undefined) { + lines.push(` ${name}: ${apiKey} (API key)`); + } else { + lines.push(` ${name}: ${account ?? 'not reported'}`); + } } else if (product.unitCount !== undefined) { const apiKeyPart = product.apiKeyUnitCount !== undefined ? ` (API key: ${formatNumber(product.apiKeyUnitCount)} units)` @@ -219,7 +232,7 @@ export class UsageCommand { productTable.push([ name, accountUsed === undefined ? '—' : this.formatMilliseconds(accountUsed), - this.formatMilliseconds(apiKeyUsed), + apiKeyUsed === undefined ? '—' : this.formatMilliseconds(apiKeyUsed), ]); } else if (product.unitCount !== undefined) { const apiKeyVal = product.apiKeyUnitCount !== undefined diff --git a/src/storage/config.ts b/src/storage/config.ts index 0f0b0e5b..06ef78d6 100644 --- a/src/storage/config.ts +++ b/src/storage/config.ts @@ -6,7 +6,7 @@ import { randomBytes } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; -import { DeepLConfig, Formality, OutputFormat } from '../types/index.js'; +import { DeepLConfig, Formality, Language, OutputFormat } from '../types/index.js'; import { resolvePaths } from '../utils/paths.js'; import { isValidLanguage, looksLikeLanguageTag } from '../data/language-registry.js'; import { ConfigError } from '../utils/errors.js'; @@ -247,17 +247,28 @@ export class ConfigService { return ConfigService.getDefaults(); } + /** + * Validates a config read from disk, and normalizes its language codes in + * place. A file written before codes were stored lowercase -- or edited by + * hand -- otherwise keeps its casing, and `TranslationService` merges + * `defaults.sourceLang` verbatim, so `DE` and an explicit `--from de` would key + * two cache entries for one request. + */ private validateLoadedConfig(config: DeepLConfig): void { if (config.api?.baseUrl) { validateApiUrl(config.api.baseUrl); } if (config.defaults?.sourceLang) { this.validateLanguage(config.defaults.sourceLang, 'defaults.sourceLang'); + config.defaults.sourceLang = config.defaults.sourceLang.toLowerCase() as Language; } if (config.defaults?.targetLangs) { for (const lang of config.defaults.targetLangs) { this.validateLanguage(lang, 'defaults.targetLangs'); } + config.defaults.targetLangs = config.defaults.targetLangs.map( + lang => lang.toLowerCase() as Language, + ); } if (config.defaults?.formality) { this.validateFormality(config.defaults.formality, 'defaults.formality'); @@ -352,8 +363,7 @@ export class ConfigService { // Validate specific paths if (path === 'defaults.sourceLang' && value !== undefined) { - - this.validateLanguage(value as string, path); + this.validateLanguage(value as string, path, true); } if (path === 'defaults.targetLangs') { @@ -361,8 +371,7 @@ export class ConfigService { throw new ConfigError('Target languages must be an array'); } for (const lang of value) { - - this.validateLanguage(lang, path); + this.validateLanguage(lang, path, true); } } @@ -399,7 +408,12 @@ export class ConfigService { * accepted when they are shaped like a language tag, because GET /v3/languages * is the authority on which languages exist and the snapshot can lag it. */ - private validateLanguage(lang: string, key?: string): void { + /** + * @param announceUnknown - warn when the code is well-formed but absent from + * the bundled snapshot. Only the write path announces: every command loads + * the config, and a note on each invocation is noise, not guidance. + */ + private validateLanguage(lang: string, key?: string, announceUnknown = false): void { // Lowercased first: the translate paths lowercase their flags before use, and // the tag pattern below is lowercase-only, so `DE` is as valid as `de` here. const normalized = typeof lang === 'string' ? lang.toLowerCase() : lang; @@ -407,10 +421,10 @@ export class ConfigService { const context = key ? ` for "${key}"` : ''; throw new ConfigError(`Invalid language code "${lang}"${context}. Run: deepl languages to see valid codes`); } - if (!isValidLanguage(normalized)) { - // Stored anyway, since the snapshot can lag the API -- but flagged here, - // because a typo in config otherwise surfaces on every later command with - // nothing pointing back at the value responsible. + if (announceUnknown && !isValidLanguage(normalized)) { + // Stored anyway, since the snapshot can lag the API -- but flagged as it is + // written, because a typo in config otherwise surfaces on every later + // command with nothing pointing back at the value responsible. const context = key ? ` for "${key}"` : ''; Logger.warn( `Note: "${lang}"${context} is not in the bundled language list; it will be sent to the API as-is.\n` + diff --git a/src/sync/sync-service.ts b/src/sync/sync-service.ts index 088d223a..3a15a861 100644 --- a/src/sync/sync-service.ts +++ b/src/sync/sync-service.ts @@ -194,9 +194,20 @@ export class SyncService { // The pair is known from the config, so a glossary that does not cover it // fails here rather than once per file, as with the translation memory // below. - const glossaryLocales = options?.localeFilter?.length - ? config.target_locales.filter(l => options.localeFilter!.includes(l)) - : config.target_locales; + // + // Locales with their own `locale_overrides..glossary` are excluded: + // the top-level glossary is never asked to translate them, so requiring it + // to cover them would reject a configuration that works. + const overriddenLocales = new Set( + Object.entries(config.translation?.locale_overrides ?? {}) + .filter(([, override]) => override?.glossary) + .map(([locale]) => locale), + ); + const glossaryLocales = ( + options?.localeFilter?.length + ? config.target_locales.filter(l => options.localeFilter!.includes(l)) + : config.target_locales + ).filter(locale => !overriddenLocales.has(locale)); resolvedGlossaryId = await this.glossaryService.resolveGlossaryId( config.translation.glossary, { from: config.source_locale as Language, targets: glossaryLocales as Language[] }, diff --git a/tests/unit/usage-command.test.ts b/tests/unit/usage-command.test.ts index 59887458..9220a0a0 100644 --- a/tests/unit/usage-command.test.ts +++ b/tests/unit/usage-command.test.ts @@ -347,6 +347,44 @@ describe('UsageCommand', () => { expect(formatted).not.toContain('speech_to_text: 0 characters'); }); + it('should not render a character count as minutes of usage', () => { + const formatted = usageCommand.formatUsage({ + characterCount: 0, + characterLimit: 20000000, + products: [ + { + productType: 'speechToText', + characterCount: 1000, + apiKeyCharacterCount: 1000, + billingUnit: 'minutes', + }, + ], + }); + + // 1000 characters scaled by 60,000 would claim 16h 40m of voice usage. + expect(formatted).not.toContain('16h 40m'); + expect(formatted).toContain('speech_to_text: not reported'); + }); + + it('should ignore a null count rather than reporting it as zero', () => { + const formatted = usageCommand.formatUsage({ + characterCount: 0, + characterLimit: 20000000, + products: [ + { + productType: 'speechToText', + characterCount: 0, + apiKeyCharacterCount: 0, + accountUnitCount: null as unknown as number, + apiKeyUnitCount: 60, + billingUnit: 'minutes', + }, + ], + }); + + expect(formatted).toContain('speech_to_text: 1h 0m 0s (API key)'); + }); + it('should report the account-wide duration when the response carries one', () => { const formatted = usageCommand.formatUsage({ characterCount: 0, From 35b273b8a4243336e9b0168b7d5f43eee8361d78 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 19:42:34 -0400 Subject: [PATCH 052/256] fix: report a failed directory run, and accept voice codes in any casing A directory translation where every file failed printed its failure list and exited 0, so a script or CI job read it as success. Since language validation defers unknown codes to the API, a plain --to typo takes that path: on 1.x it exited 6 locally. A run with no successes now exits 1 and a partial failure exits 12, matching sync. `voice` also demanded the exact mixed-case spelling of a regional code, so `--to en-gb` and `--to zh-hans` exited 6 while `--to en-GB` worked -- the lowercase form being the one `deepl languages` prints and every other command accepts. Codes are matched case-insensitively and canonicalized to what the Voice API expects. --- .../directory-translation-handler.ts | 7 +++ src/cli/commands/voice.ts | 48 ++++++++++++++----- tests/unit/voice-command.test.ts | 34 +++++++++++++ 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/src/cli/commands/translate/directory-translation-handler.ts b/src/cli/commands/translate/directory-translation-handler.ts index 70a9d534..c206e8a8 100644 --- a/src/cli/commands/translate/directory-translation-handler.ts +++ b/src/cli/commands/translate/directory-translation-handler.ts @@ -2,6 +2,7 @@ import ora from 'ora'; import { BatchTranslationService } from '../../../services/batch-translation.js'; import { ValidationError } from '../../../utils/errors.js'; import { Logger } from '../../../utils/logger.js'; +import { ExitCode } from '../../../utils/exit-codes.js'; import type { HandlerContext, TranslateOptions } from './types.js'; import { warnIgnoredOptions, validateLanguageCodes } from './translate-utils.js'; import { buildBaseTranslationOptions } from './translation-options-factory.js'; @@ -90,6 +91,12 @@ export class DirectoryTranslationHandler { result.failed.forEach(f => { output.push(` - ${f.file}: ${f.error}`); }); + // Reported in the exit code as well as the summary: a run where nothing + // translated must not look like success to a script or a CI job, and + // language validation defers to the API, so a bad --to surfaces here + // rather than as a local rejection. + process.exitCode = + stats.successful === 0 ? ExitCode.GeneralError : ExitCode.PartialFailure; } if (stats.skipped > 0) { diff --git a/src/cli/commands/voice.ts b/src/cli/commands/voice.ts index a62566d5..ebb835ea 100644 --- a/src/cli/commands/voice.ts +++ b/src/cli/commands/voice.ts @@ -31,6 +31,23 @@ const VALID_VOICE_SOURCE_LANGS: ReadonlySet = new Set( + Array.from(VALID_VOICE_TARGET_LANGS, lang => [ + lang.toLowerCase(), + lang as VoiceTargetLanguage, + ]), +); +const VOICE_SOURCE_BY_LOWERCASE = new Map( + Array.from(VALID_VOICE_SOURCE_LANGS, lang => [ + lang.toLowerCase(), + lang as VoiceSourceLanguage, + ]), +); + const VALID_VOICE_CONTENT_TYPES: ReadonlySet = new Set([ 'audio/auto', 'audio/pcm;encoding=s16le;rate=8000','audio/pcm;encoding=s16le;rate=16000', @@ -132,20 +149,29 @@ export class VoiceCommand { } private buildOptions(options: VoiceCommandOptions): VoiceTranslateOptions { - const targetLangs = options.to.split(',').map((l) => l.trim()); - - for (const lang of targetLangs) { - if (!VALID_VOICE_TARGET_LANGS.has(lang)) { + // Matched case-insensitively and canonicalized to the spelling the Voice API + // expects. The rest of the CLI accepts any casing and `deepl languages` + // prints these codes lowercase, so requiring `zh-HANS` would reject the + // spelling the CLI itself teaches. + const targetLangs = options.to.split(',').map((l) => { + const raw = l.trim(); + const canonical = VOICE_TARGET_BY_LOWERCASE.get(raw.toLowerCase()); + if (!canonical) { throw new ValidationError( - `Invalid voice target language: "${lang}". Valid codes: ${Array.from(VALID_VOICE_TARGET_LANGS).sort().join(', ')}`, + `Invalid voice target language: "${raw}". Valid codes: ${Array.from(VALID_VOICE_TARGET_LANGS).sort().join(', ')}`, ); } - } + return canonical; + }); - if (options.from && !VALID_VOICE_SOURCE_LANGS.has(options.from)) { - throw new ValidationError( - `Invalid voice source language: "${options.from}". Valid codes: ${Array.from(VALID_VOICE_SOURCE_LANGS).sort().join(', ')}`, - ); + if (options.from) { + const canonicalSource = VOICE_SOURCE_BY_LOWERCASE.get(options.from.toLowerCase()); + if (!canonicalSource) { + throw new ValidationError( + `Invalid voice source language: "${options.from}". Valid codes: ${Array.from(VALID_VOICE_SOURCE_LANGS).sort().join(', ')}`, + ); + } + options.from = canonicalSource; } if (options.contentType && !VALID_VOICE_CONTENT_TYPES.has(options.contentType)) { @@ -164,7 +190,7 @@ export class VoiceCommand { } return { - targetLangs: targetLangs as VoiceTargetLanguage[], + targetLangs, sourceLang: options.from as VoiceSourceLanguage | undefined, sourceLanguageMode: options.sourceLanguageMode as VoiceSourceLanguageMode | undefined, formality: options.formality as VoiceTranslateOptions['formality'], diff --git a/tests/unit/voice-command.test.ts b/tests/unit/voice-command.test.ts index 9a9bc769..fa7d5ba7 100644 --- a/tests/unit/voice-command.test.ts +++ b/tests/unit/voice-command.test.ts @@ -1039,4 +1039,38 @@ describe('VoiceCommand', () => { expect(result).toBe('[de] Welt\n[fr] Monde'); }); }); + describe('language code casing', () => { + it('should accept a regional target in the casing the CLI prints', async () => { + // `deepl languages` prints en-gb / zh-hans, and every other command takes + // any casing, so requiring en-GB / zh-HANS here would reject the CLI's own + // spelling. + mockService.translateFile.mockResolvedValue(mockResult); + + for (const code of ['en-gb', 'zh-hans', 'PT-br']) { + await expect(command.translate('test.mp3', { to: code })).resolves.toContain('[source]'); + } + }); + + it('should canonicalize the code the Voice API receives', async () => { + mockService.translateFile.mockResolvedValue(mockResult); + + await command.translate('test.mp3', { to: 'zh-hans,en-gb', from: 'EN' }); + + expect(mockService.translateFile).toHaveBeenLastCalledWith( + 'test.mp3', + expect.objectContaining({ targetLangs: ['zh-HANS', 'en-GB'], sourceLang: 'en' }), + undefined, + ); + }); + + it('should still reject a code that is not a voice language', async () => { + await expect(command.translate('test.mp3', { to: 'xx' })).rejects.toThrow( + /Invalid voice target language/, + ); + await expect(command.translate('test.mp3', { to: 'de', from: 'xx' })).rejects.toThrow( + /Invalid voice source language/, + ); + }); + }); + }); From 586085e163efc5196b073333a81551a8a6f13a4f Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 19:42:34 -0400 Subject: [PATCH 053/256] test: restore the guards the relational counts removed Comparing the registry against the ENTRIES array it is built from proves the plumbing and nothing about the data: deleting 71 of 125 languages left the registry suite green. Floors well under today's counts, plus named codes from every tier, fail on a snapshot that lost most of its languages while still surviving any plausible upstream change -- which was the point of de-pinning the literal counts. Three more assertions that could not fail: - the batch-abort classifier had no direct test at all; three of its four patterns were unverified, and the error-class gate untested - the tiering heuristic for a language the snapshot does not list was asserted nowhere, so a regional variant could silently land in the wrong tier - two assertions passed on substrings that occur in unrelated words ('de' inside "code", 'glossary' inside the extended-languages header) The shared /v3/languages nock fixture also carried no `features` matrix, so through the real code every language in it tiered as extended with formality unreported -- the opposite of what those four languages support. --- tests/helpers/nock-setup.ts | 48 ++++++++++-- tests/unit/language-registry.test.ts | 40 ++++++++-- tests/unit/languages-command.test.ts | 41 +++++++++- tests/unit/register-write.test.ts | 10 +-- .../unit/unrecoverable-request-error.test.ts | 74 +++++++++++++++++++ 5 files changed, 190 insertions(+), 23 deletions(-) create mode 100644 tests/unit/unrecoverable-request-error.test.ts diff --git a/tests/helpers/nock-setup.ts b/tests/helpers/nock-setup.ts index 509f2f1b..f235f02d 100644 --- a/tests/helpers/nock-setup.ts +++ b/tests/helpers/nock-setup.ts @@ -39,13 +39,51 @@ export function mockAuthError(scope: nock.Scope): nock.Scope { return scope.post('/v2/translate').reply(403, { message: 'Invalid API key' }); } +/** + * The default carries a `features` matrix because the live response does: tiers + * and formality support are both derived from it, so a fixture without one makes + * every language read as extended with formality unreported -- the opposite of + * what these four languages actually support. + */ +const STABLE = { status: 'stable' } as const; + export function mockLanguagesResponse( scope: nock.Scope, - languages: Array<{ lang: string; name: string; usable_as_source?: boolean; usable_as_target?: boolean }> = [ - { lang: 'de', name: 'German', usable_as_source: true, usable_as_target: true }, - { lang: 'en', name: 'English', usable_as_source: true, usable_as_target: true }, - { lang: 'es', name: 'Spanish', usable_as_source: true, usable_as_target: true }, - { lang: 'fr', name: 'French', usable_as_source: true, usable_as_target: true }, + languages: Array<{ + lang: string; + name: string; + usable_as_source?: boolean; + usable_as_target?: boolean; + features?: Record; + }> = [ + { + lang: 'de', + name: 'German', + usable_as_source: true, + usable_as_target: true, + features: { formality: STABLE, glossary: STABLE, tag_handling: STABLE }, + }, + { + lang: 'en', + name: 'English', + usable_as_source: true, + usable_as_target: true, + features: { glossary: STABLE, tag_handling: STABLE }, + }, + { + lang: 'es', + name: 'Spanish', + usable_as_source: true, + usable_as_target: true, + features: { formality: STABLE, glossary: STABLE, tag_handling: STABLE }, + }, + { + lang: 'fr', + name: 'French', + usable_as_source: true, + usable_as_target: true, + features: { formality: STABLE, glossary: STABLE, tag_handling: STABLE }, + }, ], resource: string = 'translate_text', ): nock.Scope { diff --git a/tests/unit/language-registry.test.ts b/tests/unit/language-registry.test.ts index 6e13e1fc..eec39ee4 100644 --- a/tests/unit/language-registry.test.ts +++ b/tests/unit/language-registry.test.ts @@ -18,16 +18,47 @@ import type { Language } from '../../src/types/common'; * regenerating it -- the documented release step -- does not turn this suite red * for a change it is supposed to accept. Drift from the API is checked by * "npm run check:languages", which is where that belongs. + * + * Comparing the registry against the array it is built from proves the plumbing + * but not the data, so the floors below stand in for the literal counts: they + * hold across any plausible upstream change and still fail on a snapshot that + * lost most of its languages. */ const TIERS = ['core', 'regional', 'extended'] as const; const entriesIn = (category: string) => ENTRIES.filter(e => e.category === category); +/** Floors chosen well under today's 125/32/11/82 and well over an empty file. */ +const MIN_TOTAL = 100; +const MIN_PER_TIER: Record<(typeof TIERS)[number], number> = { + core: 20, + regional: 5, + extended: 50, +}; + describe('Language Registry', () => { describe('LANGUAGE_REGISTRY', () => { it('should contain one entry per snapshot language', () => { expect(LANGUAGE_REGISTRY.size).toBe(ENTRIES.length); }); + it('should hold a plausible number of languages', () => { + // Independent of ENTRIES.length, which the assertion above compares against + // itself: a snapshot regenerated from a broken response fails here. + expect(LANGUAGE_REGISTRY.size).toBeGreaterThanOrEqual(MIN_TOTAL); + }); + + it.each(TIERS)('should hold a plausible number of %s languages', category => { + expect(entriesIn(category).length).toBeGreaterThanOrEqual(MIN_PER_TIER[category]); + }); + + it('should still contain a representative language from every tier', () => { + // Named codes, so losing a whole tier or a common language is caught even + // if the totals stay plausible. + for (const code of ['en', 'de', 'ja', 'zh', 'en-gb', 'pt-br', 'zh-hans', 'hi', 'sw', 'th']) { + expect(LANGUAGE_REGISTRY.has(code)).toBe(true); + } + }); + it('should have unique language codes', () => { const codes = Array.from(LANGUAGE_REGISTRY.keys()); const unique = new Set(codes); @@ -48,14 +79,7 @@ describe('Language Registry', () => { ); }); - /** - * Mirrors the generator's floor: the tiers come from the features matrix, so - * a matrix that stopped reporting `glossary` would retier every language as - * extended and make --formality and --glossary unusable everywhere. - */ - it('should keep a plausible number of core languages', () => { - expect(entriesIn('core').length).toBeGreaterThanOrEqual(20); - }); + it('should mark regional variants as targetOnly', () => { const regional = Array.from(LANGUAGE_REGISTRY.values()).filter(e => e.category === 'regional'); diff --git a/tests/unit/languages-command.test.ts b/tests/unit/languages-command.test.ts index de2eb358..095b3aba 100644 --- a/tests/unit/languages-command.test.ts +++ b/tests/unit/languages-command.test.ts @@ -467,6 +467,39 @@ describe('LanguagesCommand', () => { }); }); + describe('tiering a language the snapshot does not list', () => { + it('should treat a hyphenated code as a target-only regional variant', () => { + // LanguageInfo carries no usable_as_source, so the subtag is the only + // signal available; asserted because nothing else pins this guess. + const merged = languagesCommand.mergeWithRegistry( + [{ language: 'de-ch', name: 'German (Swiss)', features: { glossary: { status: 'stable' } } }], + 'target', + ); + const entry = merged.find(e => e.code === 'de-ch'); + + expect(entry).toBeDefined(); + expect(entry!.category).toBe('regional'); + }); + + it('should treat a bare code with glossary support as core', () => { + const merged = languagesCommand.mergeWithRegistry( + [{ language: 'xx' as never, name: 'Novel', features: { glossary: { status: 'stable' } } }], + 'target', + ); + + expect(merged.find(e => e.code === 'xx')!.category).toBe('core'); + }); + + it('should treat a code without glossary support as extended', () => { + const merged = languagesCommand.mergeWithRegistry( + [{ language: 'yy' as never, name: 'Other', features: { tag_handling: { status: 'stable' } } }], + 'target', + ); + + expect(merged.find(e => e.code === 'yy')!.category).toBe('extended'); + }); + }); + describe('partitionFeatureKeys()', () => { const entry = ( code: string, @@ -667,10 +700,10 @@ describe('LanguagesCommand', () => { ]; const formatted = languagesCommand.formatLanguages(apiLangs, 'target', true); - // Reported once for the whole listing rather than per row: the languages - // the response omitted carry no feature data, so they cannot make glossary - // look like a discriminating column. - expect(formatted).toContain('glossary'); + // Asserted on the footer line specifically: the section header for extended + // languages also contains the word "glossary", so a bare toContain would + // pass even with the flag ignored entirely. + expect(formatted).toMatch(/also support:.*glossary/); }); it('should report a language the response omitted as unknown, not as supporting nothing', () => { diff --git a/tests/unit/register-write.test.ts b/tests/unit/register-write.test.ts index 7b92a7c2..01229654 100644 --- a/tests/unit/register-write.test.ts +++ b/tests/unit/register-write.test.ts @@ -205,17 +205,15 @@ describe('registerWrite', () => { expect(mockWriteCommand.improve).toHaveBeenCalled(); const warning = (Logger.warn as jest.Mock).mock.calls.map(c => String(c[0])).join('\n'); expect(warning).toContain('not in the bundled Write language list'); - for (const code of WRITE_TARGET_LANGUAGES) { - expect(warning).toContain(code); - } + expect(warning).toContain(WRITE_TARGET_LANGUAGES.join(', ')); }); it('should enumerate every supported language when rejecting malformed input', async () => { await program.parseAsync(['node', 'test', 'write', 'Hello', '--lang', 'nope_nope']); const error = handleError.mock.calls[0]?.[0] as Error; - for (const code of WRITE_TARGET_LANGUAGES) { - expect(error.message).toContain(code); - } + // Asserted as the joined list: individual codes like 'de' occur inside + // ordinary words in the message, so per-code checks cannot fail. + expect(error.message).toContain(WRITE_TARGET_LANGUAGES.join(', ')); }); it('should accept every language in the generated list', async () => { diff --git a/tests/unit/unrecoverable-request-error.test.ts b/tests/unit/unrecoverable-request-error.test.ts new file mode 100644 index 00000000..20e1a6a9 --- /dev/null +++ b/tests/unit/unrecoverable-request-error.test.ts @@ -0,0 +1,74 @@ +/** + * Tests for the classifier that decides whether a run aborts. + * + * It matches an API error string that carries no compatibility contract, and it + * decides whether the remaining files in a batch are sent at all, so every + * pattern is pinned rather than left as a guess. + */ + +import { isUnrecoverableRequestError } from '../../src/utils/unrecoverable-request-error'; +import { + AuthError, + NetworkError, + QuotaError, + RateLimitError, + ValidationError, +} from '../../src/utils/errors'; + +describe('isUnrecoverableRequestError', () => { + describe('rejections that apply to every item', () => { + it.each([ + "API error: Value for 'target_lang' not supported.", + "API error: Value for 'source_lang' not supported.", + 'API error: target_lang not supported', + 'API error: source_lang not supported', + ])('should match %s', message => { + expect(isUnrecoverableRequestError(new ValidationError(message))).toBe(true); + }); + + it('should match regardless of case', () => { + expect( + isUnrecoverableRequestError(new ValidationError("VALUE FOR 'TARGET_LANG' NOT SUPPORTED.")), + ).toBe(true); + }); + + it('should treat a refused key and an exhausted quota as request-wide', () => { + expect(isUnrecoverableRequestError(new AuthError('Authentication failed'))).toBe(true); + expect(isUnrecoverableRequestError(new QuotaError('Quota exceeded'))).toBe(true); + }); + }); + + describe('failures specific to one attempt', () => { + it('should not match a transient error quoting the same phrase', () => { + // 5xx interpolates the upstream body, so the message alone cannot tell a + // gateway hiccup from a rejected language. + expect( + isUnrecoverableRequestError( + new NetworkError( + "Server error (502): upstream unavailable, value for 'target_lang' not supported by shard", + ), + ), + ).toBe(false); + }); + + it('should not match a rate limit', () => { + expect(isUnrecoverableRequestError(new RateLimitError('Rate limit exceeded'))).toBe(false); + }); + + it('should not match an unrelated validation error', () => { + expect(isUnrecoverableRequestError(new ValidationError('Text cannot be empty'))).toBe(false); + }); + + it('should not match a plain Error, whatever it says', () => { + expect( + isUnrecoverableRequestError(new Error("Value for 'target_lang' not supported.")), + ).toBe(false); + }); + + it('should not match a non-error value', () => { + for (const value of [undefined, null, 'target_lang not supported', 42]) { + expect(isUnrecoverableRequestError(value)).toBe(false); + } + }); + }); +}); From 83f54e72828e89f95099647325ce730da6fd9c97 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Mon, 3 Aug 2026 19:42:48 -0400 Subject: [PATCH 054/256] docs(changelog): repair the Unreleased structure and the claims that were false The Unreleased section had two `### Changed` and two `### Removed` headings, and eight entries under the first `### Removed` described features that exist and work: NO_PROXY support, `auth set-key --no-verify`, `check-deps`, the `t`/`w` aliases, the GitHub Release workflow, `--timeout`/`--max-retries`, `cached` in JSON output, and the Node<24 fast-fail. release.yml publishes this section verbatim as the release notes, so v2 would have announced eight live features as deleted. Categories are merged, one per release, in Keep a Changelog order. Three claims corrected rather than left to ship: - the cache entry scoped invalidation to "requests using any of these options"; it is every translation entry, because a preserveFormatting default is always merged. The schema bump that retires them is described instead - the tag-handling entry repeated the same scoping - "Input remains case-insensitive everywhere" was false for `voice`, which rejected the lowercase regional codes the CLI itself prints Also fixes three stale passages in docs/API.md: the `voice --glossary` row had picked up translate's repeatable/`--from` semantics, the `write` reference still said an unknown code is rejected locally, and the `usage` reference still documented the removed Speech-to-Text section with output showing the duplicated API-key figure. --- CHANGELOG.md | 190 +++++++++++++++++++++++++++++++++++++++++++++------ docs/API.md | 11 +-- 2 files changed, 173 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f052e..3c34936f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,9 +17,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **cli**: `deepl correct` command (alias `c`) — spelling and grammar correction without rewording, via the Write API's `/v2/write/correct` endpoint. Supports the same input handling and workflow flags as `write` (`--check` with exit code 8, `--fix`/`--backup`, `--diff`, `--interactive`, `--output`/`--in-place`, `--format json`, `--no-cache`), but not `--style`/`--tone`, which the correct endpoint does not accept. Results are cached under a separate `correct:` namespace so corrections and rephrasings of the same text never collide. +- **http**: `NO_PROXY` / `no_proxy` are honoured, with the standard semantics — `*` for everything, a leading dot or `*.` for subdomains, and an optional `host:port` that must agree. A corporate `HTTPS_PROXY` was previously applied to every request, including one aimed at localhost. + +- **auth**: `deepl auth set-key --no-verify` stores a key without validating it against the API. Validation ran before persisting, so on a network without proxy configuration both documented setup paths — `auth set-key` and `init` — failed and discarded the key; an unreachable API now also names `DEEPL_API_KEY` as the zero-network alternative. + +- **ci**: `npm run check-deps` fails the build when a package imported by `src/` is missing from `dependencies`, including one declared only under `devDependencies`. It runs in CI and in the publish job, and matches package names as quoted strings so indirect loads such as `requireModule('php-parser')` count as references. + +- **cli**: `t` and `w` command aliases for `translate` and `write` ([#12](https://github.com/DeepL/deepl-cli/issues/12)). The aliases appear in `--help` output and in bash/zsh/fish shell completions. `w` is deliberately assigned to `write` rather than `watch` — write is a primary API feature, watch a workflow helper. + +- **ci**: Pushing a `v*` tag now creates a GitHub Release, with notes extracted from that version's CHANGELOG section and generated notes as a fallback when the section is missing. This is why the repo has 17 tags and zero Releases. The workflow does **not** publish to npm: releases are published from GitLab, which is the source of truth and mirrors to GitHub, matching the other DeepL client libraries. The tag is checked against `package.json` first, so a mislabelled tag cannot mint a Release whose title disagrees with the version it contains. + +- **cli**: Global `--timeout ` and `--max-retries ` options override the HTTP transport defaults (30000 ms, 3 retries) for a single invocation. Neither was previously configurable from the CLI. + +- **translate**: `--format json` output now includes the documented `cached` boolean, so scripts can distinguish cache hits from fresh API calls. + +- **cli**: Running under Node.js < 24 now fails fast with a clear one-line error (exit 6) instead of surfacing a raw `node:sqlite` ExperimentalWarning or crashing later. + ### Changed -- **translate**: **`--tag-handling` requests now pin `tag_handling_version=v2`** instead of letting the API pick. The CLI previously sent the parameter only when `--tag-handling-version` was passed, so everyone else inherited the server default — which DeepL documents as moving from v1 (deprecation-bound) to v2 at an unannounced date. That flip would have changed tag-handling output with no CLI change to point at, and worse, would have gone unnoticed by the cache: a request that omits the version hashes identically before and after, so cached v1 output would have kept being served after the API started returning v2. Adopting v2 now makes that one output shift deliberate and dated, and DeepL's own docs recommend v2 for structure handling. **`--tag-handling xml`/`html` output may differ from previous releases**; pass `--tag-handling-version v1` to keep the old behaviour, which is still honoured and always wins over the default. Requests without `--tag-handling` send no version and keep their existing cache keys; tag-handling entries cached by earlier versions now miss rather than being served stale, so the first such translation after upgrading is refetched. +- **translate**: **`--tag-handling` requests now pin `tag_handling_version=v2`** instead of letting the API pick. The CLI previously sent the parameter only when `--tag-handling-version` was passed, so everyone else inherited the server default — which DeepL documents as moving from v1 (deprecation-bound) to v2 at an unannounced date. That flip would have changed tag-handling output with no CLI change to point at, and worse, would have gone unnoticed by the cache: a request that omits the version hashes identically before and after, so cached v1 output would have kept being served after the API started returning v2. Adopting v2 now makes that one output shift deliberate and dated, and DeepL's own docs recommend v2 for structure handling. **`--tag-handling xml`/`html` output may differ from previous releases**; pass `--tag-handling-version v1` to keep the old behaviour, which is still honoured and always wins over the default. Requests without `--tag-handling` send no version. Cached translations from earlier versions are retired on first open regardless — see the cache entry under Fixed — so no tag-handling entry can be served stale. - **translate**: **A glossary referenced by name is checked against the requested language pair before any translation request.** Previously only translation memories did this; a glossary whose dictionaries did not cover the pair reached the API and came back as `No dictionary found for language pair EN-DE in glossary `, naming a UUID the user never typed. It now fails locally, exit 7, naming what the glossary actually covers: `Glossary "my-terms" does not support the requested language pair` / `Glossary covers en→es; requested en→de.` This costs no extra request, because the glossary list is already fetched to resolve the name. Matching is per dictionary, so a multilingual glossary holding en→es and de→fr is not treated as covering en→fr, and when translating to several targets at once every one of them must be covered. Both sides are compared on their **base** language, because glossary dictionaries only ever name base languages while `--to` accepts regional variants: a de→en glossary covers `--to en-us`, which the API accepts, and demanding an exact match would have made glossaries unusable for every regional target — `en-us`, `en-gb`, `pt-br`, `pt-pt`, `zh-hans`, `es-419`, `fr-ca` — including the ones DeepL steers users towards over bare `en`/`pt`. The check is therefore deliberately permissive at the edges: a pair it lets through is still the API's to reject, which is much the cheaper mistake. Two deliberate exemptions: a glossary passed as a **UUID** is trusted and left to the API, matching how translation-memory resolution already behaves and giving an escape hatch if the check is ever wrong; and a glossary the API reports with no dictionaries is left alone, since that says nothing about coverage. @@ -31,61 +47,97 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **languages**: **Ten display names changed to match the API**, a consequence of generating the language list rather than hand-writing it: `ckb` Central Kurdish → Kurdish (Sorani), `es-419` Spanish (Latin America) → Spanish (Latin American), `gom` Goan Konkani → Konkani, `kmr` Northern Kurdish → Kurdish (Kurmanji), `my` Myanmar (Burmese) → Burmese, `nb` Norwegian Bokmål → Norwegian (bokmål), `pam` Pampanga → Kapampangan, `st` Southern Sotho → Sesotho, `zh-hans` Chinese (Simplified) → Chinese (simplified), `zh-hant` Chinese (Traditional) → Chinese (traditional). **Only offline output changes**: with an API key configured, `deepl languages` already took names from the API and was therefore already showing these, so this makes the no-API-key output consistent with the keyed output rather than changing what keyed users saw. Language codes are unaffected, so nothing that selects a language by code has to change; only output that scrapes display names. -- **cli**: **Language codes are displayed in lowercase everywhere.** Output previously mixed three casings: `deepl languages` printed lowercase from the registry, `glossary show` and `tm list` uppercased at display time, `translate`'s table uppercased the target language, and `write`/`correct` used BCP-47 (`en-GB`, `zh-Hans`). Lowercase matches the CLI's own normalized form, the registry, what `deepl languages` teaches users to type, and the wire format `/v3/languages` moved to; uppercase followed a v2-era docs convention that v3 abandons. **Scripts scraping these values will see a casing change** — `glossary show` now reports `Source language: en` and `en → es: 5 entries`, `tm list` renders `brand-terms (en → de, fr)`, `translate --format table` labels rows `de`, and `write --format json` reports `"language": "en-us"`. Input remains case-insensitive everywhere, so no command line has to change. `write`/`correct` also send the lowercase code as `target_lang`: the Write API accepts any casing and canonicalizes server-side (verified live on `/v2/write/rephrase` and `/v2/write/correct` — `en-gb`, `zh-hans`, and `zh-HANS` all return 200 and echo back `en-GB` / `zh-Hans`). Wire parameters that are not display are untouched: `translate` and the glossary create endpoint still send uppercase `target_lang`/`source_lang` as those endpoints document. -- **api**: Language listings migrated from the formally deprecated `GET /v2/languages` and `GET /v2/glossary-language-pairs` endpoints to `GET /v3/languages` (`resource=translate_text` / `resource=glossary`). Command output is unchanged: source/target lists derive from the v3 `usable_as_source`/`usable_as_target` flags, glossary pairs from the source×target cross-product (verified identical to the v2 pair list), and the `[F]` formality markers come from the per-language `features` matrix, which is what v2's `supports_formality` boolean became (see the Fixed entry on `pt`). A language whose features the response does not describe is left unmarked rather than marked unsupported, so the `[F]` legend cannot appear with no `[F]` beneath it. - -### Removed - -- **cli**: **BREAKING**: The `--enable-beta-languages` flag on `translate` is gone. The API deprecated the underlying `enable_beta_languages` parameter with "has no effect" — beta languages are simply part of the regular language set now — so the flag had become a silent no-op. Scripts passing it will exit with an unknown-option error; remove the flag. -- **usage**: The dedicated "Speech-to-Text Usage" section (text output) and "Speech-to-text" row (table output) are gone, along with the `speechToTextMilliseconds*` fields they read. The API deprecated `speech_to_text_milliseconds_count`/`_limit` on `GET /v2/usage` ("Always returns 0"), so the section could only ever display zero. Voice usage remains visible in the Product Breakdown, which reads the live per-product minutes data. The Admin API's per-key `speech_to_text_milliseconds` usage limit is a different, still-current field and is unaffected. +- **cli**: **Language codes are displayed in lowercase everywhere.** Output previously mixed three casings: `deepl languages` printed lowercase from the registry, `glossary show` and `tm list` uppercased at display time, `translate`'s table uppercased the target language, and `write`/`correct` used BCP-47 (`en-GB`, `zh-Hans`). Lowercase matches the CLI's own normalized form, the registry, what `deepl languages` teaches users to type, and the wire format `/v3/languages` moved to; uppercase followed a v2-era docs convention that v3 abandons. **Scripts scraping these values will see a casing change** — `glossary show` now reports `Source language: en` and `en → es: 5 entries`, `tm list` renders `brand-terms (en → de, fr)`, `translate --format table` labels rows `de`, and `write --format json` reports `"language": "en-us"`. Input is case-insensitive everywhere, so no command line has to change -- `voice` included, which previously demanded the exact mixed-case spelling of a regional code (`--to zh-HANS`) and rejected the lowercase form the rest of the CLI prints. `write`/`correct` also send the lowercase code as `target_lang`: the Write API accepts any casing and canonicalizes server-side (verified live on `/v2/write/rephrase` and `/v2/write/correct` — `en-gb`, `zh-hans`, and `zh-HANS` all return 200 and echo back `en-GB` / `zh-Hans`). Wire parameters that are not display are untouched: `translate` and the glossary create endpoint still send uppercase `target_lang`/`source_lang` as those endpoints document. -- **http**: `NO_PROXY` / `no_proxy` are honoured, with the standard semantics — `*` for everything, a leading dot or `*.` for subdomains, and an optional `host:port` that must agree. A corporate `HTTPS_PROXY` was previously applied to every request, including one aimed at localhost. -- **auth**: `deepl auth set-key --no-verify` stores a key without validating it against the API. Validation ran before persisting, so on a network without proxy configuration both documented setup paths — `auth set-key` and `init` — failed and discarded the key; an unreachable API now also names `DEEPL_API_KEY` as the zero-network alternative. -- **ci**: `npm run check-deps` fails the build when a package imported by `src/` is missing from `dependencies`, including one declared only under `devDependencies`. It runs in CI and in the publish job, and matches package names as quoted strings so indirect loads such as `requireModule('php-parser')` count as references. -- **cli**: `t` and `w` command aliases for `translate` and `write` ([#12](https://github.com/DeepL/deepl-cli/issues/12)). The aliases appear in `--help` output and in bash/zsh/fish shell completions. `w` is deliberately assigned to `write` rather than `watch` — write is a primary API feature, watch a workflow helper. - -- **ci**: Pushing a `v*` tag now creates a GitHub Release, with notes extracted from that version's CHANGELOG section and generated notes as a fallback when the section is missing. This is why the repo has 17 tags and zero Releases. The workflow does **not** publish to npm: releases are published from GitLab, which is the source of truth and mirrors to GitHub, matching the other DeepL client libraries. The tag is checked against `package.json` first, so a mislabelled tag cannot mint a Release whose title disagrees with the version it contains. -- **cli**: Global `--timeout ` and `--max-retries ` options override the HTTP transport defaults (30000 ms, 3 retries) for a single invocation. Neither was previously configurable from the CLI. -- **translate**: `--format json` output now includes the documented `cached` boolean, so scripts can distinguish cache hits from fresh API calls. -- **cli**: Running under Node.js < 24 now fails fast with a clear one-line error (exit 6) instead of surfacing a raw `node:sqlite` ExperimentalWarning or crashing later. - -### Changed +- **api**: Language listings migrated from the formally deprecated `GET /v2/languages` and `GET /v2/glossary-language-pairs` endpoints to `GET /v3/languages` (`resource=translate_text` / `resource=glossary`). Command output is unchanged: source/target lists derive from the v3 `usable_as_source`/`usable_as_target` flags, glossary pairs from the source×target cross-product (verified identical to the v2 pair list), and the `[F]` formality markers come from the per-language `features` matrix, which is what v2's `supports_formality` boolean became (see the Fixed entry on `pt`). A language whose features the response does not describe is left unmarked rather than marked unsupported, so the `[F]` legend cannot appear with no `[F]` beneath it. - **tests**: A fast-check property suite (`tests/property/`) now enforces round-trip laws across all 11 format parsers — translated values survive reconstruct/extract intact, re-applying the same translations never changes the file, and an identity sync is a fixed point — plus preservation laws for the placeholder and ICU utilities. Runs are seeded-random with 200 cases per law by default (`FC_NUM_RUNS` overrides; `FC_SEED`/`FC_PATH` replay a recorded counterexample). The suite found the U+2028 TOML corruption and the `.properties` leading-space loss fixed in this release; example tests document decisions, properties enforce laws. + - **tests**: Tests that assert only inside a `catch` block now declare their assertion count, so they fail instead of passing with zero assertions when the command under test unexpectedly succeeds — the failure they exist to detect was the one they could not see. Sites that already assert on the success path are unchanged. `npm test` also refuses to run against a **stale** `dist/`, not just a missing one: the suites execute the built CLI, so a stale build reported results that did not describe the current source. A new suite checks every documented `deepl …` invocation in the README and docs against the CLI's real surface, so a command or flag that drifts out of existence fails CI rather than waiting for a reader to find it. + - **docs**: The README leads with the npm install path and marks Homebrew as pending until the tap exists. Install instructions target `@deepl/cli` (10 occurrences across `docs/SYNC.md`, four example scripts, `examples/README.md`, and the git-hook template in `src/services/git-hooks.ts`). The README installation section documents three install paths — npm (`npm install -g @deepl/cli`), from source, and Homebrew (`brew install deepl/tap/deepl`, marked pending until the tap ships) — with an explicit Node.js 24 prerequisite, replacing the `better-sqlite3` native-compilation caveat (Xcode CLT / python3-make-gcc), which no longer applies. The `TROUBLESHOOTING.md` `NODE_MODULE_VERSION` / `npm rebuild better-sqlite3` entry is replaced by accurate guidance for the one remaining cache-degradation cause (running on Node < 24). Six stale `DeepLcom` GitHub URLs now point at the `DeepL` org directly. `CONTRIBUTING.md` states the Node 24 development prerequisite, and `SECURITY.md`'s supported-versions table reflects that only 2.x is a published, supported line. + - **tests**: Removed the global manual mocks for `p-limit` and `fast-glob` (`tests/__mocks__/`). Because a manual mock for a node module is auto-applied to every suite, and `resetMocks: true` strips its implementation, `pLimit(n)` resolved to `undefined` and `fg(...)` resolved to `undefined` in all 236 suites — so no test exercised a real concurrency limit or a real glob walk, and two concurrency defects reached a release candidate undetected. The suites that need these mocked declare them explicitly with their own implementations, so nothing depended on the global versions. Added `tests/unit/concurrency-limiting.test.ts`, which asserts that `p-limit` rejects a non-positive concurrency, that peak overlap never exceeds the limit, and that `fast-glob` returns real paths — it fails if either global mock is reintroduced. + - **tests**: Suites that shell out to the bare `deepl` command now run this tree's built CLI via a PATH shim installed in jest `globalSetup`, instead of whatever `deepl` happens to be globally installed. 23 suites (392 tests) previously required a global install — absent one they all failed with `command not found`, and present one they silently tested the installed version rather than the working tree. The shim lives in the real environment before jest workers spawn (a `setupFilesAfterEnv` hook cannot do this: test code sees a copied `process.env` that child processes never inherit). CI's now-redundant `npm link` step is removed, and a new unit suite pins that `deepl` resolves to the shim and reports the tree's version. + - **tests**: `npm test` now fails fast with an actionable message when `dist/cli/index.js` is missing. `dist/` is gitignored and `npm test` does not build, yet all three test tiers execute the built CLI, so a fresh checkout previously failed hundreds of tests with errors that never mentioned the missing build. + - **tests**: Jest's haste map no longer indexes `.claude/` (via `modulePathIgnorePatterns`). A leftover agent worktree under `.claude/worktrees/` duplicated the manual mocks in `tests/__mocks__/` and made every jest run emit a `jest-haste-map: duplicate manual mock found` warning; test files in worktrees were already excluded, but module indexing was not. Verified warning-free with a worktree present. + - **BREAKING — package**: The package is now published as the scoped **`@deepl/cli`** (previously the unpublished working name `deepl-cli`). Scoped packages default to restricted visibility, so `publishConfig.access: "public"` is set explicitly — without it the publish fails. The `bin` name is unchanged: the command is still `deepl`, and scoping changes only the install string (`npm install -g @deepl/cli`). Repository, bugs, and homepage metadata now point at `github.com/DeepL/deepl-cli` directly instead of relying on the redirect from the legacy `DeepLcom` org name. + - **BREAKING — cache/runtime**: The translation cache now uses Node's built-in `node:sqlite` module instead of the `better-sqlite3` native addon, and the CLI consequently requires **Node.js >= 24** (`engines.node` is now `>=24.0.0`). Node 24 is the first release where `node:sqlite` is non-experimental; Node 18/20 lack the module entirely and Node 22 would both warn on every invocation and downgrade the bundled SQLite (3.50.4 vs 3.53.1). Existing cache databases are read in place with no migration — the on-disk format is unchanged (SQLite 3.53.2-written files verified readable, WAL mode and `user_version` stamp included). **Migration**: upgrade to Node 24 (current LTS), e.g. `nvm install 24`; no other action is needed, and the cache keeps its contents. + - **ci**: `.nvmrc` now pins Node 24, matching the CI matrix. It still read `20`, so `nvm use` handed local developers a runtime that cannot load `node:sqlite` and does not satisfy `commander`'s `engines.node >=22.12.0`. + - **build**: `npm run build` now runs a `clean` step first, removing `dist/` and `tsconfig.tsbuildinfo` before compiling. Without it, `tsc` leaves output for sources that no longer exist, so a file rename could ship stale artifacts — verified reproducible: planting a stale module under `dist/sync/` and rebuilding left both files in `npm pack` output. Removing the build-info file is required too; deleting only `dist/` makes the incremental compiler report the output as up to date and emit nothing. + - **tests**: Raised the timeout for the `watchAndSync` test block to 30s and replaced its fixed-round setup flush with a wait on an observable readiness signal. These tests intermittently exceeded the 10s default on CI runners — always as timeouts, never assertions — failing unrelated dependency PRs. Suite duration was measured to be flat across 250/25/5 flush rounds, so the historical 20 → 50 → 250 escalation could not have addressed it; the cost is runner CPU starvation (1.8s locally vs 10.8s observed on CI). The flush now also fails with the pending state rather than a bare timeout if setup genuinely stalls. + - **deps**: `commander` 14.0.3 → 15.0.0. commander 15 is ESM-only (`"type": "module"`) and requires Node >=22.12.0, so it was unmergeable until the Node 24 baseline landed. Jest's `transformIgnorePatterns` allowlist gains `commander` so ts-jest transforms it under CommonJS test execution; without that, 28 suites fail to load with `SyntaxError: Cannot use import statement outside a module`. + - **ci**: Test matrix, release workflow, and security workflow now target Node 24 (previously Node 20 and 22). Node 20 reached end-of-life in April 2026, and Node 24 is the current LTS. This aligns CI with the runtime the project is moving to; the `engines.node` range is unchanged in this entry and is bumped separately. + - **perf**: CLI startup no longer eagerly loads the HTTP client (axios) or the format-parser stack (yaml, smol-toml): the API URL constants moved to a dependency-free module, and `sync init`'s `--file-format` choices are filled lazily via a commander `preSubcommand` hook. Measured on `--version`: ~144 ms → ~80 ms median, 358 → 137 modules loaded. Help output and invalid-value errors are unchanged. + - **perf**: YAML reconstruction now indexes every string slot in a single document walk instead of calling `setIn`/`deleteIn` per key, scaling roughly linearly with file size — measured ~3.6 s → ~170 ms for a 16,000-key file. Batched deletion also fixes a correctness bug where removing several items from one sequence shifted indices mid-iteration and deleted the wrong entries. + - **batch**: Plain-text batch translation now reads, translates, and writes one API batch at a time instead of loading every file into memory up front, so memory stays proportional to a single batch rather than the whole tree. Batch grouping also measures the form-encoded body size (what the API's 128 KiB limit actually applies to) instead of raw UTF-8 bytes, so CJK-heavy batches whose percent-encoding inflates ~3x are split correctly instead of being rejected server-side. + - **sync**: Backups are written as `.deepl.bak` instead of `.bak`, and the stale-backup sweep considers only the `.deepl.bak` suffix — a user's own `*.bak` files are never touched again. The sweep also no longer re-creates a target file that was deleted; it restores a backup only over a sibling that exists but is empty. **Migration**: `.bak` files from earlier versions are no longer swept or restored — delete leftover `.bak` files manually if desired. + - **cli**: The primary human-readable reports of `sync status`, `sync validate`, `sync audit`, `sync init`, and `auth show` print to stdout, so `deepl sync status > report.txt` and `deepl auth show > key.txt` capture output instead of producing empty files. Diagnostics, warnings, and progress stay on stderr, and `--format json` stdout purity is unchanged. + - **glossary**: `create` and `show` render language codes uppercase and the creation timestamp as a locale-independent ISO string, and the create success line prints to stdout — matching the documented output instead of a locale-dependent date on stderr. + - **hooks**: The installed pre-commit hook now actually validates translations — when a `.deepl-sync.yaml` exists and the CLI is on PATH it runs `deepl sync validate` and blocks the commit on validation errors (with a `--no-verify` hint). It was previously a no-op that grepped staged files and always exited 0. ### Removed +- **cli**: **BREAKING**: The `--enable-beta-languages` flag on `translate` is gone. The API deprecated the underlying `enable_beta_languages` parameter with "has no effect" — beta languages are simply part of the regular language set now — so the flag had become a silent no-op. Scripts passing it will exit with an unknown-option error; remove the flag. + +- **usage**: The dedicated "Speech-to-Text Usage" section (text output) and "Speech-to-text" row (table output) are gone, along with the `speechToTextMilliseconds*` fields they read. The API deprecated `speech_to_text_milliseconds_count`/`_limit` on `GET /v2/usage` ("Always returns 0"), so the section could only ever display zero. Voice usage remains visible in the Product Breakdown, which reads the live per-product minutes data. The Admin API's per-key `speech_to_text_milliseconds` usage limit is a different, still-current field and is unaffected. + - **repo**: The `VERSION` file and `.npmignore`. Nothing read `VERSION` — `deepl --version` reports `package.json`'s value — so it was a second, hand-edited source of truth that could only drift; use `npm version X.Y.Z --no-git-tag-version`, which updates the manifest and lockfile together. `.npmignore` was dead weight because the `files` array governs what is packed. + - **build**: Source maps and declaration maps are no longer emitted. They were already excluded from the published package, so emitting them only left dangling `sourceMappingURL` comments in the shipped files, giving library consumers unresolvable stack frames and broken go-to-definition. + - **deps**: `inquirer`, which no source file imported (see the `@inquirer/prompts` entry under Fixed). + - **deps**: `better-sqlite3` and `@types/better-sqlite3`. The production dependency tree no longer contains any native addon, removing the entire class of ABI-mismatch failures (`ERR_DLOPEN_FAILED` / `NODE_MODULE_VERSION` errors after a Node major upgrade, e.g. via `brew upgrade node`), a 1.9 MB platform-specific binary, and the C++ compilation-toolchain requirement for installs from source. The cacheless-degradation safety net remains: a runtime whose `node:sqlite` is missing (Node < 22.5.0) warns once and runs uncached rather than crashing, and never touches the cache database. + - **BREAKING — sync**: `deepl sync init --source-lang` and `--target-langs`, the deprecated aliases introduced in 1.x, are removed and now fail with `unknown option` (exit 1). Use `--source-locale` and `--target-locales`. This is the documented removal of 1.x aliases at the 2.0 cut. `deepl translate --target-lang` is unaffected — it is the API's wire name, not a deprecated alias. ### Fixed - **package**: **`import '@deepl/cli'` threw instead of loading.** The package is ESM, so Node requires a full specifier for every relative import, but the entry point re-exported `'./types'` — a directory — which fails with `ERR_UNSUPPORTED_DIR_IMPORT`. The whole programmatic surface was therefore unreachable, and the published typings resolved to nothing for a `nodenext` consumer, so `import type { Language } from '@deepl/cli'` was an error regardless of what the union contained. `deepl --help` never exercised this, because the `bin` entry has its own module graph. Both remaining directory specifiers now carry `/index.js`, and the manifest suite imports the built entry in a real Node ESM process and rejects any extensionless relative specifier in the emitted entry chain. -- **cache**: **The translation cache keyed on too little and could serve the wrong text.** `translationMemoryId`, `translationMemoryThreshold`, `--ignore-tags`, `--splitting-tags`, `--non-splitting-tags`, `--outline-detection` and `--preserve-formatting` were absent from the key, so `deepl translate "Hello" --to de` and the same command with `--translation-memory my-tm` collided: the second returned the cached non-TM translation, never consulted the memory, and reported `cached: true`. Likewise two runs differing only in `--ignore-tags` returned each other's output. `preserveFormatting` had been excluded on the grounds that it does not affect output, but `preserve_formatting` suppresses the sentence-boundary punctuation and case correction, which shows up in the text. **Entries cached by earlier versions for requests using any of these options now miss rather than being served wrongly**, so the first such translation after upgrading is refetched. +- **scripts**: **The language generator wrote unescaped API response fields into TypeScript.** `lang` was interpolated into a single-quoted literal with no escaping and `name` escaped only quotes, so a response field containing `' }] as const;` — or merely ending in a backslash — could close the literal and append arbitrary code to `src/data/language-entries.ts`, which the next `npm run build` compiles and the test suite imports. The existing guards checked the shape of the response, never the content of a field. Codes are now validated against the language-tag pattern, display names against a conservative character set, categories against the three tiers, and every value is quoted with escaping; validation runs before grouping, which would otherwise drop an entry with an unrecognized category before it was checked. The generator's main guard also resolves `argv[1]` through `realpathSync`, because Node reports the ESM entry by its real path — under a symlinked checkout both npm scripts exited 0 without doing anything, including the release step that keeps the Write list current. + +- **translate**: **A directory translation where every file failed exited 0.** The summary reported `✓ Successful: 0 / ✗ Failed: N` and the command still looked like success to a script or CI job. Since language validation defers unknown codes to the API, a plain `--to` typo took this path: on 1.x it exited 6 locally, and in this release it printed a failure list and exited 0. A run with no successes now exits 1, and a partial failure exits 12, matching what `sync` already does. + +- **translate**: **The rejected-request abort covered only plain-text batches.** `.txt` and `.md` files are grouped into batches and stopped after the first rejection, but `.json`, `.yaml`, `.html`, `.srt` and `.xliff` go through the per-file path, which still spent one round trip per file collecting the same answer. That path stops the same way now, and files that were never sent are reported as skipped rather than as failures carrying another batch's error. The classifier also checks the error class before the message: a 5xx interpolates the upstream body, so a gateway error quoting `target_lang` no longer aborts a run that is otherwise succeeding, while a refused key or an exhausted quota — as request-wide as a bad code — now do. + +- **voice**: **`--quiet` discarded the salvaged transcripts of a failed session.** The partial result went through the warning channel, which quiet mode suppresses, while the live display was erased regardless — so audio that had been transcribed and billed left nothing on screen. It goes through the error channel now, the display is only cleared once there is something to print in its place, and `--format json` is honoured on that path. The live display also keyed target rows by the requested spelling, so with `--to zh-HANS` a `zh-Hans` echo left the row blank for the whole session; it is matched case-insensitively, as the session already was. + +- **voice**: **Regional target and source codes had to be spelled in exactly the API's casing.** `--to en-gb` or `--to zh-hans` exited 6 while `--to en-GB` worked, even though `deepl languages` prints the lowercase form and every other command accepts any casing. Codes are matched case-insensitively and canonicalized to what the Voice API expects. + +- **glossary**: **Deduplicating repeated `--glossary` flags inverted the documented precedence.** A repeat kept its first position, so `--glossary base --glossary override --glossary base` let `override` win the terms both define, although the user put `base` last. A repeat now keeps its last position, which is the one the API applies. + +- **sync**: The startup glossary coverage check required the top-level glossary to cover every target locale, including locales configured with their own `locale_overrides..glossary`. That glossary never translates those locales, so a documented configuration aborted before any file was touched. + +- **config**: Language normalization applied to `config set` but not to the load path, so a file written or hand-edited with uppercase codes kept them — and because `TranslationService` merges `defaults.sourceLang` verbatim, `DE` in config and an explicit `--from de` keyed two cache entries for one request. Codes are normalized on load too. The note about a code the snapshot does not list is also limited to the write path; shared with the loader it printed on every invocation, including `deepl --version`. + +- **translate**: Document mode warned that `--model-type` and `--tag-handling*` would be ignored and then rejected the command over them, because the shared validation still saw them. The mode now discards what it says it discards. + +- **usage**: A duration-billed product could render a character count as hours by scaling it, and a `null` count was reported as a genuine zero. Character counts are read only for `milliseconds` billing, where they carry the duration; anything non-finite is treated as absent. + +- **languages**: `--features` contradicted its own footer: a language supporting none of the discriminating features read as `none` two lines above a note crediting it with the features every described language shares. Only a language credited with nothing at all reads `none` now. `--features` also disabled the Formality column without always replacing it, so the flag could show less than the plain listing, and the `?` cell for an undescribed language had no legend. + +- **docs**: Three passages described behaviour that no longer exists — the `voice --glossary` row had picked up `translate`'s repeatable/`--from` semantics (voice takes one glossary and requires neither), the `write` reference still said an unknown code is rejected locally, and the `usage` reference still documented the removed Speech-to-Text section along with output showing the duplicated API-key figure. + +- **cache**: **The translation cache keyed on too little and could serve the wrong text.** `translationMemoryId`, `translationMemoryThreshold`, `--ignore-tags`, `--splitting-tags`, `--non-splitting-tags`, `--outline-detection` and `--preserve-formatting` were absent from the key, so `deepl translate "Hello" --to de` and the same command with `--translation-memory my-tm` collided: the second returned the cached non-TM translation, never consulted the memory, and reported `cached: true`. Likewise two runs differing only in `--ignore-tags` returned each other's output. `preserveFormatting` had been excluded on the grounds that it does not affect output, but `preserve_formatting` suppresses the sentence-boundary punctuation and case correction, which shows up in the text. **Every translation entry cached by an earlier version is retired**, not only the ones using these options: the service merges a `preserveFormatting` default, so it is always part of the key. The cache schema version is bumped, so those rows are dropped on first open rather than sitting unreachable until their 30-day TTL — `write` and `correct` entries key the same way they always did and are kept. The first translation of any given text after upgrading is refetched. - **translate**: **A rejected language no longer costs one API round trip per batch.** Because validation defers well-formed unknown codes to the API, a two-letter typo reaches it — and a directory translation asked the same rejected question once per batch, so `deepl translate ./docs --to ex` on 200 files made 200 failing requests to be told the same thing 200 times. An unsupported `target_lang` or `source_lang` is a property of the request, not of one batch, so the remaining batches now fail without being sent; errors specific to a batch (a rate limit, say) still let the run continue. Relatedly, local validation used to be what pointed at `deepl languages`, and deferring left users with a bare `Value for 'target_lang' not supported.` from the server: a code the bundled snapshot does not list now says so up front, before anything is sent or billed. @@ -110,107 +162,205 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **languages**: `deepl languages --target` marks Portuguese (`pt`) with `[F]`. Formality support is now read from `features.formality` on `GET /v3/languages` instead of a static table in the language registry. The v3 migration had assumed v3 stopped reporting formality, but the capability was only renamed — v2's `supports_formality` boolean became the presence of a `formality` key in the per-language features matrix — so the CLI was answering from an 11-entry snapshot of the final v2 response that had already drifted from the API. The snapshot and the registry's `supportsFormality` field are gone; the registry's `category` tiers are unaffected. Output is otherwise unchanged: the `[F]` set is identical apart from `pt`. - **watch**: `--glossary` without `--from` now exits 6 before the watcher starts, instead of starting a session that fails on every single file change with a raw server message. The API rejects any translation naming a glossary without a source language ("Use of a glossary requires the source_lang parameter to be specified"), which `translate` already guarded against up front for text, files, and documents; `watch` passed `--from` straight through. A long-running command is the worst place for this, since the operator saw the failure once per edit rather than once at launch. The check runs before the glossary name is resolved, so it costs no API call. `sync` needs no equivalent: it has no `--from` at all, taking the source language from the required `source_locale` field in `.deepl-sync.yaml`, so its requests always carry one. + - **voice**: A session that ends with the audio transcribed but no translation for a requested `--to` language now fails with exit code 9 and names the languages, instead of printing an empty translation line and exiting 0. The failure **carries the transcripts that did arrive**, which the command prints to stderr before exiting: the audio is transcribed and billed before the missing translation is noticed, so with `--to de,fr,es,it,ja` one dropped target must not throw away the source transcript and four good translations and make the user re-stream to see them. Target updates are also matched **case-insensitively**, because the requested spellings include `zh-HANS` and `en-GB`: a server echoing another canonicalization had its translation silently dropped by the existing unmatched-update guard, and this check would then have reported that target as missing — turning a cosmetic mismatch into a failed session. Silent partial output was the worse failure: a script consuming `deepl voice` output saw success with the translation missing. Audio containing no speech transcribes to nothing and translates to nothing, which is legitimate and still exits 0, so the check only applies when a source transcript exists. Whitespace-only and tentative-but-never-concluded translations count as missing, since neither reaches the printed output. Investigated as an intermittent (~1 in 4) empty translated line; the leading hypothesis was disproved with frame-level traces of live sessions — the server sends `end_of_stream` strictly after `end_of_target_transcript`, and the client only tears the socket down on `end_of_stream`, so there is no client-side teardown race. Reconnect was ruled out too: a socket dropped after end-of-source cannot be resumed (the API returns `410 Gone`) and already exited non-zero. The empty line did not reproduce in 64 live runs across pacing, burst, multi-target, and concurrency variations, so the trigger appears to be server-side and transient — which is exactly why the client needs to detect it rather than report success. + - **formats**: TOML reconstruction escapes U+2028/U+2029 (Unicode line/paragraph separators) in double-quoted values, and literal-string values gaining one fall back to double quotes. Written raw, these characters broke the entry-line scan on the *next* sync (JavaScript's `.` excludes line terminators), which re-appended the key as a duplicate and made the third sync fail to parse the file at all — first sync fine, second silently corrupting, third crashing. Found by the property-based round-trip suite. + - **formats**: `.properties` reconstruction escapes leading spaces in values (`\ `), which the value parser otherwise strips on the next read — a translation beginning with a space silently lost it on every subsequent sync. Leading tabs, trailing spaces, and newlines were already escaped correctly. Found by the property-based round-trip suite. + - **sync**: `--auto-commit` now recognises its own translation output rather than only the files a given run wrote, which fixes two related problems. A translation left on disk by an earlier refused run was classed as an unrelated modification, so auto-commit refused forever and the user had to commit it by hand; it is now committed once the genuinely unrelated changes are dealt with. And in `--watch` mode, where the same path runs once per trigger, a trigger that translated nothing skipped the checks entirely and reported success while a commit was still owed. Staging is also driven by what is actually dirty, so a rewrite that produced identical bytes no longer attempts an empty commit. Ownership is derived from the lockfile's tracked source files, with each file matched to its own bucket so one bucket's `target_path_pattern` cannot claim another's output. + - **sync**: Re-running `deepl sync --auto-commit` after a refusal no longer reports success without committing. A refused run still writes its translations to disk, so the retry found nothing to translate; the preflight was skipped in that case and the command exited 0 with no commit made, the translation still uncommitted and the tree still dirty. The repo-state checks now run whenever `--auto-commit` is requested, so the refusal is reported identically on every attempt. Staging and committing are still skipped when there is nothing to commit. Two tests covered this path but could not see the defect: their assertions ran only inside a `catch` the retry never entered, which also hid an assertion that could never have matched (it looked for "detached HEAD" against a message reading "HEAD is detached"). + - **write**: `deepl write` now runs from a published install. `diff` is imported at the top of the write command but was declared only under `devDependencies`, which consumers do not install, so every command that loaded the module failed with `Cannot find package 'diff'` before producing output. `--help` did not surface it because the module loads lazily. + - **init/write/sync**: `@inquirer/prompts` is declared as a dependency. It is imported at runtime by `init`, `write --interactive` and `sync init` while only the unused `inquirer` was declared, so it resolved through npm's hoisting: under a strict layout (pnpm, `--install-strategy=nested`) those commands failed with `ERR_MODULE_NOT_FOUND`, and under npm the prompt that reads the API key bound to whatever major another dependent happened to hoist. + - **init**: `deepl init` with stdin at end-of-file now exits 6 with the documented non-interactive message, instead of starting to prompt and then exiting 1 with a Node `unsettled top-level await` warning. Reached by `docker run` without `-it`, CI, and piped invocations. The command checked only `--no-input`, where the sibling guard in `write --interactive` also checks whether stdin is a terminal; the existing test covered only the `--no-input` variant, which already worked. + - **write**: `--lang` and `--to` accept language codes in any casing and normalize them to the API's form. They previously compared against a mixed-case list with an exact match, rejecting the lowercase codes `deepl languages` prints and `translate --to` accepts — so the CLI's own discovery output was unusable with the command documented as consistent with `translate`. + - **translate**: The error raised for an unexpected API response pointed at a bug tracker under an unrelated third-party account rather than this repository's. + - **cli**: Shell completions and the did-you-mean suggester no longer offer hidden internal commands, and the suggester now knows about aliases and prefers a prefix match — `deepl tr` suggests `translate` rather than `tm`. `--version` is also no longer duplicated in the bash and zsh candidate lists. + - **cli**: The remediation for a missing API key survives `--quiet`. It was emitted as a warning, which quiet mode suppresses entirely, leaving only `Error: API key not set`; `docs/API.md` claimed quiet mode still showed such warnings and now describes the actual behaviour. + - **cache**: The size cap is now actually enforced. Size accounting previously lived in a process-local counter that drifted in both directions — downward when an overwrite's eviction deleted the very key being replaced (after which the cap stopped firing and the DB grew without bound), upward when `get()` deleted expired rows without decrementing (evicting entries that didn't need evicting), and negative when a concurrent process's rows were swept (disabling eviction for the process lifetime). The total is now always read from `SELECT SUM(size)`. Eviction now deletes oldest rows in batches until enough space is actually freed, instead of a one-shot estimate from the average row size that under-evicted whenever sizes were skewed. An entry larger than `maxSize` itself is skipped instead of stored — previously it wiped every other entry first (eviction deleted all rows and inserted the oversized one anyway), leaving a cache that re-wiped on every subsequent write. The expired-entry sweep's throttle timer is seeded so the sweep runs on a process's first operation; it was seeded to construction time, so it never ran in any process shorter than 60 seconds — essentially every CLI invocation. Repeated `getInstance()`/`close()` cycles no longer accumulate process signal listeners until Node prints `MaxListenersExceededWarning` to stderr. Docs now describe eviction as oldest-first rather than LRU, which is what the code has always done (`timestamp` is only written on `set()`). + - **cache**: Only genuine corruption (`SQLITE_CORRUPT` / `SQLITE_NOTADB`) now triggers the rename-aside-and-recreate recovery. The constructor previously treated *every* unexpected error as corruption, so transient lock contention (`SQLITE_BUSY` — e.g. two concurrent invocations against a cache on a filesystem where WAL cannot be enabled) renamed a healthy cache aside, recreated it empty, and broke the other process's open transaction; and a DB written by a newer CLI version was destroyed instead of refused, defeating the schema check's stated purpose. Lock waits now go through `PRAGMA busy_timeout` (5s), and everything that isn't corruption propagates so the CLI degrades to an uncached run with a warning. When recovery does run, the backup's WAL/SHM sidecars are copied before the failed handle is closed (SQLite deletes them on close) and named `-wal` / `-shm` so SQLite can actually recover the preserved data — the old naming (`-wal`) orphaned the WAL, silently losing any rows not yet checkpointed. Rename-aside backups are pruned to the most recent three so repeated corruption cannot fill the disk. + - **sync**: `deepl sync pull` no longer discards existing translations for keys named after `Object.prototype` members. `sanitizePullKeysResponse` built its result on a plain object, and callers test membership with `pulledKeys[key] !== undefined` / `??` — so for a source key called `toString`, `constructor`, `valueOf`, `hasOwnProperty`, or `__proto__`, the lookup returned an inherited *function*, which is neither `undefined` nor nullish. The entry was therefore treated as a freshly approved TMS translation and won over the real one, then vanished from the file when `JSON.stringify` dropped the function value — while the lockfile recorded it as `translated` / `human_reviewed`, so no later sync repaired it. The accumulator now has a null prototype. + - **sync**: ICU plural/select messages with text around the block are now preserved. The detection pattern was anchored to the start of the string, so `You have {count, plural, one {# item} other {# items}} in your cart.` — arguably the most common real shape — was not recognised as ICU at all and the raw syntax went to the engine as prose. Confirmed against the live API: it returned `{count, Plural, ein {…} weiteres {…}}`, translating the format keyword *and* both selectors, leaving a message with no valid format type and no `other` fallback. Detection now matches an ICU block anywhere in the string, and the prose on either side becomes a translatable segment instead of being silently dropped from the reassembled output (trailing text was previously lost outright). Re-verified end to end after the fix: `plural`, `one`, `other`, and the `count` argument all survive while the surrounding prose is translated. Note this affects `deepl sync`, the only path with ICU preservation; `deepl translate` on a raw ICU string is unchanged. + - **sync**: ICU structural damage is now detected instead of reported as passing. `checkIcuBrackets` compared only brace counts and nesting depth, which are identical when the engine translates the keyword, a selector, or the argument name — so `fail_on_error` never tripped on a message that no longer renders. Validation now compares the parsed argument/format-type headers and the selector keyword sets, while tolerating reordered selectors (order is not meaningful in ICU). + - **sync**: A failed ICU segment no longer yields a part-English message reported as a successful translation — the message is marked failed and retried. `reassemble` also throws on a translation-count mismatch rather than filling the gaps with empty strings, which produced empty plural branches that render nothing for that category. + - **sync**: A locale that failed completely now fails the run. The check used `.every()`, so a run was only unsuccessful when *every* locale failed — with two locales where one succeeded and the other failed outright, the run reported success and exited 0, leaving the failed locale's file absent while CI went green. It now matches the documented contract for exit 12 (`docs/API.md`: "completed with at least one failed locale"): a locale that translated nothing and recorded failures fails the run, while a locale that partly succeeded still reports its per-key failures in the summary without failing the run, and a locale with nothing to do is simply up to date. `--auto-commit` additionally now refuses to commit an unsuccessful sync — it previously checked only drift and dry-run, so it would happily commit a state with a missing locale file. + - **sync**: Staleness is now judged per target locale, so `--frozen` can detect a locale that has fallen behind. `computeDiff` compared only the entry-level `source_hash`, so once one locale was re-synced after a source edit, the entry hash matched again and every *other* locale reported `current` **forever** — no API call was ever issued for them, `sync status` showed 100%, and the CI gate whose entire purpose is catching out-of-date translations could not see it. A key is now stale if any configured target locale's stored hash lags the source or its last attempt failed. Relatedly, the failure check was previously unscoped (`Object.values(translations).some(failed)`), so **one** locale's failure marked the key stale for **all** locales and the next run re-translated locales that were already correct — overwriting human-edited files. Locales absent from `target_locales` are ignored, so a leftover entry for a de-configured locale no longer flags a key indefinitely, and a locale with no entry at all is still treated as a new-locale backfill (counted as new keys) rather than as drift. `sync status` computes the same per-locale determination inline instead of inheriting the shared source-level status. + - **BREAKING — sync**: Untranslated source text is never written into a target locale file and recorded as a translation. When a key's source was unchanged, the lockfile claimed the locale already had a translation, and the target file supplied none — because the file had been deleted to force regeneration, or held an empty value — the source string was written through as the "translation" and the lockfile kept its `translated` status, so **no later run ever corrected it**: the locale file silently retained English and the run reported success. Such a key is now re-translated, and a verbose message explains why. A translation that is present but deliberately empty is preserved rather than treated as missing. **Behaviour change to be aware of**: deleting a target locale file now causes its keys to be translated again (billed) instead of being backfilled with English — which is the point, but it does mean a deleted file costs a real re-translation. Two existing tests asserted the old fallback; neither the CHANGELOG nor `docs/SYNC.md` documented it, and both arrived inside a bulk development commit rather than as a deliberate decision, so they have been updated to the corrected contract. + - **api**: A blank `Retry-After` header no longer collapses 429 backoff into a tight retry loop. `Number('')` is `0`, which passed the finite check and returned a 0 ms delay — and because `0` is a real number the jitter-backoff fallback never engaged, so all retries fired back-to-back at an endpoint that was already rate-limiting the client. A blank or whitespace-only header is now treated as absent; an explicit `Retry-After: 0` is still honoured. + - **watch**: A file changed during its own translation is no longer translated twice concurrently. The debounce entry was deleted in the translation's `finally` rather than when the timer fired, so it removed whatever entry was current by then — usually a *newer* pending timer for the same file, which then could not be cancelled. A subsequent change therefore started a second translation racing to write the same output path, doubling API spend with a nondeterministic winner. The entry is now cleared when the timer fires, and only if it is still the entry that timer registered. + - **sync**: An invalid `--concurrency` no longer makes a sync silently do nothing while reporting success. `--concurrency` was parsed with a bare `parseInt`, so `abc` produced `NaN`, which survives `??` defaulting; `Math.min(NaN, n)` is `NaN` and `Array.from({length: NaN})` is empty, so **zero** workers were started and `mapWithConcurrency` returned an empty result having translated nothing and thrown nothing. Verified end-to-end: with a deliberately invalid API key, `deepl sync` correctly failed with exit 2 while `--concurrency 0`, `--concurrency abc`, and `--concurrency -3` each printed `Sync complete` with exit 0 and no output file. The flag (and `--debounce`) now reject non-positive and non-numeric values at the boundary, `sync.concurrency` is validated in the config like `tms.push_concurrency` already was, and `mapWithConcurrency` clamps to at least one worker as defence in depth. + - **cache/cli**: Interrupting a command no longer reports success or leaks the sync process lock. `CacheService`'s `SIGINT` handler called `process.exit(0)`, and because the cache singleton is constructed during service setup that handler ran *before* the sync engine's own — so `deepl sync` interrupted with Ctrl-C exited **0** (so `deepl sync && git commit` would commit a half-finished sync) and left `.deepl-sync.lock.pidfile` behind. The cache handler now only closes the database, and termination belongs to the CLI entry point, which defers the exit so every other listener's cleanup runs first: verified end-to-end as exit 130 with the lock released. Commands that own their shutdown — `sync --watch`, for which SIGTERM is the normal stop signal — opt out and still exit 0. + - **formats**: TOML multi-line (`"""` / `'''`) values survive a sync. Only the opening line was emitted verbatim, so body lines that happened to look like `key = "…"` were parsed as entries and **deleted from the value**, and the multi-line key — never marked as used — was re-appended at end of file, leaving a document that no longer parses (`trying to redefine an already defined table or value`). The whole block is now emitted verbatim and skipped, including the single-line `k = """text"""` form. Multi-line values remain untranslated, which is the existing documented behaviour. + - **formats**: Translating a key in an Xcode String Catalog no longer destroys that key's plural `variations`. Reconstruct replaced the locale's entire localization object with a single `stringUnit`, discarding per-category translations the parser never surfaces as entries — so they could not be recovered. Existing `variations` are now preserved alongside the updated `stringUnit`, and the `Localization` type declares the field so this cannot silently recur. + - **formats**: ARB (Flutter) files with a UTF-8 BOM are readable. `JSON.parse` rejects a leading BOM, so a BOM-prefixed `.arb` file failed outright; the BOM is now stripped on extract and reconstruct, matching the JSON parser's existing behaviour. + - **formats**: A translated Android string can no longer break out of its CDATA section. `escapeForReconstruct` wrapped the translation in `` with no escaping, so a value containing `]]>` closed the section early and the remainder was parsed as XML — allowing extra `` elements into a generated resource file. This was reachable without a malicious API response, because on translation failure the source string is written through verbatim and the source file is used as the template when the target locale file does not exist yet. Occurrences of `]]>` are now split across adjacent CDATA sections, which keeps the text literal, and extract concatenates adjacent sections so such values round-trip unchanged. + - **formats**: New TOML keys are written into the section they belong to. They were appended at end of file using the full dotted path while a `[section]` header was still in scope, so `messages.newkey` parsed back as `messages.messages.newkey` — and because the intended key was therefore still missing, it was re-appended on every subsequent run. Keys are now inserted inside their own section block, with a new `[section]` header added only when that section is absent. + - **formats**: Android XML entities no longer compound on every sync run. `extract` never decoded XML entities, and `escapeAndroid` replaced `&` *last* — so it re-escaped its own output. `Terms & Conditions` became `&amp;` after one run and `&amp;amp;` after three, verified. Entities are now decoded on extract via a single-pass decoder (so a literal `&lt;` decodes to `<`, not to `<`), and `&` is escaped before `<`/`>`; a three-run identity sync is now a fixed point. + - **formats**: Emoji and other astral characters survive `.properties` files. `escapeValue` iterated by code point but escaped with `charCodeAt(0)`, emitting only the high surrogate — `Hello 😀 world` was written as `Hello \ud83d world`, which cannot be decoded back. All UTF-16 code units of a character are now emitted, so an emoji round-trips as a complete surrogate pair. + - **formats**: XLIFF files carrying a `state` attribute are no longer mangled. `state` is a standard attribute that every CAT tool writes, but the `` and `` patterns required bare tags. For XLIFF 2.0 this meant `extract` returned nothing and `reconstruct` then **deleted every ``**; for XLIFF 1.2 an existing `` was treated as absent, so a second `` was injected, yielding schema-invalid output that retained the stale translation. Both elements now accept attributes and preserve them through a round-trip. + - **formats**: CRLF-authored resource files are no longer invisible to the tool. Line-based parsers split on `'\n'`, leaving a trailing `'\r'` that defeated their `$`-anchored patterns: a Windows-authored `.po` file extracted **zero** entries, so `deepl sync status` reported `totalKeys: 0` and 0% coverage with exit 0, and `sync export` emitted an empty XLIFF. TOML was affected differently — reconstruct failed to match existing keys and appended duplicates, producing a file that no longer parses (`trying to redefine an already defined table or value`). All line-based parsers (PO, TOML, iOS `.strings`, Java `.properties`) now split on `/\r?\n/`; the latter two already tolerated CRLF, and the change makes that explicit rather than incidental. + - **glossary**: A glossary term named after an `Object.prototype` member is no longer silently dropped or misreported. `tsvToEntries` accumulated into a plain object and tested for duplicates with `entries[source] !== undefined`, so `toString` (and friends) triggered a spurious "Duplicate source" warning, and a `__proto__` term was swallowed by the prototype setter instead of being stored. Genuine duplicate detection is unchanged. + - **sync**: A lockfile entry whose i18n key is named `__proto__` no longer vanishes on every write — the key-sorting JSON replacer accumulated into a plain object, where that assignment invokes the prototype setter. Auto-glossary term extraction, which is keyed by untrusted source strings, was fixed the same way. + - **formats**: The JSON parser no longer pollutes `Object.prototype` via a `__proto__` key in a resource file, and now round-trips prototype-named keys as ordinary data. `hasKey` used `part in record`, which reports inherited members, and `setKeyWithParts` assigned with `current[part] = …`, where `current['__proto__'] = {}` invokes the prototype setter instead of creating a property. Membership is now an own-property check, and assignment goes through `Object.defineProperty`, so a key legitimately called `toString` translates like any other. + - **hooks**: Generated git hooks no longer emit a broken install instruction. The `pre-push` hook template told users to globally install the unpublished `deepl-cli` name — which fails with `ENOVERSIONS` — and now points at `@deepl/cli`. Reported by **@maa-xx** in #70, who correctly diagnosed that the documentation instructed readers to install a package that does not resolve; their docs fix was superseded by actually publishing the package, but this source-level occurrence was the one their PR missed and is now guarded by a regression test asserting generated hook output never references an unpublished package name. + - **cache**: A cache backend that fails to load (e.g. `better-sqlite3` ABI mismatch after a Node major upgrade, `ERR_DLOPEN_FAILED`) is no longer misclassified as database corruption. Previously the constructor's catch-all renamed the user's healthy `cache.db` to `cache.db.corrupt-` and recreated an empty database — verified to quarantine a 2,646-entry cache that passed `integrity_check`. Native-module load failures now leave the database and its `-wal`/`-shm` sidecars untouched; genuine corruption still triggers the rename-aside recovery. `deepl translate` and `deepl write` degrade to running without a cache (single warning per process, exit code 0) instead of crashing, since the cache backend is loaded lazily behind a warn-once latch; `deepl cache …` subcommands, which cannot run cacheless, fail with an actionable error suggesting a reinstall or matching Node version. + - **sync**: `deepl sync --frozen` now reports an accurate key count when drift is caused by a newly-added target locale. Previously the message read `Sync drift detected: 0 new, 0 stale keys.` because the frozen branch in `processBucket` short-circuited before promoting current-status keys missing a target-locale translation into `newKeys` — even though `--dry-run` against the same state correctly reported the backfill count. The drift exit code (10) is unchanged. The drift message now also surfaces `deletedKeys` and only mentions nonzero categories, mirroring the success-path summary format. + - **cache**: `deepl cache enable` / `deepl cache disable` now persist `cache.enabled` to the config file. Both reported success but only flipped an in-memory flag in a process that exited immediately, so the state reverted instantly and subsequent translations kept using the cache after it had been disabled. `deepl cache stats` likewise read a process-local flag that always initialized to enabled, so the status line could never show `disabled` — not even after `deepl config set cache.enabled false`; it now reports the persisted state. + - **sync**: Subcommands now honor `--locale`. Commander bound the flag to the parent `sync` command even when it trailed the subcommand name, so `sync status --locale de` listed every configured locale and `sync export --locale de` emitted XLIFF for all of them. Affects `status`, `validate`, and `export`. + - **sync**: Subcommands now honor `--sync-config`. The flag was silently ignored and the auto-detected `.deepl-sync.yaml` used instead, so `sync validate --sync-config /missing.yaml` exited 0. Affects `status`, `validate`, `export`, `audit`, `resolve`, `push`, and `pull`. + - **sync**: A `--locale` value that is not in `target_locales` now exits with a `ConfigError` (exit 7) naming the offending and configured locales, as documented. Previously `sync --locale ` exited 0 reporting success while translating nothing, which made a typo'd locale in CI report green. + - **sync**: `deepl sync init --sync-config ` now writes the config at that path and checks the already-exists guard against it, instead of always using the current directory. `.deepl-sync.yaml` is also written atomically, so an interrupted run cannot leave a truncated config behind. + - **formats**: Android XML and XLIFF parsing is now linear in file size. Both parsers matched elements with a lazy `([\s\S]*?)` pattern, so every opening tag without a matching close rescanned the rest of the file; a 4 MiB resource file took minutes to parse. Android reconstruct also rescanned the whole file once per dotted key to identify `` members (2.7 s → 63 ms on a 3.6 MiB, 52,000-entry file). + - **formats**: Android XML self-closing elements (``) are no longer deleted together with the element that follows them. + - **formats**: Android XML values whose CDATA body contains `` are no longer truncated on extract, and plural `` attributes are preserved when a translation is written back. + - **formats**: XLIFF files with a CDATA section in a `` between `` and `` are no longer rejected; only CDATA inside `` / `` is unsupported. + - **hooks**: `deepl hooks install` now resolves the hooks directory git actually reads (`git rev-parse --git-path hooks`), so it honours `core.hooksPath` (husky) and works inside linked worktrees and submodules where `.git` is a pointer file. Previously it reported success while writing a hook git never ran, and crashed with a raw `ENOTDIR` on worktrees and submodules. + - **hooks**: `deepl hooks install` no longer overwrites an existing hook backup. A repeat install writes to the next free `.backup` slot, and the install output now prints the hook path and the backup path. `findGitRoot` also no longer loops forever when given a relative start path. + - **sync**: Auto-glossary sync (`translation.glossary: auto`) skips terms whose source or translation is empty or contains a tab, carriage return, or newline. Such terms were uploaded as corrupted or outright wrong glossary entries, which DeepL then applied to live translations. An unchanged dictionary is no longer re-uploaded on every run (entries were compared against a lossy TSV round trip that could never compare equal), and a glossary failure for one locale no longer ends glossary sync for the remaining locales — the warning now names the locale, glossary, and cause. + - **glossary**: `deepl glossary add-entry` / `update-entry` reject terms containing a tab, carriage return, or newline instead of shifting every following column of the uploaded dictionary, and glossary import picks the TSV or CSV dialect once per file, so a quoted CSV field containing a tab is no longer split into garbage columns. + - **sync**: `deepl sync audit` no longer uses the lock file's source hash as a stand-in for a translation. Divergent translations were reported as consistent and identical ones as an inconsistency displaying a hex hash. Targets that cannot be read are now listed separately as missing — a new additive `missingTargets` field in the `sync audit --format json` output. + - **sync**: TMS request URLs are built with the URL API: a trailing slash on `server:` no longer produces a doubled separator, a base path is preserved, and a URL with a query string or fragment is rejected instead of silently truncating the API path. TMS timeouts now exit with the network-error code (5) instead of 1, TMS error messages redact credentials embedded in the server URL, and `deepl sync pull` enforces a 32 MiB cap on the response body while reading it, instead of after the whole payload had been parsed. + - **api**: Non-idempotent POST requests are no longer re-submitted after a client-side timeout. Every failure short of a 4xx reached the retry loop for all HTTP methods, so a batch or document upload that outlived the 30 s timeout was silently re-sent up to three more times — each already accepted and billed server-side (confirmed with server-side request counts: 4 per timed-out translate and upload), with the worst case being duplicate admin API keys whose secret is returned only once. Automatic retry is now restricted to idempotent methods (GET, HEAD, PUT, DELETE); a POST is replayed only on an error that proves the request never reached the server (`ECONNREFUSED`, `ENOTFOUND`, `EAI_AGAIN`). A 429 is still retried for every method, honoring `Retry-After`. + - **api**: A client-side timeout now exits 5 (network error) instead of 6 (invalid input), matching the documented exit-code contract. Error classification substring-matched the message and missed axios's `timeout of 30000ms exceeded` (`ECONNABORTED` and `ERR_CANCELED` were absent entirely), falling through to `ValidationError` — so CI that retries on 5 and hard-fails on 6 did exactly the wrong thing on a flaky network. Classification now branches on `error.code` and the absence of a response. An HTTP 401 is likewise mapped to `AuthError` (exit 2) instead of falling through to exit 6. + - **api**: Retries run under an overall time budget rather than only a per-attempt timeout — twice the request timeout by default — so a never-responding server no longer holds a single command for two minutes (measured 125 s before). Honest `Retry-After` waits are not charged against the budget. + - **api**: The Trace ID quoted in an error message now belongs to the request that failed rather than the client's last-seen response, so concurrent requests no longer cross-quote each other's Trace IDs. + - **api**: Errors already classified by the API client are no longer re-classified when a client wraps its own error handling, which could turn a validation error into a network error and drop its recovery hint. Error messages also no longer print a doubled `Network error: Network error:` prefix. + - **api**: The document translation result endpoint is never retried — the download is effectively single-use, so a retry after a timeout on a large file could permanently lose an already-billed translation — and document transfers get their own larger timeout. + - **voice**: `deepl voice` now actually reconnects after a transport failure. The socket `error` handler marked the stream ended before the `close` event that always follows arrived, so the reconnect path (up to 3 attempts, `--reconnect` on by default) was unreachable for a real network drop — only a clean remote close ever reconnected. A reconnect that exhausts its attempts now closes the audio input instead of leaving the stream generator awaiting forever. + - **formats**: YAML files using merge keys (`<<: *anchor`) or aliases to anchored maps and sequences no longer fail at sync write-back with `Expected YAML collection`. Extraction previously emitted paths through aliases that reconstruction could not apply; aliased collections are now translated at their anchor site and the references round-trip untouched. + - **sync**: ICU plural messages using `offset:N` and messages with single-quote-escaped braces (`'{'`, `'}'`, `''` for a literal apostrophe, `'#'` in plural context) are now recognized and preserved instead of falling back to raw machine translation, which demonstrably corrupts ICU keywords and selectors. `sync validate` placeholder checking is also ICU-aware: plural/select branch braces are treated as structure, eliminating spurious `Extra placeholders in translation` warnings for translated branch bodies. + - **sync**: Template literals containing regex metacharacters in scanned source code (e.g. `` t(`item(${i}`) ``) no longer abort context resolution with a raw `SyntaxError` or silently mismatch keys — all metacharacters are escaped before the scan pattern is compiled. + - **watch**: `filesWatched` statistics now report the actual number of files under watch — seeded from the watcher's inventory once the initial scan completes and tracked live on add/unlink. It was previously never incremented and always reported 0. + - **cli**: The global `--timeout`/`--max-retries` flags now also apply to the API-key validation requests made by `deepl init` and `deepl auth set-key`, which previously always used the 30 s default. + - **utils**: Atomic file writes preserve the target's existing permissions instead of resetting them to the umask default — e.g. `deepl write --fix` on a `0600` secrets file no longer leaves it world-readable at `0644`. + - **docs**: README translate examples no longer show a `Translation (XX):` label the CLI never emits; corrected `--model-type` (no CLI default), `--config` precedence (replaces the config file only; the cache path is unaffected), unknown-command and `deepl detect` sample output, and the nonexistent 10 MB PDF cap (the document limit is 30 MB uniformly). README now covers `deepl sync`, `deepl tm`, and all nine `style-rules` subcommands, with dead in-page anchors repaired and `docs/SYNC.md` listed under Documentation. + - **docs**: TROUBLESHOOTING's exit-code table gains codes 10–12 (SyncDrift, SyncConflict, PartialFailure) and its environment-variable table gains `TMS_API_KEY`, `TMS_TOKEN`, `FORCE_COLOR`, `TERM`; sync JSON-contract stability promises are rescoped from "1.x" to "within a major version"; the GitHub Actions recipes in docs/SYNC.md pin Node 24; CONTRIBUTING no longer cites Zod (validation is commander `Option.choices()` plus hand-written validators); examples/README no longer references a nonexistent `sample-files/` directory. + - **cli**: Subcommand parse errors (unknown subcommand, unknown option, invalid choice, missing argument) exit 6 (invalid input) as documented, instead of 1 ("CLI crashed"). Top-level parse errors already exited 6; the mapping is now uniform across all subcommands, so CI scripts following the documented exit-code table no longer misread a typo as a crash. + - **usage**: Text and table output no longer report duration-billed products (e.g. speech-to-text minutes) as zero characters — duration billing units are recognized, rendered in h/m/s as documented, and product names print in the documented snake_case. `--format json` was already correct and is unchanged. + - **admin**: An entitlement failure (valid key without admin scope) no longer suggests re-running `deepl init` / `auth set-key` — the suggestion now explains that the admin API requires an administrator API key. Exit code and classification are unchanged. + - **cli**: The non-TTY `--format table` fallback notice carries the documented `WARN` prefix at all six call sites, matching `sync resolve`'s existing convention. + - **glossary**: Scoped commands (`show`, `entries`, `delete`) no longer emit an unrelated org glossary's "empty dictionaries" warning during name resolution — the warning appears only when the glossary actually operated on is affected. + - **sync**: `sync resolve` prints each parse-error fallback warning once (relative path) instead of twice. + - **write**: The unsupported-style error links to the published docs URL instead of a `docs/API.md` path npm users don't have. + - **config**: `config delete` and the config read paths can no longer walk or mutate the prototype chain — `__proto__`/`constructor`/`prototype` segments are rejected, completing the `config set` hardening. + - **perf**: `sync audit` registration no longer loads fast-glob on every CLI invocation (lazy import, matching its sibling subcommands). ### Security - **config**: `ConfigService.save()` writes through an unpredictably named temp file created with an exclusive flag, instead of a fixed `config.json.tmp`. A symlink planted at the predictable path redirected the config — which holds the API key in plaintext — to a path of the planter's choosing, and the subsequent rename left `config.json` as that symlink, so every later write followed it too. The mode is also applied with `chmod` after creation, which the umask cannot widen. + - **tests**: The test suite no longer inherits real credentials or the real config directory. Suites that spawn the bare `deepl` command cannot be intercepted by nock, so they reached the live DeepL API with whatever key was exported and read and wrote the developer's cache database; cached responses matching this suite's fixtures were recovered from a real cache, confirming it had happened. `globalSetup` now clears `DEEPL_API_KEY`, `TMS_API_KEY` and `TMS_TOKEN` and points `DEEPL_CONFIG_DIR` at a temporary directory before workers fork. + - **formats**: The prototype-pollution guard in the JSON parser is now pinned by tests. Replacing `Object.defineProperty` with plain assignment previously left the whole suite green: on a fresh object `obj['__proto__'] = value` retargets that object's prototype rather than `Object.prototype`, so the translation was silently dropped while the negative pollution assertion still passed. + - **ci**: The release workflow refuses to create a GitHub Release when the pushed tag does not match `package.json`. A tag can be pushed at any commit, so without the check a mislabelled tag would mint a Release whose title disagrees with the version it contains. + - **translate/sync**: Placeholder restoration no longer hangs the CLI with unbounded memory growth. `restorePlaceholders` looped `while (restored.includes(placeholder))`, replacing one occurrence per pass — so when the preserved original itself contained the placeholder token, every pass re-inserted it and the guard never went false. Measured before the fix: the input `{__VAR_0__}` grew from 9 to 400,009 bytes across 200,000 iterations without converging, and the real function had to be killed. It needed no attacker and no network: `preserveVariables`' pattern matches `{__VAR_0__}` (underscores and digits are in its character class), variable preservation runs unconditionally, and restoration also runs on **cached** results — so a locale value of that shape hung the process with no API call. Each placeholder is now restored in a single pass, using the function form of the replacement so `$&`/`$1` inside a preserved value stay literal. + - **sync**: Bucket `include` globs can no longer escape the project root, and `--dry-run` no longer modifies the working tree. `include` entries were validated only as non-empty strings, while `target_path_pattern` a few lines later already rejected `..`. The unvalidated glob's literal prefix was resolved and handed to the stale-`.bak` sweep, which recursed with **no containment check** — deleting every old `*.bak` it found and *re-creating* any file whose `.bak` existed while the live file was missing or empty. Verified: `include: "../../../../../../**/*.json"` produced a sweep root of `/var`, an out-of-root `.bak` was deleted and its sibling resurrected with the backup's contents. Two things made it worse: the sweep was gated only on watch runs, so it ran under `--dry-run` — the flag a cautious user reaches for to avoid side effects — and its errors were swallowed entirely, so it was silent. `include` entries are now rejected at config load for traversal segments and absolute paths (the check the `sync init` wizard already applied, which simply did not exist on the load path), the sweep independently refuses any root outside the project and logs the attempt, the sweep is skipped under `--dry-run`, and its failures are reported instead of discarded. + - **deps**: Resolved four production-dependency advisories flagged by `npm audit --omit=dev` via lockfile-only transitive bumps (no `package.json` ranges changed): `brace-expansion` 5.0.5 → 5.0.6 (GHSA-jxxr-4gwj-5jf2, ReDoS), `form-data` → 4.0.6 (GHSA-hmw2-7cc7-3qxx, CRLF injection), and `ws` 8.20.0 → 8.21.0 (GHSA-58qx-3vcg-4xpx and GHSA-96hv-2xvq-fx4p, uninitialized-memory disclosure and memory-exhaustion DoS). Production `npm audit` is back to zero vulnerabilities, unblocking the CI audit gate. + - **deps**: Resolved two further `brace-expansion` denial-of-service advisories disclosed after the bump above: `brace-expansion` 5.0.6 → 5.0.8 (GHSA-3jxr-9vmj-r5cp, exponential-time expansion of consecutive non-expanding `{}` groups; GHSA-mh99-v99m-4gvg, unbounded expansion length causing an out-of-memory crash). Reached in production through `minimatch`, which accepts `^5.0.5`, so this is again a lockfile-only bump with no `package.json` range changes. Dev-tree instances of the same advisories are intentionally left in place: npm's proposed remediation downgrades `jest` 30 → 25 and `ts-jest` 29 → 27, `devDependencies` are not installed by consumers, and the CI audit gate is production-only. + - **formats**: The YAML i18n parser no longer expands aliases at all, structurally removing the denial-of-service vector where documents with exponentially-expanding anchors ("alias bombs") or self-referential anchors hung `deepl sync` indefinitely. Aliased content is extracted and translated only at its anchor site and every alias — including merge keys — round-trips as a reference, so alias bombs now parse in milliseconds as plain references instead of being rejected by the interim expansion budget. + - **formats**: Android XML translations containing `]]>` are refused instead of being written into a CDATA section, where they closed the section and had their remainder parsed as XML — injecting elements into generated resource files. This was reachable without a malicious API response, since a failed translation is written through verbatim and the source file is used as the template for a missing target locale. + - **sync**: Target-path containment is enforced *before* any target file is read or backed up. The check previously ran after the read and the `.bak` copy, so a committed symlink directory plus a crafted `target_path_pattern` could read an out-of-root file into memory and clobber an out-of-root `.bak` sibling before the write was blocked — and the swallowed error made it repeat per locale × file. A containment violation now also aborts the sync instead of being absorbed, and the bucket pre-read loop (which had no containment check at all) now asserts it too. + - **sync**: `source_locale` and `target_locales` are validated against a BCP-47 whitelist at config load (previously a three-substring denylist), and `target_path_pattern` may not contain a `.git` or `.github` path segment — closing a write primitive where a "locale" like `config` plus a pattern like `.git/{locale}` wrote inside `.git/`. **Migration**: underscore-style locale codes (`pt_BR`) are now rejected; use hyphenated BCP-47 (`pt-BR`). + - **sync**: `.deepl-sync.yaml` discovery stops at the repository boundary (the first directory containing `.git`) instead of walking to the filesystem root, so a config planted in an ancestor directory outside the repo is no longer silently adopted as project root. + - **init**: `deepl init` masks the API-key prompt instead of echoing the key in cleartext into terminal scrollback. + - **logger**: Credential redaction now recurses through objects, arrays, and `Error` values (with cycle protection) instead of applying only to strings, so a dumped error object can no longer print an `Authorization` header or API key verbatim. + - **glossary**: Server-supplied glossary names are sanitized before terminal display, blocking ANSI escape injection via glossaries shared within a team account. Style-rule names were already sanitized; the two sites now match. + - **config**: `deepl config set` rejects `__proto__`, `constructor`, and `prototype` path segments and resolves keys with `Object.hasOwn`, so crafted paths can no longer pollute `Object.prototype`. + - **batch**: `translate --pattern` values containing `..` can no longer write translated output outside `--output-dir` (the default output branch now enforces the same containment as `--output-pattern`), and directory batch translation no longer follows symlinks out of the input directory. + - **ci**: `ci.yml` and `security.yml` explicitly request `contents: read` instead of inheriting the repository default token permissions. + ## [1.2.0] - 2026-04-25 ### Added diff --git a/docs/API.md b/docs/API.md index 078dabff..057434fb 100644 --- a/docs/API.md +++ b/docs/API.md @@ -760,7 +760,7 @@ This table is maintained by hand and reflects what the API **accepts**, which is The 14 languages are generated from `GET /v3/languages?resource=write` into `src/data/language-entries.ts` by `npm run generate:languages`, alongside the translation list, and `npm run check:languages` reports drift in either. The `WriteLanguage` type is derived from that same list, so a language added upstream widens it on regenerate rather than needing a second hand edit. -Unlike `translate --to`, which passes a well-formed unknown code to the API, `write` and `correct` **reject** a code outside this list locally (exit 6) and name every valid option. The set is small enough to enumerate, so that beats a round trip — the reasoning that makes permissiveness right for translation does not transfer to 14 of 125 languages. +`write` and `correct` check `--lang` against this list locally and name every valid option, because the set is small enough to enumerate in an error. The list is a snapshot, though, so a code that is *shaped* like a language tag but absent from it is sent to the API with the list as a warning rather than rejected — otherwise a language DeepL adds is unreachable until the snapshot is regenerated. Input that is not shaped like a language tag still exits 6 locally. **Output Options:** @@ -2327,14 +2327,10 @@ deepl usage # API Key Usage: # Used: 1,880,000 / unlimited # -# Speech-to-Text Usage: -# Used: 12m 34s / 1h 0m 0s (20.9%) -# Remaining: 47m 26s -# # Product Breakdown: # translate: 900,000 characters (API key: 880,000) # write: 1,250,000 characters (API key: 1,000,000) -# speech_to_text: 12m 34s (API key: 12m 34s) +# speech_to_text: 12m 34s (API key) ``` **Output Fields:** @@ -2348,8 +2344,7 @@ deepl usage - **Billing Period**: Start and end dates of the current billing cycle - **API Key Usage**: Characters used by this specific API key (vs. the whole account) -- **Speech-to-Text Usage**: Duration used and remaining for speech-to-text quota (displayed as hours/minutes/seconds) -- **Product Breakdown**: Per-product character counts (translate, write) and durations (speech_to_text) with API key-level breakdown +- **Product Breakdown**: Per-product character counts (translate, write) and durations (speech_to_text) with the API-key-level figure alongside. A duration-billed product shows `(API key)` when the response carries no account-wide total, rather than repeating the key's own figure as if it were one. **Notes:** From af3bf4cf3075653e86a1c3a2bfb92c7b3e41b7b0 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Tue, 4 Aug 2026 00:53:30 -0400 Subject: [PATCH 055/256] fix(usage): render a null count as unreported instead of throwing `unit_count: null` reached formatNumber on the non-duration path and threw a TypeError, so one unexpected null in the response took down `deepl usage` entirely rather than leaving one field blank. Also records in the changelog that write/correct entries for the five recased target codes are unreachable. They are left to their TTL rather than dropped: the key is a hash, so those rows cannot be distinguished from the ones for de/en/es/fr, which still serve, and purging the namespace would discard far more than it reclaimed. --- CHANGELOG.md | 2 ++ src/cli/commands/usage.ts | 6 ++++-- src/storage/cache.ts | 17 +++++++++-------- tests/unit/cache-service.test.ts | 6 ++++-- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c34936f..7828a813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,6 +137,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **docs**: Three passages described behaviour that no longer exists — the `voice --glossary` row had picked up `translate`'s repeatable/`--from` semantics (voice takes one glossary and requires neither), the `write` reference still said an unknown code is rejected locally, and the `usage` reference still documented the removed Speech-to-Text section along with output showing the duplicated API-key figure. +- **cache**: `write` and `correct` entries cached for the five target codes whose casing changed (`en-GB`, `en-US`, `pt-BR`, `pt-PT`, `zh-Hans`) are no longer reachable, since those keys hash the target language. They are left to expire with their 30-day TTL rather than dropped: the key is a hash, so the affected rows cannot be told apart from the ones for `de`/`en`/`es`/`fr`, which are unaffected and still serve. + - **cache**: **The translation cache keyed on too little and could serve the wrong text.** `translationMemoryId`, `translationMemoryThreshold`, `--ignore-tags`, `--splitting-tags`, `--non-splitting-tags`, `--outline-detection` and `--preserve-formatting` were absent from the key, so `deepl translate "Hello" --to de` and the same command with `--translation-memory my-tm` collided: the second returned the cached non-TM translation, never consulted the memory, and reported `cached: true`. Likewise two runs differing only in `--ignore-tags` returned each other's output. `preserveFormatting` had been excluded on the grounds that it does not affect output, but `preserve_formatting` suppresses the sentence-boundary punctuation and case correction, which shows up in the text. **Every translation entry cached by an earlier version is retired**, not only the ones using these options: the service merges a `preserveFormatting` default, so it is always part of the key. The cache schema version is bumped, so those rows are dropped on first open rather than sitting unreachable until their 30-day TTL — `write` and `correct` entries key the same way they always did and are kept. The first translation of any given text after upgrading is refetched. - **translate**: **A rejected language no longer costs one API round trip per batch.** Because validation defers well-formed unknown codes to the API, a two-letter typo reaches it — and a directory translation asked the same rejected question once per batch, so `deepl translate ./docs --to ex` on 200 files made 200 failing requests to be told the same thing 200 times. An unsupported `target_lang` or `source_lang` is a property of the request, not of one batch, so the remaining batches now fail without being sent; errors specific to a batch (a rate limit, say) still let the run continue. Relatedly, local validation used to be what pointed at `deepl languages`, and deferring left users with a bare `Value for 'target_lang' not supported.` from the server: a code the bundled snapshot does not list now says so up front, before anything is sent or billed. diff --git a/src/cli/commands/usage.ts b/src/cli/commands/usage.ts index 870721b2..0b44e7dd 100644 --- a/src/cli/commands/usage.ts +++ b/src/cli/commands/usage.ts @@ -78,8 +78,10 @@ export class UsageCommand { const remaining = characterLimit - characterCount; const isHighUsage = characterLimit > 0 && (characterCount / characterLimit) > 0.8; - const formatNumber = (num: number): string => { - return num.toLocaleString('en-US'); + // Tolerates a null the response may carry where the type says number: the + // alternative is `deepl usage` throwing on a field it only displays. + const formatNumber = (num: number | null | undefined): string => { + return typeof num === 'number' && Number.isFinite(num) ? num.toLocaleString('en-US') : '—'; }; const lines: string[] = []; diff --git a/src/storage/cache.ts b/src/storage/cache.ts index 72894162..15a7ac6e 100644 --- a/src/storage/cache.ts +++ b/src/storage/cache.ts @@ -69,8 +69,8 @@ function isCorruptionError(error: unknown): boolean { * * Version 2 marks a change in how translation cache keys are computed. Opening * an older DB drops the `translation:` rows -- no reader can reach them again -- - * and leaves every other namespace in place, since their keys are unchanged. The - * table layout is identical in all versions, so nothing is migrated. + * and leaves every other namespace in place. The table layout is identical in all + * versions, so nothing is migrated. */ const CACHE_SCHEMA_VERSION = 2; @@ -245,12 +245,13 @@ export class CacheService { `); if (userVersion < CACHE_SCHEMA_VERSION) { - // Only the translation namespace: its key derivation changed, so those rows - // address entries no reader can reach again, and leaving them would let - // them occupy the size budget and `cache stats` until their TTL expires. - // Every other namespace (write, correct) keys the same way it always did - // and is read in place. A fresh DB has no rows, so this is a no-op on - // first open. + // Only the translation namespace. Its keys all changed, so every row is + // unreachable and would otherwise occupy the size budget and `cache stats` + // until its TTL expires. `write`/`correct` keys changed only for the five + // target codes whose casing moved, and a hash cannot say which rows those + // are -- dropping the namespace would discard far more reachable entries + // than it reclaimed, so those few expire on their own. A fresh DB has no + // rows, so this is a no-op on first open. this.db.exec("DELETE FROM cache WHERE key LIKE 'translation:%'"); this.db.exec(`PRAGMA user_version = ${CACHE_SCHEMA_VERSION}`); } diff --git a/tests/unit/cache-service.test.ts b/tests/unit/cache-service.test.ts index 015b48b7..ef28155c 100644 --- a/tests/unit/cache-service.test.ts +++ b/tests/unit/cache-service.test.ts @@ -105,8 +105,10 @@ describe('CacheService', () => { }); it('should drop only translation rows when upgrading an older database', () => { - // Translation keys are derived differently from version 2 on, so those rows - // are unreachable; every other namespace keys the same way and is kept. + // Every translation key changed, so those rows are unreachable. write and + // correct keys changed only for the five recased target codes, which a hash + // cannot identify, so that namespace is left to its TTL rather than dropped + // wholesale. const db = (cacheService as any).db; db.exec('PRAGMA user_version = 1'); cacheService.set('translation:oldhash', { text: 'unreachable' }); From ca9e6f575295b56639c4d200e1e4abe16dd2069b Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Tue, 4 Aug 2026 09:02:37 -0400 Subject: [PATCH 056/256] fix(translate): validate languages in one place for every input mode Language validation was spread over eight call sites in five files and only three paired the target-code check with the extended-tier check, so file, directory and --dry-run runs accepted constrained options that text runs rejected locally: --to af --formality more exited 6 for text and went to the network for a file, and a dry run reported --to 'not!!a!!lang' as runnable. validateTranslationLanguages() is now the single gate. Each mode passes only the flags it honours, so nothing is refused over a flag its mode discards -- a document run still accepts --model-type, which it strips after warning, and a directory run still accepts --glossary, which it ignores. Also folds in the two rough edges around the same code path: --from is validated, with its own wording, and the deferral note is said once per code per run rather than once per call site. The extended-tier glossary arm reads hasGlossarySelection(), so an empty repeatable --glossary list no longer counts as selecting one. Closes cli-01on.2, cli-01on.1, cli-01on.7 --- CHANGELOG.md | 2 + src/cli/commands/register-translate.ts | 29 ++- .../directory-translation-handler.ts | 23 +- .../translate/document-translation-handler.ts | 12 +- .../translate/file-translation-handler.ts | 14 +- src/cli/commands/translate/index.ts | 7 + .../translate/text-translation-handler.ts | 9 +- src/cli/commands/translate/translate-utils.ts | 96 ++++++-- tests/e2e/cli-success-paths.e2e.test.ts | 43 ++++ .../cli-dry-run.integration.test.ts | 103 +++++++- .../directory-translation-handler.test.ts | 34 ++- tests/unit/translate-command.test.ts | 225 ++++++++++++++++++ tests/unit/translate-utils.test.ts | 111 ++++++++- 13 files changed, 661 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7828a813..2c19cfd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **scripts**: **The language generator wrote unescaped API response fields into TypeScript.** `lang` was interpolated into a single-quoted literal with no escaping and `name` escaped only quotes, so a response field containing `' }] as const;` — or merely ending in a backslash — could close the literal and append arbitrary code to `src/data/language-entries.ts`, which the next `npm run build` compiles and the test suite imports. The existing guards checked the shape of the response, never the content of a field. Codes are now validated against the language-tag pattern, display names against a conservative character set, categories against the three tiers, and every value is quoted with escaping; validation runs before grouping, which would otherwise drop an entry with an unrecognized category before it was checked. The generator's main guard also resolves `argv[1]` through `realpathSync`, because Node reports the ESM entry by its real path — under a symlinked checkout both npm scripts exited 0 without doing anything, including the release step that keeps the Write list current. +- **translate**: **File, directory and `--dry-run` runs skipped the extended-tier language checks that text runs made.** `translate "Hi" --to af --formality more` exited 6 locally, while the same flags on a file or a directory reached the network and came back as an API rejection; `--dry-run` reported both as runnable, along with `--to 'not!!a!!lang'`, which every real run refuses. Language validation was spread over eight call sites in five files and only three paired the code check with the extended-tier check, which is how the modes drifted apart. There is now one entry point that every input mode calls, so a constrained option fails before anything is sent whichever mode handles the argument. Each mode passes only the flags it actually honours, so nothing is refused over a flag its mode discards: a document run still accepts `--model-type`, which it strips after warning, and a directory run still accepts `--glossary`, which it ignores. **`--from` is validated too** — it never was, so a malformed source language went unremarked until the API answered — and is named as such: `Invalid source language code: "grman"`. The "not in the bundled language list; deferring to the API" note is now said once per code per run instead of once per call site, which had it printing twice for `--to de,ex` and four times for `--to ex,zz` on a directory. An empty repeatable `--glossary` list no longer trips the "do not support glossaries" rejection, and a dry run lowercases `--to`/`--from` the way the real run does. + - **translate**: **A directory translation where every file failed exited 0.** The summary reported `✓ Successful: 0 / ✗ Failed: N` and the command still looked like success to a script or CI job. Since language validation defers unknown codes to the API, a plain `--to` typo took this path: on 1.x it exited 6 locally, and in this release it printed a failure list and exited 0. A run with no successes now exits 1, and a partial failure exits 12, matching what `sync` already does. - **translate**: **The rejected-request abort covered only plain-text batches.** `.txt` and `.md` files are grouped into batches and stopped after the first rejection, but `.json`, `.yaml`, `.html`, `.srt` and `.xliff` go through the per-file path, which still spent one round trip per file collecting the same answer. That path stops the same way now, and files that were never sent are reported as skipped rather than as failures carrying another batch's error. The classifier also checks the error class before the message: a 5xx interpolates the upstream body, so a gateway error quoting `target_lang` no longer aborts a run that is otherwise succeeding, while a refused key or an exhausted quota — as request-wide as a bad code — now do. diff --git a/src/cli/commands/register-translate.ts b/src/cli/commands/register-translate.ts index ae76845b..425e6969 100644 --- a/src/cli/commands/register-translate.ts +++ b/src/cli/commands/register-translate.ts @@ -4,6 +4,7 @@ import chalk from 'chalk'; import { Logger } from '../../utils/logger.js'; import { ValidationError } from '../../utils/errors.js'; import { createTranslateCommand, type ServiceDeps } from './service-factory.js'; +import { validateTranslationLanguages } from './translate/translate-utils.js'; import { MAX_GLOSSARIES_PER_REQUEST, applyGlossarySourceLang, @@ -182,15 +183,35 @@ Examples: } if (options.dryRun) { - const targetLangs = options.to!.split(',').map(l => l.trim()); + // Normalized here because the real run lowercases inside + // TranslateCommand, past the point a dry run returns. + options.to = options.to!.toLowerCase(); + if (options.from) { + options.from = options.from.toLowerCase(); + } + + const targetLangs = options.to.split(',').map(l => l.trim()); + const isDir = !!text && fs.existsSync(text) && fs.statSync(text).isDirectory(); + const isFile = !!text && !isDir && fs.existsSync(text) && fs.statSync(text).isFile(); + + // A dry run reports a command as runnable, so it rejects whatever the + // real run rejects locally. Which extended-tier arms hold depends on the + // mode that will handle this argument: directory mode ignores --glossary + // but sends --model-type, while a document run discards --model-type + // after warning, so asserting either arm for both would refuse a command + // that works. + validateTranslationLanguages( + targetLangs, + isDir + ? { from: options.from, formality: options.formality, modelType: options.modelType } + : { from: options.from, formality: options.formality, glossary: options.glossary }, + ); + const lines: string[] = [ chalk.yellow('[dry-run] No translations will be performed.'), ]; if (text) { - const isDir = fs.existsSync(text) && fs.statSync(text).isDirectory(); - const isFile = !isDir && fs.existsSync(text) && fs.statSync(text).isFile(); - if (isDir) { lines.push(chalk.yellow(`[dry-run] Would translate directory: ${text}`)); lines.push(chalk.yellow(`[dry-run] Output directory: ${options.output ?? ''}`)); diff --git a/src/cli/commands/translate/directory-translation-handler.ts b/src/cli/commands/translate/directory-translation-handler.ts index c206e8a8..26576c4d 100644 --- a/src/cli/commands/translate/directory-translation-handler.ts +++ b/src/cli/commands/translate/directory-translation-handler.ts @@ -4,9 +4,26 @@ import { ValidationError } from '../../../utils/errors.js'; import { Logger } from '../../../utils/logger.js'; import { ExitCode } from '../../../utils/exit-codes.js'; import type { HandlerContext, TranslateOptions } from './types.js'; -import { warnIgnoredOptions, validateLanguageCodes } from './translate-utils.js'; +import { + warnIgnoredOptions, + validateTranslationLanguages, + type TranslationLanguageConstraints, +} from './translate-utils.js'; import { buildBaseTranslationOptions } from './translation-options-factory.js'; +/** + * The constrained flags this mode passes on to the request. `--glossary` is not + * one of them — directory mode announces it as ignored and resolves no glossary — + * so a glossary must not fail a directory run the way it fails a text one. + */ +function honouredConstraints(options: TranslateOptions): TranslationLanguageConstraints { + return { + from: options.from, + formality: options.formality, + modelType: options.modelType, + }; +} + export class DirectoryTranslationHandler { constructor(public ctx: HandlerContext) {} @@ -20,7 +37,7 @@ export class DirectoryTranslationHandler { if (options.to.includes(',')) { const targetLangs = options.to.split(',').map(lang => lang.trim()); - validateLanguageCodes(targetLangs); + validateTranslationLanguages(targetLangs, honouredConstraints(options)); const allOutputs: string[] = []; @@ -37,7 +54,7 @@ export class DirectoryTranslationHandler { } private async translateSingleTarget(dirPath: string, options: TranslateOptions): Promise { - validateLanguageCodes([options.to]); + validateTranslationLanguages([options.to], honouredConstraints(options)); const translationOptions = buildBaseTranslationOptions(options); diff --git a/src/cli/commands/translate/document-translation-handler.ts b/src/cli/commands/translate/document-translation-handler.ts index 17209a79..30ae1ffe 100644 --- a/src/cli/commands/translate/document-translation-handler.ts +++ b/src/cli/commands/translate/document-translation-handler.ts @@ -4,11 +4,7 @@ import { ValidationError } from '../../../utils/errors.js'; import type { Language } from '../../../types/index.js'; import type { DocumentTranslationOptions } from '../../../types/api.js'; import type { HandlerContext, TranslateOptions } from './types.js'; -import { - warnIgnoredOptions, - validateLanguageCodes, - validateExtendedLanguageConstraints, -} from './translate-utils.js'; +import { warnIgnoredOptions, validateTranslationLanguages } from './translate-utils.js'; import { buildBaseTranslationOptions, applyGlossarySelection } from './translation-options-factory.js'; import { applyGlossarySourceLang } from '../../../utils/glossary-params.js'; @@ -30,10 +26,10 @@ export class DocumentTranslationHandler { const { modelType: _model, tagHandling: _tags, tagHandlingVersion: _version, ...rest } = options; options = rest; - validateLanguageCodes([options.to]); // Documents accept glossaries, so the extended-tier constraint applies here - // too: checked before the upload rather than left to the API. - validateExtendedLanguageConstraints(options.to, options); + // too: checked before the upload rather than left to the API. After the strip + // above, so the flags this mode discards cannot fail a command it accepts. + validateTranslationLanguages([options.to], options); // The API rejects a document glossary without source_lang: "source_lang has // to be specified in order to use a glossary." diff --git a/src/cli/commands/translate/file-translation-handler.ts b/src/cli/commands/translate/file-translation-handler.ts index 64d4a885..fddef98d 100644 --- a/src/cli/commands/translate/file-translation-handler.ts +++ b/src/cli/commands/translate/file-translation-handler.ts @@ -7,7 +7,7 @@ import { Logger } from '../../../utils/logger.js'; import { safeReadFileSync } from '../../../utils/safe-read-file.js'; import type { HandlerContext, TranslateOptions } from './types.js'; import { - validateLanguageCodes, + validateTranslationLanguages, isTextBasedFile, isStructuredFile, getFileSize, @@ -33,7 +33,7 @@ export class FileTranslationHandler { if (options.to.includes(',')) { const targetLangs = options.to.split(',').map(lang => lang.trim()); - validateLanguageCodes(targetLangs); + validateTranslationLanguages(targetLangs, options); const validTargetLangs = targetLangs as Language[]; @@ -106,7 +106,13 @@ export class FileTranslationHandler { return this.documentHandler.translateDocument(filePath, options); } - validateLanguageCodes([options.to]); + // This path resolves no glossary, so the glossary arm is left out: rejecting + // over a flag the request never carries would refuse a run that works. + validateTranslationLanguages([options.to], { + from: options.from, + formality: options.formality, + modelType: options.modelType, + }); const translationOptions = buildBaseTranslationOptions(options); @@ -121,7 +127,7 @@ export class FileTranslationHandler { } async translateTextFile(filePath: string, options: TranslateOptions): Promise { - validateLanguageCodes([options.to]); + validateTranslationLanguages([options.to], options); applyGlossarySourceLang( options, diff --git a/src/cli/commands/translate/index.ts b/src/cli/commands/translate/index.ts index c8e3a9c5..b702a700 100644 --- a/src/cli/commands/translate/index.ts +++ b/src/cli/commands/translate/index.ts @@ -1,4 +1,8 @@ export type { TranslateOptions, TranslationParams, HandlerContext } from './types.js'; +export type { + ExtendedLanguageConstraints, + TranslationLanguageConstraints, +} from './translate-utils.js'; export { VALID_LANGUAGES, EXTENDED_ONLY_LANGUAGES, @@ -9,6 +13,9 @@ export { MAX_CUSTOM_INSTRUCTION_CHARS, warnIgnoredOptions, validateLanguageCodes, + validateSourceLanguage, + validateTranslationLanguages, + resetDeferredLanguageWarnings, validateExtendedLanguageConstraints, validateXmlTags, buildTranslationOptions, diff --git a/src/cli/commands/translate/text-translation-handler.ts b/src/cli/commands/translate/text-translation-handler.ts index d2cf8f61..d127f649 100644 --- a/src/cli/commands/translate/text-translation-handler.ts +++ b/src/cli/commands/translate/text-translation-handler.ts @@ -4,8 +4,7 @@ import { Logger } from '../../../utils/logger.js'; import { ValidationError, AuthError } from '../../../utils/errors.js'; import type { HandlerContext, TranslateOptions } from './types.js'; import { - validateLanguageCodes, - validateExtendedLanguageConstraints, + validateTranslationLanguages, validateXmlTags, warnIgnoredOptions, MAX_CUSTOM_INSTRUCTIONS, @@ -42,8 +41,7 @@ export class TextTranslationHandler { return this.translateToMultiple(text, options); } - validateLanguageCodes([options.to]); - validateExtendedLanguageConstraints(options.to, options); + validateTranslationLanguages([options.to], options); applyGlossarySourceLang( options, @@ -167,8 +165,7 @@ export class TextTranslationHandler { warnIgnoredOptions('multi-target', options, supported); const targetLangs = options.to.split(',').map(lang => lang.trim()); - validateLanguageCodes(targetLangs); - validateExtendedLanguageConstraints(options.to, options); + validateTranslationLanguages(targetLangs, options); applyGlossarySourceLang( options, diff --git a/src/cli/commands/translate/translate-utils.ts b/src/cli/commands/translate/translate-utils.ts index 4e448d0b..d6819716 100644 --- a/src/cli/commands/translate/translate-utils.ts +++ b/src/cli/commands/translate/translate-utils.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import { Language, Formality } from '../../../types/index.js'; import { ValidationError } from '../../../utils/errors.js'; import { Logger } from '../../../utils/logger.js'; +import { hasGlossarySelection } from '../../../utils/glossary-params.js'; import { getAllLanguageCodes, getExtendedLanguageCodes, @@ -58,32 +59,95 @@ export function warnIgnoredOptions(mode: string, options: TranslateOptions, supp } } +/** + * Codes already deferred to the API in this process. One run validates the same + * target list from more than one place — the registrar's dry run, a mode's + * multi-target split, then its per-target pass — and the note is worth saying + * once, not once per call site. + */ +const deferredCodesWarned = new Set(); + +/** + * Clears the deferral-warning state. Tests asserting the warning need this in + * `beforeEach`: the set is module state, which clearing Jest mocks leaves alone. + */ +export function resetDeferredLanguageWarnings(): void { + deferredCodesWarned.clear(); +} + /** * Rejects input that is not shaped like a language tag. Codes the bundled * snapshot does not list are passed through: GET /v3/languages is the authority * on which languages exist and the snapshot can lag it, so an unknown code is * the API's to accept or reject with a 400 of its own. + * + * `role` names the flag in the rejection, since `--from` and `--to` are rejected + * for the same reasons and a user needs to know which one to fix. */ +function validateLanguageCode(langCode: string, role: 'target' | 'source'): void { + if (VALID_LANGUAGES.has(langCode)) return; + + if (looksLikeLanguageTag(langCode)) { + if (deferredCodesWarned.has(langCode)) return; + deferredCodesWarned.add(langCode); + // Said up front, before anything is sent or billed: the API answers an + // unknown code with a bare "target_lang not supported" that points nowhere. + Logger.warn( + `Note: "${langCode}" is not in the bundled language list; deferring to the API.\n` + + ' Run: deepl languages to see the languages this build knows about.' + ); + return; + } + + throw new ValidationError( + `Invalid ${role} language code: "${langCode}".`, + 'Run: deepl languages to see all available languages' + ); +} + export function validateLanguageCodes(langCodes: string[]): void { for (const lang of langCodes) { - if (VALID_LANGUAGES.has(lang)) continue; - if (looksLikeLanguageTag(lang)) { - // Said up front, before anything is sent or billed: the API answers an - // unknown code with a bare "target_lang not supported" that points nowhere. - Logger.warn( - `Note: "${lang}" is not in the bundled language list; deferring to the API.\n` + - ' Run: deepl languages to see the languages this build knows about.' - ); - continue; - } - throw new ValidationError( - `Invalid target language code: "${lang}".`, - 'Run: deepl languages to see all available languages' - ); + validateLanguageCode(lang, 'target'); } } -export function validateExtendedLanguageConstraints(targetLang: string, options: TranslateOptions): void { +/** Validates `--from`. Absent means auto-detect, which every mode allows. */ +export function validateSourceLanguage(from: string | undefined): void { + if (!from) return; + validateLanguageCode(from, 'source'); +} + +/** + * The flags whose extended-tier arms are checked below. Callers name only the + * flags the run will honour: the input modes disagree about which they keep, and + * refusing a command over a flag its mode discards contradicts that run. + */ +export interface ExtendedLanguageConstraints { + modelType?: string; + formality?: string; + glossary?: string | string[]; +} + +/** `ExtendedLanguageConstraints` plus the source language, for the entry point below. */ +export interface TranslationLanguageConstraints extends ExtendedLanguageConstraints { + from?: string; +} + +/** + * The language gate for every translate input mode. One entry point because a + * mode that checked codes without the extended-tier arms accepted, and sent, + * commands its siblings rejected locally. + */ +export function validateTranslationLanguages( + targets: string[], + options: TranslationLanguageConstraints, +): void { + validateSourceLanguage(options.from); + validateLanguageCodes(targets); + validateExtendedLanguageConstraints(targets.join(','), options); +} + +export function validateExtendedLanguageConstraints(targetLang: string, options: ExtendedLanguageConstraints): void { const langs = targetLang.includes(',') ? targetLang.split(',').map(l => l.trim()) : [targetLang]; @@ -101,7 +165,7 @@ export function validateExtendedLanguageConstraints(targetLang: string, options: throw new ValidationError(`Language(s) ${langList} do not support formality settings`); } - if (options.glossary) { + if (hasGlossarySelection(options)) { throw new ValidationError(`Language(s) ${langList} do not support glossaries`); } } diff --git a/tests/e2e/cli-success-paths.e2e.test.ts b/tests/e2e/cli-success-paths.e2e.test.ts index 7f907fc2..d1d2d818 100644 --- a/tests/e2e/cli-success-paths.e2e.test.ts +++ b/tests/e2e/cli-success-paths.e2e.test.ts @@ -162,6 +162,49 @@ describe('CLI Success Paths E2E', () => { }); }); + describe('local language validation across input modes', () => { + it('should reject formality for an extended target before sending a file', () => { + const inputFile = path.join(testDir, 'extended-file.txt'); + fs.writeFileSync(inputFile, 'Hello', 'utf-8'); + const outputFile = path.join(testDir, 'extended-file.af.txt'); + + const result = runCLIExpectError( + `translate "${inputFile}" --to af --formality more --output "${outputFile}"`, + ); + + expect(result.status).toBe(6); + expect(result.output).toContain('do not support formality'); + expect(fs.existsSync(outputFile)).toBe(false); + }); + + it('should reject formality for an extended target before scanning a directory', () => { + const dirPath = path.join(testDir, 'extended-dir'); + fs.mkdirSync(dirPath, { recursive: true }); + fs.writeFileSync(path.join(dirPath, 'a.txt'), 'Hello', 'utf-8'); + + const result = runCLIExpectError( + `translate "${dirPath}" --to af --formality more --output "${testDir}/extended-dir-out"`, + ); + + expect(result.status).toBe(6); + expect(result.output).toContain('do not support formality'); + }); + + it('should note an unknown code once per directory run, not once per call site', () => { + const dirPath = path.join(testDir, 'deferral-dir'); + fs.mkdirSync(dirPath, { recursive: true }); + fs.writeFileSync(path.join(dirPath, 'a.txt'), 'Hello', 'utf-8'); + + const output = runCLIAll( + `translate "${dirPath}" --to de,ex --output "${testDir}/deferral-dir-out"`, + ); + + const notices = output.match(/is not in the bundled language list/g) ?? []; + expect(notices).toHaveLength(1); + expect(output).toContain('"ex" is not in the bundled language list'); + }); + }); + describe('write command success paths', () => { it('should improve text using write command', () => { const output = runCLIAll('write "Their going to the store" --lang en-US'); diff --git a/tests/integration/cli-dry-run.integration.test.ts b/tests/integration/cli-dry-run.integration.test.ts index 7c6da304..5941aa02 100644 --- a/tests/integration/cli-dry-run.integration.test.ts +++ b/tests/integration/cli-dry-run.integration.test.ts @@ -6,11 +6,12 @@ import * as fs from 'fs'; import * as path from 'path'; import { createTestConfigDir, createTestDir, makeRunCLI } from '../helpers'; +import { ExitCode } from '../../src/utils/exit-codes'; describe('--dry-run CLI Integration', () => { const testConfig = createTestConfigDir('dryrun'); const testFiles = createTestDir('dryrun-files'); - const { runCLI } = makeRunCLI(testConfig.path); + const { runCLI, runCLIAll, runCLIExpectError } = makeRunCLI(testConfig.path); afterAll(() => { testConfig.cleanup(); @@ -90,6 +91,106 @@ describe('--dry-run CLI Integration', () => { }); }); + describe('deepl translate --dry-run validation', () => { + // A dry run reports a command as runnable, so it has to reject whatever the + // real run rejects locally, with the same message and exit code. + it('should reject a malformed target language code', () => { + const result = runCLIExpectError(`deepl translate "Hi" --to 'not!!a!!lang' --dry-run`, { + excludeApiKey: true, + noColor: true, + }); + + expect(result.status).toBe(ExitCode.InvalidInput); + expect(result.output).toContain('Invalid target language code'); + expect(result.output).not.toContain('Would translate'); + }); + + it('should reject a malformed source language code', () => { + const result = runCLIExpectError( + `deepl translate "Hi" --to de --from 'not!!a!!lang' --dry-run`, + { excludeApiKey: true, noColor: true }, + ); + + expect(result.status).toBe(ExitCode.InvalidInput); + expect(result.output).toContain('Invalid source language code'); + }); + + it('should reject formality for an extended-tier target', () => { + const result = runCLIExpectError(`deepl translate "Hi" --to af --formality more --dry-run`, { + excludeApiKey: true, + noColor: true, + }); + + expect(result.status).toBe(ExitCode.InvalidInput); + expect(result.output).toContain('do not support formality'); + }); + + it('should reject a glossary for an extended-tier target', () => { + const result = runCLIExpectError( + `deepl translate "Hi" --to af --from en --glossary my-glossary --dry-run`, + { excludeApiKey: true, noColor: true }, + ); + + expect(result.status).toBe(ExitCode.InvalidInput); + expect(result.output).toContain('do not support glossaries'); + }); + + it('should not reject --model-type, which a document run discards', () => { + const testFile = path.join(testFiles.path, 'dryrun-model.pdf'); + fs.writeFileSync(testFile, 'not really a pdf'); + + const output = runCLI( + `deepl translate "${testFile}" --to af --model-type latency_optimized --output "${testFiles.path}/out.pdf" --dry-run`, + { excludeApiKey: true, noColor: true }, + ); + + expect(output).toContain('Would translate file'); + }); + + it('should not reject a glossary for a directory, which ignores one', () => { + const dirPath = path.join(testFiles.path, 'dryrun-glossary-dir'); + fs.mkdirSync(dirPath, { recursive: true }); + fs.writeFileSync(path.join(dirPath, 'a.txt'), 'hello'); + + const output = runCLI( + `deepl translate "${dirPath}" --to af --from en --glossary my-glossary --output "${testFiles.path}/out-gdir" --dry-run`, + { excludeApiKey: true, noColor: true }, + ); + + expect(output).toContain('Would translate directory'); + }); + + it('should warn once per unknown code rather than once per validation', () => { + const output = runCLIAll(`deepl translate "Hi" --to 'de,ex' --dry-run`, { + excludeApiKey: true, + noColor: true, + }); + + const notices = output.match(/is not in the bundled language list/g) ?? []; + expect(notices).toHaveLength(1); + expect(output).toContain('"ex" is not in the bundled language list'); + }); + + it('should warn about an unknown source language too', () => { + const output = runCLIAll(`deepl translate "Hi" --to de --from zz --dry-run`, { + excludeApiKey: true, + noColor: true, + }); + + expect(output).toContain('"zz" is not in the bundled language list'); + }); + + it('should lowercase language codes the way the real run does', () => { + const output = runCLI(`deepl translate "Hi" --to DE --from EN --dry-run`, { + excludeApiKey: true, + noColor: true, + }); + + expect(output).toContain('Target language(s): de'); + expect(output).toContain('Source language: en'); + }); + }); + describe('deepl glossary delete --dry-run', () => { it('should show --dry-run in glossary delete help', () => { const output = runCLI('deepl glossary delete --help'); diff --git a/tests/unit/directory-translation-handler.test.ts b/tests/unit/directory-translation-handler.test.ts index d378626e..f708669b 100644 --- a/tests/unit/directory-translation-handler.test.ts +++ b/tests/unit/directory-translation-handler.test.ts @@ -51,7 +51,7 @@ jest.mock('../../src/services/batch-translation', () => ({ jest.mock('../../src/cli/commands/translate/translate-utils', () => ({ warnIgnoredOptions: jest.fn(), - validateLanguageCodes: jest.fn(), + validateTranslationLanguages: jest.fn(), buildTranslationOptions: jest.fn().mockReturnValue({ targetLang: 'de' }), })); @@ -429,11 +429,39 @@ describe('DirectoryTranslationHandler', () => { await handler.translateDirectory('/some/dir', options); - const { validateLanguageCodes } = jest.requireMock( + const { validateTranslationLanguages } = jest.requireMock( '../../src/cli/commands/translate/translate-utils' ); - expect(validateLanguageCodes).toHaveBeenCalledWith(['de', 'fr']); + expect(validateTranslationLanguages).toHaveBeenCalledWith( + ['de', 'fr'], + expect.anything(), + ); + }); + + it('should validate the flags this mode passes on, and not --glossary', async () => { + // Directory mode announces --glossary as ignored and resolves none, so the + // extended-tier glossary arm must not fail a run that would have worked. + const options: TranslateOptions = { + ...baseOptions, + to: 'de, fr', + from: 'en', + formality: 'more', + modelType: 'latency_optimized', + glossary: ['my-glossary'], + }; + + await handler.translateDirectory('/some/dir', options); + + const { validateTranslationLanguages } = jest.requireMock( + '../../src/cli/commands/translate/translate-utils' + ); + + expect(validateTranslationLanguages).toHaveBeenCalledWith(['de', 'fr'], { + from: 'en', + formality: 'more', + modelType: 'latency_optimized', + }); }); it('should call buildTranslationOptions with each individual language', async () => { diff --git a/tests/unit/translate-command.test.ts b/tests/unit/translate-command.test.ts index 0c9a6c73..b37d37d6 100644 --- a/tests/unit/translate-command.test.ts +++ b/tests/unit/translate-command.test.ts @@ -2175,6 +2175,231 @@ describe('TranslateCommand', () => { }); }); + describe('extended-tier constraints per input mode', () => { + let mockFs: any; + + beforeEach(() => { + mockFs = jest.requireActual('fs'); + jest.spyOn(Logger, 'shouldShowSpinner').mockReturnValue(false); + jest.spyOn(mockFs, 'existsSync').mockReturnValue(true); + jest.spyOn(mockFs, 'statSync').mockReturnValue({ + size: 1024, + isDirectory: () => false, + } as any); + (safeReadFileSync as jest.Mock).mockReturnValue('Hello'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function fileHandler() { + return (translateCommand as any).fileHandler; + } + + describe('file mode', () => { + it('should reject formality for an extended target', async () => { + await expect( + fileHandler().translateTextFile('/doc.txt', { + to: 'sw', + formality: 'more', + output: '/out.txt', + }) + ).rejects.toThrow('do not support formality'); + }); + + it('should reject a glossary for an extended target', async () => { + await expect( + fileHandler().translateTextFile('/doc.txt', { + to: 'sw', + from: 'en', + glossary: ['my-glossary'], + output: '/out.txt', + }) + ).rejects.toThrow('do not support glossaries'); + }); + + it('should reject latency_optimized for an extended target', async () => { + await expect( + fileHandler().translateTextFile('/doc.txt', { + to: 'sw', + modelType: 'latency_optimized', + output: '/out.txt', + }) + ).rejects.toThrow('only support quality_optimized'); + }); + + it('should reject formality for an extended target among several', async () => { + (mockDocumentTranslationService.isDocumentSupported as jest.Mock).mockReturnValue(false); + + await expect( + fileHandler().translateFile('/doc.txt', { + to: 'es,sw', + formality: 'more', + output: '/out', + }) + ).rejects.toThrow('do not support formality'); + }); + + it('should reject formality for a format routed through fileTranslationService', async () => { + (mockDocumentTranslationService.isDocumentSupported as jest.Mock).mockReturnValue(false); + (translateCommand as any).ctx.fileTranslationService = { + translateFile: jest.fn().mockResolvedValue(undefined), + isSupportedFile: jest.fn().mockReturnValue(true), + translateFileToMultiple: jest.fn(), + }; + + await expect( + fileHandler().translateFile('/input.csv', { + to: 'sw', + formality: 'more', + output: '/output.csv', + }) + ).rejects.toThrow('do not support formality'); + }); + + it('should not reject a glossary on a path that resolves none', async () => { + // The csv path carries no glossary to the request, so refusing the run + // over one would refuse a command that works. + (mockDocumentTranslationService.isDocumentSupported as jest.Mock).mockReturnValue(false); + const mockFileService = { + translateFile: jest.fn().mockResolvedValue(undefined), + isSupportedFile: jest.fn().mockReturnValue(true), + translateFileToMultiple: jest.fn(), + }; + (translateCommand as any).ctx.fileTranslationService = mockFileService; + + await fileHandler().translateFile('/input.csv', { + to: 'sw', + from: 'en', + glossary: ['my-glossary'], + output: '/output.csv', + }); + + expect(mockFileService.translateFile).toHaveBeenCalled(); + }); + }); + + describe('directory mode', () => { + function directory(options: Record) { + return (translateCommand as any).directoryHandler.translateDirectory('/src', options); + } + + beforeEach(() => { + (translateCommand as any).ctx.batchTranslationService = { + translateDirectory: jest.fn().mockResolvedValue({ + successful: [{ file: 'a.txt', outputPath: '/out/a.txt' }], + failed: [], + skipped: [], + }), + getStatistics: jest + .fn() + .mockReturnValue({ total: 1, successful: 1, failed: 0, skipped: 0 }), + }; + }); + + it('should reject formality for an extended target', async () => { + await expect( + directory({ to: 'sw', formality: 'more', output: '/out' }) + ).rejects.toThrow('do not support formality'); + }); + + it('should reject latency_optimized for an extended target', async () => { + await expect( + directory({ to: 'sw', modelType: 'latency_optimized', output: '/out' }) + ).rejects.toThrow('only support quality_optimized'); + }); + + it('should reject formality for an extended target among several', async () => { + await expect( + directory({ to: 'es,sw', formality: 'more', output: '/out' }) + ).rejects.toThrow('do not support formality'); + }); + + it('should not reject a glossary it has announced as ignored', async () => { + const result = await directory({ + to: 'sw', + from: 'en', + glossary: ['my-glossary'], + output: '/out', + }); + + expect(result).toContain('Translation Statistics'); + }); + }); + + describe('document mode', () => { + function document(options: Record) { + return fileHandler().documentHandler.translateDocument('/doc.pdf', options); + } + + it('should reject formality for an extended target', async () => { + await expect(document({ to: 'sw', formality: 'more', output: '/out.pdf' })).rejects.toThrow( + 'do not support formality' + ); + }); + + it('should reject a glossary for an extended target', async () => { + await expect( + document({ to: 'sw', from: 'en', glossary: ['my-glossary'], output: '/out.pdf' }) + ).rejects.toThrow('do not support glossaries'); + }); + + it('should not reject a model type it strips after warning', async () => { + (mockDocumentTranslationService.translateDocument as jest.Mock).mockResolvedValue({ + success: true, + outputPath: '/out.pdf', + }); + + const result = await document({ + to: 'sw', + modelType: 'latency_optimized', + output: '/out.pdf', + }); + + expect(result).toContain('Translated /doc.pdf'); + }); + }); + + describe('source language', () => { + it('should reject a malformed --from in text mode', async () => { + await expect( + translateCommand.translateText('Hello', { to: 'es', from: 'not!!a!!lang' }) + ).rejects.toThrow('Invalid source language code'); + }); + + it('should reject a malformed --from in file mode', async () => { + await expect( + fileHandler().translateTextFile('/doc.txt', { + to: 'es', + from: 'not!!a!!lang', + output: '/out.txt', + }) + ).rejects.toThrow('Invalid source language code'); + }); + + it('should reject a malformed --from in directory mode', async () => { + await expect( + (translateCommand as any).directoryHandler.translateDirectory('/src', { + to: 'es', + from: 'not!!a!!lang', + output: '/out', + }) + ).rejects.toThrow('Invalid source language code'); + }); + + it('should reject a malformed --from in document mode', async () => { + await expect( + fileHandler().documentHandler.translateDocument('/doc.pdf', { + to: 'es', + from: 'not!!a!!lang', + output: '/out.pdf', + }) + ).rejects.toThrow('Invalid source language code'); + }); + }); + }); + describe('tag handling version', () => { it('should pass tagHandlingVersion to translation service', async () => { mockTranslationService.translate.mockResolvedValue({ diff --git a/tests/unit/translate-utils.test.ts b/tests/unit/translate-utils.test.ts index bb62fd7f..a317f013 100644 --- a/tests/unit/translate-utils.test.ts +++ b/tests/unit/translate-utils.test.ts @@ -8,6 +8,9 @@ import { MAX_CUSTOM_INSTRUCTIONS, MAX_CUSTOM_INSTRUCTION_CHARS, validateLanguageCodes, + validateSourceLanguage, + validateTranslationLanguages, + resetDeferredLanguageWarnings, validateExtendedLanguageConstraints, validateXmlTags, warnIgnoredOptions, @@ -47,6 +50,8 @@ const mockedLoggerWarn = Logger.warn as jest.MockedFunction; describe('translate-utils', () => { beforeEach(() => { jest.clearAllMocks(); + // Module state, which clearing the Logger spy does not touch. + resetDeferredLanguageWarnings(); }); describe('constants', () => { @@ -163,6 +168,103 @@ describe('translate-utils', () => { it('should throw on first invalid code in array', () => { expect(() => validateLanguageCodes(['en', 'invalid', 'de'])).toThrow(/Invalid target language code: "invalid"/); }); + + it('should warn once per unknown code however many times it is validated', () => { + // Several input modes validate the same list on the way to one request. + validateLanguageCodes(['ex']); + validateLanguageCodes(['ex']); + validateLanguageCodes(['de', 'ex']); + + expect(mockedLoggerWarn).toHaveBeenCalledTimes(1); + }); + + it('should warn once for each distinct unknown code', () => { + validateLanguageCodes(['ex', 'zz']); + validateLanguageCodes(['ex', 'zz']); + + const warnings = mockedLoggerWarn.mock.calls.map(call => String(call[0])); + expect(warnings).toHaveLength(2); + expect(warnings[0]).toContain('"ex"'); + expect(warnings[1]).toContain('"zz"'); + }); + + it('should warn again after the warned-code state is reset', () => { + validateLanguageCodes(['ex']); + resetDeferredLanguageWarnings(); + validateLanguageCodes(['ex']); + + expect(mockedLoggerWarn).toHaveBeenCalledTimes(2); + }); + }); + + describe('validateSourceLanguage()', () => { + it('should accept an absent source language, which means auto-detect', () => { + expect(() => validateSourceLanguage(undefined)).not.toThrow(); + expect(mockedLoggerWarn).not.toHaveBeenCalled(); + }); + + it('should accept a code the snapshot lists', () => { + expect(() => validateSourceLanguage('en')).not.toThrow(); + expect(mockedLoggerWarn).not.toHaveBeenCalled(); + }); + + it('should reject a malformed code as a source, not a target', () => { + expect(() => validateSourceLanguage('not!!a!!lang')).toThrow(ValidationError); + expect(() => validateSourceLanguage('not!!a!!lang')).toThrow( + /Invalid source language code: "not!!a!!lang"/, + ); + }); + + it('should defer a well-formed unknown code to the API with a warning', () => { + expect(() => validateSourceLanguage('zz')).not.toThrow(); + + const warning = mockedLoggerWarn.mock.calls.map(call => String(call[0])).join('\n'); + expect(warning).toContain('"zz" is not in the bundled language list'); + }); + + it('should share the warned-code state with target validation', () => { + validateLanguageCodes(['zz']); + validateSourceLanguage('zz'); + + expect(mockedLoggerWarn).toHaveBeenCalledTimes(1); + }); + }); + + describe('validateTranslationLanguages()', () => { + it('should reject a malformed target code', () => { + expect(() => validateTranslationLanguages(['not!!a!!lang'], {})).toThrow( + /Invalid target language code/, + ); + }); + + it('should reject a malformed source code', () => { + expect(() => validateTranslationLanguages(['de'], { from: 'not!!a!!lang' })).toThrow( + /Invalid source language code/, + ); + }); + + it('should enforce the extended-tier constraint over the whole target list', () => { + expect(() => validateTranslationLanguages(['de', 'hi'], { formality: 'more' })).toThrow( + /Language\(s\) hi do not support formality/, + ); + }); + + it('should enforce the extended-tier constraint for a single target', () => { + expect(() => + validateTranslationLanguages(['hi'], { modelType: 'latency_optimized' }), + ).toThrow(/only support quality_optimized/); + }); + + it('should accept a valid pair with no constrained options', () => { + expect(() => validateTranslationLanguages(['de', 'fr'], { from: 'en' })).not.toThrow(); + }); + + it('should not enforce an arm the caller left out', () => { + // The input modes disagree about which flags they honour; a mode that + // discards a flag passes it here as absent. + expect(() => validateTranslationLanguages(['hi'], { formality: 'more' })).toThrow(); + expect(() => validateTranslationLanguages(['hi'], {})).not.toThrow(); + }); }); describe('validateExtendedLanguageConstraints()', () => { @@ -201,10 +303,15 @@ describe('translate-utils', () => { ).toThrow(/do not support glossaries/); }); + it('should not throw for an empty glossary list, which selects nothing', () => { + expect(() => + validateExtendedLanguageConstraints('hi', { ...baseOptions, glossary: [] }) + ).not.toThrow(); + }); + it('should not throw for non-extended languages', () => { expect(() => validateExtendedLanguageConstraints('de', { - to: 'de', modelType: 'latency_optimized', formality: 'more', glossary: ['some-glossary'], @@ -220,7 +327,7 @@ describe('translate-utils', () => { it('should not throw when only non-extended langs in comma-separated list', () => { expect(() => - validateExtendedLanguageConstraints('en, de', { to: 'en', modelType: 'latency_optimized' }) + validateExtendedLanguageConstraints('en, de', { modelType: 'latency_optimized' }) ).not.toThrow(); }); From 0789e109c4093a02628f3006f6afd03986f848e1 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Tue, 4 Aug 2026 09:08:10 -0400 Subject: [PATCH 057/256] fix(voice): validate options before resolving --glossary, and check coverage Resolution costs a glossary-list round trip, and it was spent before the target language, source language and content type were checked -- so 'voice a.ogg --to bogus --glossary g' paid for a request and then failed locally. The local checks move into an exported validateVoiceOptions() that the registrar calls first and buildOptions reuses. Resolution now also passes the requested pair, so a glossary that does not cover it fails locally the way it already did for translate, watch and sync. The pair is the canonical spelling voice sends; without --from there is no pair and the API keeps the judgement. Closes cli-01on.3 --- CHANGELOG.md | 2 + src/cli/commands/register-voice.ts | 16 +++- src/cli/commands/voice.ts | 85 +++++++++++++------- tests/e2e/cli-voice.e2e.test.ts | 49 +++++++++++ tests/unit/voice-glossary-resolution.test.ts | 81 ++++++++++++++++++- 5 files changed, 199 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c19cfd6..c1ee1057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **scripts**: **The language generator wrote unescaped API response fields into TypeScript.** `lang` was interpolated into a single-quoted literal with no escaping and `name` escaped only quotes, so a response field containing `' }] as const;` — or merely ending in a backslash — could close the literal and append arbitrary code to `src/data/language-entries.ts`, which the next `npm run build` compiles and the test suite imports. The existing guards checked the shape of the response, never the content of a field. Codes are now validated against the language-tag pattern, display names against a conservative character set, categories against the three tiers, and every value is quoted with escaping; validation runs before grouping, which would otherwise drop an entry with an unrecognized category before it was checked. The generator's main guard also resolves `argv[1]` through `realpathSync`, because Node reports the ESM entry by its real path — under a symlinked checkout both npm scripts exited 0 without doing anything, including the release step that keeps the Write list current. +- **voice**: **`--glossary` was resolved before the rest of the command was checked, and without the language pair.** `voice a.ogg --to bogus --glossary my-terms` spent a glossary-list round trip and only then failed locally on the target language; every local check now runs first. Resolution also passes the requested pair, so a glossary whose dictionaries do not cover it fails locally with `Glossary "my-terms" does not support the requested language pair` instead of at the API — the preflight the other commands already had. The pair is the canonical one voice sends, so `--from EN --to zh-hans` is checked as `en→zh-HANS`. Without `--from` there is no pair to check and the API still judges it. + - **translate**: **File, directory and `--dry-run` runs skipped the extended-tier language checks that text runs made.** `translate "Hi" --to af --formality more` exited 6 locally, while the same flags on a file or a directory reached the network and came back as an API rejection; `--dry-run` reported both as runnable, along with `--to 'not!!a!!lang'`, which every real run refuses. Language validation was spread over eight call sites in five files and only three paired the code check with the extended-tier check, which is how the modes drifted apart. There is now one entry point that every input mode calls, so a constrained option fails before anything is sent whichever mode handles the argument. Each mode passes only the flags it actually honours, so nothing is refused over a flag its mode discards: a document run still accepts `--model-type`, which it strips after warning, and a directory run still accepts `--glossary`, which it ignores. **`--from` is validated too** — it never was, so a malformed source language went unremarked until the API answered — and is named as such: `Invalid source language code: "grman"`. The "not in the bundled language list; deferring to the API" note is now said once per code per run instead of once per call site, which had it printing twice for `--to de,ex` and four times for `--to ex,zz` on a directory. An empty repeatable `--glossary` list no longer trips the "do not support glossaries" rejection, and a dry run lowercases `--to`/`--from` the way the real run does. - **translate**: **A directory translation where every file failed exited 0.** The summary reported `✓ Successful: 0 / ✗ Failed: N` and the command still looked like success to a script or CI job. Since language validation defers unknown codes to the API, a plain `--to` typo took this path: on 1.x it exited 6 locally, and in this release it printed a failure list and exited 0. A run with no successes now exits 1, and a partial failure exits 12, matching what `sync` already does. diff --git a/src/cli/commands/register-voice.ts b/src/cli/commands/register-voice.ts index 117de3db..9ac63c58 100644 --- a/src/cli/commands/register-voice.ts +++ b/src/cli/commands/register-voice.ts @@ -1,6 +1,7 @@ import { Command, InvalidArgumentError, Option } from 'commander'; import { Logger } from '../../utils/logger.js'; import { createVoiceCommand, type ServiceDeps } from './service-factory.js'; +import type { Language } from '../../types/index.js'; function parsePositiveInt(value: string, name: string, max: number): number { const n = parseInt(value, 10); @@ -72,11 +73,24 @@ Examples: format?: string; }) => { try { + // Ahead of the resolution below, which costs a glossary-list round trip: + // a command that fails locally must not spend one first. Imported + // dynamically to keep the voice module off every other command's path. + const { validateVoiceOptions } = await import('./voice.js'); + const { targetLangs, sourceLang } = validateVoiceOptions(options); + if (options.glossary) { const client = await deps.createDeepLClient(); const { GlossaryService } = await import('../../services/glossary.js'); const glossaryService = new GlossaryService(client); - options.glossary = await glossaryService.resolveGlossaryId(options.glossary); + // The pair lets resolution check dictionary coverage locally. Without + // `--from` there is no pair to check, so the API judges it instead. + options.glossary = await glossaryService.resolveGlossaryId( + options.glossary, + sourceLang + ? { from: sourceLang as Language, targets: targetLangs as unknown as Language[] } + : undefined, + ); } const voiceCommand = await createVoiceCommand(deps.getApiKeyAndOptions); diff --git a/src/cli/commands/voice.ts b/src/cli/commands/voice.ts index ebb835ea..aff12e7f 100644 --- a/src/cli/commands/voice.ts +++ b/src/cli/commands/voice.ts @@ -60,6 +60,58 @@ const VALID_VOICE_CONTENT_TYPES: ReadonlySet = new Set { + const raw = l.trim(); + const canonical = VOICE_TARGET_BY_LOWERCASE.get(raw.toLowerCase()); + if (!canonical) { + throw new ValidationError( + `Invalid voice target language: "${raw}". Valid codes: ${Array.from(VALID_VOICE_TARGET_LANGS).sort().join(', ')}`, + ); + } + return canonical; + }); + + let sourceLang: VoiceSourceLanguage | undefined; + if (options.from) { + sourceLang = VOICE_SOURCE_BY_LOWERCASE.get(options.from.toLowerCase()); + if (!sourceLang) { + throw new ValidationError( + `Invalid voice source language: "${options.from}". Valid codes: ${Array.from(VALID_VOICE_SOURCE_LANGS).sort().join(', ')}`, + ); + } + } + + if (options.contentType && !VALID_VOICE_CONTENT_TYPES.has(options.contentType)) { + throw new ValidationError( + `Invalid voice content type: "${options.contentType}". Valid types: ${Array.from(VALID_VOICE_CONTENT_TYPES).sort().join(', ')}`, + ); + } + + return { targetLangs, sourceLang }; +} + interface VoiceCommandOptions { to: string; from?: string; @@ -149,36 +201,7 @@ export class VoiceCommand { } private buildOptions(options: VoiceCommandOptions): VoiceTranslateOptions { - // Matched case-insensitively and canonicalized to the spelling the Voice API - // expects. The rest of the CLI accepts any casing and `deepl languages` - // prints these codes lowercase, so requiring `zh-HANS` would reject the - // spelling the CLI itself teaches. - const targetLangs = options.to.split(',').map((l) => { - const raw = l.trim(); - const canonical = VOICE_TARGET_BY_LOWERCASE.get(raw.toLowerCase()); - if (!canonical) { - throw new ValidationError( - `Invalid voice target language: "${raw}". Valid codes: ${Array.from(VALID_VOICE_TARGET_LANGS).sort().join(', ')}`, - ); - } - return canonical; - }); - - if (options.from) { - const canonicalSource = VOICE_SOURCE_BY_LOWERCASE.get(options.from.toLowerCase()); - if (!canonicalSource) { - throw new ValidationError( - `Invalid voice source language: "${options.from}". Valid codes: ${Array.from(VALID_VOICE_SOURCE_LANGS).sort().join(', ')}`, - ); - } - options.from = canonicalSource; - } - - if (options.contentType && !VALID_VOICE_CONTENT_TYPES.has(options.contentType)) { - throw new ValidationError( - `Invalid voice content type: "${options.contentType}". Valid types: ${Array.from(VALID_VOICE_CONTENT_TYPES).sort().join(', ')}`, - ); - } + const { targetLangs, sourceLang } = validateVoiceOptions(options); if (options.glossary && targetLangs.length > 1) { process.stderr.write( @@ -191,7 +214,7 @@ export class VoiceCommand { return { targetLangs, - sourceLang: options.from as VoiceSourceLanguage | undefined, + sourceLang, sourceLanguageMode: options.sourceLanguageMode as VoiceSourceLanguageMode | undefined, formality: options.formality as VoiceTranslateOptions['formality'], glossaryId: options.glossary, diff --git a/tests/e2e/cli-voice.e2e.test.ts b/tests/e2e/cli-voice.e2e.test.ts index eb7a775c..3bbc464c 100644 --- a/tests/e2e/cli-voice.e2e.test.ts +++ b/tests/e2e/cli-voice.e2e.test.ts @@ -96,6 +96,55 @@ describe('Voice CLI E2E', () => { }); }); + describe('Validation before the glossary round trip', () => { + // Resolving --glossary lists the account's glossaries, so a command that + // fails locally must fail before that request rather than after it. The + // config points at a dead port, so a regression that resolved first would + // report a network error instead of the local rejection. + const orderConfig = createTestConfigDir('voice-e2e-glossary-order'); + const orderCLI = makeRunCLI(orderConfig.path, { noColor: true }); + + beforeAll(() => { + fs.writeFileSync( + path.join(orderConfig.path, 'config.json'), + JSON.stringify({ + auth: { apiKey: 'mock-api-key-for-testing:fx' }, + api: { baseUrl: 'http://127.0.0.1:9/v2', usePro: false }, + }), + ); + }); + + afterAll(() => { + orderConfig.cleanup(); + }); + + it('should reject an invalid target language ahead of glossary resolution', () => { + const testFile = path.join(testDir, 'glossary-order.mp3'); + fs.writeFileSync(testFile, Buffer.alloc(100)); + + const result = orderCLI.runCLIExpectError( + `deepl voice ${testFile} --to bogus --glossary my-glossary`, + ); + + expect(result.status).toBe(6); + expect(result.output).toContain('Invalid voice target language'); + expect(result.output).not.toMatch(/Network error/); + }); + + it('should reject an invalid content type ahead of glossary resolution', () => { + const testFile = path.join(testDir, 'glossary-order-ct.mp3'); + fs.writeFileSync(testFile, Buffer.alloc(100)); + + const result = orderCLI.runCLIExpectError( + `deepl voice ${testFile} --to de --content-type audio/wav --glossary my-glossary`, + ); + + expect(result.status).toBe(6); + expect(result.output).toContain('Invalid voice content type'); + expect(result.output).not.toMatch(/Network error/); + }); + }); + describe('Error messages', () => { it('should show clear error when API key is not set', () => { const testFile = path.join(testDir, 'error-msg-test.mp3'); diff --git a/tests/unit/voice-glossary-resolution.test.ts b/tests/unit/voice-glossary-resolution.test.ts index e8739009..fda7cd1f 100644 --- a/tests/unit/voice-glossary-resolution.test.ts +++ b/tests/unit/voice-glossary-resolution.test.ts @@ -90,7 +90,7 @@ describe('Voice Glossary Resolution', () => { await program.parseAsync(['node', 'test', 'voice', 'test.ogg', '--to', 'de', '--glossary', uuid]); expect(createDeepLClient).toHaveBeenCalled(); - expect(mockResolveGlossaryId).toHaveBeenCalledWith(uuid); + expect(mockResolveGlossaryId).toHaveBeenCalledWith(uuid, undefined); expect(mockTranslate).toHaveBeenCalledWith( 'test.ogg', expect.objectContaining({ glossary: uuid }), @@ -105,13 +105,90 @@ describe('Voice Glossary Resolution', () => { await program.parseAsync(['node', 'test', 'voice', 'test.ogg', '--to', 'de', '--glossary', 'my-glossary']); expect(createDeepLClient).toHaveBeenCalled(); - expect(mockResolveGlossaryId).toHaveBeenCalledWith('my-glossary'); + expect(mockResolveGlossaryId).toHaveBeenCalledWith('my-glossary', undefined); expect(mockTranslate).toHaveBeenCalledWith( 'test.ogg', expect.objectContaining({ glossary: resolvedId }), ); }); + it('should resolve with the requested pair so coverage is checked locally', async () => { + mockResolveGlossaryId.mockResolvedValue('aaaabbbb-cccc-dddd-eeee-ffffffffffff'); + + await loadAndRegister(); + await program.parseAsync([ + 'node', 'test', 'voice', 'test.ogg', + '--from', 'en', '--to', 'de,fr', '--glossary', 'my-glossary', + ]); + + expect(mockResolveGlossaryId).toHaveBeenCalledWith('my-glossary', { + from: 'en', + targets: ['de', 'fr'], + }); + }); + + it('should pass the canonical spellings of the pair, not what was typed', async () => { + mockResolveGlossaryId.mockResolvedValue('aaaabbbb-cccc-dddd-eeee-ffffffffffff'); + + await loadAndRegister(); + await program.parseAsync([ + 'node', 'test', 'voice', 'test.ogg', + '--from', 'EN', '--to', 'zh-hans', '--glossary', 'my-glossary', + ]); + + expect(mockResolveGlossaryId).toHaveBeenCalledWith('my-glossary', { + from: 'en', + targets: ['zh-HANS'], + }); + }); + + it('should resolve without a pair when --from is absent, since coverage needs both', async () => { + mockResolveGlossaryId.mockResolvedValue('aaaabbbb-cccc-dddd-eeee-ffffffffffff'); + + await loadAndRegister(); + await program.parseAsync([ + 'node', 'test', 'voice', 'test.ogg', '--to', 'de', '--glossary', 'my-glossary', + ]); + + expect(mockResolveGlossaryId).toHaveBeenCalledWith('my-glossary', undefined); + }); + + it('should reject an invalid target language before spending the resolution call', async () => { + await loadAndRegister(); + await expect( + program.parseAsync([ + 'node', 'test', 'voice', 'test.ogg', '--to', 'bogus', '--glossary', 'my-glossary', + ]), + ).rejects.toThrow('Invalid voice target language'); + + expect(createDeepLClient).not.toHaveBeenCalled(); + expect(mockResolveGlossaryId).not.toHaveBeenCalled(); + }); + + it('should reject an invalid source language before spending the resolution call', async () => { + await loadAndRegister(); + await expect( + program.parseAsync([ + 'node', 'test', 'voice', 'test.ogg', + '--to', 'de', '--from', 'bogus', '--glossary', 'my-glossary', + ]), + ).rejects.toThrow('Invalid voice source language'); + + expect(mockResolveGlossaryId).not.toHaveBeenCalled(); + }); + + it('should reject an invalid content type before spending the resolution call', async () => { + await loadAndRegister(); + await expect( + program.parseAsync([ + 'node', 'test', 'voice', 'test.ogg', + '--to', 'de', '--content-type', 'audio/wav', '--glossary', 'my-glossary', + ]), + ).rejects.toThrow('Invalid voice content type'); + + expect(mockResolveGlossaryId).not.toHaveBeenCalled(); + }); + it('should not create DeepLClient or call resolveGlossaryId when no glossary is specified', async () => { await loadAndRegister(); await program.parseAsync(['node', 'test', 'voice', 'test.ogg', '--to', 'de']); From c4b5b0e9bedf72b32a4292ce884e801554b64496 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Tue, 4 Aug 2026 09:18:51 -0400 Subject: [PATCH 058/256] fix(sync): check override glossaries at startup, and validate during --dry-run locale_overrides..glossary was resolved with no expected pair, so it skipped the coverage preflight the top-level glossary and the TM override already had. It was also resolved inside the bucket loop, once per file, which means a reference that does not cover its locale failed only after earlier files had been translated and written. Both kinds of override now resolve once in the orchestrator, against their own locale, and the resolved maps are threaded into processBucket -- so that function no longer needs the glossary service, the translation service or the TM cache. sync --dry-run also drops its resolution guards. It already requires an API key to get that far, and a glossary or TM the config names but the account cannot use for the pair is what a preview exists to report. Zero /v2/translate calls is unchanged and still asserted. Closes cli-01on.4 --- CHANGELOG.md | 2 + src/sync/sync-process-bucket.ts | 38 ++---- src/sync/sync-service.ts | 56 ++++++-- tests/integration/sync.integration.test.ts | 48 ++++++- tests/unit/sync/sync-process-bucket.test.ts | 7 +- tests/unit/sync/sync-service.test.ts | 137 ++++++++++++++++++-- 6 files changed, 226 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1ee1057..dec0e21d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **scripts**: **The language generator wrote unescaped API response fields into TypeScript.** `lang` was interpolated into a single-quoted literal with no escaping and `name` escaped only quotes, so a response field containing `' }] as const;` — or merely ending in a backslash — could close the literal and append arbitrary code to `src/data/language-entries.ts`, which the next `npm run build` compiles and the test suite imports. The existing guards checked the shape of the response, never the content of a field. Codes are now validated against the language-tag pattern, display names against a conservative character set, categories against the three tiers, and every value is quoted with escaping; validation runs before grouping, which would otherwise drop an entry with an unrecognized category before it was checked. The generator's main guard also resolves `argv[1]` through `realpathSync`, because Node reports the ESM entry by its real path — under a symlinked checkout both npm scripts exited 0 without doing anything, including the release step that keeps the Write list current. +- **sync**: **Per-locale glossary overrides skipped the coverage preflight, and `--dry-run` performed no resolution at all.** `locale_overrides..glossary` was resolved without the language pair, so an override that did not cover its own locale reached the API — unlike the top-level glossary and the translation-memory override, which were already checked. Both kinds of override now resolve once at startup against their own locale, rather than being re-resolved for every file, so a bad reference fails before anything is translated instead of after earlier files have been written. `sync --dry-run` resolves and checks the same references a real run does: the command already needs an API key to reach that point, and a glossary or translation memory the config names but the account cannot use for the requested pair is exactly what a preview should report. A dry run still sends nothing to `/v2/translate`. + - **voice**: **`--glossary` was resolved before the rest of the command was checked, and without the language pair.** `voice a.ogg --to bogus --glossary my-terms` spent a glossary-list round trip and only then failed locally on the target language; every local check now runs first. Resolution also passes the requested pair, so a glossary whose dictionaries do not cover it fails locally with `Glossary "my-terms" does not support the requested language pair` instead of at the API — the preflight the other commands already had. The pair is the canonical one voice sends, so `--from EN --to zh-hans` is checked as `en→zh-HANS`. Without `--from` there is no pair to check and the API still judges it. - **translate**: **File, directory and `--dry-run` runs skipped the extended-tier language checks that text runs made.** `translate "Hi" --to af --formality more` exited 6 locally, while the same flags on a file or a directory reached the network and came back as an API rejection; `--dry-run` reported both as runnable, along with `--to 'not!!a!!lang'`, which every real run refuses. Language validation was spread over eight call sites in five files and only three paired the code check with the extended-tier check, which is how the modes drifted apart. There is now one entry point that every input mode calls, so a constrained option fails before anything is sent whichever mode handles the argument. Each mode passes only the flags it actually honours, so nothing is refused over a flag its mode discards: a document run still accepts `--model-type`, which it strips after warning, and a directory run still accepts `--glossary`, which it ignores. **`--from` is validated too** — it never was, so a malformed source language went unremarked until the API answered — and is named as such: `Invalid source language code: "grman"`. The "not in the bundled language list; deferring to the API" note is now said once per code per run instead of once per call site, which had it printing twice for `--to de,ex` and four times for `--to ex,zz` on a directory. An empty repeatable `--glossary` list no longer trips the "do not support glossaries" rejection, and a dry run lowercases `--to`/`--from` the way the real run does. diff --git a/src/sync/sync-process-bucket.ts b/src/sync/sync-process-bucket.ts index 47a0be88..848fdb29 100644 --- a/src/sync/sync-process-bucket.ts +++ b/src/sync/sync-process-bucket.ts @@ -1,7 +1,5 @@ import * as fs from 'fs'; import * as path from 'path'; -import type { TranslationService } from '../services/translation.js'; -import type { GlossaryService } from '../services/glossary.js'; import { computeDiff } from './sync-differ.js'; import { mapWithConcurrency, MULTI_TARGET_CONCURRENCY } from '../utils/concurrency.js'; import { ValidationError } from '../utils/errors.js'; @@ -9,10 +7,7 @@ import { resolveTargetPath, assertPathWithinRoot } from './sync-utils.js'; import { computeSourceHash } from './sync-lock.js'; import type { ResolvedSyncConfig } from './sync-config.js'; import type { SyncLockFile, SyncLockTranslation } from './types.js'; -import type { Language } from '../types/common.js'; import type { KeyContext } from './sync-context.js'; -import { resolveTranslationMemoryId } from '../services/translation-memory.js'; -import type { TmCacheLike } from './tm-cache.js'; import { Logger } from '../utils/logger.js'; import { extractTranslatable, type WalkedBucketFile } from './sync-bucket-walker.js'; import type { LocaleTranslator } from './sync-locale-translator.js'; @@ -31,9 +26,13 @@ export interface ProcessBucketDeps { // Read-only refs: keyContexts: Map; localeTranslator: LocaleTranslator; - glossaryService: GlossaryService; - translationService: TranslationService; - tmCache: TmCacheLike; + /** + * Glossary and translation-memory IDs for locales carrying a + * `locale_overrides.` entry, resolved once by the orchestrator so a + * reference that does not cover its locale fails before any file is touched. + */ + localeGlossaryIds: Map; + localeTmIds: Map; // For cost-cap check — orchestrator's cumulative as of this bucket: currentTotalCharsBilled: number; } @@ -66,7 +65,7 @@ export async function processBucket( ): Promise { const { config, options, lockFile, sourceEntryMap, targetEntryMap, allContextSentKeys, allInstructionSentKeys, allInstructionGroupTotals, - keyContexts, localeTranslator, glossaryService, translationService, tmCache, + keyContexts, localeTranslator, localeGlossaryIds, localeTmIds, currentTotalCharsBilled } = deps; const { bucketConfig, parser, relPath, content, entries, isMultiLocale } = walked; @@ -230,27 +229,6 @@ export async function processBucket( } } - const localeGlossaryIds = new Map(); - for (const locale of locales) { - const override = config.translation?.locale_overrides?.[locale]?.glossary; - if (override && override !== 'auto' && !options?.dryRun) { - localeGlossaryIds.set(locale, await glossaryService.resolveGlossaryId(override)); - } - } - - const localeTmIds = new Map(); - for (const locale of locales) { - const override = config.translation?.locale_overrides?.[locale]?.translation_memory; - if (override && !options?.dryRun) { - localeTmIds.set(locale, await resolveTranslationMemoryId( - translationService, - override, - tmCache, - { from: config.source_locale as Language, targets: [locale as Language] }, - )); - } - } - await mapWithConcurrency(locales, async (locale) => { if (options?.cancellationSignal?.cancelled) { return; diff --git a/src/sync/sync-service.ts b/src/sync/sync-service.ts index 3a15a861..c8c78f68 100644 --- a/src/sync/sync-service.ts +++ b/src/sync/sync-service.ts @@ -189,8 +189,16 @@ export class SyncService { let driftDetected = false; let lockDirty = false; + // Resolution runs for a dry run too. It costs one listing per kind, the + // command already needs an API key to reach this point, and a glossary or + // translation memory the config names but the account cannot use for the + // requested pair is exactly what a preview exists to report. + const effectiveLocales = options?.localeFilter?.length + ? config.target_locales.filter(l => options.localeFilter!.includes(l)) + : config.target_locales; + let resolvedGlossaryId: string | undefined; - if (config.translation?.glossary && config.translation.glossary !== 'auto' && !options?.dryRun) { + if (config.translation?.glossary && config.translation.glossary !== 'auto') { // The pair is known from the config, so a glossary that does not cover it // fails here rather than once per file, as with the translation memory // below. @@ -203,11 +211,7 @@ export class SyncService { .filter(([, override]) => override?.glossary) .map(([locale]) => locale), ); - const glossaryLocales = ( - options?.localeFilter?.length - ? config.target_locales.filter(l => options.localeFilter!.includes(l)) - : config.target_locales - ).filter(locale => !overriddenLocales.has(locale)); + const glossaryLocales = effectiveLocales.filter(locale => !overriddenLocales.has(locale)); resolvedGlossaryId = await this.glossaryService.resolveGlossaryId( config.translation.glossary, { from: config.source_locale as Language, targets: glossaryLocales as Language[] }, @@ -215,10 +219,7 @@ export class SyncService { } let resolvedTmId: string | undefined; - if (config.translation?.translation_memory && !options?.dryRun) { - const effectiveLocales = options?.localeFilter?.length - ? config.target_locales.filter(l => options.localeFilter!.includes(l)) - : config.target_locales; + if (config.translation?.translation_memory) { resolvedTmId = await resolveTranslationMemoryId( this.translationService, config.translation.translation_memory, @@ -227,6 +228,37 @@ export class SyncService { ); } + // Per-locale overrides are resolved here rather than inside the bucket loop, + // which repeated the work for every file: an override that does not cover its + // own locale has to fail before anything is translated, not after earlier + // files have already been written. `auto` names no glossary to resolve — it + // is managed by the auto-glossary pass after translation. + const localeGlossaryIds = new Map(); + const localeTmIds = new Map(); + for (const locale of effectiveLocales) { + const override = config.translation?.locale_overrides?.[locale]; + if (override?.glossary && override.glossary !== 'auto') { + localeGlossaryIds.set( + locale, + await this.glossaryService.resolveGlossaryId(override.glossary, { + from: config.source_locale as Language, + targets: [locale as Language], + }), + ); + } + if (override?.translation_memory) { + localeTmIds.set( + locale, + await resolveTranslationMemoryId( + this.translationService, + override.translation_memory, + this.tmCache, + { from: config.source_locale as Language, targets: [locale as Language] }, + ), + ); + } + } + let keyContexts = new Map(); let templatePatterns: TemplatePatternMatch[] = []; if (config.context?.enabled) { @@ -307,9 +339,7 @@ export class SyncService { sourceEntryMap, targetEntryMap, allContextSentKeys, allInstructionSentKeys, allInstructionGroupTotals, keyContexts, localeTranslator, - glossaryService: this.glossaryService, - translationService: this.translationService, - tmCache: this.tmCache, + localeGlossaryIds, localeTmIds, currentTotalCharsBilled: totalCharsBilled, }); diff --git a/tests/integration/sync.integration.test.ts b/tests/integration/sync.integration.test.ts index 46593591..5993c027 100644 --- a/tests/integration/sync.integration.test.ts +++ b/tests/integration/sync.integration.test.ts @@ -2527,9 +2527,9 @@ translation: }); }); - // ---- 9. Dry-run suppression ---- - describe('dry-run suppression', () => { - it('makes zero list calls and zero translate calls when dryRun is set even with TM configured', async () => { + // ---- 9. Dry-run resolution ---- + describe('dry-run resolution', () => { + it('resolves configured TMs during a dry run but sends nothing to translate', async () => { writeYamlConfig( tmpDir, `version: 1 @@ -2549,15 +2549,53 @@ translation: ); writeSourceFile(tmpDir, 'locales/en.json', JSON.stringify({ greeting: 'Hello' }, null, 2) + '\n'); - // No nock interceptors registered — any outbound request would - // surface as an unmatched-request error and fail the test. + // Only the listing is mocked. A /v2/translate request would be unmatched + // and fail the test, which is the invariant a dry run has to keep. + // Twice: the top-level name and the override name are separate cache keys. + const listScope = mockListTms( + [ + { id: TM_UUID_MY, name: 'my-tm', source: 'en', target: 'de' }, + { id: TM_UUID_DE_SPECIFIC, name: 'de-specific-tm', source: 'en', target: 'de' }, + ], + 2, + ); const config = await loadSyncConfig(tmpDir); const result = await syncService.sync(config, { dryRun: true }); expect(result.dryRun).toBe(true); + expect(listScope.isDone()).toBe(true); expect(nock.pendingMocks()).toEqual([]); }); + + it('fails a dry run when a per-locale override TM does not cover its locale', async () => { + writeYamlConfig( + tmpDir, + `version: 1 +source_locale: en +target_locales: + - de + - fr +buckets: + json: + include: + - "locales/en.json" +translation: + locale_overrides: + fr: + translation_memory: "de-only" +`, + ); + writeSourceFile(tmpDir, 'locales/en.json', JSON.stringify({ greeting: 'Hello' }, null, 2) + '\n'); + + mockListTms([{ id: TM_UUID_DE, name: 'de-only', source: 'en', target: 'de' }]); + + const config = await loadSyncConfig(tmpDir); + const caught = await syncService.sync(config, { dryRun: true }).catch((e: unknown) => e); + + expect(caught).toBeInstanceOf(ConfigError); + expect((caught as Error).message).toMatch(/does not support the requested language pair/); + }); }); }); diff --git a/tests/unit/sync/sync-process-bucket.test.ts b/tests/unit/sync/sync-process-bucket.test.ts index d14c963f..07e78844 100644 --- a/tests/unit/sync/sync-process-bucket.test.ts +++ b/tests/unit/sync/sync-process-bucket.test.ts @@ -7,9 +7,7 @@ import type { ResolvedSyncConfig } from '../../../src/sync/sync-config'; import type { SyncLockFile } from '../../../src/sync/types'; import type { WalkedBucketFile } from '../../../src/sync/sync-bucket-walker'; import type { LocaleTranslator, TranslateLocaleResult } from '../../../src/sync/sync-locale-translator'; -import type { GlossaryService } from '../../../src/services/glossary'; import { ValidationError } from '../../../src/utils/errors'; -import { createMockTranslationService } from '../../helpers/mock-factories'; jest.mock('../../../src/utils/logger', () => ({ Logger: { @@ -77,9 +75,8 @@ function makeDeps( allInstructionGroupTotals: new Map(), keyContexts: new Map(), localeTranslator: { translate } as unknown as LocaleTranslator, - glossaryService: { resolveGlossaryId: jest.fn() } as unknown as GlossaryService, - translationService: createMockTranslationService(), - tmCache: { has: () => false, get: () => undefined, set: () => undefined }, + localeGlossaryIds: new Map(), + localeTmIds: new Map(), currentTotalCharsBilled: 0, }; } diff --git a/tests/unit/sync/sync-service.test.ts b/tests/unit/sync/sync-service.test.ts index 40699b4f..309b17b2 100644 --- a/tests/unit/sync/sync-service.test.ts +++ b/tests/unit/sync/sync-service.test.ts @@ -7,7 +7,7 @@ import { FormatRegistry } from '../../../src/formats/index'; import type { FormatParser, ExtractedEntry, TranslatedEntry } from '../../../src/formats/format'; import { JsonFormatParser } from '../../../src/formats/json'; import { YamlFormatParser } from '../../../src/formats/yaml'; -import { ValidationError } from '../../../src/utils/errors'; +import { ValidationError, ConfigError } from '../../../src/utils/errors'; jest.mock('fast-glob', () => { const mockFn = Object.assign( @@ -1783,7 +1783,12 @@ describe('SyncService', () => { }); await service.sync(config); - expect(mockGlossary.resolveGlossaryId).toHaveBeenCalledWith('my-glossary'); + // With the override's own locale as the pair, so a glossary that does not + // cover it fails at startup rather than reaching the API. + expect(mockGlossary.resolveGlossaryId).toHaveBeenCalledWith('my-glossary', { + from: 'en', + targets: ['de'], + }); expect(translateBatch).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ glossaryId: 'resolved-glossary-id-123' }), @@ -2353,20 +2358,20 @@ describe('SyncService', () => { expect((deCall![1] as Record)['translationMemoryThreshold']).toBe(90); }); - it('should not resolve TM during dryRun', async () => { + it('should resolve the top-level TM during dryRun so the preview validates it', async () => { setupLockManager(makeEmptyLockFile()); const { service, mockTranslation } = createService(); + mockTranslation.listTranslationMemories.mockResolvedValue([ + { translation_memory_id: TM_UUID_A, name: 'my-tm', source_language: 'en', target_languages: ['de'] }, + ]); mockFg.mockResolvedValue(['/test/locales/en.json'] as never); mockReadFile.mockResolvedValue(SOURCE_JSON); await service.sync(makeConfig({ - translation: { - translation_memory: 'my-tm', - locale_overrides: { de: { translation_memory: 'de-tm' } }, - }, + translation: { translation_memory: 'my-tm' }, }), { dryRun: true }); - expect(mockTranslation.listTranslationMemories).not.toHaveBeenCalled(); + expect(mockTranslation.listTranslationMemories).toHaveBeenCalled(); }); it('should throw ConfigError when top-level TM does not support all target locales', async () => { @@ -2432,16 +2437,130 @@ describe('SyncService', () => { }); }); - it('should not resolve glossary during dryRun', async () => { + it('should resolve glossary during dryRun so the preview validates the config', async () => { + // A dry run reports what a real run would do, and already needs an API key + // to get this far, so a glossary the config names but the account cannot + // use is something the preview has to surface. const { service, mockGlossary } = createService(); setupLockManager(makeEmptyLockFile()); mockFg.mockResolvedValue(['/test/locales/en.json']); mockReadFile.mockResolvedValue(SOURCE_JSON); + mockGlossary.resolveGlossaryId.mockResolvedValue('glos-123'); await service.sync(makeConfig({ translation: { glossary: 'my-glossary' }, }), { dryRun: true }); + expect(mockGlossary.resolveGlossaryId).toHaveBeenCalledWith('my-glossary', { + from: 'en', + targets: ['de'], + }); + }); + + it('should surface a top-level glossary that does not cover the pair during dryRun', async () => { + const { service, mockGlossary } = createService(); + setupLockManager(makeEmptyLockFile()); + mockFg.mockResolvedValue(['/test/locales/en.json']); + mockReadFile.mockResolvedValue(SOURCE_JSON); + mockGlossary.resolveGlossaryId.mockRejectedValue( + new ConfigError('Glossary "my-glossary" does not support the requested language pair'), + ); + + await expect( + service.sync(makeConfig({ translation: { glossary: 'my-glossary' } }), { dryRun: true }), + ).rejects.toThrow(/does not support the requested language pair/); + }); + + it('should check a per-locale override glossary against its own locale at startup', async () => { + const { service, mockGlossary } = createService(); + setupLockManager(makeEmptyLockFile()); + mockFg.mockResolvedValue(['/test/locales/en.json']); + mockReadFile.mockResolvedValue(SOURCE_JSON); + mockGlossary.resolveGlossaryId.mockResolvedValue('glos-fr'); + + await service.sync(makeConfig({ + target_locales: ['de', 'fr'], + translation: { + locale_overrides: { fr: { glossary: 'fr-terms' } }, + }, + })); + + expect(mockGlossary.resolveGlossaryId).toHaveBeenCalledWith('fr-terms', { + from: 'en', + targets: ['fr'], + }); + }); + + it('should fail once at startup when an override glossary does not cover its locale', async () => { + // Resolution also runs per file, where the same rejection was reported + // once per file rather than once for the run. + const { service, mockGlossary } = createService(); + setupLockManager(makeEmptyLockFile()); + mockFg.mockResolvedValue(['/test/locales/en.json']); + mockReadFile.mockResolvedValue(SOURCE_JSON); + mockGlossary.resolveGlossaryId.mockRejectedValue( + new ConfigError('Glossary "fr-terms" does not support the requested language pair'), + ); + + await expect(service.sync(makeConfig({ + target_locales: ['de', 'fr'], + translation: { + locale_overrides: { fr: { glossary: 'fr-terms' } }, + }, + }))).rejects.toThrow(/does not support the requested language pair/); + + expect(mockGlossary.resolveGlossaryId).toHaveBeenCalledTimes(1); + }); + + it('should check override glossaries during dryRun too', async () => { + const { service, mockGlossary } = createService(); + setupLockManager(makeEmptyLockFile()); + mockFg.mockResolvedValue(['/test/locales/en.json']); + mockReadFile.mockResolvedValue(SOURCE_JSON); + mockGlossary.resolveGlossaryId.mockResolvedValue('glos-fr'); + + await service.sync(makeConfig({ + target_locales: ['de', 'fr'], + translation: { + locale_overrides: { fr: { glossary: 'fr-terms' } }, + }, + }), { dryRun: true }); + + expect(mockGlossary.resolveGlossaryId).toHaveBeenCalledWith('fr-terms', { + from: 'en', + targets: ['fr'], + }); + }); + + it('should not resolve an "auto" override glossary, which is managed rather than named', async () => { + const { service, mockGlossary } = createService(); + setupLockManager(makeEmptyLockFile()); + mockFg.mockResolvedValue(['/test/locales/en.json']); + mockReadFile.mockResolvedValue(SOURCE_JSON); + + await service.sync(makeConfig({ + target_locales: ['de'], + translation: { + locale_overrides: { de: { glossary: 'auto' } }, + }, + })); + + expect(mockGlossary.resolveGlossaryId).not.toHaveBeenCalled(); + }); + + it('should skip an override for a locale the filter excludes', async () => { + const { service, mockGlossary } = createService(); + setupLockManager(makeEmptyLockFile()); + mockFg.mockResolvedValue(['/test/locales/en.json']); + mockReadFile.mockResolvedValue(SOURCE_JSON); + + await service.sync(makeConfig({ + target_locales: ['de', 'fr'], + translation: { + locale_overrides: { fr: { glossary: 'fr-terms' } }, + }, + }), { localeFilter: ['de'] }); + expect(mockGlossary.resolveGlossaryId).not.toHaveBeenCalled(); }); }); From 0fb522a0244720e0663af0288ff659650c94102b Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Tue, 4 Aug 2026 09:23:18 -0400 Subject: [PATCH 059/256] test(languages): pin the supportsFormality JSON contract The field moved from v2's supports_formality boolean to the v3 features matrix, and is now omitted for a target whose features the response does not describe rather than reported as false. /v3/languages carries a matrix for all 125 languages it lists, so the serialized shape is the same one 1.2.0 produced -- present and boolean for every target, absent for every source. Nothing was asserting that, so nothing would have caught it changing. Also states the omission case in the CHANGELOG next to the "existing JSON consumers are unaffected" claim, which spoke only for the features matrix. Closes cli-01on.5 --- CHANGELOG.md | 2 +- tests/e2e/cli-success-paths.e2e.test.ts | 34 ++++++++++++++++++ tests/unit/deepl-client.test.ts | 47 +++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dec0e21d..ef43e990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **translate**: `--glossary` is repeatable, applying up to 5 glossaries to one request via the API's `glossary_ids` parameter. Entries are merged, so terms unique to each glossary all apply; when several glossaries define the same source term the **last** `--glossary` on the command line wins, so the order is significant and is never sorted — reordering the flags is a different request with its own cache entry. Names and UUIDs may be mixed and are resolved independently. A single `--glossary` still goes out as `glossary_id`, leaving existing commands and their cache keys untouched; the new field is appended last in the cache key for the same reason. A 6th `--glossary` exits 6 (ValidationError) before any API call. `watch` and `sync` keep their single-glossary configuration. Verified against the live API: `POST /v2/translate` accepts `glossary_ids` as repeated form fields and resolves them in order, rejects a sixth with `A maximum of 5 glossaries can be specified per request.`, and rejects `glossary_id` and `glossary_ids` together with `Specify either glossary_id or glossary_ids, not both.` -- which is why the CLI collapses a single glossary to `glossary_id` rather than sending both. The multipart `POST /v2/document` takes one comma-joined value, since multipart does not parse repeated fields as a list; that endpoint answers any unresolvable glossary with `glossary_ids is not valid` regardless of encoding, so it was confirmed only to the extent that it recognises the parameter, and the list semantics there rest on the API documentation rather than on a live round trip. -- **languages**: `deepl languages --features` shows which features each language supports — formality, glossary, style rules, translation memory, tag handling and auto-detection — from the `features` matrix on `GET /v3/languages`, which the CLI previously discarded. Support no longer has to be discovered by making a request and reading the error. Which features get a column is derived from the response rather than a fixed list: a feature appears when its support differs across the languages listed, and one supported by all of them is reported once as `All listed languages also support: ...` instead of being repeated on every row (`All languages with reported features also support: ...` when the listing also carries languages the response did not describe, since the note must not speak for those). A language the response omitted reads as `no feature data` rather than as supporting nothing, and does not count towards whether a feature varies. That makes the columns differ between listings — `auto detection` appears under `--target`, where target-only variants lack it, but is uniform under `--source` — and means a newly reported feature shows up without a code change. Support is signalled by the API reporting a feature at all; `status` describes maturity, so anything short of generally available renders verbatim (`glossary (beta)`) rather than collapsing to `yes`. Works with `--format table` (one column per feature) and `--format json` (the raw matrix including each status, present only when `--features` is passed, so existing JSON consumers are unaffected). `--features` supersedes the `[F]` shorthand and replaces it when given. It needs an API key; without one the command warns and falls back to the registry, which carries no feature data. Note that the matrix is finer-grained than the core/regional/extended tiers: some extended languages support style rules and translation memory even though they support neither formality nor glossary. +- **languages**: `deepl languages --features` shows which features each language supports — formality, glossary, style rules, translation memory, tag handling and auto-detection — from the `features` matrix on `GET /v3/languages`, which the CLI previously discarded. Support no longer has to be discovered by making a request and reading the error. Which features get a column is derived from the response rather than a fixed list: a feature appears when its support differs across the languages listed, and one supported by all of them is reported once as `All listed languages also support: ...` instead of being repeated on every row (`All languages with reported features also support: ...` when the listing also carries languages the response did not describe, since the note must not speak for those). A language the response omitted reads as `no feature data` rather than as supporting nothing, and does not count towards whether a feature varies. That makes the columns differ between listings — `auto detection` appears under `--target`, where target-only variants lack it, but is uniform under `--source` — and means a newly reported feature shows up without a code change. Support is signalled by the API reporting a feature at all; `status` describes maturity, so anything short of generally available renders verbatim (`glossary (beta)`) rather than collapsing to `yes`. Works with `--format table` (one column per feature) and `--format json` (the raw matrix including each status, present only when `--features` is passed, so existing JSON consumers are unaffected). The `supportsFormality` field is unchanged for every language `GET /v3/languages` describes, which is all of them today; it now comes from the matrix rather than v2's `supports_formality` boolean, and is **omitted rather than reported as `false`** for a target the response says nothing about, since silence is not evidence that formality is absent. `--features` supersedes the `[F]` shorthand and replaces it when given. It needs an API key; without one the command warns and falls back to the registry, which carries no feature data. Note that the matrix is finer-grained than the core/regional/extended tiers: some extended languages support style rules and translation memory even though they support neither formality nor glossary. - **cli**: `deepl correct` command (alias `c`) — spelling and grammar correction without rewording, via the Write API's `/v2/write/correct` endpoint. Supports the same input handling and workflow flags as `write` (`--check` with exit code 8, `--fix`/`--backup`, `--diff`, `--interactive`, `--output`/`--in-place`, `--format json`, `--no-cache`), but not `--style`/`--tone`, which the correct endpoint does not accept. Results are cached under a separate `correct:` namespace so corrections and rephrasings of the same text never collide. diff --git a/tests/e2e/cli-success-paths.e2e.test.ts b/tests/e2e/cli-success-paths.e2e.test.ts index d1d2d818..5245aafb 100644 --- a/tests/e2e/cli-success-paths.e2e.test.ts +++ b/tests/e2e/cli-success-paths.e2e.test.ts @@ -271,5 +271,39 @@ describe('CLI Success Paths E2E', () => { const output = runCLIAll('languages'); expect(output).toContain('Source Languages:'); }); + + it('should report supportsFormality on every target in --format json', () => { + // The documented JSON shape for targets. The features matrix rides along on + // the same objects and is stripped without --features, so the two must not + // be confused: this field stays. + const output = runCLI('languages --target --format json'); + const parsed = JSON.parse(output.trim()) as Array>; + + expect(parsed.length).toBeGreaterThan(0); + for (const entry of parsed) { + expect(entry).toHaveProperty('supportsFormality'); + expect(typeof entry['supportsFormality']).toBe('boolean'); + expect(entry).not.toHaveProperty('features'); + } + expect(parsed.find(e => e['language'] === 'de')?.['supportsFormality']).toBe(true); + expect(parsed.find(e => e['language'] === 'en')?.['supportsFormality']).toBe(false); + }); + + it('should omit supportsFormality from source languages in --format json', () => { + const output = runCLI('languages --source --format json'); + const parsed = JSON.parse(output.trim()) as Array>; + + expect(parsed.length).toBeGreaterThan(0); + for (const entry of parsed) { + expect(entry).not.toHaveProperty('supportsFormality'); + } + }); + + it('should include the features matrix only with --features', () => { + const output = runCLI('languages --target --features --format json'); + const parsed = JSON.parse(output.trim()) as Array>; + + expect(parsed.find(e => e['language'] === 'de')).toHaveProperty('features'); + }); }); }); diff --git a/tests/unit/deepl-client.test.ts b/tests/unit/deepl-client.test.ts index 4d28befa..293a1d2c 100644 --- a/tests/unit/deepl-client.test.ts +++ b/tests/unit/deepl-client.test.ts @@ -857,6 +857,53 @@ describe('DeepLClient', () => { expect(languages.find(l => l.language === 'ko')?.supportsFormality).toBe(false); }); + it('should report formality for every target the response describes', async () => { + // The field is part of the JSON contract for targets: /v3/languages carries + // a features matrix for every language it lists, so every target answers. + nock(baseUrl) + .get('/v3/languages') + .query({ resource: 'translate_text' }) + .reply(200, [ + { lang: 'de', name: 'German', usable_as_target: true, features: { formality: { status: 'stable' } } }, + { lang: 'sw', name: 'Swahili', usable_as_target: true, features: { tag_handling: { status: 'stable' } } }, + ]); + + const languages = await client.getSupportedLanguages('target'); + + expect(languages).toHaveLength(2); + for (const language of languages) { + expect(language).toHaveProperty('supportsFormality'); + } + }); + + it('should omit formality rather than deny it for a target with no features', async () => { + // Silence about a language is not evidence that formality is absent, and + // `false` would turn on the [F] legend with nothing beneath it. + nock(baseUrl) + .get('/v3/languages') + .query({ resource: 'translate_text' }) + .reply(200, [ + { lang: 'de', name: 'German', usable_as_target: true }, + ]); + + const languages = await client.getSupportedLanguages('target'); + + expect(languages[0]).not.toHaveProperty('supportsFormality'); + }); + + it('should never report formality for source languages', async () => { + nock(baseUrl) + .get('/v3/languages') + .query({ resource: 'translate_text' }) + .reply(200, [ + { lang: 'de', name: 'German', usable_as_source: true, features: { formality: { status: 'stable' } } }, + ]); + + const languages = await client.getSupportedLanguages('source'); + + expect(languages[0]).not.toHaveProperty('supportsFormality'); + }); + it('should handle language API errors', async () => { nock(baseUrl) .get('/v3/languages') From 8e209fe8185e124f2252eba8e6d513aed41ad4fa Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Tue, 4 Aug 2026 09:30:11 -0400 Subject: [PATCH 060/256] docs: correct example output, the language endpoint, and the glossary rule Display output went lowercase in d15bc89 but several examples still showed `[ES]` and `| ES |` table cells, so copied output did not match reality. Two file-translation examples were wrong beyond casing: a single-target run prints `Translated x -> y`, not a `to 1 language(s):` list. Verified each against a real run. TROUBLESHOOTING's reachability check called /v2/languages, which this release migrates off; it now uses /v3/languages?resource=translate_text and names both hosts, since the free host answers a Pro key with 403 -- a reachable server, which is what the check is asking about. --glossary was documented as requiring --from in four places, which stopped being true when the requirement started accepting defaults.sourceLang, and the translate flag reference omitted it entirely. All five now state the implemented rule. Adds two mechanical guards to the docs suite, which previously checked only that documented commands and flags exist: no documented line may show an uppercase language code (matched against the registry, so markdown badges are left alone), and no doc may reference a retired v2 language endpoint. Closes cli-01on.9 --- README.md | 38 ++++++++++---------- docs/API.md | 18 +++++----- docs/TROUBLESHOOTING.md | 6 +++- tests/unit/docs/documented-surface.test.ts | 41 ++++++++++++++++++++++ 4 files changed, 73 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 1e1b61ea..fdd7f4a1 100644 --- a/README.md +++ b/README.md @@ -283,9 +283,9 @@ deepl translate "Bonjour" --from fr --to en # Multiple target languages (each result is prefixed with its language) deepl translate "Good morning" --to es,fr,de -# [ES] Buenos días -# [FR] Bonjour -# [DE] Guten Morgen +# [es] Buenos días +# [fr] Bonjour +# [de] Guten Morgen # Read from stdin echo "Hello world" | deepl translate --to es @@ -325,15 +325,14 @@ Small text-based files (under 100 KiB) automatically use the cached text transla ```bash # Single file translation (uses cache for small text files) deepl translate README.md --to es --output README.es.md -# Translated README.md to 1 language(s): -# [ES] README.es.md +# Translated README.md -> README.es.md # Multiple target languages (creates README.es.md, README.fr.md, etc.) deepl translate docs.md --to es,fr,de --output ./translated/ -# Translated docs.md to 3 language(s): -# [ES] ./translated/docs.es.md -# [FR] ./translated/docs.fr.md -# [DE] ./translated/docs.de.md +# Translated docs.md to 3 languages: +# [es] ./translated/docs.es.md +# [fr] ./translated/docs.fr.md +# [de] ./translated/docs.de.md # With code preservation (preserves code blocks in markdown) deepl translate tutorial.md --to ja --output tutorial.ja.md --preserve-code @@ -341,8 +340,7 @@ deepl translate tutorial.md --to ja --output tutorial.ja.md --preserve-code # Large text file (over 100 KiB) - automatic fallback with warning deepl translate large-document.txt --to es --output large-document.es.txt # ⚠ File exceeds 100 KiB limit for cached translation (150.5 KiB), using document API instead -# Translated large-document.txt to 1 language(s): -# [ES] large-document.es.txt +# Translated large-document.txt -> large-document.es.txt ``` #### Document Translation @@ -377,7 +375,7 @@ deepl translate contract.pdf --to de --formality more --output contract.de.pdf # Specify source language deepl translate document.pdf --from en --to es --output document.es.pdf -# Apply a glossary (--from is required whenever a glossary is used) +# Apply a glossary (needs a source language: --from, or defaults.sourceLang) deepl translate report.docx --from en --to de --glossary tech-terms --output report.de.docx # Repeat --glossary for up to 5; the last one wins a conflicting term @@ -400,7 +398,7 @@ deepl translate document.pdf --to es --output-format docx --output document.es.d - ✅ **Progress Tracking** - Real-time status updates during translation - ✅ **Large Files** - Handles documents up to 30MB - ✅ **Cost Tracking** - Shows billed characters after translation -- ✅ **Glossaries** - `--glossary` applies to documents too, repeatable up to 5 (requires `--from`). Translation memories are not supported for documents. +- ✅ **Glossaries** - `--glossary` applies to documents too, repeatable up to 5 (needs a source language: `--from`, or `defaults.sourceLang`). Translation memories are not supported for documents. - ✅ **Async Processing** - Documents are translated on DeepL servers with polling **Supported Formats:** @@ -523,9 +521,9 @@ deepl translate "Hello, world!" --to es,fr,de --format table # ┌──────────┬──────────────────────────────────────────────────────────────────────┐ # │ Language │ Translation │ # ├──────────┼──────────────────────────────────────────────────────────────────────┤ -# │ ES │ ¡Hola, mundo! │ -# │ FR │ Bonjour le monde! │ -# │ DE │ Hallo, Welt! │ +# │ es │ ¡Hola, mundo! │ +# │ fr │ Bonjour le monde! │ +# │ de │ Hallo, Welt! │ # └──────────┴──────────────────────────────────────────────────────────────────────┘ # Table format with cost tracking (adds Characters column) @@ -533,9 +531,9 @@ deepl translate "Cost analysis" --to es,fr,de --format table --show-billed-chara # ┌──────────┬────────────────────────────────────────────────────────────┬────────────┐ # │ Language │ Translation │ Characters │ # ├──────────┼────────────────────────────────────────────────────────────┼────────────┤ -# │ ES │ Análisis de costes │ 14 │ -# │ FR │ Analyse des coûts │ 14 │ -# │ DE │ Kostenanalyse │ 14 │ +# │ es │ Análisis de costes │ 14 │ +# │ fr │ Analyse des coûts │ 14 │ +# │ de │ Kostenanalyse │ 14 │ # └──────────┴────────────────────────────────────────────────────────────┴────────────┘ # Preview what would be translated without making API calls (file/directory mode) @@ -1307,7 +1305,7 @@ authentication Authentifizierung - **Direct updates** - v3 API uses PATCH endpoints for efficient updates (no delete+recreate) - **Smart defaults** - `--target` flag only required for multilingual glossaries - **Visual indicators** - 📖 for single-target, 📚 for multilingual glossaries -- **Translation integration** - Use `--glossary` flag in translate and watch commands to apply glossary terms (`--from` is required alongside it; the API rejects a glossary without a source language) +- **Translation integration** - Use `--glossary` flag in translate and watch commands to apply glossary terms (a source language is required, since the API rejects a glossary without one: pass `--from`, or set `defaults.sourceLang`) - **Several glossaries at once** - Repeat `--glossary` on `translate` for up to 5 glossaries; entries are merged and the last glossary given wins any conflicting term ```bash diff --git a/docs/API.md b/docs/API.md index 057434fb..689a2e41 100644 --- a/docs/API.md +++ b/docs/API.md @@ -251,7 +251,7 @@ Translate text directly, from stdin, from files, or entire directories. Supports - `--non-splitting-tags TAGS` - Comma-separated XML tags that should not be used to split sentences (requires `--tag-handling xml`) - `--ignore-tags TAGS` - Comma-separated XML tags with content to ignore (requires `--tag-handling xml`) - `--tag-handling-version VERSION` - Tag handling version: `v1`, `v2`. v2 improves XML/HTML structure handling (requires `--tag-handling`). **Defaults to `v2`**, sent explicitly on every `--tag-handling` request rather than left to the API's own default, which is documented as moving from v1 to v2 at some point — pinning keeps output from shifting on DeepL's timetable. Pass `--tag-handling-version v1` for the older behaviour, which DeepL documents as heading for deprecation -- `--glossary NAME-OR-ID` - Use glossary by name or ID for consistent terminology. Repeatable, up to 5 per request; when several glossaries define the same source term, the last one given wins. Passing a 6th exits 6 (ValidationError). +- `--glossary NAME-OR-ID` - Use glossary by name or ID for consistent terminology. Repeatable, up to 5 per request; when several glossaries define the same source term, the last one given wins. Passing a 6th exits 6 (ValidationError). A source language is required, because the API rejects a glossary without one: supply `--from`, or set `defaults.sourceLang` and it is used automatically. With neither, the command exits 6 before any request. - `--translation-memory NAME-OR-UUID` - Use translation memory by name or UUID (forces `quality_optimized` model). Requires `--from` because TMs are pinned to a specific source→target language pair. Invalid use exits 6 (ValidationError); unresolvable/misconfigured TM exits 7 (ConfigError). - `--tm-threshold N` - Minimum match score 0–100 (default 75, requires `--translation-memory`). Invalid use exits 6 (ValidationError); unresolvable/misconfigured TM exits 7 (ConfigError). - `--custom-instruction INSTRUCTION` - Custom instruction for translation (repeatable, max 10, max 300 chars each). Forces `quality_optimized` model. Cannot be used with `latency_optimized`. @@ -447,7 +447,7 @@ deepl translate report.docx --from en --to de --output report.de.docx \ - Large documents may take several seconds to translate - Maximum file sizes: 30MB (document API, all formats), 100 KiB (cached text API) - **Document minification** (`--enable-minification`): Reduces file size for PPTX and DOCX files only. Useful for large presentations and documents. -- **Glossaries**: `--glossary` applies to documents and is repeatable up to 5, with the same last-one-wins precedence as text translation. `--from` is required — the API rejects a document glossary without a source language ("source_lang has to be specified in order to use a glossary"). Glossary matching is context-dependent exactly as it is for text: a term may be applied in one sentence and left alone in another, and a bare newline-separated word list often gets few terms applied. `--translation-memory` remains unsupported for documents. +- **Glossaries**: `--glossary` applies to documents and is repeatable up to 5, with the same last-one-wins precedence as text translation. A source language is required — the API rejects a document glossary without one ("source_lang has to be specified in order to use a glossary") — so pass `--from`, or set `defaults.sourceLang` and it is used automatically. Glossary matching is context-dependent exactly as it is for text: a term may be applied in one sentence and left alone in another, and a bare newline-separated word list often gets few terms applied. `--translation-memory` remains unsupported for documents. **Directory translation:** @@ -665,9 +665,9 @@ deepl translate "Hello, world!" --to es,fr,de --format table # ┌──────────┬──────────────────────────────────────────────────────────────────────┐ # │ Language │ Translation │ # ├──────────┼──────────────────────────────────────────────────────────────────────┤ -# │ ES │ ¡Hola mundo! │ -# │ FR │ Bonjour le monde! │ -# │ DE │ Hallo Welt! │ +# │ es │ ¡Hola mundo! │ +# │ fr │ Bonjour le monde! │ +# │ de │ Hallo Welt! │ # └──────────┴──────────────────────────────────────────────────────────────────────┘ # Add --show-billed-characters to display the Characters column @@ -675,9 +675,9 @@ deepl translate "Cost tracking" --to es,fr,de --format table --show-billed-chara # ┌──────────┬────────────────────────────────────────────────────────────────┬────────────┐ # │ Language │ Translation │ Characters │ # ├──────────┼────────────────────────────────────────────────────────────────┼────────────┤ -# │ ES │ Seguimiento de costes │ 16 │ -# │ FR │ Suivi des coûts │ 16 │ -# │ DE │ Kostenverfolgung │ 16 │ +# │ es │ Seguimiento de costes │ 16 │ +# │ fr │ Suivi des coûts │ 16 │ +# │ de │ Kostenverfolgung │ 16 │ # └──────────┴────────────────────────────────────────────────────────────────┴────────────┘ # Long translations automatically wrap in the Translation column @@ -1122,7 +1122,7 @@ Monitor files or directories for changes and automatically translate them. Suppo - `--formality LEVEL` - Formality level: `default`, `more`, `less`, `prefer_more`, `prefer_less`, `formal`, `informal` - `--preserve-code` - Preserve code blocks - `--preserve-formatting` - Preserve line breaks and whitespace formatting -- `--glossary NAME-OR-ID` - Use glossary by name or ID for consistent terminology. `--from` is required, because the API refuses a glossary without a source language ("Use of a glossary requires the source_lang parameter to be specified"); omitting it exits 6 before the watcher starts rather than failing on every file change. Unlike `translate`, `watch` takes a single glossary. +- `--glossary NAME-OR-ID` - Use glossary by name or ID for consistent terminology. A source language is required, because the API refuses a glossary without one ("Use of a glossary requires the source_lang parameter to be specified"): pass `--from`, or set `defaults.sourceLang` and it is used automatically. With neither, the command exits 6 before the watcher starts rather than failing on every file change. Unlike `translate`, `watch` takes a single glossary. **Git Integration:** diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index d6146efa..a639e965 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -156,7 +156,11 @@ Common issues and solutions when using the DeepL CLI. 2. Verify DeepL API is reachable: ```bash - curl -s https://api-free.deepl.com/v2/languages -H "Authorization: DeepL-Auth-Key YOUR_KEY" + # Free keys (those ending in :fx) + curl -s "https://api-free.deepl.com/v3/languages?resource=translate_text" -H "Authorization: DeepL-Auth-Key YOUR_KEY" + + # Pro keys — the free host answers a Pro key with 403, which is a reachable server + curl -s "https://api.deepl.com/v3/languages?resource=translate_text" -H "Authorization: DeepL-Auth-Key YOUR_KEY" ``` 3. If behind a corporate proxy, configure it via environment variables: diff --git a/tests/unit/docs/documented-surface.test.ts b/tests/unit/docs/documented-surface.test.ts index 0872d9a1..b1be8c93 100644 --- a/tests/unit/docs/documented-surface.test.ts +++ b/tests/unit/docs/documented-surface.test.ts @@ -7,6 +7,7 @@ import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; +import { getAllLanguageCodes } from '../../../src/data/language-registry'; interface DescribedCommand { name: string; @@ -22,6 +23,8 @@ const DOCS = ['README.md', 'docs/API.md', 'docs/SYNC.md', 'docs/TROUBLESHOOTING. /** Deliberate misspellings used to demonstrate did-you-mean suggestions. */ const TYPO_EXAMPLES = new Set(['transalte', 'translte', 'glossry', 'conifg', 'descibe']); +const LANGUAGE_CODES = getAllLanguageCodes(); + function longFlags(options: { flags: string }[]): string[] { return options.flatMap((option) => option.flags.match(/--[a-z0-9-]+/g) ?? []); } @@ -119,5 +122,43 @@ describe('documented CLI surface', () => { expect(unknown).toEqual([]); }); + + it('shows language codes in the lowercase the CLI prints', () => { + // Every code the CLI displays is lowercase. Documented output that shows + // `[ES]` or a `│ ES │` table cell does not match what a reader will see, + // and copying it into a script that compares codes gives a wrong answer. + // Matched against the registry so markdown badges like `[![CI](...)]` and + // labels that are not languages are left alone. + const lines = fs.readFileSync(path.join(ROOT, docPath), 'utf-8').split('\n'); + const offenders: string[] = []; + + for (const line of lines) { + const candidates = [ + ...(line.match(/\[([A-Z]{2,3}(?:-[A-Z0-9]{2,4})?)\]/g) ?? []), + ...(line.match(/│\s*([A-Z]{2,3}(?:-[A-Z0-9]{2,4})?)\s*│/g) ?? []), + ]; + for (const candidate of candidates) { + const code = candidate.replace(/[[\]│\s]/g, '').toLowerCase(); + if (LANGUAGE_CODES.has(code)) { + offenders.push(`${candidate.trim()} in ${line.trim()}`); + } + } + } + + expect(offenders).toEqual([]); + }); + }); + + describe('retired endpoints', () => { + // Language listings moved to GET /v3/languages; the v2 endpoints are + // formally deprecated, so no doc should teach a reader to call them. + const RETIRED = ['/v2/languages', '/v2/glossary-language-pairs']; + + it.each(DOCS)('%s references no retired language endpoint', (docPath) => { + const contents = fs.readFileSync(path.join(ROOT, docPath), 'utf-8'); + const found = RETIRED.filter((endpoint) => contents.includes(endpoint)); + + expect(found).toEqual([]); + }); }); }); From fa7b4eb542a081a270985782eeff099a26f267f4 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Tue, 4 Aug 2026 16:41:18 -0400 Subject: [PATCH 061/256] fix(watch): settle the glossary source language in the handler, not the registrar register-watch filled options.from from defaults.sourceLang before calling watch(), while the handler kept a flag-only guard -- so a library consumer calling WatchCommand.watch() directly with a glossary and a configured source language got a rejection the CLI does not make. The handler now calls the shared applyGlossarySourceLang, as every other command does, and WatchCommand takes the config service to read the default from. The parameter is optional, so a caller with no configuration still gets the requirement, just without a default to satisfy it from. Closes cli-01on.6 --- src/cli/commands/service-factory.ts | 2 +- src/cli/commands/watch.ts | 27 ++++++++--- tests/unit/register-commands-group1.test.ts | 8 +++- tests/unit/watch-command.test.ts | 50 +++++++++++++++++++++ 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/src/cli/commands/service-factory.ts b/src/cli/commands/service-factory.ts index 31210c1e..67df690c 100644 --- a/src/cli/commands/service-factory.ts +++ b/src/cli/commands/service-factory.ts @@ -111,7 +111,7 @@ export async function createWatchCommand( const { WatchCommand: WatchCmd } = await import('./watch.js'); const translationService = new TranslationService(client, deps.getConfigService(), await deps.getCacheService()); const glossaryService = new GlossaryService(client); - return new WatchCmd(translationService, glossaryService); + return new WatchCmd(translationService, glossaryService, deps.getConfigService()); } export async function createDetectCommand( diff --git a/src/cli/commands/watch.ts b/src/cli/commands/watch.ts index e3c03cfa..759bdb4d 100644 --- a/src/cli/commands/watch.ts +++ b/src/cli/commands/watch.ts @@ -14,6 +14,8 @@ import { Language, Formality } from '../../types/index.js'; import { FileTranslationResult, WatchTranslationResult } from '../../services/watch.js'; import { Logger } from '../../utils/logger.js'; import { ValidationError } from '../../utils/errors.js'; +import { applyGlossarySourceLang, hasGlossarySelection } from '../../utils/glossary-params.js'; +import type { ConfigService } from '../../storage/config.js'; interface WatchOptions { to: string; @@ -33,11 +35,22 @@ interface WatchOptions { export class WatchCommand { private fileTranslationService: FileTranslationService; private glossaryService: GlossaryService; + private config?: ConfigService; private watchService?: WatchService; - constructor(translationService: TranslationService, glossaryService: GlossaryService) { + /** + * `config` supplies `defaults.sourceLang` for the glossary requirement below. + * Optional so a caller that has no configuration still gets the requirement, + * just without a default to satisfy it from. + */ + constructor( + translationService: TranslationService, + glossaryService: GlossaryService, + config?: ConfigService, + ) { this.fileTranslationService = new FileTranslationService(translationService); this.glossaryService = glossaryService; + this.config = config; } private async resolveGlossaryId( @@ -89,10 +102,14 @@ export class WatchCommand { // The API rejects any translation naming a glossary without source_lang, so // an unguarded watch session fails once per file change instead of at launch. - if (options.glossary && !options.from) { - throw new ValidationError( - 'Source language (--from) is required when using a glossary', - 'Example: deepl watch ./docs --from en --to es --glossary my-glossary' + // Settled from `defaults.sourceLang` when the flag is absent, the same way + // every other command does it, so a direct call and the CLI agree on what is + // runnable. + if (hasGlossarySelection(options)) { + applyGlossarySourceLang( + options, + this.config?.getValue('defaults.sourceLang'), + 'Example: deepl watch ./docs --from en --to es --glossary my-glossary', ); } diff --git a/tests/unit/register-commands-group1.test.ts b/tests/unit/register-commands-group1.test.ts index 7c756d83..b090bd6c 100644 --- a/tests/unit/register-commands-group1.test.ts +++ b/tests/unit/register-commands-group1.test.ts @@ -372,7 +372,13 @@ describe('service-factory', () => { const { GlossaryService } = require('../../src/services/glossary'); expect(GlossaryService).toHaveBeenCalledWith(mockClient); const { WatchCommand } = require('../../src/cli/commands/watch'); - expect(WatchCommand).toHaveBeenCalledWith(mockTranslationServiceObj, mockGlossaryServiceObj); + // The config service too: WatchCommand settles the glossary source language + // from defaults.sourceLang, so a CLI watch and a direct call agree. + expect(WatchCommand).toHaveBeenCalledWith( + mockTranslationServiceObj, + mockGlossaryServiceObj, + mockConfigService, + ); expect(cmd).toBe(mockWatchCmdObj); }); diff --git a/tests/unit/watch-command.test.ts b/tests/unit/watch-command.test.ts index c68068a5..7e354679 100644 --- a/tests/unit/watch-command.test.ts +++ b/tests/unit/watch-command.test.ts @@ -14,6 +14,7 @@ import { createMockGlossaryService, createMockFileTranslationService, createMockWatchService, + createMockConfigService, } from '../helpers/mock-factories'; // Mock chalk @@ -448,6 +449,55 @@ describe('WatchCommand', () => { expect(mockGlossaryService.resolveGlossaryId).not.toHaveBeenCalled(); }); + it('should accept a configured defaults.sourceLang in place of --from', async () => { + // The requirement is for a source language, not for the flag. A direct + // WatchCommand call has to read the configured default the same way the + // CLI path does, or the two disagree about what is runnable. + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); + mockGlossaryService.resolveGlossaryId.mockResolvedValue('glossary-123'); + // Thrown so the watcher does not keep the test open, as elsewhere here. + mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); + + const configured = new WatchCommand( + mockTranslationService, + mockGlossaryService, + createMockConfigService({ + getValue: jest.fn((key: string) => + key === 'defaults.sourceLang' ? 'en' : undefined, + ), + }), + ); + + await expect( + configured.watch('/some/file.md', { to: 'es', glossary: 'my-glossary' }), + ).rejects.toThrow('Test complete'); + + expect(mockGlossaryService.resolveGlossaryId).toHaveBeenCalledWith('my-glossary', { + from: 'en', + targets: ['es'], + }); + expect(mockWatchService.watch).toHaveBeenCalledWith( + '/some/file.md', + expect.objectContaining({ glossaryId: 'glossary-123', sourceLang: 'en' }), + ); + }); + + it('should still reject when the configured default is absent too', async () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); + + const configured = new WatchCommand( + mockTranslationService, + mockGlossaryService, + createMockConfigService({ getValue: jest.fn(() => undefined) }), + ); + + await expect( + configured.watch('/some/file.md', { to: 'es', glossary: 'my-glossary' }), + ).rejects.toThrow('Source language (--from) is required when using a glossary'); + }); + it('should accept --glossary when --from is provided', async () => { expect.assertions(1); (fs.existsSync as jest.Mock).mockReturnValue(true); From e66f18ad65e79280a4573873ad175d09b7ef6b07 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Tue, 4 Aug 2026 16:41:58 -0400 Subject: [PATCH 062/256] fix(languages,voice): sanitize API-provided strings before printing them languages printed the name, feature keys and feature statuses from /v3/languages raw, and voice did the same with transcript text and language labels, while the glossary and style-rule listings hardened in this release already wrapped API strings in sanitizeForTerminal. Logger's own sanitizer only redacts secrets; it does not strip ANSI or bidi. It matters most for voice, whose live display clears a fixed number of lines: a newline inside a transcript deranges the whole rendering rather than one line of it. --format json keeps the text verbatim, since JSON.stringify escapes control characters and that path feeds machines. Test strings spell the hostile characters as JS unicode escapes rather than embedding literal control bytes, which do not survive editing reliably. Closes cli-01on.8 --- CHANGELOG.md | 4 ++ src/cli/commands/languages.ts | 26 +++++++------ src/cli/commands/voice.ts | 30 ++++++++++++--- tests/unit/languages-command.test.ts | 57 ++++++++++++++++++++++++++++ tests/unit/voice-command.test.ts | 39 +++++++++++++++++++ 5 files changed, 139 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef43e990..7ffeea5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **scripts**: **The language generator wrote unescaped API response fields into TypeScript.** `lang` was interpolated into a single-quoted literal with no escaping and `name` escaped only quotes, so a response field containing `' }] as const;` — or merely ending in a backslash — could close the literal and append arbitrary code to `src/data/language-entries.ts`, which the next `npm run build` compiles and the test suite imports. The existing guards checked the shape of the response, never the content of a field. Codes are now validated against the language-tag pattern, display names against a conservative character set, categories against the three tiers, and every value is quoted with escaping; validation runs before grouping, which would otherwise drop an entry with an unrecognized category before it was checked. The generator's main guard also resolves `argv[1]` through `realpathSync`, because Node reports the ESM entry by its real path — under a symlinked checkout both npm scripts exited 0 without doing anything, including the release step that keeps the Write list current. +- **languages**, **voice**: **Strings the API supplies are sanitized before they reach the terminal.** `deepl languages` printed the name, feature keys and feature statuses from `GET /v3/languages` verbatim, and `voice` did the same with transcript text and language labels, so a hostile or intercepted endpoint could move the cursor, clear the screen, or hide text behind a bidi override. Both now go through the same `sanitizeForTerminal` the glossary and style-rule listings already used, replacing control and zero-width characters with `?`. It matters most for `voice`, whose live display clears a fixed number of lines: a newline inside a transcript threw off the whole rendering, not just its own line. `voice --format json` keeps the text byte-for-byte, since JSON escaping already makes it inert and that is the path machines read. + +- **watch**: **A direct `WatchCommand.watch()` call rejected `--glossary` without `--from` even when `defaults.sourceLang` was set.** The config-aware resolution every other command uses lived only in watch's CLI registration layer, while the handler kept the older flag-only guard — so the CLI accepted a command a library caller was refused. The handler now settles the source language the same way, from `defaults.sourceLang` when the flag is absent, and `WatchCommand` takes the config service to do it. The requirement itself is unchanged: with neither a flag nor a default, the command still exits 6 before the watcher starts. + - **sync**: **Per-locale glossary overrides skipped the coverage preflight, and `--dry-run` performed no resolution at all.** `locale_overrides..glossary` was resolved without the language pair, so an override that did not cover its own locale reached the API — unlike the top-level glossary and the translation-memory override, which were already checked. Both kinds of override now resolve once at startup against their own locale, rather than being re-resolved for every file, so a bad reference fails before anything is translated instead of after earlier files have been written. `sync --dry-run` resolves and checks the same references a real run does: the command already needs an API key to reach that point, and a glossary or translation memory the config names but the account cannot use for the requested pair is exactly what a preview should report. A dry run still sends nothing to `/v2/translate`. - **voice**: **`--glossary` was resolved before the rest of the command was checked, and without the language pair.** `voice a.ogg --to bogus --glossary my-terms` spent a glossary-list round trip and only then failed locally on the target language; every local check now runs first. Resolution also passes the requested pair, so a glossary whose dictionaries do not cover it fails locally with `Glossary "my-terms" does not support the requested language pair` instead of at the API — the preflight the other commands already had. The pair is the canonical one voice sends, so `--from EN --to zh-hans` is checked as `en→zh-HANS`. Without `--from` there is no pair to check and the API still judges it. diff --git a/src/cli/commands/languages.ts b/src/cli/commands/languages.ts index a283540a..02bd23f2 100644 --- a/src/cli/commands/languages.ts +++ b/src/cli/commands/languages.ts @@ -8,6 +8,7 @@ import { deriveLanguageEntry, } from '../../data/language-registry.js'; import { isColorEnabled } from '../../utils/formatters.js'; +import { sanitizeForTerminal } from '../../utils/control-chars.js'; export interface LanguageDisplayEntry { code: string; @@ -37,13 +38,13 @@ const FEATURE_LABELS: Record = { }; function featureLabel(key: string): string { - return ( - FEATURE_LABELS[key] ?? - key - .split('_') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') - ); + const known = FEATURE_LABELS[key]; + if (known) return known; + // The key is a response field, so it is sanitized before it is displayed. + return sanitizeForTerminal(key) + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); } /** Cell text for a language the response carried no feature data for at all. */ @@ -72,7 +73,10 @@ function featureCell(entry: LanguageDisplayEntry, key: string): string { if (!hasFeatureData(entry)) return UNKNOWN_CELL; const feature = entry.features?.[key]; if (!feature) return '—'; - return !feature.status || feature.status === 'stable' ? 'yes' : feature.status; + if (!feature.status || feature.status === 'stable') return 'yes'; + // `status` is an open enum echoed verbatim, so it is sanitized like any other + // response field before it reaches the terminal. + return sanitizeForTerminal(feature.status); } function sortFeatureKeys(keys: string[]): string[] { @@ -318,7 +322,7 @@ export class LanguagesCommand { coreAndRegional.forEach(entry => { const code = entry.code.padEnd(maxCodeLength + 2); const formalityMarker = showFormality && entry.supportsFormality ? chalk.green(' [F]') : ''; - lines.push(` ${chalk.cyan(code)} ${entry.name}${formalityMarker}${suffix(entry)}`); + lines.push(` ${chalk.cyan(code)} ${sanitizeForTerminal(entry.name)}${formalityMarker}${suffix(entry)}`); }); if (extended.length > 0) { @@ -326,7 +330,7 @@ export class LanguagesCommand { lines.push(chalk.gray(' Extended Languages (quality_optimized only, no formality/glossary):')); extended.forEach(entry => { const code = entry.code.padEnd(maxCodeLength + 2); - lines.push(` ${chalk.gray(code)} ${chalk.gray(entry.name)}${suffix(entry)}`); + lines.push(` ${chalk.gray(code)} ${chalk.gray(sanitizeForTerminal(entry.name))}${suffix(entry)}`); }); } @@ -410,7 +414,7 @@ export class LanguagesCommand { }); for (const entry of entries) { - const row: string[] = [entry.code, entry.name, entry.category]; + const row: string[] = [entry.code, sanitizeForTerminal(entry.name), entry.category]; if (showFormality) { row.push(entry.supportsFormality ? 'yes' : '—'); } diff --git a/src/cli/commands/voice.ts b/src/cli/commands/voice.ts index aff12e7f..f27d32e9 100644 --- a/src/cli/commands/voice.ts +++ b/src/cli/commands/voice.ts @@ -18,6 +18,7 @@ import type { } from '../../types/index.js'; import { ValidationError } from '../../utils/errors.js'; import { Logger } from '../../utils/logger.js'; +import { sanitizeForTerminal } from '../../utils/control-chars.js'; import { VoicePartialResultError } from '../../services/voice-stream-session.js'; const VALID_VOICE_TARGET_LANGS: ReadonlySet = new Set([ @@ -60,6 +61,16 @@ const VALID_VOICE_CONTENT_TYPES: ReadonlySet = new Set): string { + return segments.map((segment) => sanitizeForTerminal(segment.text)).join(' '); +} + /** The canonical language pair a validated `voice` invocation will send. */ export interface VoiceLanguagePair { targetLangs: VoiceTargetLanguage[]; @@ -282,11 +293,11 @@ export class VoiceCommand { }, onSourceTranscript: (update) => { const src = state['source']!; - const concludedText = update.concluded.map((s) => s.text).join(' '); + const concludedText = segmentText(update.concluded); if (concludedText) { src.concluded += (src.concluded ? ' ' : '') + concludedText; } - src.tentative = update.tentative.map((s) => s.text).join(' '); + src.tentative = segmentText(update.tentative); if (src.tentative) { src.tentative = ' ' + src.tentative; } @@ -295,11 +306,11 @@ export class VoiceCommand { onTargetTranscript: (update) => { const tgt = state[update.language.toLowerCase()]; if (!tgt) return; - const concludedText = update.concluded.map((s) => s.text).join(' '); + const concludedText = segmentText(update.concluded); if (concludedText) { tgt.concluded += (tgt.concluded ? ' ' : '') + concludedText; } - tgt.tentative = update.tentative.map((s) => s.text).join(' '); + tgt.tentative = segmentText(update.tentative); if (tgt.tentative) { tgt.tentative = ' ' + tgt.tentative; } @@ -350,6 +361,13 @@ export class VoiceCommand { Logger.error(salvaged); } + /** + * Transcripts and language labels are response fields, so the text format + * sanitizes them: the live display below moves the cursor and clears a fixed + * line count, which an embedded newline or escape sequence would throw off + * beyond the line it sits on. `--format json` keeps them verbatim, since + * JSON escaping makes them inert and that is the path machines read. + */ private formatResult(result: VoiceSessionResult, format?: string): string { if (format === 'json') { return formatVoiceJson(result); @@ -358,11 +376,11 @@ export class VoiceCommand { const lines: string[] = []; if (result.source.text) { - lines.push(`[source] ${result.source.text}`); + lines.push(`[source] ${sanitizeForTerminal(result.source.text)}`); } for (const target of result.targets) { - lines.push(`[${target.lang}] ${target.text}`); + lines.push(`[${sanitizeForTerminal(target.lang)}] ${sanitizeForTerminal(target.text)}`); } return lines.join('\n'); diff --git a/tests/unit/languages-command.test.ts b/tests/unit/languages-command.test.ts index 095b3aba..b1d35521 100644 --- a/tests/unit/languages-command.test.ts +++ b/tests/unit/languages-command.test.ts @@ -259,6 +259,63 @@ describe('LanguagesCommand', () => { }); }); + describe('untrusted API strings', () => { + // Names, feature keys and feature statuses all come straight from + // /v3/languages. A hostile or intercepted endpoint could otherwise move the + // cursor, clear the screen, or hide text with a bidi override. + const HOSTILE_NAME = 'Ger\u001b[2Kman\u200b'; + + it('should strip control characters from a language name in text output', () => { + const formatted = languagesCommand.formatDisplayEntries( + [{ code: 'de', name: HOSTILE_NAME, category: 'core' as const }], + 'target', + ); + + expect(formatted).not.toContain('\u001b'); + expect(formatted).not.toContain('\u200b'); + expect(formatted).toContain('Ger?[2Kman?'); + }); + + it('should strip control characters from an extended-tier language name', () => { + const formatted = languagesCommand.formatDisplayEntries( + [{ code: 'hi', name: HOSTILE_NAME, category: 'extended' as const }], + 'target', + ); + + expect(formatted).not.toContain('\u001b'); + }); + + it('should strip control characters from a language name in table output', () => { + const formatted = languagesCommand.formatLanguagesTable( + [{ language: 'de' as const, name: HOSTILE_NAME }], + 'target', + ); + + // cli-table3 colours its own borders, so the assertion is about the cell: + // the hostile name must not survive, and its sanitized form must appear. + expect(formatted).not.toContain(HOSTILE_NAME); + expect(formatted).toContain('Ger?[2Kman?'); + }); + + it('should strip control characters from a feature key and status', () => { + const formatted = languagesCommand.formatDisplayEntries( + [ + { + code: 'de', + name: 'German', + category: 'core' as const, + features: { 'glo\u001b[2Kssary': { status: 'be\u001b[2Kta' } }, + }, + { code: 'fr', name: 'French', category: 'core' as const, features: {} }, + ], + 'target', + true, + ); + + expect(formatted).not.toContain('\u001b'); + }); + }); + describe('formatAllLanguages()', () => { it('should format both source and target languages', () => { const formatted = languagesCommand.formatAllLanguages( diff --git a/tests/unit/voice-command.test.ts b/tests/unit/voice-command.test.ts index fa7d5ba7..d56b6d72 100644 --- a/tests/unit/voice-command.test.ts +++ b/tests/unit/voice-command.test.ts @@ -1038,6 +1038,45 @@ describe('VoiceCommand', () => { expect(result).toBe('[de] Welt\n[fr] Monde'); }); + + it('should strip control characters from transcript text and language labels', async () => { + // The live display moves the cursor and clears a fixed number of lines, so + // a newline or escape sequence inside transcript text would corrupt the + // rendering, not just the line it sits on. --format json keeps the text + // verbatim, since JSON escapes it and machines read that path. + // Cast because `lang` is a typed union, while on the wire it is whatever + // the endpoint sends. + const hostileResult = { + sessionId: 'session-hostile', + source: { lang: 'en', text: 'He\u001b[2Kllo\nthere', segments: [] }, + targets: [ + { lang: 'd\u001b[2Ke', text: 'Hal\u001b[2Klo', segments: [] }, + ], + } as unknown as VoiceSessionResult; + mockService.translateFile.mockResolvedValue(hostileResult); + + const result = await command.translate('test.mp3', { to: 'de' }); + + expect(result).not.toContain('\u001b'); + expect(result).toContain('[source] He?[2Kllo?there'); + expect(result).toContain('[d?[2Ke] Hal?[2Klo'); + }); + + it('should keep transcript text verbatim in --format json', async () => { + const hostileResult: VoiceSessionResult = { + sessionId: 'session-hostile-json', + source: { lang: 'en', text: 'He\u001b[2Kllo', segments: [] }, + targets: [{ lang: 'de', text: 'Hallo', segments: [] }], + }; + mockService.translateFile.mockResolvedValue(hostileResult); + + const result = await command.translate('test.mp3', { to: 'de', format: 'json' }); + + // JSON.stringify escapes the control character, so it cannot act on a + // terminal, and a consumer still gets the text the API returned. + expect(result).toContain('\\u001b[2K'); + expect(JSON.parse(result).source.text).toBe('He\u001b[2Kllo'); + }); }); describe('language code casing', () => { it('should accept a regional target in the casing the CLI prints', async () => { From e0f185418c20d55f59c5eeb71ccb19a9d00668bf Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Tue, 4 Aug 2026 16:55:53 -0400 Subject: [PATCH 063/256] fix(translate): report a directory run's rejection class in the exit code A directory run where the API refused the request itself exited 1, the generic "nothing succeeded" code, even though the run aborted for a reason the caller can act on. The batch services already tracked that rejection to stop sending doomed batches; it is now surfaced on the result, and the handler reports its exit code -- 6 for a refused target_lang, 4 for an exhausted quota, 2 for a refused key. A run whose files failed individually still exits 1, or 12 with some successes. Text and file modes already exited 6 here, since the rejection reaches the top-level handler as a typed ValidationError; only the directory path substituted its own code. Also resets process.exitCode after each test in the directory handler suite. The handler writes to the jest process's own exit code, which left set made the suite exit non-zero with every test passing. --- .../directory-translation-handler.ts | 15 ++++-- src/services/batch-translation.ts | 12 ++++- .../directory-translation-handler.test.ts | 47 +++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/cli/commands/translate/directory-translation-handler.ts b/src/cli/commands/translate/directory-translation-handler.ts index 26576c4d..b75734e3 100644 --- a/src/cli/commands/translate/directory-translation-handler.ts +++ b/src/cli/commands/translate/directory-translation-handler.ts @@ -2,7 +2,7 @@ import ora from 'ora'; import { BatchTranslationService } from '../../../services/batch-translation.js'; import { ValidationError } from '../../../utils/errors.js'; import { Logger } from '../../../utils/logger.js'; -import { ExitCode } from '../../../utils/exit-codes.js'; +import { ExitCode, exitCodeForError } from '../../../utils/exit-codes.js'; import type { HandlerContext, TranslateOptions } from './types.js'; import { warnIgnoredOptions, @@ -112,8 +112,17 @@ export class DirectoryTranslationHandler { // translated must not look like success to a script or a CI job, and // language validation defers to the API, so a bad --to surfaces here // rather than as a local rejection. - process.exitCode = - stats.successful === 0 ? ExitCode.GeneralError : ExitCode.PartialFailure; + // + // When one rejection stopped the whole run, its own code is reported + // instead of the generic failure — a refused `target_lang` is invalid + // input (6) and an exhausted quota is a quota error (4), which a script + // can act on differently from a file that happened to fail. + if (result.requestRejected !== undefined) { + process.exitCode = exitCodeForError(result.requestRejected); + } else { + process.exitCode = + stats.successful === 0 ? ExitCode.GeneralError : ExitCode.PartialFailure; + } } if (stats.skipped > 0) { diff --git a/src/services/batch-translation.ts b/src/services/batch-translation.ts index 554f15a3..ae4ac6ab 100644 --- a/src/services/batch-translation.ts +++ b/src/services/batch-translation.ts @@ -38,6 +38,13 @@ interface BatchResult { successful: Array<{ file: string; outputPath: string }>; failed: Array<{ file: string; error: string }>; skipped: Array<{ file: string; reason: string }>; + /** + * The rejection that stopped the run, when one described the request rather + * than a single file. Surfaced so the caller can report the rejection's own + * exit code instead of a generic failure: a refused `target_lang` is user + * input, not an unclassified error. + */ + requestRejected?: unknown; } interface BatchStatistics { @@ -145,6 +152,7 @@ export class BatchTranslationService { result.successful.push(...batchResult.successful); result.failed.push(...batchResult.failed); result.skipped.push(...batchResult.skipped); + result.requestRejected ??= batchResult.requestRejected; completed += batchResult.successful.length + batchResult.failed.length + batchResult.skipped.length; } @@ -205,6 +213,7 @@ export class BatchTranslationService { ); await Promise.all(tasks); + result.requestRejected ??= requestRejected; } return result; @@ -227,6 +236,7 @@ export class BatchTranslationService { successful: Array<{ file: string; outputPath: string }>; failed: Array<{ file: string; error: string }>; skipped: Array<{ file: string; reason: string }>; + requestRejected?: unknown; }> { const successful: Array<{ file: string; outputPath: string }> = []; const failed: Array<{ file: string; error: string }> = []; @@ -369,7 +379,7 @@ export class BatchTranslationService { } await flushBatch(); - return { successful, failed, skipped }; + return { successful, failed, skipped, requestRejected }; } /** diff --git a/tests/unit/directory-translation-handler.test.ts b/tests/unit/directory-translation-handler.test.ts index f708669b..e91c362d 100644 --- a/tests/unit/directory-translation-handler.test.ts +++ b/tests/unit/directory-translation-handler.test.ts @@ -266,6 +266,13 @@ describe('DirectoryTranslationHandler', () => { getStatistics: jest.Mock; }; + // The handler reports a failed run through process.exitCode, which is this + // process's own: left set, it makes jest exit non-zero even when every test + // passed, so a real failure elsewhere would be indistinguishable. + afterEach(() => { + process.exitCode = undefined; + }); + beforeEach(() => { // Re-apply mock implementations cleared by resetMocks: true in jest.config const translateUtilsMock = jest.requireMock( @@ -439,6 +446,46 @@ describe('DirectoryTranslationHandler', () => { ); }); + it('should classify the exit code from a request-level rejection', async () => { + // A rejected target_lang aborts the whole run, so it is user input rather + // than an unclassified failure: exit 6, not the generic 1 that a run where + // every file failed for its own reason gets. + mockBatchService.translateDirectory.mockResolvedValue({ + successful: [], + failed: [{ file: 'a.txt', error: "Value for 'target_lang' not supported." }], + skipped: [], + requestRejected: new ValidationError("Value for 'target_lang' not supported."), + }); + mockBatchService.getStatistics.mockReturnValue({ + total: 1, + successful: 0, + failed: 1, + skipped: 0, + }); + + await handler.translateDirectory('/some/dir', baseOptions); + + expect(process.exitCode).toBe(6); + }); + + it('should still exit 1 when every file failed for its own reason', async () => { + mockBatchService.translateDirectory.mockResolvedValue({ + successful: [], + failed: [{ file: 'a.txt', error: 'Network error' }], + skipped: [], + }); + mockBatchService.getStatistics.mockReturnValue({ + total: 1, + successful: 0, + failed: 1, + skipped: 0, + }); + + await handler.translateDirectory('/some/dir', baseOptions); + + expect(process.exitCode).toBe(1); + }); + it('should validate the flags this mode passes on, and not --glossary', async () => { // Directory mode announces --glossary as ignored and resolves none, so the // extended-tier glossary arm must not fail a run that would have worked. From dbebba4a86f76437cc2ac4788fd48165833b3e47 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Tue, 4 Aug 2026 16:56:03 -0400 Subject: [PATCH 064/256] fix(glossary): relax the coverage check on the requested side only The preflight compared both sides on their base language, so a pt-br dictionary satisfied a request for pt-pt -- a different language pair. The relaxation exists because dictionaries name base languages while --to accepts regional variants, which needs it in one direction: a dictionary language now matches the requested one exactly, or matches the base it reduces to. de->en still covers --to en-us, and a dictionary naming pt-br still matches --to pt-br. Not reachable through the current API, whose glossary language set is entirely base codes, but GlossaryClient types targets as Language, which includes the variants. --- src/services/glossary.ts | 13 ++++++++--- tests/unit/glossary-service.test.ts | 34 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/services/glossary.ts b/src/services/glossary.ts index d1831626..5f98940d 100644 --- a/src/services/glossary.ts +++ b/src/services/glossary.ts @@ -212,11 +212,18 @@ export class GlossaryService { // judgement to the API rather than rejecting on no evidence. if (expected && match.dictionaries.length > 0) { const from = expected.from.toLowerCase(); + // Relaxed on the requested side only: a dictionary language matches the + // requested one exactly, or matches the base it reduces to. Dictionaries + // name base languages while `--to` accepts regional variants, so `de→en` + // has to cover `de→en-us` -- but reducing the dictionary's side too would + // let a `pt-br` dictionary satisfy `pt-pt`, a different pair. + const matches = (dictionaryLang: string, requested: string): boolean => { + const dictionary = dictionaryLang.toLowerCase(); + return dictionary === requested.toLowerCase() || dictionary === baseLanguage(requested); + }; const covered = (target: string): boolean => match.dictionaries.some( - d => - baseLanguage(d.source_lang) === baseLanguage(from) && - baseLanguage(d.target_lang) === baseLanguage(target), + d => matches(d.source_lang, from) && matches(d.target_lang, target), ); const missing = expected.targets.filter(target => !covered(target)); if (missing.length > 0) { diff --git a/tests/unit/glossary-service.test.ts b/tests/unit/glossary-service.test.ts index 37783236..b5256b1c 100644 --- a/tests/unit/glossary-service.test.ts +++ b/tests/unit/glossary-service.test.ts @@ -452,6 +452,40 @@ describe('GlossaryService', () => { ).rejects.toThrow('does not support the requested language pair'); }); + it('should not let one regional dictionary cover a different region', async () => { + // The relaxation exists because dictionaries name base languages while + // --to accepts variants, so it is needed in one direction only. Comparing + // both sides on their base language would make a pt-br dictionary satisfy + // pt-pt, which is a different language pair. + mockDeepLClient.listGlossaries.mockResolvedValue( + listing([{ source_lang: 'en', target_lang: 'pt-br' }]) as never, + ); + + await expect( + glossaryService.resolveGlossaryId('tech-terms', { from: 'en', targets: ['pt-pt'] }), + ).rejects.toThrow('does not support the requested language pair'); + }); + + it('should not let a regional dictionary source cover a different region', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue( + listing([{ source_lang: 'pt-br', target_lang: 'de' }]) as never, + ); + + await expect( + glossaryService.resolveGlossaryId('tech-terms', { from: 'pt-pt', targets: ['de'] }), + ).rejects.toThrow('does not support the requested language pair'); + }); + + it('should still match a regional dictionary asked for exactly', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue( + listing([{ source_lang: 'en', target_lang: 'pt-br' }]) as never, + ); + + await expect( + glossaryService.resolveGlossaryId('tech-terms', { from: 'en', targets: ['pt-br'] }), + ).resolves.toBe('found-glossary-id'); + }); + it('should compare languages case-insensitively', async () => { mockDeepLClient.listGlossaries.mockResolvedValue( listing([{ source_lang: 'EN', target_lang: 'ES' }]) as never, From 149cccfd3527ce56f993781203dbbe085d4c2b7c Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Tue, 4 Aug 2026 16:56:16 -0400 Subject: [PATCH 065/256] fix(languages): stop inferring a tier, and a role, from a silent response Three related conflations of "unknown" with "no", all in how a /v3/languages entry is read: deriveLanguageEntry filed a language with no features matrix as extended, inferring absent glossary support from silence. The extended tier is what refuses formality and glossary before a request is sent, so that claims knowledge the response did not give -- the same fault already fixed for the --features table. Such a language is now tiered by source usability and the API keeps the judgement. An empty matrix is still evidence, and still extended. The bundled snapshot is unchanged, since every language the API lists today carries a matrix. The role flags had two conventions: the registry reads `usable_as_source !== false` while the translation and glossary clients filtered on truthiness, so an entry with the flag absent was dropped from `deepl languages` but recorded as core by the generator. Both clients now read it the registry's way. LanguageEntry's fields are readonly, because the accessors hand out the registry's own objects: mutating one changed the registry for the rest of the process, which the generated `as const` snapshot forbids but the widened element type allowed. --- src/api/glossary-client.ts | 6 ++++-- src/api/translation-client.ts | 7 ++++++- src/data/language-registry.ts | 22 +++++++++++++++++----- tests/unit/glossary-client.test.ts | 20 ++++++++++++++++++++ tests/unit/language-registry.test.ts | 24 ++++++++++++++++++++++-- tests/unit/translation-client.test.ts | 23 +++++++++++++++++++++++ 6 files changed, 92 insertions(+), 10 deletions(-) diff --git a/src/api/glossary-client.ts b/src/api/glossary-client.ts index 2203e6ca..53c7d701 100644 --- a/src/api/glossary-client.ts +++ b/src/api/glossary-client.ts @@ -27,8 +27,10 @@ export class GlossaryClient extends HttpClient { { resource: 'glossary' } ); - const sources = response.filter((lang) => lang.usable_as_source); - const targets = response.filter((lang) => lang.usable_as_target); + // `!== false`, not truthiness: an absent flag is not a denial, and the + // language registry reads it the same way. + const sources = response.filter((lang) => lang.usable_as_source !== false); + const targets = response.filter((lang) => lang.usable_as_target !== false); const pairs: GlossaryLanguagePair[] = []; for (const source of sources) { diff --git a/src/api/translation-client.ts b/src/api/translation-client.ts index a2e4a4f9..f229009e 100644 --- a/src/api/translation-client.ts +++ b/src/api/translation-client.ts @@ -322,7 +322,12 @@ export class TranslationClient extends HttpClient { const response = await this.fetchTranslateLanguages(); return response - .filter((lang) => (type === 'source' ? lang.usable_as_source : lang.usable_as_target)) + // `!== false`, not truthiness: an absent flag is not a denial, and the + // language registry reads it the same way, so a truthy filter here would + // drop a language the generator records as usable. + .filter((lang) => + type === 'source' ? lang.usable_as_source !== false : lang.usable_as_target !== false, + ) .map((lang) => { const code = this.normalizeLanguage(lang.lang); return { diff --git a/src/data/language-registry.ts b/src/data/language-registry.ts index c1f7b081..4fa85fd1 100644 --- a/src/data/language-registry.ts +++ b/src/data/language-registry.ts @@ -32,16 +32,21 @@ export type LanguageCategory = 'core' | 'regional' | 'extended'; * (not as a source). Applies to regional variants like 'en-gb' and 'pt-br'. */ export interface LanguageEntry { - code: string; - name: string; - category: LanguageCategory; - targetOnly?: boolean; + readonly code: string; + readonly name: string; + readonly category: LanguageCategory; + readonly targetOnly?: boolean; } /** * The snapshot as plain entries. It is generated `as const` so the `Language` * union can derive from its codes; the lookups below want the interface rather * than one literal type per language. + * + * `LanguageEntry`'s fields are `readonly` because the accessors below hand out + * these very objects: a caller mutating one would change the registry for the + * whole process, which the generated `as const` tuple forbids but a widened + * element type would have silently permitted. */ const ENTRIES: readonly LanguageEntry[] = GENERATED_ENTRIES; @@ -69,9 +74,16 @@ export interface DerivableLanguage { export function deriveLanguageEntry(language: DerivableLanguage): LanguageEntry { const code = language.lang.toLowerCase(); const usableAsSource = language.usable_as_source !== false; + + // An empty matrix is evidence — it says the language supports none of them, + // glossary included — while a missing one says nothing. Since the extended + // tier is what refuses formality and glossary before a request is sent, + // silence must not put a language there; tiering it by source usability + // leaves the judgement to the API instead. + const described = language.features !== undefined; const supportsGlossary = language.features?.['glossary'] !== undefined; - const category: LanguageCategory = !supportsGlossary + const category: LanguageCategory = described && !supportsGlossary ? 'extended' : usableAsSource ? 'core' diff --git a/tests/unit/glossary-client.test.ts b/tests/unit/glossary-client.test.ts index a036daac..b891b9a5 100644 --- a/tests/unit/glossary-client.test.ts +++ b/tests/unit/glossary-client.test.ts @@ -67,6 +67,26 @@ describe('GlossaryClient', () => { ); }); + it('should treat an absent role flag as usable, matching the registry', async () => { + mockAxiosInstance.request.mockResolvedValue({ + data: [ + { lang: 'en', name: 'English' }, + { lang: 'de', name: 'German' }, + ], + status: 200, + headers: {}, + }); + + const pairs = await client.getGlossaryLanguages(); + + expect(pairs).toEqual( + expect.arrayContaining([ + { sourceLang: 'en', targetLang: 'de' }, + { sourceLang: 'de', targetLang: 'en' }, + ]), + ); + }); + it('should exclude identity pairs', async () => { mockAxiosInstance.request.mockResolvedValue({ data: [ diff --git a/tests/unit/language-registry.test.ts b/tests/unit/language-registry.test.ts index eec39ee4..08c14295 100644 --- a/tests/unit/language-registry.test.ts +++ b/tests/unit/language-registry.test.ts @@ -344,12 +344,32 @@ describe('Language Registry', () => { ).toEqual({ code: 'hi', name: 'Hindi', category: 'extended' }); }); - it('should treat a missing features object as extended', () => { + it('should classify an empty features matrix as extended, which is evidence', () => { + // An empty matrix says the language supports none of them, glossary + // included. A missing one says nothing -- see below. expect( - deriveLanguageEntry({ lang: 'xx', name: 'Test', usable_as_source: true }).category, + deriveLanguageEntry({ + lang: 'hi', + name: 'Hindi', + usable_as_source: true, + features: {}, + }).category, ).toBe('extended'); }); + it('should not file a language with no features matrix as extended', () => { + // Silence about a language is not evidence that it lacks glossary support, + // and the extended tier is what suppresses formality and glossary locally. + // Tiering it by source usability instead leaves the judgement to the API, + // which is the same choice the --features table makes. + expect( + deriveLanguageEntry({ lang: 'xx', name: 'Test', usable_as_source: true }).category, + ).toBe('core'); + expect( + deriveLanguageEntry({ lang: 'xx', name: 'Test', usable_as_source: false }).category, + ).toBe('regional'); + }); + it('should lowercase the code', () => { expect(deriveLanguageEntry({ lang: 'ZH-Hans', name: 'Chinese' }).code).toBe('zh-hans'); }); diff --git a/tests/unit/translation-client.test.ts b/tests/unit/translation-client.test.ts index 9fad3ab8..a0cea26f 100644 --- a/tests/unit/translation-client.test.ts +++ b/tests/unit/translation-client.test.ts @@ -399,6 +399,29 @@ describe('TranslationClient', () => { }); describe('getSupportedLanguages()', () => { + it('should treat an absent role flag as usable, matching the registry', async () => { + // The registry reads `usable_as_source !== false`, so an entry with the + // flag absent is recorded as core there. Dropping it here instead would + // make `deepl languages` omit a language the generator files as usable. + mockAxiosInstance.request.mockResolvedValue({ + data: [{ lang: 'en', name: 'English' }], + status: 200, + headers: {}, + }); + + await expect(client.getSupportedLanguages('source')).resolves.toHaveLength(1); + }); + + it('should still drop a language whose role flag is explicitly false', async () => { + mockAxiosInstance.request.mockResolvedValue({ + data: [{ lang: 'en-gb', name: 'English (British)', usable_as_source: false }], + status: 200, + headers: {}, + }); + + await expect(client.getSupportedLanguages('source')).resolves.toEqual([]); + }); + it('should return source languages', async () => { mockAxiosInstance.request.mockResolvedValue({ data: [ From 97e567c764695b56114a21864b28c3ae04f071d1 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Tue, 4 Aug 2026 16:56:25 -0400 Subject: [PATCH 066/256] fix(scripts): report a thrown fetch from the language generator as an error The generator had no top-level catch, so a rejected fetch or an error body that does not parse surfaced as an unhandled rejection with a stack trace rather than the script's own "error:" line and exit 1. Every other failure in the script already reported itself that way. Covered by a test that preloads a throwing fetch, which fails without the catch. --- CHANGELOG.md | 12 ++++++++++ scripts/generate-language-registry.mjs | 9 +++++++- tests/fixtures/throwing-fetch.mjs | 7 ++++++ tests/unit/generate-language-registry.test.ts | 23 +++++++++++++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/throwing-fetch.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ffeea5d..13d1d5e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **scripts**: **The language generator wrote unescaped API response fields into TypeScript.** `lang` was interpolated into a single-quoted literal with no escaping and `name` escaped only quotes, so a response field containing `' }] as const;` — or merely ending in a backslash — could close the literal and append arbitrary code to `src/data/language-entries.ts`, which the next `npm run build` compiles and the test suite imports. The existing guards checked the shape of the response, never the content of a field. Codes are now validated against the language-tag pattern, display names against a conservative character set, categories against the three tiers, and every value is quoted with escaping; validation runs before grouping, which would otherwise drop an entry with an unrecognized category before it was checked. The generator's main guard also resolves `argv[1]` through `realpathSync`, because Node reports the ESM entry by its real path — under a symlinked checkout both npm scripts exited 0 without doing anything, including the release step that keeps the Write list current. +- **translate**: **A directory run stopped by one request-level rejection now reports that rejection's exit code** instead of the generic `1` for "nothing succeeded". A refused `target_lang` is invalid input and exits 6, an exhausted quota exits 4, a refused key exits 2 — all cases where every file draws the same answer and the run aborts. A run where files failed for their own reasons still exits 1 with no successes, or 12 with some. Text and file modes already exited 6 for this. + +- **glossary**: **The language-pair preflight no longer treats two regional variants of one language as interchangeable.** Both sides were compared on their base language, so a `pt-br` dictionary satisfied a request for `pt-pt`. The relaxation is needed in one direction only — dictionaries name base languages while `--to` accepts variants — so a dictionary language now matches the requested one exactly or matches the base it reduces to. `de→en` still covers `--to en-us`, and a dictionary naming `pt-br` still matches `--to pt-br`. Not reachable through the current API, whose glossary language set is entirely base codes. + +- **languages**: **A language `GET /v3/languages` describes with no feature matrix is no longer filed as extended.** The tier was derived from the absence of glossary support, and an absent matrix is silence rather than a denial — the same unknown-versus-none conflation already fixed for the `--features` table. Since the extended tier is what refuses formality and glossary before a request is sent, such a language is now tiered by source usability and the API keeps the judgement. An **empty** matrix is still evidence and still means extended. The bundled snapshot is unchanged, since every language the API currently lists carries a matrix. + +- **languages**: An absent `usable_as_source` / `usable_as_target` flag is read as "usable" in the language and glossary-pair listings, matching how the language registry has always read it. The two disagreed, so a language whose flag was absent was dropped from `deepl languages` while the snapshot generator recorded it as core. + +- **types**: `LanguageEntry`'s fields are `readonly`. The accessors hand out the registry's own objects, so `getTargetLanguages()[0].code = 'x'` changed the registry for the rest of the process — something the generated `as const` snapshot forbids but the widened element type permitted. + +- **scripts**: `npm run generate:languages` reports a thrown fetch or an unparseable error body as its own `error:` line and exit 1, rather than as an unhandled rejection with a stack trace. + - **languages**, **voice**: **Strings the API supplies are sanitized before they reach the terminal.** `deepl languages` printed the name, feature keys and feature statuses from `GET /v3/languages` verbatim, and `voice` did the same with transcript text and language labels, so a hostile or intercepted endpoint could move the cursor, clear the screen, or hide text behind a bidi override. Both now go through the same `sanitizeForTerminal` the glossary and style-rule listings already used, replacing control and zero-width characters with `?`. It matters most for `voice`, whose live display clears a fixed number of lines: a newline inside a transcript threw off the whole rendering, not just its own line. `voice --format json` keeps the text byte-for-byte, since JSON escaping already makes it inert and that is the path machines read. - **watch**: **A direct `WatchCommand.watch()` call rejected `--glossary` without `--from` even when `defaults.sourceLang` was set.** The config-aware resolution every other command uses lived only in watch's CLI registration layer, while the handler kept the older flag-only guard — so the CLI accepted a command a library caller was refused. The handler now settles the source language the same way, from `defaults.sourceLang` when the flag is absent, and `WatchCommand` takes the config service to do it. The requirement itself is unchanged: with neither a flag nor a default, the command still exits 6 before the watcher starts. diff --git a/scripts/generate-language-registry.mjs b/scripts/generate-language-registry.mjs index 15596bac..c1f26b13 100644 --- a/scripts/generate-language-registry.mjs +++ b/scripts/generate-language-registry.mjs @@ -274,5 +274,12 @@ const invokedDirectly = })(); if (invokedDirectly) { - await main(); + // Caught so a thrown fetch, or an HTML error body that does not parse, reports + // itself the way every other failure in this script does rather than as an + // unhandled rejection with a stack trace. + try { + await main(); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } } diff --git a/tests/fixtures/throwing-fetch.mjs b/tests/fixtures/throwing-fetch.mjs new file mode 100644 index 00000000..432e1fad --- /dev/null +++ b/tests/fixtures/throwing-fetch.mjs @@ -0,0 +1,7 @@ +/** + * Preload that makes every fetch throw, so a script's transport-failure handling + * can be exercised without a network. Used via `node --import`. + */ +globalThis.fetch = () => { + throw new Error('simulated transport failure'); +}; diff --git a/tests/unit/generate-language-registry.test.ts b/tests/unit/generate-language-registry.test.ts index 923f009e..b09fcdf1 100644 --- a/tests/unit/generate-language-registry.test.ts +++ b/tests/unit/generate-language-registry.test.ts @@ -10,6 +10,7 @@ import { spawnSync } from 'child_process'; import * as path from 'path'; +import { pathToFileURL } from 'url'; const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'generate-language-registry.mjs'); @@ -111,4 +112,26 @@ describe('generate-language-registry', () => { .toContain("'de\\' ], evil = ['"); }); }); + + describe('failure reporting', () => { + it('should report a thrown fetch as its own error line, not an unhandled rejection', () => { + // A DNS failure or a socket reset throws rather than returning a response, + // and the script has to name it the way it names every other failure. + const preload = path.join( + __dirname, + '..', + 'fixtures', + 'throwing-fetch.mjs', + ); + const result = spawnSync('node', ['--import', pathToFileURL(preload).href, SCRIPT], { + encoding: 'utf-8', + env: { ...process.env, DEEPL_API_KEY: 'test-key-for-generator:fx' }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('error: simulated transport failure'); + expect(result.stderr).not.toContain('UnhandledPromiseRejection'); + expect(result.stderr).not.toContain('at async main'); + }); + }); }); From 24da5ec1fc6ac0358947a5554e52fdbb5900e61d Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Tue, 4 Aug 2026 17:34:11 -0400 Subject: [PATCH 067/256] docs(glossary): record the verified multipart glossary_ids semantics The multi-glossary work could not establish how the multipart POST /v2/document parses glossary_ids, because that endpoint answers any unresolvable glossary with `glossary_ids is not valid` whatever the encoding, so probing with fake UUIDs cannot separate a parse failure from a non-existent glossary. Two real glossaries settle it. A comma-joined glossary_ids applies the terms from every glossary in the list. The same two IDs sent as repeated multipart fields apply only the first, and the API reports no error for the ones it drops, so the comma-joining encodeGlossaryIdsForMultipart() performs is required rather than merely sufficient and its absence would fail silently. Whitespace after the commas turns out to be tolerated, contrary to what the comment claimed; the encoder emits none either way, so this is a comment correction only. --- CHANGELOG.md | 2 +- src/utils/glossary-params.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13d1d5e2..d6cea16e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **translate**: `--glossary` now applies to document translation (PDF, DOCX, PPTX, XLSX, images, and text-based files routed to the document API). It was previously accepted and then silently discarded with a "document mode does not support --glossary" warning, even though `POST /v2/document` supports glossaries. Repeating the flag works here too, with the same last-one-wins precedence. `--from` is required, because the API rejects a document glossary without a source language; `--translation-memory` remains unsupported for documents. Note that glossary matching is context-dependent for documents exactly as it is for text — a term applied in one sentence may be left alone in another. -- **translate**: `--glossary` is repeatable, applying up to 5 glossaries to one request via the API's `glossary_ids` parameter. Entries are merged, so terms unique to each glossary all apply; when several glossaries define the same source term the **last** `--glossary` on the command line wins, so the order is significant and is never sorted — reordering the flags is a different request with its own cache entry. Names and UUIDs may be mixed and are resolved independently. A single `--glossary` still goes out as `glossary_id`, leaving existing commands and their cache keys untouched; the new field is appended last in the cache key for the same reason. A 6th `--glossary` exits 6 (ValidationError) before any API call. `watch` and `sync` keep their single-glossary configuration. Verified against the live API: `POST /v2/translate` accepts `glossary_ids` as repeated form fields and resolves them in order, rejects a sixth with `A maximum of 5 glossaries can be specified per request.`, and rejects `glossary_id` and `glossary_ids` together with `Specify either glossary_id or glossary_ids, not both.` -- which is why the CLI collapses a single glossary to `glossary_id` rather than sending both. The multipart `POST /v2/document` takes one comma-joined value, since multipart does not parse repeated fields as a list; that endpoint answers any unresolvable glossary with `glossary_ids is not valid` regardless of encoding, so it was confirmed only to the extent that it recognises the parameter, and the list semantics there rest on the API documentation rather than on a live round trip. +- **translate**: `--glossary` is repeatable, applying up to 5 glossaries to one request via the API's `glossary_ids` parameter. Entries are merged, so terms unique to each glossary all apply; when several glossaries define the same source term the **last** `--glossary` on the command line wins, so the order is significant and is never sorted — reordering the flags is a different request with its own cache entry. Names and UUIDs may be mixed and are resolved independently. A single `--glossary` still goes out as `glossary_id`, leaving existing commands and their cache keys untouched; the new field is appended last in the cache key for the same reason. A 6th `--glossary` exits 6 (ValidationError) before any API call. `watch` and `sync` keep their single-glossary configuration. Verified against the live API: `POST /v2/translate` accepts `glossary_ids` as repeated form fields and resolves them in order, rejects a sixth with `A maximum of 5 glossaries can be specified per request.`, and rejects `glossary_id` and `glossary_ids` together with `Specify either glossary_id or glossary_ids, not both.` -- which is why the CLI collapses a single glossary to `glossary_id` rather than sending both. The multipart `POST /v2/document` takes one comma-joined value, since multipart does not parse repeated fields as a list. Both halves of that are now confirmed against the live API with two real single-entry glossaries: a comma-joined `glossary_ids` applies the terms from both, while sending the same two IDs as repeated multipart fields applies only the first and reports no error, so the comma-joining is required rather than merely sufficient. - **languages**: `deepl languages --features` shows which features each language supports — formality, glossary, style rules, translation memory, tag handling and auto-detection — from the `features` matrix on `GET /v3/languages`, which the CLI previously discarded. Support no longer has to be discovered by making a request and reading the error. Which features get a column is derived from the response rather than a fixed list: a feature appears when its support differs across the languages listed, and one supported by all of them is reported once as `All listed languages also support: ...` instead of being repeated on every row (`All languages with reported features also support: ...` when the listing also carries languages the response did not describe, since the note must not speak for those). A language the response omitted reads as `no feature data` rather than as supporting nothing, and does not count towards whether a feature varies. That makes the columns differ between listings — `auto detection` appears under `--target`, where target-only variants lack it, but is uniform under `--source` — and means a newly reported feature shows up without a code change. Support is signalled by the API reporting a feature at all; `status` describes maturity, so anything short of generally available renders verbatim (`glossary (beta)`) rather than collapsing to `yes`. Works with `--format table` (one column per feature) and `--format json` (the raw matrix including each status, present only when `--features` is passed, so existing JSON consumers are unaffected). The `supportsFormality` field is unchanged for every language `GET /v3/languages` describes, which is all of them today; it now comes from the matrix rather than v2's `supports_formality` boolean, and is **omitted rather than reported as `false`** for a target the response says nothing about, since silence is not evidence that formality is absent. `--features` supersedes the `[F]` shorthand and replaces it when given. It needs an API key; without one the command warns and falls back to the registry, which carries no feature data. Note that the matrix is finer-grained than the core/regional/extended tiers: some extended languages support style rules and translation memory even though they support neither formality nor glossary. diff --git a/src/utils/glossary-params.ts b/src/utils/glossary-params.ts index c03f0f41..9622cedd 100644 --- a/src/utils/glossary-params.ts +++ b/src/utils/glossary-params.ts @@ -99,8 +99,8 @@ export function resolveGlossaryWireParams( * Encode `glossary_ids` for multipart requests. Unlike form-urlencoded bodies, * multipart uploads do not parse repeated `glossary_ids` fields as a list — the * API keeps only the first and silently applies that one glossary — so the IDs - * travel as a single comma-joined value. Whitespace around the commas makes the - * API ignore the parameter outright. + * travel as a single comma-joined value, which the API applies in full. Spaces + * after the commas are tolerated, but nothing depends on that, so none are sent. */ export function encodeGlossaryIdsForMultipart(ids: string[]): string { return ids.join(','); From c541e1a212d542ad563945ebe69e086ffbfc3a47 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Tue, 4 Aug 2026 17:35:02 -0400 Subject: [PATCH 068/256] fix(glossary): stop documenting flag order as resolving a conflicting term MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --glossary was documented as last-one-wins: with a term defined in several glossaries, the last flag on the command line was said to decide the translation. Two real glossaries defining the same source term show that it does not. The winner did not track list order — the same glossary won under both orderings in five of six trials, and the first-listed one won in the sixth. Differing verb choices between those two runs confirm they were separate translations rather than cache hits, so conflict resolution belongs to the API and is context-dependent, exactly as glossary matching itself already is. Nothing about the request changes. The list is still sent in the caller's order and never sorted, which stays correct for the reason the cache always needed it: a reordered list is a different request, so it gets its own cache entry. Only the rationale was wrong, and four tests carried that rationale in their names. The --help text now says the API picks the winner regardless of flag order, so users have no reason to reorder flags expecting an override. --- CHANGELOG.md | 4 ++-- README.md | 3 ++- docs/API.md | 3 ++- src/cli/commands/register-translate.ts | 2 +- src/services/translation.ts | 5 +++-- src/types/api.ts | 9 ++++++--- src/utils/glossary-params.ts | 6 ++++-- tests/e2e/cli-multi-glossary.e2e.test.ts | 9 +++++++-- tests/unit/document-client.test.ts | 4 ++-- tests/unit/glossary-params.test.ts | 2 +- tests/unit/translation-client.test.ts | 2 +- tests/unit/translation-service.test.ts | 2 +- 12 files changed, 32 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6cea16e..c92f801b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **translate**: `--glossary` now applies to document translation (PDF, DOCX, PPTX, XLSX, images, and text-based files routed to the document API). It was previously accepted and then silently discarded with a "document mode does not support --glossary" warning, even though `POST /v2/document` supports glossaries. Repeating the flag works here too, with the same last-one-wins precedence. `--from` is required, because the API rejects a document glossary without a source language; `--translation-memory` remains unsupported for documents. Note that glossary matching is context-dependent for documents exactly as it is for text — a term applied in one sentence may be left alone in another. +- **translate**: `--glossary` now applies to document translation (PDF, DOCX, PPTX, XLSX, images, and text-based files routed to the document API). It was previously accepted and then silently discarded with a "document mode does not support --glossary" warning, even though `POST /v2/document` supports glossaries. Repeating the flag works here too, with the same merge behaviour and the same API-decided conflict resolution. `--from` is required, because the API rejects a document glossary without a source language; `--translation-memory` remains unsupported for documents. Note that glossary matching is context-dependent for documents exactly as it is for text — a term applied in one sentence may be left alone in another. -- **translate**: `--glossary` is repeatable, applying up to 5 glossaries to one request via the API's `glossary_ids` parameter. Entries are merged, so terms unique to each glossary all apply; when several glossaries define the same source term the **last** `--glossary` on the command line wins, so the order is significant and is never sorted — reordering the flags is a different request with its own cache entry. Names and UUIDs may be mixed and are resolved independently. A single `--glossary` still goes out as `glossary_id`, leaving existing commands and their cache keys untouched; the new field is appended last in the cache key for the same reason. A 6th `--glossary` exits 6 (ValidationError) before any API call. `watch` and `sync` keep their single-glossary configuration. Verified against the live API: `POST /v2/translate` accepts `glossary_ids` as repeated form fields and resolves them in order, rejects a sixth with `A maximum of 5 glossaries can be specified per request.`, and rejects `glossary_id` and `glossary_ids` together with `Specify either glossary_id or glossary_ids, not both.` -- which is why the CLI collapses a single glossary to `glossary_id` rather than sending both. The multipart `POST /v2/document` takes one comma-joined value, since multipart does not parse repeated fields as a list. Both halves of that are now confirmed against the live API with two real single-entry glossaries: a comma-joined `glossary_ids` applies the terms from both, while sending the same two IDs as repeated multipart fields applies only the first and reports no error, so the comma-joining is required rather than merely sufficient. +- **translate**: `--glossary` is repeatable, applying up to 5 glossaries to one request via the API's `glossary_ids` parameter. Entries are merged, so terms unique to each glossary all apply. When several glossaries define the same source term, **which mapping wins is the API's choice and does not follow flag order** — verified live with two glossaries defining the same term, where the same one won in both orderings in five of six trials and the first-listed one won in the sixth, so position is not a way to override a term. The order is still sent as given and never sorted, because it is part of the cache key: reordering the flags is a different request with its own cache entry. Names and UUIDs may be mixed and are resolved independently. A single `--glossary` still goes out as `glossary_id`, leaving existing commands and their cache keys untouched; the new field is appended last in the cache key for the same reason. A 6th `--glossary` exits 6 (ValidationError) before any API call. `watch` and `sync` keep their single-glossary configuration. Verified against the live API: `POST /v2/translate` accepts `glossary_ids` as repeated form fields and resolves them in order, rejects a sixth with `A maximum of 5 glossaries can be specified per request.`, and rejects `glossary_id` and `glossary_ids` together with `Specify either glossary_id or glossary_ids, not both.` -- which is why the CLI collapses a single glossary to `glossary_id` rather than sending both. The multipart `POST /v2/document` takes one comma-joined value, since multipart does not parse repeated fields as a list. Both halves of that are now confirmed against the live API with two real single-entry glossaries: a comma-joined `glossary_ids` applies the terms from both, while sending the same two IDs as repeated multipart fields applies only the first and reports no error, so the comma-joining is required rather than merely sufficient. - **languages**: `deepl languages --features` shows which features each language supports — formality, glossary, style rules, translation memory, tag handling and auto-detection — from the `features` matrix on `GET /v3/languages`, which the CLI previously discarded. Support no longer has to be discovered by making a request and reading the error. Which features get a column is derived from the response rather than a fixed list: a feature appears when its support differs across the languages listed, and one supported by all of them is reported once as `All listed languages also support: ...` instead of being repeated on every row (`All languages with reported features also support: ...` when the listing also carries languages the response did not describe, since the note must not speak for those). A language the response omitted reads as `no feature data` rather than as supporting nothing, and does not count towards whether a feature varies. That makes the columns differ between listings — `auto detection` appears under `--target`, where target-only variants lack it, but is uniform under `--source` — and means a newly reported feature shows up without a code change. Support is signalled by the API reporting a feature at all; `status` describes maturity, so anything short of generally available renders verbatim (`glossary (beta)`) rather than collapsing to `yes`. Works with `--format table` (one column per feature) and `--format json` (the raw matrix including each status, present only when `--features` is passed, so existing JSON consumers are unaffected). The `supportsFormality` field is unchanged for every language `GET /v3/languages` describes, which is all of them today; it now comes from the matrix rather than v2's `supports_formality` boolean, and is **omitted rather than reported as `false`** for a target the response says nothing about, since silence is not evidence that formality is absent. `--features` supersedes the `[F]` shorthand and replaces it when given. It needs an API key; without one the command warns and falls back to the registry, which carries no feature data. Note that the matrix is finer-grained than the core/regional/extended tiers: some extended languages support style rules and translation memory even though they support neither formality nor glossary. diff --git a/README.md b/README.md index fdd7f4a1..edadd994 100644 --- a/README.md +++ b/README.md @@ -378,7 +378,8 @@ deepl translate document.pdf --from en --to es --output document.es.pdf # Apply a glossary (needs a source language: --from, or defaults.sourceLang) deepl translate report.docx --from en --to de --glossary tech-terms --output report.de.docx -# Repeat --glossary for up to 5; the last one wins a conflicting term +# Repeat --glossary for up to 5; entries merge, and a term defined in +# several is resolved by the API, not by flag order deepl translate report.docx --from en --to de --output report.de.docx \ --glossary base-terms --glossary project-overrides diff --git a/docs/API.md b/docs/API.md index 689a2e41..566111a9 100644 --- a/docs/API.md +++ b/docs/API.md @@ -414,7 +414,8 @@ deepl translate report.docx --to fr --output report.fr.docx --enable-minificatio # Apply a glossary (--from is required for document glossaries) deepl translate report.docx --from en --to de --output report.de.docx --glossary tech-terms -# Repeat --glossary for up to 5; the last one wins a conflicting term +# Repeat --glossary for up to 5; entries merge, and a term defined in +# several is resolved by the API, not by flag order deepl translate report.docx --from en --to de --output report.de.docx \ --glossary base-terms --glossary project-overrides ``` diff --git a/src/cli/commands/register-translate.ts b/src/cli/commands/register-translate.ts index 425e6969..3c9eb8e8 100644 --- a/src/cli/commands/register-translate.ts +++ b/src/cli/commands/register-translate.ts @@ -31,7 +31,7 @@ export function registerTranslate( .option('--context ', 'Additional context to improve translation quality') .option( '--glossary ', - `Use glossary by name or ID (requires --from; repeatable, max ${MAX_GLOSSARIES_PER_REQUEST}; when several define the same term, the last one wins). Applies to text, files and documents.`, + `Use glossary by name or ID (requires --from; repeatable, max ${MAX_GLOSSARIES_PER_REQUEST}; entries are merged, and for a term defined in several the API picks the winner regardless of flag order). Applies to text, files and documents.`, (val: string, prev: string[] | undefined) => (prev ?? []).concat([val]), ) .option( diff --git a/src/services/translation.ts b/src/services/translation.ts index e8edbd87..cde7e2a9 100644 --- a/src/services/translation.ts +++ b/src/services/translation.ts @@ -397,8 +397,9 @@ export class TranslationService { * does not perturb the key -- `JSON.stringify` omits it. `preserveFormatting` * is the exception: the service merges a config default for it, so it is always * materialized and every key reflects it. `glossaryIds` is hashed in the - * caller's order rather than sorted, because reordering the list changes which - * glossary wins a conflicting term and therefore the translation itself. + * caller's order rather than sorted, because that order is what goes on the + * wire, and the API is free to return a different translation for a different + * ordering of the same glossaries. * * `tagHandlingVersion` is resolved rather than read straight off the options, * so it holds the version the request will actually carry. Leaving it unset diff --git a/src/types/api.ts b/src/types/api.ts index 0dde7419..cfc2e429 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -15,9 +15,12 @@ export interface TranslationOptions { targetLang: Language; glossaryId?: string; /** - * Two to five glossaries applied to one request. Their entries are merged; - * when more than one defines the same source term the last one wins, so the - * order is significant. Mutually exclusive with `glossaryId`. + * Two to five glossaries applied to one request. Their entries are merged, so + * a term unique to any one of them applies. When more than one defines the + * same source term the API picks the winner, and it does not follow this + * order — do not rely on position to resolve a conflict. The order is still + * sent as given and never sorted, since it is part of the cache key. + * Mutually exclusive with `glossaryId`. */ glossaryIds?: string[]; translationMemoryId?: string; diff --git a/src/utils/glossary-params.ts b/src/utils/glossary-params.ts index 9622cedd..2b2ed5b1 100644 --- a/src/utils/glossary-params.ts +++ b/src/utils/glossary-params.ts @@ -57,8 +57,10 @@ export type GlossaryWireParams = * cache keys — they had before `glossary_ids` existed. Two or more go out as * `glossary_ids`, which the API refuses to accept alongside `glossary_id`. * - * The list order is preserved and never sorted: when several glossaries define - * the same source term, the API applies the last one that names it. + * The list order is preserved and never sorted, because it is part of the cache + * key. It does not decide conflicts: when several glossaries define the same + * source term, which mapping wins is the API's choice and does not track + * position in this list. */ export function resolveGlossaryWireParams( selection: GlossarySelection, diff --git a/tests/e2e/cli-multi-glossary.e2e.test.ts b/tests/e2e/cli-multi-glossary.e2e.test.ts index 8ae6e131..48fc2c8e 100644 --- a/tests/e2e/cli-multi-glossary.e2e.test.ts +++ b/tests/e2e/cli-multi-glossary.e2e.test.ts @@ -25,8 +25,13 @@ describe('translate --glossary repetition E2E', () => { expect(runCLI('translate --help')).toMatch(/max 5/i); }); - it('should document that the last glossary wins a conflict', () => { - expect(runCLI('translate --help')).toMatch(/last one wins/i); + it('should document that flag order does not resolve a conflicting term', () => { + const output = runCLI('translate --help'); + + expect(output).toMatch(/merged/i); + // The help text is wrapped, so the phrase can carry a newline and indent. + expect(output).toMatch(/regardless\s+of\s+flag\s+order/i); + expect(output).not.toMatch(/last one wins/i); }); }); diff --git a/tests/unit/document-client.test.ts b/tests/unit/document-client.test.ts index b9a51ea3..b0ae55d6 100644 --- a/tests/unit/document-client.test.ts +++ b/tests/unit/document-client.test.ts @@ -175,12 +175,12 @@ describe('DocumentClient', () => { expect(body.match(/name="glossary_ids"/g)).toHaveLength(1); }); - it('should not pad the joined IDs with whitespace, which voids the parameter', async () => { + it('should join the IDs without padding whitespace', async () => { await upload({ glossaryIds: [A, B] }); expect(getMultipartBody()).not.toContain(`${A}, ${B}`); }); - it('should keep the caller order, since the last glossary wins', async () => { + it('should keep the caller order rather than sorting it', async () => { await upload({ glossaryIds: [B, A] }); expect(getMultipartBody()).toContain(`${B},${A}`); }); diff --git a/tests/unit/glossary-params.test.ts b/tests/unit/glossary-params.test.ts index 12a12acc..ff76014f 100644 --- a/tests/unit/glossary-params.test.ts +++ b/tests/unit/glossary-params.test.ts @@ -36,7 +36,7 @@ describe('resolveGlossaryWireParams', () => { expect(resolveGlossaryWireParams({ glossaryIds: [A, B] })).toEqual({ glossary_ids: [A, B] }); }); - it('should preserve the caller order, since the last glossary wins conflicts', () => { + it('should preserve the caller order rather than sorting it', () => { expect(resolveGlossaryWireParams({ glossaryIds: [C, A, B] })).toEqual({ glossary_ids: [C, A, B], }); diff --git a/tests/unit/translation-client.test.ts b/tests/unit/translation-client.test.ts index a0cea26f..8264a54a 100644 --- a/tests/unit/translation-client.test.ts +++ b/tests/unit/translation-client.test.ts @@ -285,7 +285,7 @@ describe('TranslationClient', () => { expect(body).not.toMatch(/(^|&)glossary_id=/); }); - it('should keep the caller order on the wire, since the last glossary wins', async () => { + it('should keep the caller order on the wire rather than sorting it', async () => { await client.translate('Hello', { targetLang: 'es', glossaryIds: [C, A] }); expect(getRequestBody()).toContain(`glossary_ids=${C}&glossary_ids=${A}`); }); diff --git a/tests/unit/translation-service.test.ts b/tests/unit/translation-service.test.ts index 615bedde..e203e6c0 100644 --- a/tests/unit/translation-service.test.ts +++ b/tests/unit/translation-service.test.ts @@ -1187,7 +1187,7 @@ describe('TranslationService', () => { expect(one).not.toBe(two); }); - /** Reordering changes which glossary wins a conflicting term, so the keys must differ. */ + /** A reordered list is a different request to the API, so the keys must differ. */ it('should separate the two orderings of the same glossaries', async () => { const [ab, ba] = await keysFor([{ glossaryIds: [A, B] }, { glossaryIds: [B, A] }]); expect(ab).not.toBe(ba); From 193510ccb3bc764448530464bb5c0e73cad073fc Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Tue, 4 Aug 2026 18:07:20 -0400 Subject: [PATCH 069/256] docs(test): correct the nock leak comment's attribution and audit rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block added in dd7591c pinned the leak on six `replyWithError` sites in deepl-client.test.ts plus three integration files. Tracing the handles says otherwise: they arise in the translate() and error handling blocks, through ordinary `.reply(...)` interceptions. That file has 122 `.reply()` calls against 2 `replyWithError`, only one of which even sits in a leaking block, and the replyWithError inventory has since grown to 14 call sites across 6 files. That stale baseline made the audit instruction unusable: it asked for "any NEW leak source beyond the six known replyWithError sites", which names the wrong construct and drifts every time a test moves. The rule is now type-based — every reported handle should be HTTPINCOMINGMESSAGE from @mswjs/interceptors, and anything else is a real leak. The mechanism and both mitigations were re-verified and kept: forceExit does not suppress the warning, and --runInBand avoids it at ~5x wall clock. Whether a newer @mswjs/interceptors helps is now recorded as untested rather than assumed, since 0.42 is ESM-only and fails the CJS transform. --- jest.config.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/jest.config.js b/jest.config.js index 8a2c83ff..290b6b46 100644 --- a/jest.config.js +++ b/jest.config.js @@ -93,18 +93,18 @@ export default { resetMocks: true, restoreMocks: true, - // KNOWN BENIGN WARNING: jest emits "A worker process has failed to exit - // gracefully" at the end of every full-suite run. Stack traces lead to six - // `nock(...).replyWithError(...)` test sites in deepl-client.test.ts and - // three integration files. The leak is in nock v14 + @mswjs/interceptors: - // each replyWithError call constructs a synthetic Node IncomingMessage - // that is never drained, leaving an HTTPINCOMINGMESSAGE handle pinned in - // the worker. The affected tests all PASS — only the orphaned handles - // trigger the warning. `forceExit: true` does not suppress it (the warning - // fires from the worker, not the main process, before forceExit applies), - // and `--runInBand` eliminates it but is 5× slower. Fixing upstream - // (nock/mswjs) is out of scope. Run `npm run test:debug` to audit for - // any NEW leak source beyond the six known replyWithError sites. + // KNOWN BENIGN WARNING: every full-suite run ends with "A worker process has + // failed to exit gracefully". nock's interceptor (@mswjs/interceptors) leaves + // undrained IncomingMessage objects behind, each pinned as an + // HTTPINCOMINGMESSAGE handle the worker cannot shed. All tests still pass. + // + // Nothing available here suppresses it: the teardown in tests/setup.ts and + // HttpClient.destroy() never own that socket, `forceExit` fires too late, and + // `--runInBand` avoids it at ~5x wall clock. Going past interceptors 0.41 is + // untested — 0.42 is ESM-only and fails the CJS transform. + // + // Audit real leaks with `npm run test:debug` by handle type, not count: + // anything but HTTPINCOMINGMESSAGE from @mswjs/interceptors is new. // Verbose output verbose: true, From 80717393dba348579e6cd5e400ffd76c2a7dbf21 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Tue, 4 Aug 2026 22:26:20 -0400 Subject: [PATCH 070/256] docs: correct inaccuracies found in the pre-2.0 audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs/API.md` and `README.md` said the last `--glossary` wins a conflicting term, contradicting the same files, the flag's own help text, and the verified behaviour: which mapping wins is the API's choice and does not follow flag order. `docs/API.md`'s command-group table omitted `correct` while claiming to match `deepl --help`, and the README's table of contents omitted its Spelling and Grammar Correction section. A new check in the documented-surface suite fails when the table and the CLI's top-level commands disagree in either direction. `docs/API.md`'s environment-variable reference omitted `NO_PROXY`, which the HTTP client honours and the README already documented. `docs/TROUBLESHOOTING.md` attributed "Translation cache backend failed to load" to running Node.js < 24, which the startup version check rejects earlier with its own message, so the stated cause was unreachable. That entry now describes the reachable case — a v24+ runtime with no usable `node:sqlite` — and the version error is documented under exit code 6, where it appeared nowhere. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CHANGELOG.md | 2 + README.md | 6 +-- docs/API.md | 22 +++++++---- docs/TROUBLESHOOTING.md | 14 +++++-- tests/unit/docs/documented-surface.test.ts | 43 ++++++++++++++++++++++ 5 files changed, 73 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c92f801b..8214fd2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **docs**: Five documentation inaccuracies found in the pre-2.0 audit. `docs/API.md` and `README.md` still said the **last** `--glossary` wins a conflicting term, contradicting the same files, the flag's own help text, and the verified behaviour — which mapping wins is the API's choice and does not follow flag order. `docs/API.md`'s command-group table omitted `correct` while claiming to match `deepl --help`, and the README's table of contents omitted its Spelling and Grammar Correction section. `docs/API.md`'s environment-variable reference omitted `NO_PROXY`, which the HTTP client honours and the README already documented. `docs/TROUBLESHOOTING.md` attributed "Translation cache backend failed to load" to running Node.js < 24, which the startup version check now rejects earlier with its own message, so the stated cause was unreachable; that entry now describes the reachable case (a v24+ runtime with no usable `node:sqlite`) and the version error is documented under exit code 6, where it previously appeared nowhere. + - **package**: **`import '@deepl/cli'` threw instead of loading.** The package is ESM, so Node requires a full specifier for every relative import, but the entry point re-exported `'./types'` — a directory — which fails with `ERR_UNSUPPORTED_DIR_IMPORT`. The whole programmatic surface was therefore unreachable, and the published typings resolved to nothing for a `nodenext` consumer, so `import type { Language } from '@deepl/cli'` was an error regardless of what the union contained. `deepl --help` never exercised this, because the `bin` entry has its own module graph. Both remaining directory specifiers now carry `/index.js`, and the manifest suite imports the built entry in a real Node ESM process and rejects any extensionless relative specifier in the emitted entry chain. - **scripts**: **The language generator wrote unescaped API response fields into TypeScript.** `lang` was interpolated into a single-quoted literal with no escaping and `name` escaped only quotes, so a response field containing `' }] as const;` — or merely ending in a backslash — could close the literal and append arbitrary code to `src/data/language-entries.ts`, which the next `npm run build` compiles and the test suite imports. The existing guards checked the shape of the response, never the content of a field. Codes are now validated against the language-tag pattern, display names against a conservative character set, categories against the three tiers, and every value is quoted with escaping; validation runs before grouping, which would otherwise drop an entry with an unrecognized category before it was checked. The generator's main guard also resolves `argv[1]` through `realpathSync`, because Node reports the ESM entry by its real path — under a symlinked checkout both npm scripts exited 0 without doing anything, including the release step that keeps the Write list current. diff --git a/README.md b/README.md index edadd994..60edc71d 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ For security policy and vulnerability reporting, see [SECURITY.md](SECURITY.md). - [Proxy Configuration](#proxy-configuration) - [Retry and Timeout Configuration](#retry-and-timeout-configuration) - [Usage](#-usage) - - **Core Commands:** [Translation](#translation) | [Writing Enhancement](#writing-enhancement) | [Voice Translation](#voice-translation) + - **Core Commands:** [Translation](#translation) | [Writing Enhancement](#writing-enhancement) | [Spelling and Grammar Correction](#spelling-and-grammar-correction) | [Voice Translation](#voice-translation) - **Resources:** [Glossaries](#glossaries) | [Translation Memories](#translation-memories) - **Workflow:** [Continuous Localization (sync)](#continuous-localization-deepl-sync) | [Watch Mode](#watch-mode) | [Git Hooks](#git-hooks) - **Configuration:** [Setup Wizard](#setup-wizard) | [Authentication](#authentication) | [Configure Defaults](#configure-defaults) | [Cache Management](#cache-management) | [Style Rules](#style-rules) @@ -1307,10 +1307,10 @@ authentication Authentifizierung - **Smart defaults** - `--target` flag only required for multilingual glossaries - **Visual indicators** - 📖 for single-target, 📚 for multilingual glossaries - **Translation integration** - Use `--glossary` flag in translate and watch commands to apply glossary terms (a source language is required, since the API rejects a glossary without one: pass `--from`, or set `defaults.sourceLang`) -- **Several glossaries at once** - Repeat `--glossary` on `translate` for up to 5 glossaries; entries are merged and the last glossary given wins any conflicting term +- **Several glossaries at once** - Repeat `--glossary` on `translate` for up to 5 glossaries; entries are merged, and a term defined in more than one is resolved by the API, not by flag order ```bash -# Layer project overrides on top of shared base terminology +# Combine shared base terminology with project-specific terms deepl translate "Hello world" --from en --to de --glossary base-terms --glossary project-overrides ``` diff --git a/docs/API.md b/docs/API.md index 566111a9..a6c51983 100644 --- a/docs/API.md +++ b/docs/API.md @@ -186,7 +186,7 @@ Commands are organized into six groups, matching the `deepl --help` output: | Group | Commands | Description | | ------------------ | ------------------------------------------------ | ------------------------------------------------------------------------- | -| **Core Commands** | `translate`, `write`, `voice` | Translation, writing enhancement, and speech translation | +| **Core Commands** | `translate`, `write`, `correct`, `voice` | Translation, writing enhancement, spelling and grammar correction, and speech translation | | **Resources** | `glossary`, `tm` | Manage translation glossaries and translation memory | | **Workflow** | `watch`, `sync`, `hooks` | File watching, project sync, and git hook automation | | **Configuration** | `init`, `auth`, `config`, `cache`, `style-rules` | Setup wizard, authentication, settings, caching, and style rules | @@ -251,7 +251,7 @@ Translate text directly, from stdin, from files, or entire directories. Supports - `--non-splitting-tags TAGS` - Comma-separated XML tags that should not be used to split sentences (requires `--tag-handling xml`) - `--ignore-tags TAGS` - Comma-separated XML tags with content to ignore (requires `--tag-handling xml`) - `--tag-handling-version VERSION` - Tag handling version: `v1`, `v2`. v2 improves XML/HTML structure handling (requires `--tag-handling`). **Defaults to `v2`**, sent explicitly on every `--tag-handling` request rather than left to the API's own default, which is documented as moving from v1 to v2 at some point — pinning keeps output from shifting on DeepL's timetable. Pass `--tag-handling-version v1` for the older behaviour, which DeepL documents as heading for deprecation -- `--glossary NAME-OR-ID` - Use glossary by name or ID for consistent terminology. Repeatable, up to 5 per request; when several glossaries define the same source term, the last one given wins. Passing a 6th exits 6 (ValidationError). A source language is required, because the API rejects a glossary without one: supply `--from`, or set `defaults.sourceLang` and it is used automatically. With neither, the command exits 6 before any request. +- `--glossary NAME-OR-ID` - Use glossary by name or ID for consistent terminology. Repeatable, up to 5 per request; entries are merged, so terms unique to each glossary all apply. When several glossaries define the same source term, which mapping wins is the API's choice and does not follow flag order, so position is not a way to override a term. Passing a 6th exits 6 (ValidationError). A source language is required, because the API rejects a glossary without one: supply `--from`, or set `defaults.sourceLang` and it is used automatically. With neither, the command exits 6 before any request. - `--translation-memory NAME-OR-UUID` - Use translation memory by name or UUID (forces `quality_optimized` model). Requires `--from` because TMs are pinned to a specific source→target language pair. Invalid use exits 6 (ValidationError); unresolvable/misconfigured TM exits 7 (ConfigError). - `--tm-threshold N` - Minimum match score 0–100 (default 75, requires `--translation-memory`). Invalid use exits 6 (ValidationError); unresolvable/misconfigured TM exits 7 (ConfigError). - `--custom-instruction INSTRUCTION` - Custom instruction for translation (repeatable, max 10, max 300 chars each). Forces `quality_optimized` model. Cannot be used with `latency_optimized`. @@ -553,15 +553,13 @@ deepl translate README.md --from en --to fr --glossary abc-123-def-456 --output **Multiple glossaries on one request:** -Repeat `--glossary` to apply up to 5 glossaries to a single request. Their entries are merged, so terms unique to each glossary all apply. When more than one glossary defines the same source term, the **last** `--glossary` on the command line wins — order is significant, and reordering the flags produces a different translation (and a separate cache entry). Names and UUIDs can be mixed; each value is resolved independently. A 6th `--glossary` exits 6 (ValidationError). A name that cannot be resolved — unknown, ambiguous, or covering a different language pair than the one requested — exits 7 (ConfigError) without sending a translation request. +Repeat `--glossary` to apply up to 5 glossaries to a single request. Their entries are merged, so terms unique to each glossary all apply. When more than one glossary defines the same source term, **which mapping wins is the API's choice and does not follow flag order** — position is not a way to override a term, so avoid relying on one glossary to shadow another. The order is still sent as given and never sorted, because it is part of the cache key: reordering the flags is a different request with its own cache entry. Names and UUIDs can be mixed; each value is resolved independently. A 6th `--glossary` exits 6 (ValidationError). A name that cannot be resolved — unknown, ambiguous, or covering a different language pair than the one requested — exits 7 (ConfigError) without sending a translation request. ```bash -# Shared base terminology, overridden by project-specific terms +# Shared base terminology combined with project-specific terms; terms unique +# to each apply, and a term defined in both is resolved by the API deepl translate "Hello world" --from en --to de --glossary base-terms --glossary project-overrides -# Reversing the order makes base-terms win any conflicting entry -deepl translate "Hello world" --from en --to de --glossary project-overrides --glossary base-terms - # Names and UUIDs can be mixed deepl translate README.md --from en --to fr --output README.fr.md \ --glossary abc-123-def-456 --glossary house-style @@ -1114,7 +1112,7 @@ Monitor files or directories for changes and automatically translate them. Suppo - `--to, -t LANGS` - Target language(s), comma-separated (uses configured `defaults.targetLangs` if omitted) - `--output, -o DIR` - Output directory (default: `/translations` for directories, same dir for files) - `--pattern GLOB` - File pattern filter (e.g., `*.md`, `**/*.json`) -- `--debounce MS` - Debounce delay in milliseconds (default: 500) +- `--debounce MS` - Debounce delay in milliseconds. The flag wins, then the configured `watch.debounceMs`, then the default of 500 - `--concurrency NUM` - Maximum parallel translations (default: 5) **Translation Options:** @@ -3199,6 +3197,14 @@ Route outbound DeepL API requests through an HTTPS proxy. Takes precedence over export HTTPS_PROXY="http://proxy.example.com:3128" ``` +### `NO_PROXY` + +Comma-separated list of hosts that bypass `HTTP_PROXY` / `HTTPS_PROXY`. Standard semantics apply: `*` bypasses everything, a leading dot or `*.` matches subdomains, and an entry may carry a `host:port` that must agree with the target port. Also recognized as lowercase `no_proxy`. + +```bash +export NO_PROXY="localhost,127.0.0.1,.internal.example.com" +``` + ### `TMS_API_KEY` API key used by `deepl sync push` and `deepl sync pull` to authenticate against the external translation management system configured under `tms.server` in `.deepl-sync.yaml`. See [docs/SYNC.md](SYNC.md) for setup details. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index a639e965..3dd74858 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -392,6 +392,14 @@ deepl languages --source deepl languages --target ``` +### Unsupported Node.js version + +The CLI exits 6 with a single line naming the required and the running version — `requires Node.js >= 24, you are running v22.11.0. Upgrade Node.js to use the DeepL CLI.` + +**Cause:** The CLI requires Node.js 24 or later and checks the version at startup, before loading anything else, so an unsupported runtime gets that one line instead of an experimental-module warning or a later crash. + +**Solution:** upgrade Node.js — e.g. `nvm install 24 && nvm use 24`, or install Node 24 from [nodejs.org](https://nodejs.org/). Confirm with `node --version` that the runtime invoking `deepl` is the upgraded one; a globally linked CLI can otherwise still run under an older default. + --- ## Cache Issues @@ -423,11 +431,11 @@ deepl cache enable ### "Translation cache backend failed to load" -**Cause:** The cache uses Node's built-in `node:sqlite` module, which requires Node.js 24 or later (the CLI's minimum supported version). On an older runtime the module doesn't exist, so caching cannot start. +**Cause:** The cache uses Node's built-in `node:sqlite` module and the runtime could not load it. Running on Node.js older than 24 is reported earlier and separately — see [Unsupported Node.js version](#unsupported-nodejs-version) — so what reaches this message is a runtime that reports version 24 or later but still has no usable `node:sqlite`: a Node built without SQLite support, or a non-Node runtime claiming a compatible version. -Translation and write commands keep working with caching disabled for the run; your cache database is not modified. `deepl cache` subcommands fail until the CLI runs on a supported Node.js version. +Translation and write commands keep working with caching disabled for the run; your cache database is not modified. `deepl cache` subcommands fail until the module loads. -**Solution:** run the CLI with Node.js 24 or later — e.g. `nvm install 24 && nvm use 24`, or install Node 24 from [nodejs.org](https://nodejs.org/). +**Solution:** run the CLI on an official Node.js 24+ build — e.g. `nvm install 24 && nvm use 24`, or install Node 24 from [nodejs.org](https://nodejs.org/). To confirm the module is the problem, check that `node -e "require('node:sqlite')"` succeeds on the same runtime. --- diff --git a/tests/unit/docs/documented-surface.test.ts b/tests/unit/docs/documented-surface.test.ts index b1be8c93..f41bf5b4 100644 --- a/tests/unit/docs/documented-surface.test.ts +++ b/tests/unit/docs/documented-surface.test.ts @@ -149,6 +149,49 @@ describe('documented CLI surface', () => { }); }); + describe('command-group table in docs/API.md', () => { + // The table claims to match `deepl --help`, so a command missing from it + // reads as one the CLI does not have. `correct` was absent from the Core + // Commands row for a whole release while having its own reference section, + // which no invocation-level check can see. + function groupedCommands(): Set { + const contents = fs.readFileSync(path.join(ROOT, 'docs/API.md'), 'utf-8'); + const table = contents.slice(contents.indexOf('## Commands')); + const names = new Set(); + + let started = false; + for (const line of table.split('\n')) { + const isGroupRow = line.startsWith('| **'); + if (isGroupRow) started = true; + else if (started) break; + if (!isGroupRow) continue; + + for (const cell of line.match(/`([a-z-]+)`/g) ?? []) { + names.add(cell.replace(/`/g, '')); + } + } + return names; + } + + it('lists every command the CLI exposes in --help', () => { + const documented = groupedCommands(); + const missing = surface.commands + .map((command) => command.name) + .filter((name) => !name.startsWith('_') && name !== 'help') + .filter((name) => !documented.has(name)); + + expect(missing).toEqual([]); + }); + + it('lists no command the CLI does not provide', () => { + const unknown = [...groupedCommands()].filter( + (name) => resolveCommand([name]) === undefined, + ); + + expect(unknown).toEqual([]); + }); + }); + describe('retired endpoints', () => { // Language listings moved to GET /v3/languages; the v2 endpoints are // formally deprecated, so no doc should teach a reader to call them. From ca19cf1243a57162d3cb3c36e9cdd345182dda8f Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Tue, 4 Aug 2026 22:26:41 -0400 Subject: [PATCH 071/256] fix(watch): honour the documented 500ms debounce default and watch.debounceMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `deepl watch` debounced at 300 ms while `--help`, the docs and the config schema all said 500. The command forwarded a debounce only when `--debounce` was passed, so an omitted flag fell through to `WatchService`'s own 300 ms fallback — a third copy of a value the schema and the help text both put at 500. `watch.debounceMs` was also dead: `deepl config set` accepted it and the documented schema listed it, but nothing read it for `watch`. Resolution is now the flag, then `watch.debounceMs`, then the documented default, which is a single exported constant shared with the config schema so the two cannot drift again. `deepl sync --watch` already resolved to 500 and is unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CHANGELOG.md | 2 + src/cli/commands/watch.ts | 11 +++-- src/storage/config.ts | 5 ++- tests/unit/watch-command.test.ts | 76 ++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8214fd2e..a27a461e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **docs**: Five documentation inaccuracies found in the pre-2.0 audit. `docs/API.md` and `README.md` still said the **last** `--glossary` wins a conflicting term, contradicting the same files, the flag's own help text, and the verified behaviour — which mapping wins is the API's choice and does not follow flag order. `docs/API.md`'s command-group table omitted `correct` while claiming to match `deepl --help`, and the README's table of contents omitted its Spelling and Grammar Correction section. `docs/API.md`'s environment-variable reference omitted `NO_PROXY`, which the HTTP client honours and the README already documented. `docs/TROUBLESHOOTING.md` attributed "Translation cache backend failed to load" to running Node.js < 24, which the startup version check now rejects earlier with its own message, so the stated cause was unreachable; that entry now describes the reachable case (a v24+ runtime with no usable `node:sqlite`) and the version error is documented under exit code 6, where it previously appeared nowhere. +- **watch**: **`deepl watch` debounced at 300 ms while `--help` and every doc said 500, and `watch.debounceMs` did nothing.** The command forwarded a debounce only when `--debounce` was passed, so an omitted flag fell through to `WatchService`'s own 300 ms fallback — a third copy of a value the config schema and the flag's help text both put at 500. The configured key was accepted by `deepl config set` and listed in the documented schema, but nothing ever read it for `watch`. Resolution is now flag, then `watch.debounceMs`, then the documented 500 ms default, and that default is a single exported constant shared with the config schema so the two cannot drift again. `deepl sync --watch` was already correct at 500 and is unchanged. + - **package**: **`import '@deepl/cli'` threw instead of loading.** The package is ESM, so Node requires a full specifier for every relative import, but the entry point re-exported `'./types'` — a directory — which fails with `ERR_UNSUPPORTED_DIR_IMPORT`. The whole programmatic surface was therefore unreachable, and the published typings resolved to nothing for a `nodenext` consumer, so `import type { Language } from '@deepl/cli'` was an error regardless of what the union contained. `deepl --help` never exercised this, because the `bin` entry has its own module graph. Both remaining directory specifiers now carry `/index.js`, and the manifest suite imports the built entry in a real Node ESM process and rejects any extensionless relative specifier in the emitted entry chain. - **scripts**: **The language generator wrote unescaped API response fields into TypeScript.** `lang` was interpolated into a single-quoted literal with no escaping and `name` escaped only quotes, so a response field containing `' }] as const;` — or merely ending in a backslash — could close the literal and append arbitrary code to `src/data/language-entries.ts`, which the next `npm run build` compiles and the test suite imports. The existing guards checked the shape of the response, never the content of a field. Codes are now validated against the language-tag pattern, display names against a conservative character set, categories against the three tiers, and every value is quoted with escaping; validation runs before grouping, which would otherwise drop an entry with an unrecognized category before it was checked. The generator's main guard also resolves `argv[1]` through `realpathSync`, because Node reports the ESM entry by its real path — under a symlinked checkout both npm scripts exited 0 without doing anything, including the release step that keeps the Write list current. diff --git a/src/cli/commands/watch.ts b/src/cli/commands/watch.ts index 759bdb4d..f2820833 100644 --- a/src/cli/commands/watch.ts +++ b/src/cli/commands/watch.ts @@ -16,6 +16,7 @@ import { Logger } from '../../utils/logger.js'; import { ValidationError } from '../../utils/errors.js'; import { applyGlossarySourceLang, hasGlossarySelection } from '../../utils/glossary-params.js'; import type { ConfigService } from '../../storage/config.js'; +import { DEFAULT_DEBOUNCE_MS } from '../../storage/config.js'; interface WatchOptions { to: string; @@ -164,9 +165,13 @@ export class WatchCommand { stagedFiles?: Set; } = { pattern: options.pattern, stagedFiles }; - if (options.debounce) { - watchServiceOptions.debounceMs = options.debounce; - } + // The default is applied here rather than left to WatchService so that the + // documented value holds and `watch.debounceMs` takes effect: the flag wins, + // then configuration, then the documented default. + watchServiceOptions.debounceMs = + options.debounce ?? + this.config?.getValue('watch.debounceMs') ?? + DEFAULT_DEBOUNCE_MS; if (options.concurrency) { watchServiceOptions.concurrency = options.concurrency; } diff --git a/src/storage/config.ts b/src/storage/config.ts index 06ef78d6..4ee76055 100644 --- a/src/storage/config.ts +++ b/src/storage/config.ts @@ -43,7 +43,10 @@ const FORBIDDEN_KEY_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype'] const DEFAULT_CACHE_SIZE = 1024 * 1024 * 1024; // 1GB const DEFAULT_CACHE_TTL = 30 * 24 * 60 * 60; // 30 days in seconds -const DEFAULT_DEBOUNCE_MS = 500; +/** Debounce delay applied by `watch` when neither the flag nor configuration + * names one. Exported so the CLI default and this schema default stay one + * value. */ +export const DEFAULT_DEBOUNCE_MS = 500; /** * Language values are stored lowercase, matching what `deepl languages` prints diff --git a/tests/unit/watch-command.test.ts b/tests/unit/watch-command.test.ts index 7e354679..344331fb 100644 --- a/tests/unit/watch-command.test.ts +++ b/tests/unit/watch-command.test.ts @@ -243,6 +243,82 @@ describe('WatchCommand', () => { ); }); + it('should apply the documented 500ms default when --debounce is omitted', async () => { + expect.assertions(1); + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); + mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); + + try { + await watchCommand.watch('/some/file.md', { to: 'es' }); + } catch { + // Expected + } + + expect(WatchService).toHaveBeenCalledWith( + mockFileTranslationService, + expect.objectContaining({ + debounceMs: 500, + }) + ); + }); + + it('should honour watch.debounceMs from config when --debounce is omitted', async () => { + expect.assertions(1); + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); + mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); + + const configured = new WatchCommand( + mockTranslationService, + mockGlossaryService, + createMockConfigService({ + getValue: jest.fn((key: string) => (key === 'watch.debounceMs' ? 1200 : undefined)), + }), + ); + + try { + await configured.watch('/some/file.md', { to: 'es' }); + } catch { + // Expected + } + + expect(WatchService).toHaveBeenCalledWith( + mockFileTranslationService, + expect.objectContaining({ + debounceMs: 1200, + }) + ); + }); + + it('should let --debounce override watch.debounceMs from config', async () => { + expect.assertions(1); + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); + mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); + + const configured = new WatchCommand( + mockTranslationService, + mockGlossaryService, + createMockConfigService({ + getValue: jest.fn((key: string) => (key === 'watch.debounceMs' ? 1200 : undefined)), + }), + ); + + try { + await configured.watch('/some/file.md', { to: 'es', debounce: 250 }); + } catch { + // Expected + } + + expect(WatchService).toHaveBeenCalledWith( + mockFileTranslationService, + expect.objectContaining({ + debounceMs: 250, + }) + ); + }); + it('should pass pattern option to WatchService', async () => { expect.assertions(1); (fs.existsSync as jest.Mock).mockReturnValue(true); From b627b424145135ce6fb4aa7fd336b9629fc701dc Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Tue, 4 Aug 2026 22:29:06 -0400 Subject: [PATCH 072/256] refactor(exit-codes): consolidate on exitCodeForError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getExitCodeFromError` was a same-body alias documented as kept "for backwards compatibility with callers that import the old name", but `exit-codes.ts` is not exported from the package entry, so no such caller could exist — the only `src/` use was the CLI's own top-level handler. Dropping it also removes a redundant guard at that call site: the alias narrowed its parameter to `Error`, so the handler tested `instanceof Error` and supplied `GeneralError` itself. `exitCodeForError` takes `unknown` and already returns `GeneralError` for a non-`Error`, so the ternary is now a single call with identical behaviour. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/cli/index.ts | 7 ++----- src/utils/exit-codes.ts | 8 -------- tests/unit/exit-codes.test.ts | 16 ++++++++-------- tests/unit/voice-types-errors.test.ts | 8 ++++---- 4 files changed, 14 insertions(+), 25 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 1c1365ce..1a853154 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -16,7 +16,7 @@ import { resolvePaths } from '../utils/paths.js'; import type { DeepLClient } from '../api/deepl-client.js'; import { Logger } from '../utils/logger.js'; import { AuthError, DeepLCLIError } from '../utils/errors.js'; -import { ExitCode, getExitCodeFromError } from '../utils/exit-codes.js'; +import { ExitCode, exitCodeForError } from '../utils/exit-codes.js'; import { isSymlink } from '../utils/safe-read-file.js'; import { setNoInput } from '../utils/confirm.js'; import { registerAuth } from './commands/register-auth.js'; @@ -76,10 +76,7 @@ const getCacheService = createCacheServiceGetter(() => */ function handleError(error: unknown): never { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - const exitCode = - error instanceof Error - ? getExitCodeFromError(error) - : ExitCode.GeneralError; + const exitCode = exitCodeForError(error); Logger.error(chalk.red('Error:'), errorMessage); diff --git a/src/utils/exit-codes.ts b/src/utils/exit-codes.ts index cb1bb5ce..6b156f72 100644 --- a/src/utils/exit-codes.ts +++ b/src/utils/exit-codes.ts @@ -66,14 +66,6 @@ export function exitCodeForError(error: unknown): ExitCode { return ExitCode.GeneralError; } -/** - * Legacy alias kept for backwards compatibility with callers that import - * the old name. Prefer `exitCodeForError`. - */ -export function getExitCodeFromError(error: Error): ExitCode { - return exitCodeForError(error); -} - function classifyByMessage(rawMessage: string): ExitCode { Logger.verbose(`Untyped error reached fallback classifier: "${rawMessage.substring(0, 120)}"`); const message = rawMessage.toLowerCase(); diff --git a/tests/unit/exit-codes.test.ts b/tests/unit/exit-codes.test.ts index 7be7f989..e7605783 100644 --- a/tests/unit/exit-codes.test.ts +++ b/tests/unit/exit-codes.test.ts @@ -3,7 +3,7 @@ * Tests for exit code classification and retry logic */ -import { ExitCode, getExitCodeFromError, isRetryableError } from '../../src/utils/exit-codes'; +import { ExitCode, exitCodeForError, isRetryableError } from '../../src/utils/exit-codes'; import { AuthError } from '../../src/utils/errors'; import { Logger } from '../../src/utils/logger'; @@ -33,7 +33,7 @@ describe('ExitCode', () => { }); }); - describe('getExitCodeFromError', () => { + describe('exitCodeForError', () => { it.each<[string, string, ExitCode]>([ // AuthError classification ['authentication failed', 'Authentication failed', ExitCode.AuthError], @@ -96,19 +96,19 @@ describe('ExitCode', () => { ])( 'should classify %s error message "%s" → exit code %i', (_, message, expectedCode) => { - expect(getExitCodeFromError(new Error(message))).toBe(expectedCode); + expect(exitCodeForError(new Error(message))).toBe(expectedCode); }, ); describe('priority ordering', () => { it('should prioritize specific auth patterns over generic invalid pattern', () => { const error = new Error('invalid api key'); - expect(getExitCodeFromError(error)).toBe(ExitCode.AuthError); + expect(exitCodeForError(error)).toBe(ExitCode.AuthError); }); it('should classify pure config file errors correctly', () => { const error = new Error('Config file corrupted'); - expect(getExitCodeFromError(error)).toBe(ExitCode.ConfigError); + expect(exitCodeForError(error)).toBe(ExitCode.ConfigError); }); }); @@ -134,20 +134,20 @@ describe('ExitCode', () => { ])( 'should fall back to GeneralError for %s ("%s")', (_, message) => { - expect(getExitCodeFromError(new Error(message))).toBe(ExitCode.GeneralError); + expect(exitCodeForError(new Error(message))).toBe(ExitCode.GeneralError); }, ); it('should log verbose warning when classifyByMessage is invoked', () => { const verboseSpy = jest.spyOn(Logger, 'verbose').mockImplementation(); - getExitCodeFromError(new Error('some unknown error')); + exitCodeForError(new Error('some unknown error')); expect(verboseSpy).toHaveBeenCalledWith(expect.stringContaining('Untyped error')); verboseSpy.mockRestore(); }); it('should not log verbose warning for typed DeepLCLIError', () => { const verboseSpy = jest.spyOn(Logger, 'verbose').mockImplementation(); - getExitCodeFromError(new AuthError('test')); + exitCodeForError(new AuthError('test')); expect(verboseSpy).not.toHaveBeenCalled(); verboseSpy.mockRestore(); }); diff --git a/tests/unit/voice-types-errors.test.ts b/tests/unit/voice-types-errors.test.ts index 6022ebcf..bdf354ae 100644 --- a/tests/unit/voice-types-errors.test.ts +++ b/tests/unit/voice-types-errors.test.ts @@ -3,7 +3,7 @@ */ import { VoiceError } from '../../src/utils/errors.js'; -import { ExitCode, getExitCodeFromError } from '../../src/utils/exit-codes.js'; +import { ExitCode, exitCodeForError } from '../../src/utils/exit-codes.js'; import { formatVoiceJson } from '../../src/utils/formatters.js'; import type { VoiceSessionResult } from '../../src/types/voice.js'; @@ -47,17 +47,17 @@ describe('ExitCode.VoiceError', () => { it('should return VoiceError exit code for VoiceError instances', () => { const error = new VoiceError('test'); - expect(getExitCodeFromError(error)).toBe(ExitCode.VoiceError); + expect(exitCodeForError(error)).toBe(ExitCode.VoiceError); }); it('should classify "voice api" messages as VoiceError', () => { const error = new Error('Voice API not available'); - expect(getExitCodeFromError(error)).toBe(ExitCode.VoiceError); + expect(exitCodeForError(error)).toBe(ExitCode.VoiceError); }); it('should classify "voice session" messages as VoiceError', () => { const error = new Error('Voice session creation failed'); - expect(getExitCodeFromError(error)).toBe(ExitCode.VoiceError); + expect(exitCodeForError(error)).toBe(ExitCode.VoiceError); }); }); From 01ec9a3890845750bfc052339adb3296e5e18c60 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Tue, 4 Aug 2026 22:37:51 -0400 Subject: [PATCH 073/256] style: apply prettier to src and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm run format:check` failed on 409 of the repo's TypeScript files, so the gate could not be used to catch anything. This applies the existing `.prettierrc` with no configuration changes. `src/data/language-entries.ts` stays untouched, as `.prettierignore` requires: `check:languages` compares its raw text, so reformatting it would report permanent drift. Reformatting relocated four `eslint-disable-next-line` directives away from the lines they suppressed, because rewrapping to the 80-column width split those lines in two. That turned one into a `require-yield` error and left `prefer-nullish-coalescing` reported 15 times against deliberate `||` uses. Each directive is restored over the code it covers, using the block disable/enable form where a statement now spans several lines and a `-next-line` directive cannot reach it. `paths.ts` and `run-cli.ts` keep `||` on purpose — an empty string is a value `??` would pass through, but which the XDG spec and the exit-status handling both need to treat as unset. Lint, type-check, `format:check` and all 284 suites pass; lint is back to the zero-warning baseline it had before the reformat. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/api/admin-client.ts | 59 +- src/api/deepl-client.ts | 94 +- src/api/document-client.ts | 27 +- src/api/glossary-client.ts | 45 +- src/api/http-client.ts | 36 +- src/api/style-rules-client.ts | 74 +- src/api/translation-client.ts | 100 +- src/api/voice-client.ts | 3 +- src/api/write-client.ts | 8 +- src/cli/cache-loader.ts | 14 +- src/cli/commands/admin.ts | 61 +- src/cli/commands/auth.ts | 13 +- src/cli/commands/cache.ts | 16 +- src/cli/commands/completion.ts | 33 +- src/cli/commands/config.ts | 45 +- src/cli/commands/detect.ts | 12 +- src/cli/commands/glossary.ts | 36 +- src/cli/commands/hooks.ts | 20 +- src/cli/commands/init.ts | 5 +- src/cli/commands/languages.ts | 127 +- src/cli/commands/parse-int-option.ts | 8 +- src/cli/commands/register-admin.ts | 144 +- src/cli/commands/register-auth.ts | 102 +- src/cli/commands/register-cache.ts | 72 +- src/cli/commands/register-completion.ts | 15 +- src/cli/commands/register-config.ts | 38 +- src/cli/commands/register-correct.ts | 48 +- src/cli/commands/register-describe.ts | 14 +- src/cli/commands/register-detect.ts | 24 +- src/cli/commands/register-glossary.ts | 484 ++++--- src/cli/commands/register-hooks.ts | 34 +- src/cli/commands/register-init.ts | 29 +- src/cli/commands/register-languages.ts | 239 ++-- src/cli/commands/register-style-rules.ts | 581 +++++--- src/cli/commands/register-tm.ts | 17 +- src/cli/commands/register-translate.ts | 556 +++++--- src/cli/commands/register-usage.ts | 24 +- src/cli/commands/register-voice.ts | 188 ++- src/cli/commands/register-watch.ts | 260 ++-- src/cli/commands/register-write.ts | 151 +- src/cli/commands/service-factory.ts | 93 +- src/cli/commands/style-rules.ts | 82 +- src/cli/commands/sync-command.ts | 233 +++- src/cli/commands/sync/register-sync-audit.ts | 45 +- src/cli/commands/sync/register-sync-export.ts | 49 +- src/cli/commands/sync/register-sync-init.ts | 59 +- src/cli/commands/sync/register-sync-pull.ts | 34 +- src/cli/commands/sync/register-sync-push.ts | 35 +- .../commands/sync/register-sync-resolve.ts | 38 +- src/cli/commands/sync/register-sync-root.ts | 69 +- src/cli/commands/sync/register-sync-status.ts | 29 +- .../commands/sync/register-sync-validate.ts | 26 +- src/cli/commands/sync/sync-options.ts | 20 +- src/cli/commands/tm.ts | 2 +- src/cli/commands/translate.ts | 23 +- .../directory-translation-handler.ts | 45 +- .../translate/document-translation-handler.ts | 86 +- .../translate/file-translation-handler.ts | 69 +- src/cli/commands/translate/index.ts | 11 +- .../translate/text-translation-handler.ts | 103 +- src/cli/commands/translate/translate-utils.ts | 112 +- .../translate/translation-options-factory.ts | 22 +- src/cli/commands/usage.ts | 108 +- src/cli/commands/voice.ts | 212 ++- src/cli/commands/watch.ts | 79 +- src/cli/commands/write.ts | 94 +- src/cli/index.ts | 14 +- src/cli/node-version-check.ts | 4 +- src/data/language-registry.ts | 25 +- src/formats/android-xml.ts | 159 ++- src/formats/arb.ts | 7 +- src/formats/format.ts | 6 +- src/formats/index.ts | 11 +- src/formats/ios-strings.ts | 71 +- src/formats/json.ts | 56 +- src/formats/php-arrays.ts | 42 +- src/formats/po.ts | 53 +- src/formats/properties.ts | 57 +- src/formats/toml.ts | 40 +- src/formats/xcstrings.ts | 19 +- src/formats/xliff.ts | 68 +- src/formats/xml-scan.ts | 28 +- src/formats/yaml.ts | 44 +- src/services/admin.ts | 8 +- src/services/batch-translation.ts | 131 +- src/services/detect.ts | 8 +- src/services/document-translation.ts | 2 +- src/services/file-translation.ts | 16 +- src/services/git-hooks.ts | 113 +- src/services/glossary.ts | 177 ++- src/services/languages.ts | 4 +- src/services/structured-file-translation.ts | 27 +- src/services/style-rules.ts | 28 +- src/services/translation-memory.ts | 26 +- src/services/translation.ts | 115 +- src/services/voice-stream-session.ts | 81 +- src/services/voice.ts | 59 +- src/services/watch.ts | 54 +- src/services/write.ts | 17 +- src/storage/cache.ts | 60 +- src/storage/config.ts | 94 +- src/sync/sync-bak-cleanup.ts | 37 +- src/sync/sync-bucket-walker.ts | 34 +- src/sync/sync-config.ts | 260 ++-- src/sync/sync-context.ts | 193 ++- src/sync/sync-differ.ts | 40 +- src/sync/sync-export.ts | 25 +- src/sync/sync-finalize.ts | 4 +- src/sync/sync-glossary-report.ts | Bin 3095 -> 3150 bytes src/sync/sync-glossary.ts | 67 +- src/sync/sync-init-validate.ts | 30 +- src/sync/sync-init.ts | 50 +- src/sync/sync-instructions.ts | 57 +- src/sync/sync-locale-translator.ts | 383 +++-- src/sync/sync-lock.ts | 73 +- src/sync/sync-message-preprocess.ts | 65 +- src/sync/sync-process-bucket.ts | 374 +++-- src/sync/sync-process-lock.ts | 12 +- src/sync/sync-resolve.ts | 64 +- src/sync/sync-service.ts | 200 ++- src/sync/sync-status.ts | 27 +- src/sync/sync-tms.ts | 84 +- src/sync/sync-utils.ts | 38 +- src/sync/sync-validate.ts | 32 +- src/sync/tm-cache.ts | 7 +- src/sync/tms-client.ts | 136 +- src/sync/translation-validator.ts | Bin 8450 -> 8684 bytes src/sync/types.ts | 4 +- src/types/api.ts | 22 +- src/types/glossary.ts | 13 +- src/types/voice.ts | 10 +- src/utils/atomic-write.ts | 36 +- src/utils/errors.ts | 34 +- src/utils/exit-codes.ts | 12 +- src/utils/formality.ts | 5 +- src/utils/formatters.ts | 25 +- src/utils/glob-prefix.ts | 26 +- src/utils/glossary-params.ts | 20 +- src/utils/icu-preservation.ts | 28 +- src/utils/logger.ts | 28 +- src/utils/output-helper.ts | 6 +- src/utils/parse-size.ts | 20 +- src/utils/paths.ts | 11 +- src/utils/safe-read-file.ts | 23 +- src/utils/tag-handling-version.ts | 2 +- src/utils/text-preservation.ts | 25 +- src/utils/unrecoverable-request-error.ts | 2 +- src/utils/validate-url.ts | 6 +- src/version.ts | 4 +- tests/e2e/cli-aliases.e2e.test.ts | 2 +- tests/e2e/cli-auth.e2e.test.ts | 5 +- tests/e2e/cli-cache-degraded.e2e.test.ts | 35 +- tests/e2e/cli-cache.e2e.test.ts | 12 +- tests/e2e/cli-config.e2e.test.ts | 4 +- tests/e2e/cli-correct.e2e.test.ts | 30 +- tests/e2e/cli-detect.e2e.test.ts | 8 +- .../e2e/cli-document-translation.e2e.test.ts | 8 +- tests/e2e/cli-file-translation.e2e.test.ts | 7 +- tests/e2e/cli-http-options.e2e.test.ts | 19 +- tests/e2e/cli-languages.e2e.test.ts | 82 +- tests/e2e/cli-multi-glossary.e2e.test.ts | 28 +- tests/e2e/cli-no-input.e2e.test.ts | 13 +- tests/e2e/cli-parse-errors.e2e.test.ts | 2 +- tests/e2e/cli-stdin-stdout.e2e.test.ts | 82 +- tests/e2e/cli-stdout-routing.e2e.test.ts | 33 +- tests/e2e/cli-structured-file.e2e.test.ts | 63 +- tests/e2e/cli-success-paths.e2e.test.ts | 71 +- tests/e2e/cli-sync-error-envelope.e2e.test.ts | 91 +- tests/e2e/cli-sync-force-guard.e2e.test.ts | 47 +- tests/e2e/cli-sync-init.e2e.test.ts | 32 +- tests/e2e/cli-sync-json-contract.e2e.test.ts | 69 +- tests/e2e/cli-sync-option-routing.e2e.test.ts | 166 ++- tests/e2e/cli-sync-push-pull.e2e.test.ts | 128 +- tests/e2e/cli-sync-tms.e2e.test.ts | 80 +- tests/e2e/cli-sync-watch.e2e.test.ts | 26 +- tests/e2e/cli-sync.e2e.test.ts | 613 +++++--- tests/e2e/cli-voice.e2e.test.ts | 37 +- tests/e2e/cli-watch.e2e.test.ts | 54 +- tests/e2e/cli-workflow.e2e.test.ts | 2 +- tests/e2e/cli-write.e2e.test.ts | 46 +- tests/global-setup.ts | 4 +- tests/helpers/assert-error-envelope.ts | 63 +- tests/helpers/mock-factories.ts | 320 +++-- tests/helpers/nock-setup.ts | 23 +- tests/helpers/run-cli.ts | 92 +- tests/helpers/sync-harness.ts | 84 +- tests/helpers/tms-nock.ts | 27 +- tests/hermetic-deepl.ts | 15 +- .../admin-client.integration.test.ts | 12 +- .../batch-translation.integration.test.ts | 80 +- .../integration/cli-auth.integration.test.ts | 8 +- .../cli-completion.integration.test.ts | 20 +- .../cli-config-file.integration.test.ts | 111 +- .../cli-config.integration.test.ts | 10 +- .../cli-correct.integration.test.ts | 75 +- .../cli-detect.integration.test.ts | 25 +- ...i-document-translation.integration.test.ts | 169 +-- .../cli-dry-run.integration.test.ts | 63 +- .../cli-help-hint.integration.test.ts | 19 +- .../integration/cli-hooks.integration.test.ts | 7 +- .../cli-languages.integration.test.ts | 4 +- .../cli-lazy-cache.integration.test.ts | 19 +- .../cli-no-input.integration.test.ts | 4 +- ...uctured-file-translate.integration.test.ts | 162 ++- .../cli-style-rules.integration.test.ts | 116 +- .../cli-translate.integration.test.ts | 97 +- .../integration/cli-watch.integration.test.ts | 103 +- .../integration/cli-write.integration.test.ts | 286 ++-- .../deepl-client.integration.test.ts | 337 +++-- .../deepl-glossary-v3.integration.test.ts | 159 ++- .../file-translation.integration.test.ts | 33 +- .../git-hooks-resolution.integration.test.ts | 68 +- .../sync-auto-commit.integration.test.ts | 96 +- .../sync-concurrent.integration.test.ts | 65 +- ...-export-path-traversal.integration.test.ts | 34 +- ...nc-init-format-choices.integration.test.ts | 12 +- .../integration/sync-init.integration.test.ts | 112 +- ...translator-plural-perf.integration.test.ts | 22 +- .../sync-php-arrays.integration.test.ts | 40 +- .../sync-properties.integration.test.ts | 30 +- .../sync-scan-bounds.integration.test.ts | 29 +- ...stale-lock-fg-coalesce.integration.test.ts | 84 +- .../sync-symlink-safety.integration.test.ts | 64 +- ...te-patterns-dedup-perf.integration.test.ts | 40 +- .../sync-template-prep.integration.test.ts | 27 +- .../sync-tms-push.integration.test.ts | 78 +- .../integration/sync-tms.integration.test.ts | 83 +- .../integration/sync-toml.integration.test.ts | 31 +- ...sync-watch-reliability.integration.test.ts | 37 +- .../sync-watch.integration.test.ts | 15 +- .../sync-xcstrings.integration.test.ts | 42 +- tests/integration/sync.integration.test.ts | 1227 +++++++++++++---- .../watch-auto-commit.integration.test.ts | 39 +- tests/require-build.ts | 16 +- tests/setup.ts | 2 +- tests/unit/admin-client.test.ts | 24 +- tests/unit/admin-command.test.ts | 48 +- tests/unit/atomic-write.test.ts | 118 +- tests/unit/auth-command.test.ts | 216 ++- tests/unit/cache-backcompat.test.ts | 19 +- tests/unit/cache-command.test.ts | 35 +- tests/unit/cache-corruption-allowlist.test.ts | 20 +- tests/unit/cache-loader.test.ts | 31 +- tests/unit/cache-native-failure.test.ts | 34 +- tests/unit/cache-service.test.ts | 90 +- tests/unit/cli-did-you-mean.test.ts | 19 +- tests/unit/cli-no-args-exit.test.ts | 4 +- tests/unit/cli-translate-workflow.test.ts | 212 +-- tests/unit/cli.test.ts | 67 +- tests/unit/cli/node-version-check.test.ts | 4 +- .../unit/cli/register-sync-audit-lazy.test.ts | 5 +- .../unit/cli/register-sync-force-help.test.ts | 7 +- tests/unit/cli/register-sync-init.test.ts | 73 +- tests/unit/cli/register-sync-root.test.ts | 10 +- .../register-sync-scan-context-help.test.ts | 5 +- tests/unit/cli/register-sync-tms-help.test.ts | 6 +- .../register-sync.commander-snapshot.test.ts | 472 ++++--- tests/unit/cli/sync-options.test.ts | 17 +- tests/unit/completion-command.test.ts | 65 +- tests/unit/concurrency-limiting.test.ts | 64 +- tests/unit/concurrency.test.ts | 17 +- tests/unit/config-command.test.ts | 75 +- tests/unit/config-service.test.ts | 48 +- tests/unit/confirm.test.ts | 126 +- tests/unit/deepl-client-document.test.ts | 4 +- tests/unit/deepl-client-lazy.test.ts | 60 +- tests/unit/deepl-client.test.ts | 466 ++++--- tests/unit/detect-command.test.ts | 92 +- tests/unit/detect-service.test.ts | 4 +- .../directory-translation-handler.test.ts | 130 +- tests/unit/docs/documented-surface.test.ts | 57 +- tests/unit/docs/sync-terminology.test.ts | 4 +- tests/unit/document-client.test.ts | 64 +- .../unit/document-translation-handler.test.ts | 202 ++- .../unit/document-translation-service.test.ts | 127 +- tests/unit/dry-run.test.ts | 118 +- tests/unit/error-suggestions.test.ts | 81 +- tests/unit/errors.test.ts | 5 +- tests/unit/exit-codes.test.ts | 154 ++- tests/unit/file-translation-handler.test.ts | 436 ++++-- tests/unit/file-translation-service.test.ts | 15 +- tests/unit/formats-android-cdata.test.ts | 48 +- tests/unit/formats-crlf.test.ts | 24 +- tests/unit/formats-roundtrip.test.ts | 23 +- tests/unit/formats-toml-sections.test.ts | 74 +- tests/unit/formats-xcstrings-arb.test.ts | 58 +- tests/unit/formats-xliff-attributes.test.ts | 11 +- tests/unit/formats/android-xml.test.ts | 30 +- tests/unit/formats/arb.test.ts | 282 ++-- tests/unit/formats/detect-indent.test.ts | 21 +- tests/unit/formats/format-registry.test.ts | 59 +- tests/unit/formats/ios-strings.test.ts | 29 +- tests/unit/formats/json.test.ts | 191 ++- tests/unit/formats/php-arrays.test.ts | 97 +- tests/unit/formats/po.test.ts | 167 +-- tests/unit/formats/properties.test.ts | 17 +- tests/unit/formats/toml.test.ts | 64 +- tests/unit/formats/xcstrings.test.ts | 163 ++- tests/unit/formats/xliff.test.ts | 12 +- tests/unit/formats/xml-scan.test.ts | 35 +- tests/unit/formats/yaml.test.ts | 55 +- tests/unit/formatters.test.ts | 12 +- tests/unit/generate-language-registry.test.ts | 101 +- tests/unit/glossary-client.test.ts | 98 +- tests/unit/glossary-command.test.ts | 443 ++++-- tests/unit/glossary-params.test.ts | 57 +- tests/unit/glossary-service.test.ts | 611 +++++--- tests/unit/hermetic-deepl.test.ts | 8 +- tests/unit/hooks-command.test.ts | 30 +- tests/unit/http-client.test.ts | 99 +- tests/unit/http-retry-policy.test.ts | 94 +- tests/unit/icu-surrounding-text.test.ts | 7 +- tests/unit/init-command.test.ts | 9 +- tests/unit/language-registry.test.ts | 210 ++- tests/unit/languages-command.test.ts | 351 +++-- tests/unit/languages-service.test.ts | 8 +- tests/unit/logger.test.ts | 42 +- tests/unit/package-manifest.test.ts | 42 +- tests/unit/paths.test.ts | 12 +- tests/unit/prototype-key-safety.test.ts | 184 ++- tests/unit/read-stdin.test.ts | 9 +- tests/unit/register-admin.test.ts | 196 ++- .../unit/register-cache-registration.test.ts | 186 ++- tests/unit/register-commands-group1.test.ts | 182 ++- tests/unit/register-config.test.ts | 89 +- tests/unit/register-correct.test.ts | 142 +- tests/unit/register-glossary.test.ts | 534 +++++-- tests/unit/register-hooks.test.ts | 33 +- tests/unit/register-init.test.ts | 19 +- tests/unit/register-languages.test.ts | 389 ++++-- tests/unit/register-style-rules.test.ts | 421 +++++- tests/unit/register-translate.test.ts | 187 ++- tests/unit/register-usage.test.ts | 39 +- tests/unit/register-voice.test.ts | 180 ++- tests/unit/register-write.test.ts | 588 ++++++-- tests/unit/resolve-endpoint.test.ts | 30 +- tests/unit/retry-after-blank.test.ts | 9 +- tests/unit/safe-read-file.test.ts | 11 +- tests/unit/service-factory-voice.test.ts | 25 +- tests/unit/services/batch-translation.test.ts | 134 +- tests/unit/services/git-hooks.test.ts | 145 +- tests/unit/services/watch.test.ts | 64 +- tests/unit/signal-exit.test.ts | 4 +- tests/unit/stdin-translation-handler.test.ts | 25 +- .../unit/structured-file-translation.test.ts | 285 ++-- tests/unit/style-rules-client.test.ts | 313 +++-- tests/unit/style-rules-command.test.ts | 108 +- tests/unit/style-rules-service.test.ts | 41 +- .../sync/icu-structure-validation.test.ts | 42 +- tests/unit/sync/sync-bak-cleanup.test.ts | 54 +- tests/unit/sync/sync-bucket-walker.test.ts | 36 +- tests/unit/sync/sync-command.test.ts | 521 +++++-- .../sync-config-include-containment.test.ts | 16 +- tests/unit/sync/sync-config.test.ts | 1119 ++++++++++----- tests/unit/sync/sync-context.test.ts | 272 +++- .../unit/sync/sync-differ-per-locale.test.ts | 38 +- tests/unit/sync/sync-differ.test.ts | 94 +- tests/unit/sync/sync-export.test.ts | 16 +- tests/unit/sync/sync-finalize-failure.test.ts | 8 +- tests/unit/sync/sync-glossary-report.test.ts | 214 ++- tests/unit/sync/sync-glossary.test.ts | 353 +++-- .../sync/sync-init-detector-table.test.ts | 193 ++- tests/unit/sync/sync-init-validate.test.ts | 68 +- tests/unit/sync/sync-init.test.ts | 171 ++- tests/unit/sync/sync-instructions.test.ts | 78 +- .../unit/sync/sync-locale-translator.test.ts | 684 +++++++-- tests/unit/sync/sync-lock.test.ts | 100 +- .../unit/sync/sync-message-preprocess.test.ts | 43 +- tests/unit/sync/sync-process-bucket.test.ts | 67 +- tests/unit/sync/sync-push-pull.test.ts | 131 +- tests/unit/sync/sync-resolve.test.ts | 77 +- tests/unit/sync/sync-service.test.ts | 1089 ++++++++++----- .../sync/sync-source-as-translation.test.ts | 95 +- tests/unit/sync/sync-status.test.ts | 69 +- tests/unit/sync/sync-tms.test.ts | 77 +- tests/unit/sync/sync-utils.test.ts | 167 ++- tests/unit/sync/sync-validate.test.ts | 101 +- tests/unit/sync/tms-client.test.ts | 298 +++- tests/unit/sync/translation-validator.test.ts | 148 +- tests/unit/tag-handling-version.test.ts | 14 +- tests/unit/text-preservation-loop.test.ts | 11 +- tests/unit/text-preservation.test.ts | 9 +- tests/unit/text-translation-handler.test.ts | 442 ++++-- tests/unit/tm-command.test.ts | 28 +- tests/unit/translate-command.test.ts | 1208 +++++++++++----- tests/unit/translate-default-target.test.ts | 44 +- tests/unit/translate-utils.test.ts | 317 +++-- tests/unit/translation-client.test.ts | 161 ++- tests/unit/translation-memory.test.ts | 96 +- .../unit/translation-options-factory.test.ts | 224 ++- tests/unit/translation-service.test.ts | 464 +++++-- tests/unit/type-guards.test.ts | 34 +- tests/unit/types/glossary-types.test.ts | 12 +- .../unit/unrecoverable-request-error.test.ts | 36 +- tests/unit/usage-command.test.ts | 108 +- tests/unit/utils/glob-prefix.test.ts | 40 +- tests/unit/utils/icu-preservation.test.ts | 101 +- tests/unit/uuid.test.ts | 30 +- tests/unit/validate-url.test.ts | 40 +- .../voice-command-endpoint-resolution.test.ts | 9 +- tests/unit/voice-command.test.ts | 615 ++++++--- tests/unit/voice-glossary-resolution.test.ts | 157 ++- tests/unit/voice-service.test.ts | 666 +++++---- tests/unit/voice-stream-session.test.ts | 648 +++++---- tests/unit/voice-types-errors.test.ts | 8 +- tests/unit/watch-command.test.ts | 253 +++- tests/unit/write-client.test.ts | 107 +- tests/unit/write-command.test.ts | 94 +- tests/unit/write-service.test.ts | 125 +- 409 files changed, 29299 insertions(+), 12204 deletions(-) diff --git a/src/api/admin-client.ts b/src/api/admin-client.ts index bfe8550e..64852c9e 100644 --- a/src/api/admin-client.ts +++ b/src/api/admin-client.ts @@ -1,5 +1,10 @@ import { HttpClient, DeepLClientOptions } from './http-client.js'; -import { AdminApiKey, AdminUsageOptions, AdminUsageReport, UsageBreakdown } from '../types/index.js'; +import { + AdminApiKey, + AdminUsageOptions, + AdminUsageReport, + UsageBreakdown, +} from '../types/index.js'; import { AuthError } from '../utils/errors.js'; export class AdminClient extends HttpClient { @@ -15,26 +20,31 @@ export class AdminClient extends HttpClient { protected override handleError( error: unknown, context?: string, - traceId?: string, + traceId?: string ): Error { const result = super.handleError(error, context, traceId); if (result instanceof AuthError) { return new AuthError( result.message, - 'The admin API requires an administrator API key; a valid regular API key is not sufficient. Use a key created by your DeepL account administrator.', + 'The admin API requires an administrator API key; a valid regular API key is not sufficient. Use a key created by your DeepL account administrator.' ); } return result; } async listApiKeys(): Promise { - const response = await this.makeJsonRequest>('GET', '/v2/admin/developer-keys'); + const response = await this.makeJsonRequest< + Array<{ + key_id: string; + label: string; + creation_time: string; + is_deactivated: boolean; + usage_limits?: { + characters?: number | null; + speech_to_text_milliseconds?: number | null; + }; + }> + >('GET', '/v2/admin/developer-keys'); return response.map((key) => this.normalizeApiKey(key)); } @@ -51,7 +61,10 @@ export class AdminClient extends HttpClient { label: string; creation_time: string; is_deactivated: boolean; - usage_limits?: { characters?: number | null; speech_to_text_milliseconds?: number | null }; + usage_limits?: { + characters?: number | null; + speech_to_text_milliseconds?: number | null; + }; }>('POST', '/v2/admin/developer-keys', body); return this.normalizeApiKey(response); @@ -59,27 +72,31 @@ export class AdminClient extends HttpClient { async deactivateApiKey(keyId: string): Promise { await this.makeJsonRequest( - 'PUT', '/v2/admin/developer-keys/deactivate', { key_id: keyId } + 'PUT', + '/v2/admin/developer-keys/deactivate', + { key_id: keyId } ); } async renameApiKey(keyId: string, label: string): Promise { - await this.makeJsonRequest( - 'PUT', '/v2/admin/developer-keys/label', { key_id: keyId, label } - ); + await this.makeJsonRequest('PUT', '/v2/admin/developer-keys/label', { + key_id: keyId, + label, + }); } async setApiKeyLimit( keyId: string, characters: number | null, - speechToTextMilliseconds?: number | null, + speechToTextMilliseconds?: number | null ): Promise { const body: Record = { key_id: keyId, characters }; if (speechToTextMilliseconds !== undefined) { body['speech_to_text_milliseconds'] = speechToTextMilliseconds; } await this.makeJsonRequest( - 'PUT', '/v2/admin/developer-keys/limits', + 'PUT', + '/v2/admin/developer-keys/limits', body ); } @@ -152,7 +169,10 @@ export class AdminClient extends HttpClient { label: string; creation_time: string; is_deactivated: boolean; - usage_limits?: { characters?: number | null; speech_to_text_milliseconds?: number | null }; + usage_limits?: { + characters?: number | null; + speech_to_text_milliseconds?: number | null; + }; }): AdminApiKey { const result: AdminApiKey = { keyId: key.key_id, @@ -164,7 +184,8 @@ export class AdminClient extends HttpClient { result.usageLimits = { characters: key.usage_limits.characters, ...(key.usage_limits.speech_to_text_milliseconds !== undefined && { - speechToTextMilliseconds: key.usage_limits.speech_to_text_milliseconds, + speechToTextMilliseconds: + key.usage_limits.speech_to_text_milliseconds, }), }; } diff --git a/src/api/deepl-client.ts b/src/api/deepl-client.ts index 31e77166..3496317e 100644 --- a/src/api/deepl-client.ts +++ b/src/api/deepl-client.ts @@ -1,5 +1,14 @@ import { HttpClient, DeepLClientOptions } from './http-client.js'; -import { TranslationClient, TranslationResult, isTranslationResult, ProductUsage, UsageInfo, LanguageInfo, LanguageFeature, LanguageFeatures } from './translation-client.js'; +import { + TranslationClient, + TranslationResult, + isTranslationResult, + ProductUsage, + UsageInfo, + LanguageInfo, + LanguageFeature, + LanguageFeatures, +} from './translation-client.js'; import { GlossaryClient } from './glossary-client.js'; import { DocumentClient } from './document-client.js'; import { WriteClient } from './write-client.js'; @@ -31,7 +40,15 @@ import { AdminUsageReport, } from '../types/index.js'; -export { TranslationResult, isTranslationResult, ProductUsage, UsageInfo, LanguageInfo, LanguageFeature, LanguageFeatures }; +export { + TranslationResult, + isTranslationResult, + ProductUsage, + UsageInfo, + LanguageInfo, + LanguageFeature, + LanguageFeatures, +}; export class DeepLClient { private readonly apiKey: string; @@ -51,7 +68,10 @@ export class DeepLClient { } private get translationClient(): TranslationClient { - this._translationClient ??= new TranslationClient(this.apiKey, this.options); + this._translationClient ??= new TranslationClient( + this.apiKey, + this.options + ); return this._translationClient; } @@ -131,7 +151,12 @@ export class DeepLClient { targetLangs: Language[], entries: string ): Promise { - return this.glossaryClient.createGlossary(name, sourceLang, targetLangs, entries); + return this.glossaryClient.createGlossary( + name, + sourceLang, + targetLangs, + entries + ); } async listGlossaries(): Promise { @@ -151,7 +176,11 @@ export class DeepLClient { sourceLang: Language, targetLang: Language ): Promise { - return this.glossaryClient.getGlossaryEntries(glossaryId, sourceLang, targetLang); + return this.glossaryClient.getGlossaryEntries( + glossaryId, + sourceLang, + targetLang + ); } async updateGlossaryEntries( @@ -160,7 +189,12 @@ export class DeepLClient { targetLang: Language, entries: string ): Promise { - return this.glossaryClient.updateGlossaryEntries(glossaryId, sourceLang, targetLang, entries); + return this.glossaryClient.updateGlossaryEntries( + glossaryId, + sourceLang, + targetLang, + entries + ); } async replaceGlossaryDictionary( @@ -169,7 +203,12 @@ export class DeepLClient { targetLang: Language, entries: string ): Promise { - return this.glossaryClient.replaceGlossaryDictionary(glossaryId, sourceLang, targetLang, entries); + return this.glossaryClient.replaceGlossaryDictionary( + glossaryId, + sourceLang, + targetLang, + entries + ); } async updateGlossary( @@ -196,7 +235,11 @@ export class DeepLClient { sourceLang: Language, targetLang: Language ): Promise { - return this.glossaryClient.deleteGlossaryDictionary(glossaryId, sourceLang, targetLang); + return this.glossaryClient.deleteGlossaryDictionary( + glossaryId, + sourceLang, + targetLang + ); } async uploadDocument( @@ -238,11 +281,17 @@ export class DeepLClient { return this.styleRulesClient.createStyleRule(options); } - async getStyleRule(styleId: string, detailed = false): Promise { + async getStyleRule( + styleId: string, + detailed = false + ): Promise { return this.styleRulesClient.getStyleRule(styleId, detailed); } - async updateStyleRule(styleId: string, options: UpdateStyleRuleOptions): Promise { + async updateStyleRule( + styleId: string, + options: UpdateStyleRuleOptions + ): Promise { return this.styleRulesClient.updateStyleRule(styleId, options); } @@ -250,27 +299,37 @@ export class DeepLClient { return this.styleRulesClient.deleteStyleRule(styleId); } - async replaceConfiguredRules(styleId: string, rules: ConfiguredRules): Promise { + async replaceConfiguredRules( + styleId: string, + rules: ConfiguredRules + ): Promise { return this.styleRulesClient.replaceConfiguredRules(styleId, rules); } async createCustomInstruction( styleId: string, - options: CreateCustomInstructionOptions, + options: CreateCustomInstructionOptions ): Promise { return this.styleRulesClient.createCustomInstruction(styleId, options); } - async getCustomInstruction(styleId: string, label: string): Promise { + async getCustomInstruction( + styleId: string, + label: string + ): Promise { return this.styleRulesClient.getCustomInstruction(styleId, label); } async updateCustomInstruction( styleId: string, label: string, - options: UpdateCustomInstructionOptions, + options: UpdateCustomInstructionOptions ): Promise { - return this.styleRulesClient.updateCustomInstruction(styleId, label, options); + return this.styleRulesClient.updateCustomInstruction( + styleId, + label, + options + ); } async deleteCustomInstruction(styleId: string, label: string): Promise { @@ -293,7 +352,10 @@ export class DeepLClient { return this.adminClient.renameApiKey(keyId, label); } - async setApiKeyLimit(keyId: string, characters: number | null): Promise { + async setApiKeyLimit( + keyId: string, + characters: number | null + ): Promise { return this.adminClient.setApiKeyLimit(keyId, characters); } diff --git a/src/api/document-client.ts b/src/api/document-client.ts index 020084df..6e4bf905 100644 --- a/src/api/document-client.ts +++ b/src/api/document-client.ts @@ -1,5 +1,9 @@ import { HttpClient, DeepLClientOptions } from './http-client.js'; -import { DocumentTranslationOptions, DocumentHandle, DocumentStatus } from '../types/index.js'; +import { + DocumentTranslationOptions, + DocumentHandle, + DocumentStatus, +} from '../types/index.js'; import { ValidationError } from '../utils/errors.js'; import { normalizeFormality } from '../utils/formality.js'; import { @@ -42,7 +46,9 @@ export class DocumentClient extends HttpClient { } if (!options.filename) { - throw new ValidationError('filename is required when uploading document as Buffer'); + throw new ValidationError( + 'filename is required when uploading document as Buffer' + ); } const { default: FormData } = await import('form-data'); @@ -54,13 +60,22 @@ export class DocumentClient extends HttpClient { () => { const formData = new FormData(); formData.append('file', file, options.filename); - formData.append('target_lang', this.normalizeLanguage(options.targetLang).toUpperCase()); + formData.append( + 'target_lang', + this.normalizeLanguage(options.targetLang).toUpperCase() + ); if (options.sourceLang) { - formData.append('source_lang', this.normalizeLanguage(options.sourceLang).toUpperCase()); + formData.append( + 'source_lang', + this.normalizeLanguage(options.sourceLang).toUpperCase() + ); } if (options.formality) { - formData.append('formality', normalizeFormality(options.formality, 'text')); + formData.append( + 'formality', + normalizeFormality(options.formality, 'text') + ); } const glossaryParams = resolveGlossaryWireParams(options); if (glossaryParams) { @@ -69,7 +84,7 @@ export class DocumentClient extends HttpClient { } else { formData.append( 'glossary_ids', - encodeGlossaryIdsForMultipart(glossaryParams.glossary_ids), + encodeGlossaryIdsForMultipart(glossaryParams.glossary_ids) ); } } diff --git a/src/api/glossary-client.ts b/src/api/glossary-client.ts index 53c7d701..2aed47b8 100644 --- a/src/api/glossary-client.ts +++ b/src/api/glossary-client.ts @@ -1,5 +1,11 @@ import { HttpClient, DeepLClientOptions } from './http-client.js'; -import { Language, GlossaryInfo, GlossaryLanguagePair, normalizeGlossaryInfo, GlossaryApiResponse } from '../types/index.js'; +import { + Language, + GlossaryInfo, + GlossaryLanguagePair, + normalizeGlossaryInfo, + GlossaryApiResponse, +} from '../types/index.js'; import { ValidationError } from '../utils/errors.js'; interface DeepLV3GlossaryLanguageResponse { @@ -58,7 +64,7 @@ export class GlossaryClient extends HttpClient { throw new ValidationError('At least one target language is required'); } - const dictionaries = targetLangs.map(targetLang => ({ + const dictionaries = targetLangs.map((targetLang) => ({ source_lang: sourceLang.toUpperCase(), target_lang: targetLang.toUpperCase(), entries, @@ -66,7 +72,8 @@ export class GlossaryClient extends HttpClient { })); const response = await this.makeJsonRequest( - 'POST', '/v3/glossaries', + 'POST', + '/v3/glossaries', { name, dictionaries } ); @@ -75,13 +82,12 @@ export class GlossaryClient extends HttpClient { async listGlossaries(): Promise { try { - const response = await this.makeRequest<{ glossaries: GlossaryApiResponse[] }>( - 'GET', - '/v3/glossaries' - ); + const response = await this.makeRequest<{ + glossaries: GlossaryApiResponse[]; + }>('GET', '/v3/glossaries'); return (response.glossaries || []).map((g) => - normalizeGlossaryInfo(g, { warnOnEmpty: false }), + normalizeGlossaryInfo(g, { warnOnEmpty: false }) ); } catch (error) { throw this.handleError(error, 'listGlossaries'); @@ -100,10 +106,7 @@ export class GlossaryClient extends HttpClient { async deleteGlossary(glossaryId: string): Promise { this.validateGlossaryId(glossaryId); - await this.makeRequest( - 'DELETE', - `/v3/glossaries/${glossaryId}` - ); + await this.makeRequest('DELETE', `/v3/glossaries/${glossaryId}`); } async getGlossaryEntries( @@ -143,18 +146,16 @@ export class GlossaryClient extends HttpClient { entries: string ): Promise { this.validateGlossaryId(glossaryId); - await this.makeJsonRequest( - 'PATCH', - `/v3/glossaries/${glossaryId}`, - { - dictionaries: [{ + await this.makeJsonRequest('PATCH', `/v3/glossaries/${glossaryId}`, { + dictionaries: [ + { source_lang: sourceLang.toUpperCase(), target_lang: targetLang.toUpperCase(), entries, entries_format: 'tsv', - }], - } - ); + }, + ], + }); } async replaceGlossaryDictionary( @@ -190,7 +191,9 @@ export class GlossaryClient extends HttpClient { ): Promise { this.validateGlossaryId(glossaryId); if (!updates.name && !updates.dictionaries) { - throw new ValidationError('At least one of name or dictionaries must be provided'); + throw new ValidationError( + 'At least one of name or dictionaries must be provided' + ); } await this.makeJsonRequest( 'PATCH', diff --git a/src/api/http-client.ts b/src/api/http-client.ts index 52055ff3..c4712128 100644 --- a/src/api/http-client.ts +++ b/src/api/http-client.ts @@ -102,7 +102,10 @@ export interface RequestPolicy { * and passes it straight to `sleep()`. */ export function computeBackoffWithJitter(attempt: number): number { - const cap = Math.min(RETRY_INITIAL_DELAY_MS * 2 ** attempt, RETRY_MAX_DELAY_MS); + const cap = Math.min( + RETRY_INITIAL_DELAY_MS * 2 ** attempt, + RETRY_MAX_DELAY_MS + ); return Math.floor(Math.random() * cap); } @@ -158,7 +161,9 @@ export class HttpClient { }); } - private static parseProxyFromEnv(targetUrl?: string): ProxyConfig | undefined { + private static parseProxyFromEnv( + targetUrl?: string + ): ProxyConfig | undefined { if (targetUrl !== undefined && HttpClient.isProxyBypassed(targetUrl)) { return undefined; } @@ -257,8 +262,8 @@ export class HttpClient { if (proxyConfig.protocol === 'http' && baseURL.startsWith('https:')) { Logger.warn( `Warning: routing HTTPS traffic to ${baseURL} via HTTP proxy ${proxyConfig.host}:${proxyConfig.port}. ` + - `TLS is tunneled end-to-end via CONNECT, but a malicious proxy that terminates TLS would see the Authorization header. ` + - `Set HTTPS_PROXY to an https:// URL if possible, or unset it if the proxy isn't required.`, + `TLS is tunneled end-to-end via CONNECT, but a malicious proxy that terminates TLS would see the Authorization header. ` + + `Set HTTPS_PROXY to an https:// URL if possible, or unset it if the proxy isn't required.` ); } axiosConfig['proxy'] = { @@ -275,8 +280,7 @@ export class HttpClient { destroy(): void { const httpAgent = this.client.defaults?.httpAgent as http.Agent | undefined; const httpsAgent = this.client.defaults?.httpsAgent as - | https.Agent - | undefined; + https.Agent | undefined; httpAgent?.destroy(); httpsAgent?.destroy(); } @@ -391,8 +395,7 @@ export class HttpClient { ); const responseTraceId = response.headers?.['x-trace-id'] as - | string - | undefined; + string | undefined; if (responseTraceId) { this._lastTraceId = responseTraceId; } @@ -404,8 +407,7 @@ export class HttpClient { if (this.isAxiosError(error)) { const responseTraceId = error.response?.headers?.['x-trace-id'] as - | string - | undefined; + string | undefined; if (responseTraceId) { traceId = responseTraceId; this._lastTraceId = responseTraceId; @@ -420,8 +422,7 @@ export class HttpClient { // backoff with full jitter. Jitter prevents concurrent sync // buckets that all 429 at the same moment from forming a // thundering herd on the next attempt. - const delay = - retryAfterDelay ?? computeBackoffWithJitter(attempt); + const delay = retryAfterDelay ?? computeBackoffWithJitter(attempt); Logger.verbose( `[verbose] HTTP ${method} ${path} retry ${attempt + 1}/${maxRetries} in ${delay}ms (status 429${retryAfterDelay !== null && retryAfterDelay !== undefined ? ', Retry-After' : ', jitter backoff'})` ); @@ -439,7 +440,9 @@ export class HttpClient { this.isReplayable(method, error) ) { const delay = computeBackoffWithJitter(attempt); - const status = this.isAxiosError(error) ? error.response?.status : undefined; + const status = this.isAxiosError(error) + ? error.response?.status + : undefined; Logger.verbose( `[verbose] HTTP ${method} ${path} retry ${attempt + 1}/${maxRetries} in ${delay}ms (${status ? `status ${status}` : 'network error'}, jitter backoff)` ); @@ -500,15 +503,16 @@ export class HttpClient { if (this.isAxiosError(error)) { const status = error.response?.status; const responseData = error.response?.data as - | { message?: string } - | undefined; + { message?: string } | undefined; // Sanitize the server-returned message before any interpolation into // user-facing error strings. Defense-in-depth against a malicious or // buggy server scribbling ANSI escape codes / control chars on the // user's terminal, matching the sanitization in tms-client.ts. // Coalesce to '' before sanitizing — some axios error shapes have no // `.message` field, and sanitizeForTerminal expects a string. - const message = sanitizeForTerminal(responseData?.message ?? error.message ?? ''); + const message = sanitizeForTerminal( + responseData?.message ?? error.message ?? '' + ); switch (status) { case 401: diff --git a/src/api/style-rules-client.ts b/src/api/style-rules-client.ts index 11e9f8f3..571e5569 100644 --- a/src/api/style-rules-client.ts +++ b/src/api/style-rules-client.ts @@ -19,12 +19,16 @@ interface CustomInstructionWireShape { source_language?: string; } -function mapCustomInstruction(wire: CustomInstructionWireShape): CustomInstruction { +function mapCustomInstruction( + wire: CustomInstructionWireShape +): CustomInstruction { return { ...(wire.id !== undefined && { id: wire.id }), label: wire.label, prompt: wire.prompt, - ...(wire.source_language !== undefined && { sourceLanguage: wire.source_language }), + ...(wire.source_language !== undefined && { + sourceLanguage: wire.source_language, + }), }; } @@ -54,7 +58,9 @@ function mapStyleRuleDetailed(wire: StyleRuleWireShape): StyleRuleDetailed { return { ...mapStyleRule(wire), configuredRules: wire.configured_rules ?? {}, - customInstructions: (wire.custom_instructions ?? []).map(mapCustomInstruction), + customInstructions: (wire.custom_instructions ?? []).map( + mapCustomInstruction + ), }; } @@ -85,7 +91,7 @@ export class StyleRulesClient extends HttpClient { }>('GET', '/v3/style_rules', params); return response.style_rules.map((rule) => - options.detailed ? mapStyleRuleDetailed(rule) : mapStyleRule(rule), + options.detailed ? mapStyleRuleDetailed(rule) : mapStyleRule(rule) ); } @@ -98,7 +104,7 @@ export class StyleRulesClient extends HttpClient { body['configured_rules'] = options.configuredRules; } if (options.customInstructions !== undefined) { - body['custom_instructions'] = options.customInstructions.map(ci => ({ + body['custom_instructions'] = options.customInstructions.map((ci) => ({ label: ci.label, prompt: ci.prompt, ...(ci.sourceLanguage && { source_language: ci.sourceLanguage }), @@ -107,12 +113,15 @@ export class StyleRulesClient extends HttpClient { const wire = await this.makeJsonRequest( 'POST', '/v3/style_rules', - body, + body ); return mapStyleRule(wire); } - async getStyleRule(styleId: string, detailed = false): Promise { + async getStyleRule( + styleId: string, + detailed = false + ): Promise { const params: Record = {}; if (detailed) { params['detailed'] = true; @@ -121,12 +130,15 @@ export class StyleRulesClient extends HttpClient { 'GET', `/v3/style_rules/${encodeURIComponent(styleId)}`, undefined, - params, + params ); return detailed ? mapStyleRuleDetailed(wire) : mapStyleRule(wire); } - async updateStyleRule(styleId: string, options: UpdateStyleRuleOptions): Promise { + async updateStyleRule( + styleId: string, + options: UpdateStyleRuleOptions + ): Promise { const body: Record = {}; if (options.name !== undefined) { body['name'] = options.name; @@ -135,7 +147,7 @@ export class StyleRulesClient extends HttpClient { body['configured_rules'] = options.configuredRules; } if (options.customInstructions !== undefined) { - body['custom_instructions'] = options.customInstructions.map(ci => ({ + body['custom_instructions'] = options.customInstructions.map((ci) => ({ label: ci.label, prompt: ci.prompt, ...(ci.sourceLanguage && { source_language: ci.sourceLanguage }), @@ -144,7 +156,7 @@ export class StyleRulesClient extends HttpClient { const wire = await this.makeJsonRequest( 'PATCH', `/v3/style_rules/${encodeURIComponent(styleId)}`, - body, + body ); return mapStyleRule(wire); } @@ -152,25 +164,28 @@ export class StyleRulesClient extends HttpClient { async deleteStyleRule(styleId: string): Promise { await this.makeJsonRequest( 'DELETE', - `/v3/style_rules/${encodeURIComponent(styleId)}`, + `/v3/style_rules/${encodeURIComponent(styleId)}` ); } - async replaceConfiguredRules(styleId: string, rules: ConfiguredRules): Promise { + async replaceConfiguredRules( + styleId: string, + rules: ConfiguredRules + ): Promise { // The PUT endpoint at /configured_rules takes the rules dict as the entire body // (no `configured_rules` outer wrapper). The wrapper is only used on POST /v3/style_rules // and PATCH /v3/style_rules/{id} where the body has multiple top-level fields. const wire = await this.makeJsonRequest( 'PUT', `/v3/style_rules/${encodeURIComponent(styleId)}/configured_rules`, - rules, + rules ); return mapStyleRuleDetailed(wire); } async createCustomInstruction( styleId: string, - options: CreateCustomInstructionOptions, + options: CreateCustomInstructionOptions ): Promise { const body: Record = { label: options.label, @@ -182,7 +197,7 @@ export class StyleRulesClient extends HttpClient { const wire = await this.makeJsonRequest( 'POST', `/v3/style_rules/${encodeURIComponent(styleId)}/custom_instructions`, - body, + body ); return mapCustomInstruction(wire); } @@ -194,22 +209,31 @@ export class StyleRulesClient extends HttpClient { * instructions by label. This helper does the lookup via a detailed * `getStyleRule`. Throws ValidationError if no instruction with that label exists. */ - private async resolveInstructionId(styleId: string, label: string): Promise { - const detailed = await this.getStyleRule(styleId, true) as StyleRuleDetailed; - const found = detailed.customInstructions.find(ci => ci.label === label); + private async resolveInstructionId( + styleId: string, + label: string + ): Promise { + const detailed = (await this.getStyleRule( + styleId, + true + )) as StyleRuleDetailed; + const found = detailed.customInstructions.find((ci) => ci.label === label); if (!found?.id) { throw new ValidationError( - `No custom instruction with label "${label}" found on style rule ${styleId}.`, + `No custom instruction with label "${label}" found on style rule ${styleId}.` ); } return found.id; } - async getCustomInstruction(styleId: string, label: string): Promise { + async getCustomInstruction( + styleId: string, + label: string + ): Promise { const instructionId = await this.resolveInstructionId(styleId, label); const wire = await this.makeJsonRequest( 'GET', - `/v3/style_rules/${encodeURIComponent(styleId)}/custom_instructions/${encodeURIComponent(instructionId)}`, + `/v3/style_rules/${encodeURIComponent(styleId)}/custom_instructions/${encodeURIComponent(instructionId)}` ); return mapCustomInstruction(wire); } @@ -217,7 +241,7 @@ export class StyleRulesClient extends HttpClient { async updateCustomInstruction( styleId: string, label: string, - options: UpdateCustomInstructionOptions, + options: UpdateCustomInstructionOptions ): Promise { const instructionId = await this.resolveInstructionId(styleId, label); // The PUT body requires `label` even though `instruction_id` appears in the URL path. @@ -231,7 +255,7 @@ export class StyleRulesClient extends HttpClient { const wire = await this.makeJsonRequest( 'PUT', `/v3/style_rules/${encodeURIComponent(styleId)}/custom_instructions/${encodeURIComponent(instructionId)}`, - body, + body ); return mapCustomInstruction(wire); } @@ -240,7 +264,7 @@ export class StyleRulesClient extends HttpClient { const instructionId = await this.resolveInstructionId(styleId, label); await this.makeJsonRequest( 'DELETE', - `/v3/style_rules/${encodeURIComponent(styleId)}/custom_instructions/${encodeURIComponent(instructionId)}`, + `/v3/style_rules/${encodeURIComponent(styleId)}/custom_instructions/${encodeURIComponent(instructionId)}` ); } } diff --git a/src/api/translation-client.ts b/src/api/translation-client.ts index f229009e..c4150d93 100644 --- a/src/api/translation-client.ts +++ b/src/api/translation-client.ts @@ -1,5 +1,9 @@ import { HttpClient, DeepLClientOptions } from './http-client.js'; -import { TranslationOptions, Language, TranslationMemory } from '../types/index.js'; +import { + TranslationOptions, + Language, + TranslationMemory, +} from '../types/index.js'; import { NetworkError } from '../utils/errors.js'; import { normalizeFormality } from '../utils/formality.js'; import { resolveGlossaryWireParams } from '../utils/glossary-params.js'; @@ -137,12 +141,16 @@ export class TranslationClient extends HttpClient { ); if (!response.translations || response.translations.length === 0) { - throw new NetworkError(`No translation returned from DeepL API. Request: translate text (${text.length} chars) to ${options.targetLang}`); + throw new NetworkError( + `No translation returned from DeepL API. Request: translate text (${text.length} chars) to ${options.targetLang}` + ); } const translation = response.translations[0]; if (!translation) { - throw new NetworkError(`Empty translation in API response. Request: translate text (${text.length} chars) to ${options.targetLang}`); + throw new NetworkError( + `Empty translation in API response. Request: translate text (${text.length} chars) to ${options.targetLang}` + ); } return { @@ -150,7 +158,8 @@ export class TranslationClient extends HttpClient { detectedSourceLang: translation.detected_source_language ? this.normalizeLanguage(translation.detected_source_language) : undefined, - billedCharacters: translation.billed_characters ?? response.billed_characters, + billedCharacters: + translation.billed_characters ?? response.billed_characters, modelTypeUsed: translation.model_type_used, }; } catch (error) { @@ -176,11 +185,15 @@ export class TranslationClient extends HttpClient { ); if (!response.translations) { - throw new NetworkError('Unexpected API response. Please retry your translation. If the issue persists, report it at https://github.com/DeepL/deepl-cli/issues'); + throw new NetworkError( + 'Unexpected API response. Please retry your translation. If the issue persists, report it at https://github.com/DeepL/deepl-cli/issues' + ); } if (response.translations.length !== texts.length) { - throw new NetworkError('Unexpected API response. Please retry your translation. If the issue persists, report it at https://github.com/DeepL/deepl-cli/issues'); + throw new NetworkError( + 'Unexpected API response. Please retry your translation. If the issue persists, report it at https://github.com/DeepL/deepl-cli/issues' + ); } return response.translations.map((translation) => ({ @@ -188,7 +201,8 @@ export class TranslationClient extends HttpClient { detectedSourceLang: translation.detected_source_language ? this.normalizeLanguage(translation.detected_source_language) : undefined, - billedCharacters: translation.billed_characters ?? response.billed_characters, + billedCharacters: + translation.billed_characters ?? response.billed_characters, modelTypeUsed: translation.model_type_used, })); } catch (error) { @@ -233,13 +247,17 @@ export class TranslationClient extends HttpClient { usage.accountUnitLimit = response.account_unit_limit; } if (response.products) { - usage.products = response.products.map(p => ({ + usage.products = response.products.map((p) => ({ productType: p.product_type, characterCount: p.character_count, apiKeyCharacterCount: p.api_key_character_count, ...(p.unit_count !== undefined && { unitCount: p.unit_count }), - ...(p.account_unit_count !== undefined && { accountUnitCount: p.account_unit_count }), - ...(p.api_key_unit_count !== undefined && { apiKeyUnitCount: p.api_key_unit_count }), + ...(p.account_unit_count !== undefined && { + accountUnitCount: p.account_unit_count, + }), + ...(p.api_key_unit_count !== undefined && { + apiKeyUnitCount: p.api_key_unit_count, + }), ...(p.billing_unit && { billingUnit: p.billing_unit }), })); } @@ -257,7 +275,9 @@ export class TranslationClient extends HttpClient { total_count?: number; }>('GET', '/v3/translation_memories'); - const aggregated: TranslationMemory[] = [...(first.translation_memories ?? [])]; + const aggregated: TranslationMemory[] = [ + ...(first.translation_memories ?? []), + ]; const total = first.total_count; if (typeof total !== 'number' || aggregated.length >= total) { return aggregated; @@ -321,27 +341,31 @@ export class TranslationClient extends HttpClient { try { const response = await this.fetchTranslateLanguages(); - return response - // `!== false`, not truthiness: an absent flag is not a denial, and the - // language registry reads it the same way, so a truthy filter here would - // drop a language the generator records as usable. - .filter((lang) => - type === 'source' ? lang.usable_as_source !== false : lang.usable_as_target !== false, - ) - .map((lang) => { - const code = this.normalizeLanguage(lang.lang); - return { - language: code, - name: lang.name, - // Only claimed when the response described this language's features; - // silence about a language is not evidence that formality is absent. - ...(type === 'target' && - lang.features && { - supportsFormality: lang.features['formality'] !== undefined, - }), - ...(lang.features && { features: lang.features }), - }; - }); + return ( + response + // `!== false`, not truthiness: an absent flag is not a denial, and the + // language registry reads it the same way, so a truthy filter here would + // drop a language the generator records as usable. + .filter((lang) => + type === 'source' + ? lang.usable_as_source !== false + : lang.usable_as_target !== false + ) + .map((lang) => { + const code = this.normalizeLanguage(lang.lang); + return { + language: code, + name: lang.name, + // Only claimed when the response described this language's features; + // silence about a language is not evidence that formality is absent. + ...(type === 'target' && + lang.features && { + supportsFormality: lang.features['formality'] !== undefined, + }), + ...(lang.features && { features: lang.features }), + }; + }) + ); } catch (error) { throw this.handleError(error); } @@ -357,7 +381,9 @@ export class TranslationClient extends HttpClient { }; if (options.sourceLang) { - params['source_lang'] = this.normalizeLanguage(options.sourceLang).toUpperCase(); + params['source_lang'] = this.normalizeLanguage( + options.sourceLang + ).toUpperCase(); } if (options.formality) { @@ -371,7 +397,9 @@ export class TranslationClient extends HttpClient { if (options.translationMemoryId) { params['translation_memory_id'] = options.translationMemoryId; - params['translation_memory_threshold'] = String(options.translationMemoryThreshold ?? 75); + params['translation_memory_threshold'] = String( + options.translationMemoryThreshold ?? 75 + ); } if (options.preserveFormatting) { @@ -384,7 +412,8 @@ export class TranslationClient extends HttpClient { if (options.splitSentences) { const splitMap: Record = { on: '1', off: '0' }; - params['split_sentences'] = splitMap[options.splitSentences] ?? options.splitSentences; + params['split_sentences'] = + splitMap[options.splitSentences] ?? options.splitSentences; } if (options.tagHandling) { @@ -428,7 +457,6 @@ export class TranslationClient extends HttpClient { params['tag_handling_version'] = tagHandlingVersion; } - return params; } } diff --git a/src/api/voice-client.ts b/src/api/voice-client.ts index d19600e1..dd3b789a 100644 --- a/src/api/voice-client.ts +++ b/src/api/voice-client.ts @@ -165,8 +165,7 @@ export class VoiceClient extends HttpClient { if (this.isAxiosError(error)) { const status = error.response?.status; const responseData = error.response?.data as - | { message?: string } - | undefined; + { message?: string } | undefined; if (status === 403) { return new VoiceError( diff --git a/src/api/write-client.ts b/src/api/write-client.ts index 43892fad..393ac511 100644 --- a/src/api/write-client.ts +++ b/src/api/write-client.ts @@ -1,5 +1,9 @@ import { HttpClient, DeepLClientOptions } from './http-client.js'; -import { WriteOptions, CorrectOptions, WriteImprovement } from '../types/index.js'; +import { + WriteOptions, + CorrectOptions, + WriteImprovement, +} from '../types/index.js'; import { NetworkError, ValidationError } from '../utils/errors.js'; interface DeepLWriteResponse { @@ -87,7 +91,7 @@ export class WriteClient extends HttpClient { throw new NetworkError('No improvements returned'); } - return response.improvements.map(improvement => ({ + return response.improvements.map((improvement) => ({ text: improvement.text, targetLanguage: improvement.target_language, detectedSourceLanguage: improvement.detected_source_language, diff --git a/src/cli/cache-loader.ts b/src/cli/cache-loader.ts index 3dbbf7c9..3e8a2dd4 100644 --- a/src/cli/cache-loader.ts +++ b/src/cli/cache-loader.ts @@ -19,7 +19,10 @@ type CacheModule = Pick; * here is what keeps the service's in-memory flag from diverging from config: * `cache stats` and the translation path then agree on the same value. */ -export function resolveCacheOptions(config: ConfigService, dbPath: string): CacheServiceOptions { +export function resolveCacheOptions( + config: ConfigService, + dbPath: string +): CacheServiceOptions { const ttlSeconds = config.getValue('cache.ttl'); return { dbPath, @@ -32,7 +35,8 @@ export function resolveCacheOptions(config: ConfigService, dbPath: string): Cach export function createCacheServiceGetter( getOptions: () => CacheServiceOptions, - importCacheModule: () => Promise = () => import('../storage/cache.js'), + importCacheModule: () => Promise = () => + import('../storage/cache.js') ): () => Promise { let instance: CacheService | undefined; let unavailable = false; @@ -50,12 +54,12 @@ export function createCacheServiceGetter( if (isNativeModuleLoadError(error)) { Logger.warn( `Translation cache backend failed to load (${detail}). ` + - 'Your cache database has not been modified. Caching is disabled for this run. ' + - 'Reinstall the CLI, or run it with the Node.js version it was installed with, to restore caching.', + 'Your cache database has not been modified. Caching is disabled for this run. ' + + 'Reinstall the CLI, or run it with the Node.js version it was installed with, to restore caching.' ); } else { Logger.warn( - `Translation cache is unavailable (${detail}). Caching is disabled for this run.`, + `Translation cache is unavailable (${detail}). Caching is disabled for this run.` ); } } diff --git a/src/cli/commands/admin.ts b/src/cli/commands/admin.ts index 0a3bb3e3..e9fc14f7 100644 --- a/src/cli/commands/admin.ts +++ b/src/cli/commands/admin.ts @@ -4,7 +4,12 @@ */ import type { AdminService } from '../../services/admin.js'; -import { AdminApiKey, AdminUsageOptions, AdminUsageReport, UsageBreakdown } from '../../types/index.js'; +import { + AdminApiKey, + AdminUsageOptions, + AdminUsageReport, + UsageBreakdown, +} from '../../types/index.js'; /** * Manages DeepL admin API operations for team accounts. @@ -42,7 +47,11 @@ export class AdminCommand { * @param characters - Maximum characters allowed, or null for unlimited. * @param sttLimit - Optional speech-to-text milliseconds limit. */ - async setKeyLimit(keyId: string, characters: number | null, sttLimit?: number | null): Promise { + async setKeyLimit( + keyId: string, + characters: number | null, + sttLimit?: number | null + ): Promise { return this.service.setApiKeyLimit(keyId, characters, sttLimit); } @@ -69,15 +78,17 @@ export class AdminCommand { lines.push(` ID: ${key.keyId}`); lines.push(` Created: ${key.creationTime}`); if (key.usageLimits?.characters !== undefined) { - const limit = key.usageLimits.characters === null - ? 'unlimited' - : key.usageLimits.characters.toLocaleString(); + const limit = + key.usageLimits.characters === null + ? 'unlimited' + : key.usageLimits.characters.toLocaleString(); lines.push(` Limit: ${limit} characters`); } if (key.usageLimits?.speechToTextMilliseconds !== undefined) { - const sttLimit = key.usageLimits.speechToTextMilliseconds === null - ? 'unlimited' - : this.formatMilliseconds(key.usageLimits.speechToTextMilliseconds); + const sttLimit = + key.usageLimits.speechToTextMilliseconds === null + ? 'unlimited' + : this.formatMilliseconds(key.usageLimits.speechToTextMilliseconds); lines.push(` STT Limit: ${sttLimit}`); } lines.push(''); @@ -98,15 +109,17 @@ export class AdminCommand { lines.push(` Status: ${status}`); lines.push(` Created: ${key.creationTime}`); if (key.usageLimits?.characters !== undefined) { - const limit = key.usageLimits.characters === null - ? 'unlimited' - : key.usageLimits.characters.toLocaleString(); + const limit = + key.usageLimits.characters === null + ? 'unlimited' + : key.usageLimits.characters.toLocaleString(); lines.push(` Limit: ${limit} characters`); } if (key.usageLimits?.speechToTextMilliseconds !== undefined) { - const sttLimit = key.usageLimits.speechToTextMilliseconds === null - ? 'unlimited' - : this.formatMilliseconds(key.usageLimits.speechToTextMilliseconds); + const sttLimit = + key.usageLimits.speechToTextMilliseconds === null + ? 'unlimited' + : this.formatMilliseconds(key.usageLimits.speechToTextMilliseconds); lines.push(` STT Limit: ${sttLimit}`); } return lines.join('\n'); @@ -115,11 +128,21 @@ export class AdminCommand { /** Format a per-product usage breakdown (translation, documents, write, voice). */ private formatBreakdown(usage: UsageBreakdown, indent = ' '): string[] { const lines: string[] = []; - lines.push(`${indent}Total: ${usage.totalCharacters.toLocaleString()}`); - lines.push(`${indent}Translation: ${usage.textTranslationCharacters.toLocaleString()}`); - lines.push(`${indent}Documents: ${usage.documentTranslationCharacters.toLocaleString()}`); - lines.push(`${indent}Write: ${usage.textImprovementCharacters.toLocaleString()}`); - lines.push(`${indent}Voice: ${this.formatMilliseconds(usage.speechToTextMilliseconds)}`); + lines.push( + `${indent}Total: ${usage.totalCharacters.toLocaleString()}` + ); + lines.push( + `${indent}Translation: ${usage.textTranslationCharacters.toLocaleString()}` + ); + lines.push( + `${indent}Documents: ${usage.documentTranslationCharacters.toLocaleString()}` + ); + lines.push( + `${indent}Write: ${usage.textImprovementCharacters.toLocaleString()}` + ); + lines.push( + `${indent}Voice: ${this.formatMilliseconds(usage.speechToTextMilliseconds)}` + ); return lines; } diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts index 3ab3e65e..90337113 100644 --- a/src/cli/commands/auth.ts +++ b/src/cli/commands/auth.ts @@ -6,7 +6,11 @@ import { ConfigService } from '../../storage/config.js'; import { DeepLClient } from '../../api/deepl-client.js'; import type { DeepLClientOptions } from '../../api/http-client.js'; -import { ValidationError, AuthError, NetworkError } from '../../utils/errors.js'; +import { + ValidationError, + AuthError, + NetworkError, +} from '../../utils/errors.js'; import { resolveEndpoint } from '../../utils/resolve-endpoint.js'; export class AuthCommand { @@ -21,7 +25,10 @@ export class AuthCommand { /** * Set API key and validate it */ - async setKey(apiKey: string, options: { verify?: boolean } = {}): Promise { + async setKey( + apiKey: string, + options: { verify?: boolean } = {} + ): Promise { // Validate input if (!apiKey || apiKey.trim() === '') { throw new ValidationError('API key cannot be empty'); @@ -55,7 +62,7 @@ export class AuthCommand { if (error instanceof NetworkError) { throw new NetworkError( `Could not reach the DeepL API to validate the key: ${error.message}`, - 'Store the key without validating with --no-verify, or set DEEPL_API_KEY in your environment instead.', + 'Store the key without validating with --no-verify, or set DEEPL_API_KEY in your environment instead.' ); } throw error; diff --git a/src/cli/commands/cache.ts b/src/cli/commands/cache.ts index d8567e8a..28ddfb77 100644 --- a/src/cli/commands/cache.ts +++ b/src/cli/commands/cache.ts @@ -67,9 +67,10 @@ export class CacheCommand { const totalSizeMB = (stats.totalSize / (1024 * 1024)).toFixed(2); const maxSizeMB = (stats.maxSize / (1024 * 1024)).toFixed(2); const status = stats.enabled ? 'enabled' : 'disabled'; - const percentUsed = stats.maxSize > 0 - ? ((stats.totalSize / stats.maxSize) * 100).toFixed(1) - : '0.0'; + const percentUsed = + stats.maxSize > 0 + ? ((stats.totalSize / stats.maxSize) * 100).toFixed(1) + : '0.0'; return [ `Cache Status: ${status}`, @@ -82,9 +83,10 @@ export class CacheCommand { formatStatsTable(stats: CacheStats): string { const totalSizeMB = (stats.totalSize / (1024 * 1024)).toFixed(2); const maxSizeMB = (stats.maxSize / (1024 * 1024)).toFixed(2); - const percentUsed = stats.maxSize > 0 - ? ((stats.totalSize / stats.maxSize) * 100).toFixed(1) - : '0.0'; + const percentUsed = + stats.maxSize > 0 + ? ((stats.totalSize / stats.maxSize) * 100).toFixed(1) + : '0.0'; const colorDisabled = !isColorEnabled(); const table = new Table({ @@ -99,7 +101,7 @@ export class CacheCommand { ['Entries', String(stats.entries)], ['Used', `${totalSizeMB} MB`], ['Limit', `${maxSizeMB} MB`], - ['Usage', `${percentUsed}%`], + ['Usage', `${percentUsed}%`] ); return table.toString(); diff --git a/src/cli/commands/completion.ts b/src/cli/commands/completion.ts index 521e6bc7..3a43538e 100644 --- a/src/cli/commands/completion.ts +++ b/src/cli/commands/completion.ts @@ -42,7 +42,9 @@ export class CompletionCommand { } private findCommand(name: string): Command | undefined { - return this.program.commands.find((c) => c.name() === name || c.aliases().includes(name)); + return this.program.commands.find( + (c) => c.name() === name || c.aliases().includes(name) + ); } private getCommandOptions(cmdName: string): string[] { @@ -59,7 +61,7 @@ export class CompletionCommand { this.program.options .map((opt) => opt.long) .filter((o): o is string => !!o) - .concat('--help', '--version'), + .concat('--help', '--version') ), ]; } @@ -76,7 +78,9 @@ export class CompletionCommand { } const cmdOpts = this.getCommandOptions(parent); const words = [...subs, ...cmdOpts].join(' '); - subcommandCases.push(` ${parent})\n COMPREPLY=($(compgen -W "${words}" -- "\${cur}"))\n return 0\n ;;`); + subcommandCases.push( + ` ${parent})\n COMPREPLY=($(compgen -W "${words}" -- "\${cur}"))\n return 0\n ;;` + ); } const topLevelWords = [...topLevel, ...globalOpts].join(' '); @@ -153,7 +157,9 @@ complete -F _deepl_completions deepl _describe -t ${safeName}-commands '${parent} subcommand' subcmds }`); - subcommandDispatch.push(` ${parent})\n _deepl_${safeName}\n ;;`); + subcommandDispatch.push( + ` ${parent})\n _deepl_${safeName}\n ;;` + ); } const topLevelDescriptions: string[] = []; @@ -232,7 +238,9 @@ _deepl "$@" for (const cmdName of topLevel) { const cmd = this.findCommand(cmdName); const desc = cmd ? cmd.description() : ''; - lines.push(`complete -c deepl -n '${noSubcmdCondition}' -a '${cmdName}' -d '${desc.replace(/'/g, "\\'")}'`); + lines.push( + `complete -c deepl -n '${noSubcmdCondition}' -a '${cmdName}' -d '${desc.replace(/'/g, "\\'")}'` + ); } for (const opt of globalOpts) { @@ -257,15 +265,18 @@ _deepl "$@" } const parentCmd = this.findCommand(parent); const seenCondition = `__fish_seen_subcommand_from ${parent}`; - const notSeenSub = subs.length > 0 - ? `; and not __fish_seen_subcommand_from ${subs.join(' ')}` - : ''; + const notSeenSub = + subs.length > 0 + ? `; and not __fish_seen_subcommand_from ${subs.join(' ')}` + : ''; lines.push(`# ${parent} subcommands`); if (parentCmd) { for (const sub of this.visibleCommands(parentCmd)) { const desc = sub.description().replace(/'/g, "\\'"); - lines.push(`complete -c deepl -n '${seenCondition}${notSeenSub}' -a '${sub.name()}' -d '${desc}'`); + lines.push( + `complete -c deepl -n '${seenCondition}${notSeenSub}' -a '${sub.name()}' -d '${desc}'` + ); } } @@ -274,7 +285,9 @@ _deepl "$@" const optObj = parentCmd?.options.find((o) => o.long === opt); const desc = optObj ? optObj.description.replace(/'/g, "\\'") : ''; const longFlag = opt.replace(/^--/, ''); - lines.push(`complete -c deepl -n '${seenCondition}' -l '${longFlag}' -d '${desc}'`); + lines.push( + `complete -c deepl -n '${seenCondition}' -l '${longFlag}' -d '${desc}'` + ); } lines.push(''); } diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts index 570fc39d..345ae9cf 100644 --- a/src/cli/commands/config.ts +++ b/src/cli/commands/config.ts @@ -14,11 +14,7 @@ const BOOLEAN_KEYS = [ 'defaults.preserveFormatting', ]; -const NUMERIC_KEYS = [ - 'cache.maxSize', - 'cache.ttl', - 'watch.debounceMs', -]; +const NUMERIC_KEYS = ['cache.maxSize', 'cache.ttl', 'watch.debounceMs']; export class ConfigCommand { private config: ConfigService; @@ -33,8 +29,14 @@ export class ConfigCommand { async get(key?: string): Promise { if (key) { const value = this.config.getValue(key); - if (key === 'auth.apiKey' && typeof value === 'string' && value.length > 8) { - return value.substring(0, 4) + '...' + value.substring(value.length - 4); + if ( + key === 'auth.apiKey' && + typeof value === 'string' && + value.length > 8 + ) { + return ( + value.substring(0, 4) + '...' + value.substring(value.length - 4) + ); } return value; } @@ -87,13 +89,22 @@ export class ConfigCommand { return lines.join('\n'); } - private flattenConfig(obj: Record, prefix: string, lines: string[]): void { + private flattenConfig( + obj: Record, + prefix: string, + lines: string[] + ): void { for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key; - if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + if ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) + ) { this.flattenConfig(value as Record, fullKey, lines); } else { - const display = value === undefined ? '(not set)' : JSON.stringify(value); + const display = + value === undefined ? '(not set)' : JSON.stringify(value); lines.push(`${fullKey} = ${display}`); } } @@ -105,7 +116,7 @@ export class ConfigCommand { private parseValue(key: string, value: string): unknown { // Handle array values (comma-separated) if (key.includes('targetLangs') || value.includes(',')) { - return value.split(',').map(v => v.trim()); + return value.split(',').map((v) => v.trim()); } // Auto-coerce string values to booleans for known boolean config keys @@ -126,15 +137,21 @@ export class ConfigCommand { /** * Mask sensitive values like API keys */ - private maskSensitiveValues(config: Record): Record { - const masked = JSON.parse(JSON.stringify(config)) as Record; + private maskSensitiveValues( + config: Record + ): Record { + const masked = JSON.parse(JSON.stringify(config)) as Record< + string, + unknown + >; // Mask API key if (masked['auth'] && typeof masked['auth'] === 'object') { const auth = masked['auth'] as Record; if (auth['apiKey'] && typeof auth['apiKey'] === 'string') { const apiKey = auth['apiKey']; - auth['apiKey'] = apiKey.substring(0, 4) + '...' + apiKey.substring(apiKey.length - 4); + auth['apiKey'] = + apiKey.substring(0, 4) + '...' + apiKey.substring(apiKey.length - 4); } } diff --git a/src/cli/commands/detect.ts b/src/cli/commands/detect.ts index 9ce5a045..50dd09a9 100644 --- a/src/cli/commands/detect.ts +++ b/src/cli/commands/detect.ts @@ -18,9 +18,13 @@ export class DetectCommand { } formatJson(result: DetectResult): string { - return JSON.stringify({ - detected_language: result.detectedLanguage, - language_name: result.languageName, - }, null, 2); + return JSON.stringify( + { + detected_language: result.detectedLanguage, + language_name: result.languageName, + }, + null, + 2 + ); } } diff --git a/src/cli/commands/glossary.ts b/src/cli/commands/glossary.ts index d0e6a158..d487acf5 100644 --- a/src/cli/commands/glossary.ts +++ b/src/cli/commands/glossary.ts @@ -5,7 +5,14 @@ import * as fs from 'fs'; import { GlossaryService } from '../../services/glossary.js'; -import { GlossaryInfo, GlossaryLanguagePair, Language, getTargetLang, getTotalEntryCount, isMultilingual } from '../../types/index.js'; +import { + GlossaryInfo, + GlossaryLanguagePair, + Language, + getTargetLang, + getTotalEntryCount, + isMultilingual, +} from '../../types/index.js'; import { safeReadFileSync } from '../../utils/safe-read-file.js'; import { sanitizeForTerminal } from '../../utils/control-chars.js'; import { ValidationError, ConfigError } from '../../utils/errors.js'; @@ -75,7 +82,10 @@ export class GlossaryCommand { /** * Get glossary entries (v3 API - requires target lang for multilingual glossaries) */ - async entries(nameOrId: string, targetLang?: Language): Promise> { + async entries( + nameOrId: string, + targetLang?: Language + ): Promise> { const glossary = await this.show(nameOrId); // Get target language (will throw if glossary is multilingual and targetLang not provided) const target = getTargetLang(glossary, targetLang); @@ -170,7 +180,7 @@ export class GlossaryCommand { const glossary = await this.show(nameOrId); await this.glossaryService.updateGlossary(glossary.glossary_id, { name: options.name, - dictionaries: options.dictionaries?.map(dict => ({ + dictionaries: options.dictionaries?.map((dict) => ({ sourceLang: glossary.source_lang, targetLang: dict.targetLang, entries: dict.entries, @@ -181,10 +191,7 @@ export class GlossaryCommand { /** * Rename a glossary (v3 API - uses PATCH) */ - async rename( - nameOrId: string, - newName: string - ): Promise { + async rename(nameOrId: string, newName: string): Promise { const glossary = await this.show(nameOrId); await this.glossaryService.renameGlossary(glossary.glossary_id, newName); } @@ -250,10 +257,12 @@ export class GlossaryCommand { if (multilingual) { lines.push('\nLanguage pairs:'); - glossary.dictionaries.forEach(dict => { + glossary.dictionaries.forEach((dict) => { // Lowercased like the summary above; normalizeGlossaryInfo only touches // the top-level fields. - lines.push(` ${dict.source_lang.toLowerCase()} → ${dict.target_lang.toLowerCase()}: ${dict.entry_count} entries`); + lines.push( + ` ${dict.source_lang.toLowerCase()} → ${dict.target_lang.toLowerCase()}: ${dict.entry_count} entries` + ); }); } @@ -268,11 +277,12 @@ export class GlossaryCommand { return 'No glossaries found'; } - const lines = glossaries.map(g => { + const lines = glossaries.map((g) => { const totalEntries = getTotalEntryCount(g); - const targetStr = g.target_langs.length === 1 - ? g.target_langs[0] - : `${g.target_langs.length} targets`; + const targetStr = + g.target_langs.length === 1 + ? g.target_langs[0] + : `${g.target_langs.length} targets`; const icon = isMultilingual(g) ? '📚' : '📖'; return `${icon} ${sanitizeForTerminal(g.name)} (${g.source_lang}→${targetStr}) - ${totalEntries} entries`; }); diff --git a/src/cli/commands/hooks.ts b/src/cli/commands/hooks.ts index bdf91348..b4e43a0c 100644 --- a/src/cli/commands/hooks.ts +++ b/src/cli/commands/hooks.ts @@ -26,7 +26,9 @@ export class HooksCommand { */ install(hookType: HookType): string { if (!this.gitHooksService) { - throw new ValidationError('Not in a git repository. Run this command from within a git repository.'); + throw new ValidationError( + 'Not in a git repository. Run this command from within a git repository.' + ); } const result = this.gitHooksService.install(hookType); @@ -36,7 +38,9 @@ export class HooksCommand { lines.push(chalk.gray(` Path: ${result.hookPath}`)); } if (result?.backupPath) { - lines.push(chalk.gray(` Previous hook backed up to: ${result.backupPath}`)); + lines.push( + chalk.gray(` Previous hook backed up to: ${result.backupPath}`) + ); } return lines.join('\n'); @@ -47,7 +51,9 @@ export class HooksCommand { */ uninstall(hookType: HookType): string { if (!this.gitHooksService) { - throw new ValidationError('Not in a git repository. Run this command from within a git repository.'); + throw new ValidationError( + 'Not in a git repository. Run this command from within a git repository.' + ); } this.gitHooksService.uninstall(hookType); @@ -78,7 +84,9 @@ export class HooksCommand { for (const [hook, installed] of Object.entries(status)) { const icon = installed ? chalk.green('✓') : chalk.gray('✗'); - const text = installed ? chalk.green('installed') : chalk.gray('not installed'); + const text = installed + ? chalk.green('installed') + : chalk.gray('not installed'); lines.push(` ${icon} ${hook.padEnd(15)} ${text}`); } @@ -90,7 +98,9 @@ export class HooksCommand { */ showPath(hookType: HookType): string { if (!this.gitHooksService) { - throw new ValidationError('Not in a git repository. Run this command from within a git repository.'); + throw new ValidationError( + 'Not in a git repository. Run this command from within a git repository.' + ); } const hookPath = this.gitHooksService.getHookPath(hookType); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index e7b0d6bf..0d3924d4 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -52,7 +52,10 @@ export class InitCommand { configBaseUrl, usePro, }); - const client = new DeepLClient(apiKey.trim(), { ...this.httpOptions, baseUrl }); + const client = new DeepLClient(apiKey.trim(), { + ...this.httpOptions, + baseUrl, + }); await client.getUsage(); this.config.set('auth.apiKey', apiKey.trim()); diff --git a/src/cli/commands/languages.ts b/src/cli/commands/languages.ts index 02bd23f2..fab78eaf 100644 --- a/src/cli/commands/languages.ts +++ b/src/cli/commands/languages.ts @@ -43,7 +43,7 @@ function featureLabel(key: string): string { // The key is a response field, so it is sanitized before it is displayed. return sanitizeForTerminal(key) .split('_') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); } @@ -115,7 +115,7 @@ export function partitionFeatureKeys(entries: LanguageDisplayEntry[]): { const uniform: Array<{ key: string; cell: string }> = []; for (const key of allKeys) { const first = featureCell(described[0]!, key); - if (described.some(entry => featureCell(entry, key) !== first)) { + if (described.some((entry) => featureCell(entry, key) !== first)) { columns.push(key); } else { uniform.push({ key, cell: first }); @@ -124,8 +124,8 @@ export function partitionFeatureKeys(entries: LanguageDisplayEntry[]): { return { columns: sortFeatureKeys(columns), - uniform: sortFeatureKeys(uniform.map(u => u.key)).map( - key => uniform.find(u => u.key === key)!, + uniform: sortFeatureKeys(uniform.map((u) => u.key)).map((key) => + uniform.find((u) => u.key === key)! ), }; } @@ -144,8 +144,8 @@ function featureList(entry: LanguageDisplayEntry, keys: string[]): string { if (!hasFeatureData(entry)) return 'no feature data'; if (keys.length === 0) return ''; const supported = keys - .filter(key => featureCell(entry, key) !== '—') - .map(key => { + .filter((key) => featureCell(entry, key) !== '—') + .map((key) => { const cell = featureCell(entry, key); const label = featureLabel(key).toLowerCase(); return cell === 'yes' ? label : `${label} (${cell})`; @@ -164,12 +164,14 @@ function featureList(entry: LanguageDisplayEntry, keys: string[]): string { */ function uniformNote( uniform: Array<{ key: string; cell: string }>, - entries: LanguageDisplayEntry[], + entries: LanguageDisplayEntry[] ): string | undefined { - const supported = uniform.filter(u => u.cell !== '—' && u.cell !== UNKNOWN_CELL); + const supported = uniform.filter( + (u) => u.cell !== '—' && u.cell !== UNKNOWN_CELL + ); if (supported.length === 0) return undefined; const list = supported - .map(u => { + .map((u) => { const label = featureLabel(u.key).toLowerCase(); return u.cell === 'yes' ? label : `${label} (${u.cell})`; }) @@ -213,22 +215,25 @@ export class LanguagesCommand { apiMap.set(lang.language.toLowerCase(), lang); } - const registryEntries = type === 'source' - ? getRegistrySourceLanguages() - : getRegistryTargetLanguages(); + const registryEntries = + type === 'source' + ? getRegistrySourceLanguages() + : getRegistryTargetLanguages(); - const merged = registryEntries.map(entry => { + const merged = registryEntries.map((entry) => { const apiLang = apiMap.get(entry.code); return { code: entry.code, name: apiLang?.name ?? entry.name, category: entry.category, - ...(apiLang?.supportsFormality !== undefined && { supportsFormality: apiLang.supportsFormality }), + ...(apiLang?.supportsFormality !== undefined && { + supportsFormality: apiLang.supportsFormality, + }), ...(apiLang?.features && { features: apiLang.features }), }; }); - const known = new Set(registryEntries.map(entry => entry.code)); + const known = new Set(registryEntries.map((entry) => entry.code)); for (const lang of apiLanguages) { const code = lang.language.toLowerCase(); if (known.has(code)) continue; @@ -237,7 +242,11 @@ export class LanguagesCommand { // always carries a subtag, which is the stable signal available here; core // and regional render in the same section anyway, and regenerating the // snapshot replaces the guess with the API's own answer. - const { code: derivedCode, name, category } = deriveLanguageEntry({ + const { + code: derivedCode, + name, + category, + } = deriveLanguageEntry({ lang: code, name: lang.name, usable_as_source: !code.includes('-'), @@ -247,7 +256,9 @@ export class LanguagesCommand { code: derivedCode, name, category, - ...(lang.supportsFormality !== undefined && { supportsFormality: lang.supportsFormality }), + ...(lang.supportsFormality !== undefined && { + supportsFormality: lang.supportsFormality, + }), ...(lang.features && { features: lang.features }), }); } @@ -259,11 +270,12 @@ export class LanguagesCommand { * Get display entries from registry only (no API call). */ getRegistryLanguages(type: 'source' | 'target'): LanguageDisplayEntry[] { - const entries = type === 'source' - ? getRegistrySourceLanguages() - : getRegistryTargetLanguages(); + const entries = + type === 'source' + ? getRegistrySourceLanguages() + : getRegistryTargetLanguages(); - return entries.map(entry => ({ + return entries.map((entry) => ({ code: entry.code, name: entry.name, category: entry.category, @@ -290,13 +302,16 @@ export class LanguagesCommand { showFeatures = false ): string { const lines: string[] = []; - const header = type === 'source' ? 'Source Languages:' : 'Target Languages:'; + const header = + type === 'source' ? 'Source Languages:' : 'Target Languages:'; const renderFeatures = showFeatures && hasAnyFeatures(entries); // Formality is one of the feature columns, so the [F] shorthand would say it // twice. `=== true` because a language the response did not describe carries // no answer, and a legend with no [F] beneath it reads as "none support it". const showFormality = - !renderFeatures && type === 'target' && entries.some(e => e.supportsFormality === true); + !renderFeatures && + type === 'target' && + entries.some((e) => e.supportsFormality === true); lines.push(chalk.bold(header)); @@ -305,11 +320,13 @@ export class LanguagesCommand { return lines.join('\n'); } - const coreAndRegional = entries.filter(e => e.category === 'core' || e.category === 'regional'); - const extended = entries.filter(e => e.category === 'extended'); + const coreAndRegional = entries.filter( + (e) => e.category === 'core' || e.category === 'regional' + ); + const extended = entries.filter((e) => e.category === 'extended'); const allEntries = [...coreAndRegional, ...extended]; - const maxCodeLength = Math.max(...allEntries.map(e => e.code.length)); + const maxCodeLength = Math.max(...allEntries.map((e) => e.code.length)); const { columns, uniform } = renderFeatures ? partitionFeatureKeys(entries) : { columns: [], uniform: [] }; @@ -319,18 +336,27 @@ export class LanguagesCommand { return list ? chalk.gray(` — ${list}`) : ''; }; - coreAndRegional.forEach(entry => { + coreAndRegional.forEach((entry) => { const code = entry.code.padEnd(maxCodeLength + 2); - const formalityMarker = showFormality && entry.supportsFormality ? chalk.green(' [F]') : ''; - lines.push(` ${chalk.cyan(code)} ${sanitizeForTerminal(entry.name)}${formalityMarker}${suffix(entry)}`); + const formalityMarker = + showFormality && entry.supportsFormality ? chalk.green(' [F]') : ''; + lines.push( + ` ${chalk.cyan(code)} ${sanitizeForTerminal(entry.name)}${formalityMarker}${suffix(entry)}` + ); }); if (extended.length > 0) { lines.push(''); - lines.push(chalk.gray(' Extended Languages (quality_optimized only, no formality/glossary):')); - extended.forEach(entry => { + lines.push( + chalk.gray( + ' Extended Languages (quality_optimized only, no formality/glossary):' + ) + ); + extended.forEach((entry) => { const code = entry.code.padEnd(maxCodeLength + 2); - lines.push(` ${chalk.gray(code)} ${chalk.gray(sanitizeForTerminal(entry.name))}${suffix(entry)}`); + lines.push( + ` ${chalk.gray(code)} ${chalk.gray(sanitizeForTerminal(entry.name))}${suffix(entry)}` + ); }); } @@ -353,8 +379,16 @@ export class LanguagesCommand { targetLanguages: LanguageInfo[], showFeatures = false ): string { - const sourcePart = this.formatLanguages(sourceLanguages, 'source', showFeatures); - const targetPart = this.formatLanguages(targetLanguages, 'target', showFeatures); + const sourcePart = this.formatLanguages( + sourceLanguages, + 'source', + showFeatures + ); + const targetPart = this.formatLanguages( + targetLanguages, + 'target', + showFeatures + ); return `${sourcePart}\n\n${targetPart}`; } @@ -365,9 +399,10 @@ export class LanguagesCommand { type: 'source' | 'target', showFeatures = false ): string { - const entries = languages.length === 0 && !this.service.hasClient() - ? this.getRegistryLanguages(type) - : this.mergeWithRegistry(languages, type); + const entries = + languages.length === 0 && !this.service.hasClient() + ? this.getRegistryLanguages(type) + : this.mergeWithRegistry(languages, type); return this.formatDisplayEntriesTable(entries, type, showFeatures); } @@ -392,7 +427,7 @@ export class LanguagesCommand { const showFormality = (!renderFeatures || columns.length === 0) && type === 'target' && - entries.some(e => e.supportsFormality === true); + entries.some((e) => e.supportsFormality === true); const head = ['Code', 'Name', 'Category']; const colWidths = [10, renderFeatures ? 24 : showFormality ? 30 : 36, 12]; @@ -414,7 +449,11 @@ export class LanguagesCommand { }); for (const entry of entries) { - const row: string[] = [entry.code, sanitizeForTerminal(entry.name), entry.category]; + const row: string[] = [ + entry.code, + sanitizeForTerminal(entry.name), + entry.category, + ]; if (showFormality) { row.push(entry.supportsFormality ? 'yes' : '—'); } @@ -425,8 +464,14 @@ export class LanguagesCommand { } const notes: string[] = []; - if (renderFeatures && columns.length > 0 && entries.some(e => !hasFeatureData(e))) { - notes.push(`${UNKNOWN_CELL} = the API response did not describe this language`); + if ( + renderFeatures && + columns.length > 0 && + entries.some((e) => !hasFeatureData(e)) + ) { + notes.push( + `${UNKNOWN_CELL} = the API response did not describe this language` + ); } const note = renderFeatures ? uniformNote(uniform, entries) : undefined; if (note) notes.push(note); diff --git a/src/cli/commands/parse-int-option.ts b/src/cli/commands/parse-int-option.ts index b897f543..493de9a1 100644 --- a/src/cli/commands/parse-int-option.ts +++ b/src/cli/commands/parse-int-option.ts @@ -8,11 +8,15 @@ import { InvalidArgumentError } from 'commander'; * the worker-pool sizing and silently produced zero workers. Rejecting at the * boundary means the user is told instead. */ -export function parsePositiveIntOption(value: string, name: string, max: number): number { +export function parsePositiveIntOption( + value: string, + name: string, + max: number +): number { const parsed = Number.parseInt(value, 10); if (!Number.isInteger(parsed) || parsed <= 0 || parsed > max) { throw new InvalidArgumentError( - `--${name} must be an integer between 1 and ${max}, got '${value}'`, + `--${name} must be an integer between 1 and ${max}, got '${value}'` ); } return parsed; diff --git a/src/cli/commands/register-admin.ts b/src/cli/commands/register-admin.ts index 77f4a875..ea20bcf8 100644 --- a/src/cli/commands/register-admin.ts +++ b/src/cli/commands/register-admin.ts @@ -2,7 +2,11 @@ import { Command, Option } from 'commander'; import chalk from 'chalk'; import { Logger } from '../../utils/logger.js'; import { ValidationError } from '../../utils/errors.js'; -import { createAdminCommand, type CreateDeepLClient, type GetApiKeyAndOptions } from './service-factory.js'; +import { + createAdminCommand, + type CreateDeepLClient, + type GetApiKeyAndOptions, +} from './service-factory.js'; export function registerAdmin( program: Command, @@ -10,14 +14,18 @@ export function registerAdmin( createDeepLClient: CreateDeepLClient; getApiKeyAndOptions?: GetApiKeyAndOptions; handleError: (error: unknown) => never; - }, + } ): void { const { createDeepLClient, handleError } = deps; const adminCmd = program .command('admin') - .description('Admin API: manage API keys and view organization usage (requires admin key)') - .addHelpText('after', ` + .description( + 'Admin API: manage API keys and view organization usage (requires admin key)' + ) + .addHelpText( + 'after', + ` Examples: $ deepl admin keys list $ deepl admin keys create --label "CI/CD key" @@ -27,17 +35,20 @@ Examples: $ deepl admin usage --start 2024-01-01 --end 2024-01-31 $ deepl admin usage --start 2024-01-01 --end 2024-01-31 --group-by key $ deepl admin keys list --format json -`); +` + ); - const adminKeysCmd = adminCmd - .command('keys') - .description('Manage API keys'); + const adminKeysCmd = adminCmd.command('keys').description('Manage API keys'); adminKeysCmd .addCommand( new Command('list') .description('List all API keys') - .addOption(new Option('--format ', 'Output format').choices(['text', 'json']).default('text')) + .addOption( + new Option('--format ', 'Output format') + .choices(['text', 'json']) + .default('text') + ) .action(async (options: { format?: string }) => { try { const admin = await createAdminCommand(createDeepLClient); @@ -56,7 +67,11 @@ Examples: new Command('create') .description('Create a new API key') .option('--label