From 934d63e0cd4c356c156e592f00841411cbbc78eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=C2=A0Charv=C3=A1t?= Date: Wed, 12 Aug 2026 11:52:29 +0200 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=90=9B=20fix(api-reference):=20point?= =?UTF-8?q?=20AI=20translation=20link=20at=20the=20live=20docs=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `@see` link on `ai.translate` referenced `/docs/api/ai-translation`, which returns 404. The published page is `/docs/api/ai-translation-api`. Co-Authored-By: Claude Opus 5 (1M context) --- src/api/methods/api-ai.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/methods/api-ai.ts b/src/api/methods/api-ai.ts index fa2cb0f..72b7495 100644 --- a/src/api/methods/api-ai.ts +++ b/src/api/methods/api-ai.ts @@ -12,7 +12,7 @@ export class ApiAi extends ApiBase { * @param request AI translate request config. * @param config Request config. * - * @see {@link https://localazy.com/docs/api/ai-translation#translate Localazy API Docs} + * @see {@link https://localazy.com/docs/api/ai-translation-api#translate Localazy API Docs} */ public async translate( request: AiTranslateRequest, From e67e64b0655d5e7927ee47a4d0adbcf15290b78d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=C2=A0Charv=C3=A1t?= Date: Wed, 12 Aug 2026 11:52:30 +0200 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8=20feat(suggestions):=20add=20per-?= =?UTF-8?q?key=20translation=20suggestion=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Features - Add `api.suggestions` with `tm()`, `mt()` and `ai()` over `/projects/{p}/keys/{id}/suggestions/{tm|mt|ai}` - `ai()` is a POST because it spends AI credits, mirroring the API - Accept a locale code or numeric language id for `to` and `from` - Mirror the response envelope exactly so `enabled`, `errors` and empty `items` stay distinguishable from one another - Type engine names as open unions: the active engine set is deployment configuration and can grow without a client release ## Tests - Cover both language forms, soft errors, the disabled case and the preservation of caller-supplied `config.params` Co-Authored-By: Claude Opus 5 (1M context) --- src/api/api-client.ts | 4 + src/api/methods/api-suggestions.ts | 127 +++++++++++++ src/enums/translation-engine.ts | 28 +++ src/types/ai-suggestion.ts | 17 ++ src/types/ai-suggestions-response.ts | 46 +++++ src/types/localazy-ai-engine-name.ts | 15 ++ src/types/mt-suggestion.ts | 17 ++ src/types/mt-suggestions-response.ts | 59 ++++++ src/types/suggestions-request.ts | 30 ++++ src/types/tm-suggestion.ts | 46 +++++ src/types/tm-suggestions-response.ts | 45 +++++ src/types/translation-engine-name.ts | 16 ++ tests/fixtures/full-project/index.ts | 51 ++++++ .../fixtures/full-project/suggestionsAi.json | 14 ++ .../fixtures/full-project/suggestionsMt.json | 22 +++ .../full-project/suggestionsMtDisabled.json | 4 + .../fixtures/full-project/suggestionsTm.json | 20 +++ tests/specs/suggestions.spec.ts | 169 ++++++++++++++++++ 18 files changed, 730 insertions(+) create mode 100644 src/api/methods/api-suggestions.ts create mode 100644 src/enums/translation-engine.ts create mode 100644 src/types/ai-suggestion.ts create mode 100644 src/types/ai-suggestions-response.ts create mode 100644 src/types/localazy-ai-engine-name.ts create mode 100644 src/types/mt-suggestion.ts create mode 100644 src/types/mt-suggestions-response.ts create mode 100644 src/types/suggestions-request.ts create mode 100644 src/types/tm-suggestion.ts create mode 100644 src/types/tm-suggestions-response.ts create mode 100644 src/types/translation-engine-name.ts create mode 100644 tests/fixtures/full-project/suggestionsAi.json create mode 100644 tests/fixtures/full-project/suggestionsMt.json create mode 100644 tests/fixtures/full-project/suggestionsMtDisabled.json create mode 100644 tests/fixtures/full-project/suggestionsTm.json create mode 100644 tests/specs/suggestions.spec.ts diff --git a/src/api/api-client.ts b/src/api/api-client.ts index 248654e..ef5f3b1 100644 --- a/src/api/api-client.ts +++ b/src/api/api-client.ts @@ -7,6 +7,7 @@ import { ApiImport } from '@/api/methods/api-import.js'; import { ApiKeys } from '@/api/methods/api-keys.js'; import { ApiProjects } from '@/api/methods/api-projects.js'; import { ApiScreenshots } from '@/api/methods/api-screenshots.js'; +import { ApiSuggestions } from '@/api/methods/api-suggestions.js'; import { ApiWebhooks } from '@/api/methods/api-webhooks.js'; import { FetchHttpAdapter } from '@/http/fetch-http-adapter.js'; import type { IHttpAdapter } from '@/http/i-http-adapter.js'; @@ -35,6 +36,8 @@ export class ApiClient { public screenshots: ApiScreenshots; + public suggestions: ApiSuggestions; + constructor(options: ApiClientOptions) { this.client = new FetchHttpAdapter(options); @@ -48,5 +51,6 @@ export class ApiClient { this.glossary = new ApiGlossary(this); this.webhooks = new ApiWebhooks(this); this.screenshots = new ApiScreenshots(this); + this.suggestions = new ApiSuggestions(this); } } diff --git a/src/api/methods/api-suggestions.ts b/src/api/methods/api-suggestions.ts new file mode 100644 index 0000000..27d3415 --- /dev/null +++ b/src/api/methods/api-suggestions.ts @@ -0,0 +1,127 @@ +import { ApiBase } from '@/api/methods/api-base.js'; +import type { AiSuggestionsResponse } from '@/types/ai-suggestions-response.js'; +import type { MtSuggestionsResponse } from '@/types/mt-suggestions-response.js'; +import type { RequestConfig } from '@/types/request-config.js'; +import type { SuggestionsRequest } from '@/types/suggestions-request.js'; +import type { TmSuggestionsResponse } from '@/types/tm-suggestions-response.js'; + +export class ApiSuggestions extends ApiBase { + /** + * Translation Memory (InTM) suggestions for a single key. + * + * `to` is required. `from` overrides the source language the suggestions are + * computed from and defaults to the project's source language. Both accept a + * locale code or Localazy's numeric language id. + * + * Read the response deliberately: `enabled: false` means Translation Memory + * could not run for this project or language, whereas `enabled: true` with + * empty `items` means it ran and found nothing. `errors` carries soft + * failures keyed by engine and never fails the request. + * + * @param request Suggestions request config. + * @param config Request config. Its `params` are preserved; the `to`/`from` + * language parameters are merged over them. + */ + public async tm( + request: SuggestionsRequest, + config?: RequestConfig, + ): Promise { + return (await this.api.client.get(ApiSuggestions.suggestionsUrl(request, 'tm'), { + ...config, + params: ApiSuggestions.langParams(request, config), + })) as TmSuggestionsResponse; + } + + /** + * Machine Translation suggestions for a single key. + * + * `to` is required. `from` overrides the source language the suggestions are + * computed from and defaults to the project's source language. Both accept a + * locale code or Localazy's numeric language id. + * + * `enabled` reflects only the project's own Machine Translation switch (and + * that the target is not the source). + * `errors` carries soft failures keyed by engine — an exhausted quota or a + * timed-out engine — and never fails the request. + * + * @param request Suggestions request config. + * @param config Request config. Its `params` are preserved; the `to`/`from` + * language parameters are merged over them. + */ + public async mt( + request: SuggestionsRequest, + config?: RequestConfig, + ): Promise { + return (await this.api.client.get(ApiSuggestions.suggestionsUrl(request, 'mt'), { + ...config, + params: ApiSuggestions.langParams(request, config), + })) as MtSuggestionsResponse; + } + + /** + * Localazy AI suggestions for a single key. + * + * This method **spends AI credits** — that is why the underlying endpoint is + * a `POST` rather than a `GET`. + * + * `to` is required. `from` overrides the source language the suggestions are + * computed from and defaults to the project's source language. Both accept a + * locale code or Localazy's numeric language id. Because this is a `POST`, + * the languages travel in the body and `config.params` is not sent. + * + * `enabled` requires both AI suggestions and Machine Translation to be + * switched on in the project's settings; producing results additionally + * requires an active paid MT tier, so `enabled: true` can still yield empty + * `items` with no error. `errors` carries soft failures keyed by engine, + * including depleted credits, and never fails the request. + * + * Not to be confused with {@link ApiAi.translate}, which translates arbitrary + * texts you supply rather than an existing key. + * + * @param request Suggestions request config. + * @param config Request config. + */ + public async ai( + request: SuggestionsRequest, + config?: RequestConfig, + ): Promise { + return (await this.api.client.post( + ApiSuggestions.suggestionsUrl(request, 'ai'), + ApiSuggestions.langParams(request), + config, + )) as AiSuggestionsResponse; + } + + protected static suggestionsUrl(request: SuggestionsRequest, family: string): string { + const { project, key }: SuggestionsRequest = request; + const projectId: string = ApiBase.getId(project, 'project'); + const keyId: string = ApiBase.getId(key, 'key'); + + return `/projects/${projectId}/keys/${keyId}/suggestions/${family}`; + } + + /** + * Builds the language query parameters, merged over whichever parameters the + * caller supplied in `config.params` (a `Record`, or omitted + * entirely). + * + * `to` always wins over a caller-supplied `to`. `from` is written only when + * the request actually specifies it, so a caller-supplied `from` survives + * when the request omits one — and, more importantly, a `null` or `undefined` + * `from` is left out rather than coerced to the literal string `"null"` / + * `"undefined"`, which the API would reject as an unknown language instead of + * falling back to the project's source language. + */ + protected static langParams( + request: SuggestionsRequest, + config?: RequestConfig, + ): Record { + const { to, from }: SuggestionsRequest = request; + + return { + ...config?.params, + to: ApiBase.requireLang(to, 'to'), + ...(from === undefined || from === null ? {} : { from: String(from) }), + }; + } +} diff --git a/src/enums/translation-engine.ts b/src/enums/translation-engine.ts new file mode 100644 index 0000000..872e36d --- /dev/null +++ b/src/enums/translation-engine.ts @@ -0,0 +1,28 @@ +/** + * Engines a Machine Translation suggestion can be attributed to, i.e. the + * values `api.suggestions.mt()` can report. + * + * These are third-party engines Localazy calls on your behalf. Membership is + * about which endpoint reports the engine, not about the underlying + * technology: `openai` is an LLM, but Localazy drives it as a translation + * engine, so it is reported here and never by `api.suggestions.ai()`. + * + * This is **not** a closed set. Which engines are active is deployment + * configuration and can change without a client release, so treat an + * unrecognised value as valid rather than as an error — the list exists for + * autocomplete, not for validation. + */ +export const TRANSLATION_ENGINES = ['amazon', 'azure', 'deepl', 'google', 'openai'] as const; + +/** + * Engines that produce Localazy AI suggestions, i.e. the values + * `api.suggestions.ai()` can report. + * + * Localazy AI is a Localazy product rather than a third-party engine, which is + * why it forms its own family. Internal and preview tiers are deliberately + * omitted — they are gated behind feature flags and are not part of the public + * surface. + * + * As with {@link TRANSLATION_ENGINES} this is an open set. + */ +export const LOCALAZY_AI_ENGINES = ['localazyAi'] as const; diff --git a/src/types/ai-suggestion.ts b/src/types/ai-suggestion.ts new file mode 100644 index 0000000..2a02ffd --- /dev/null +++ b/src/types/ai-suggestion.ts @@ -0,0 +1,17 @@ +import type { LocalazyAiEngineName } from '@/types/localazy-ai-engine-name.js'; + +/** + * A Localazy AI suggestion. + */ +export type AiSuggestion = { + /** + * The suggested translation value. + */ + value: string; + + /** + * The engine that produced this suggestion. Known engines autocomplete; + * unrecognised values are still valid. + */ + engine: LocalazyAiEngineName; +}; diff --git a/src/types/ai-suggestions-response.ts b/src/types/ai-suggestions-response.ts new file mode 100644 index 0000000..4bf488d --- /dev/null +++ b/src/types/ai-suggestions-response.ts @@ -0,0 +1,46 @@ +import type { AiSuggestion } from '@/types/ai-suggestion.js'; + +/** + * A source form and its Localazy AI suggestions. + */ +export type AiSuggestionSource = { + /** + * The source string these suggestions were computed for. + */ + source: string; + + /** + * Suggestions found for this source form. Empty when there are no hits. + */ + suggestions: AiSuggestion[]; +}; + +/** + * Localazy AI suggestions for a single key. + */ +export type AiSuggestionsResponse = { + /** + * `true` when both AI suggestions and Machine Translation are enabled in the + * project's settings and the target language is not the (possibly + * overridden) source. + * + * Producing AI results additionally requires an active paid MT tier — with + * both flags on but no such tier, the result can be empty with no error. + */ + enabled: boolean; + + /** + * Soft failures keyed by engine name — an engine erroring, AI credits being + * depleted, or an engine timing out. A soft error never fails the request. + * The reserved key `general` carries failures that belong to no single + * engine, most commonly the key having no value in the source language. + */ + errors?: Record; + + /** + * One entry per source form: a singular key yields one entry, a plural or + * array key one per form. Empty when the key has no source text or the + * target equals the source language. + */ + items: AiSuggestionSource[]; +}; diff --git a/src/types/localazy-ai-engine-name.ts b/src/types/localazy-ai-engine-name.ts new file mode 100644 index 0000000..ee97a00 --- /dev/null +++ b/src/types/localazy-ai-engine-name.ts @@ -0,0 +1,15 @@ +import type { LOCALAZY_AI_ENGINES } from '@/enums/translation-engine.js'; + +/** + * Localazy AI engines known at the time this client was published. + */ +export type KnownLocalazyAiEngine = (typeof LOCALAZY_AI_ENGINES)[number]; + +/** + * The name of the engine that produced a Localazy AI suggestion. + * + * Known engines are offered as autocomplete, but any string is accepted: the + * active engine set is deployment configuration and can grow without a client + * release. + */ +export type LocalazyAiEngineName = KnownLocalazyAiEngine | (string & Record); diff --git a/src/types/mt-suggestion.ts b/src/types/mt-suggestion.ts new file mode 100644 index 0000000..a6526a5 --- /dev/null +++ b/src/types/mt-suggestion.ts @@ -0,0 +1,17 @@ +import type { TranslationEngineName } from '@/types/translation-engine-name.js'; + +/** + * A Machine Translation suggestion produced by a single engine. + */ +export type MtSuggestion = { + /** + * The suggested translation value. + */ + value: string; + + /** + * The machine translation engine that produced this suggestion. Known engines + * autocomplete; unrecognised values are still valid. + */ + engine: TranslationEngineName; +}; diff --git a/src/types/mt-suggestions-response.ts b/src/types/mt-suggestions-response.ts new file mode 100644 index 0000000..bb77536 --- /dev/null +++ b/src/types/mt-suggestions-response.ts @@ -0,0 +1,59 @@ +import type { TranslationEngineName } from '@/types/translation-engine-name.js'; +import type { MtSuggestion } from '@/types/mt-suggestion.js'; + +/** + * A source form and its Machine Translation suggestions, one per engine that + * returned a result. + */ +export type MtSuggestionSource = { + /** + * The source string these suggestions were computed for. + */ + source: string; + + /** + * Suggestions found for this source form. Empty when there are no hits. + */ + suggestions: MtSuggestion[]; +}; + +/** + * Machine Translation suggestions for a single key. + */ +export type MtSuggestionsResponse = { + /** + * Whether the project's Machine Translation switch is on and the target is + * not the (possibly overridden) source language. + * + * This reflects the project switch only — it does not account for the + * organization's entitlements. An organization whose paid MT tier has lapsed + * still reports `enabled: true`, with empty `items` and no `errors`. So + * `enabled: true` plus empty `items` means "no suggestions available", which + * is not the same as "no matches exist". + */ + enabled: boolean; + + /** + * The project's **explicit** machine translation engine allow-list, present + * only when one has been configured — which is uncommon, so this field is + * usually absent. It is a restriction, not the effective engine set, so do + * not treat its absence as "no engines available". + */ + allowedEngines?: TranslationEngineName[]; + + /** + * Soft failures keyed by engine name — an engine erroring, the MT fair-use + * quota being exhausted, or an engine timing out. A soft error never fails + * the request. The reserved key `general` carries failures that belong to no + * single engine, most commonly the key having no value in the source + * language. + */ + errors?: Record; + + /** + * One entry per source form: a singular key yields one entry, a plural or + * array key one per form. Empty when the key has no source text or the + * target equals the source language. + */ + items: MtSuggestionSource[]; +}; diff --git a/src/types/suggestions-request.ts b/src/types/suggestions-request.ts new file mode 100644 index 0000000..541de07 --- /dev/null +++ b/src/types/suggestions-request.ts @@ -0,0 +1,30 @@ +import type { Key } from '@/types/key.js'; +import type { Project } from '@/types/project.js'; +import type { Locales } from '@localazy/languages'; + +/** + * Shared request shape for the per-key suggestion endpoints. + */ +export type SuggestionsRequest = { + /** + * Project object or Project ID. + */ + project: Project | string; + + /** + * Key object or Key ID. + */ + key: Key | string; + + /** + * The target language to translate into, as a locale code (e.g. `pt_BR`) + * or Localazy's numeric language id (e.g. `112`). + */ + to: `${Locales}` | number; + + /** + * Optional source language override, as a locale code (e.g. `en`) or numeric + * language id (e.g. `85`). Defaults to the project's source language. + */ + from?: `${Locales}` | number; +}; diff --git a/src/types/tm-suggestion.ts b/src/types/tm-suggestion.ts new file mode 100644 index 0000000..6915585 --- /dev/null +++ b/src/types/tm-suggestion.ts @@ -0,0 +1,46 @@ +/** + * Lightweight identification of the project that holds the matching phrase + * (almost always the current project). + */ +export type TmSuggestionProject = { + /** + * Project identifier. + */ + id: string; + + /** + * Project name. + */ + name: string; + + /** + * Absolute URL of the project image. + */ + image: string; + + /** + * Path to the project, relative to the Localazy site root (e.g. `/p/my-app`). + */ + url: string; +}; + +/** + * A Translation Memory suggestion: a translation reused from another phrase + * in the project. + */ +export type TmSuggestion = { + /** + * The suggested translation value. + */ + value: string; + + /** + * Identifier of the key (phrase) the translation was reused from. + */ + phraseId: string; + + /** + * The project that holds the matching phrase. + */ + project: TmSuggestionProject; +}; diff --git a/src/types/tm-suggestions-response.ts b/src/types/tm-suggestions-response.ts new file mode 100644 index 0000000..4917f0c --- /dev/null +++ b/src/types/tm-suggestions-response.ts @@ -0,0 +1,45 @@ +import type { TmSuggestion } from '@/types/tm-suggestion.js'; + +/** + * A source form and its Translation Memory suggestions. + */ +export type TmSuggestionSource = { + /** + * The source string these suggestions were computed for. + */ + source: string; + + /** + * Suggestions found for this source form. Empty when there are no hits. + */ + suggestions: TmSuggestion[]; +}; + +/** + * Translation Memory (InTM) suggestions for a single key. + */ +export type TmSuggestionsResponse = { + /** + * Whether Translation Memory could run for this request. `false` means the + * feature is unavailable for the project, or the target language is the + * (possibly overridden) source language. + * + * Note that `enabled: true` with empty `items` means "no hits", which is a + * different answer from "feature off". + */ + enabled: boolean; + + /** + * Soft failures keyed by engine name. A soft error never fails the request. + * The reserved key `general` carries failures that belong to no single + * engine, most commonly the key having no value in the source language. + */ + errors?: Record; + + /** + * One entry per source form: a singular key yields one entry, a plural or + * array key one per form. Empty when the key has no source text or the + * target equals the source language. + */ + items: TmSuggestionSource[]; +}; diff --git a/src/types/translation-engine-name.ts b/src/types/translation-engine-name.ts new file mode 100644 index 0000000..2b8235b --- /dev/null +++ b/src/types/translation-engine-name.ts @@ -0,0 +1,16 @@ +import type { TRANSLATION_ENGINES } from '@/enums/translation-engine.js'; + +/** + * Translation engines known at the time this client was published. + */ +export type KnownTranslationEngine = (typeof TRANSLATION_ENGINES)[number]; + +/** + * The name of the translation engine that produced a Machine Translation + * suggestion. + * + * Known engines are offered as autocomplete, but any string is accepted: the + * active engine set is deployment configuration and can grow without a client + * release. + */ +export type TranslationEngineName = KnownTranslationEngine | (string & Record); diff --git a/tests/fixtures/full-project/index.ts b/tests/fixtures/full-project/index.ts index 6c35719..edca9d0 100644 --- a/tests/fixtures/full-project/index.ts +++ b/tests/fixtures/full-project/index.ts @@ -10,6 +10,10 @@ import projects from '@tests/fixtures/full-project/projects.json' with { type: ' import projectsOrgsLangs from '@tests/fixtures/full-project/projectsOrgsLangs.json' with { type: 'json' }; import screenshots from '@tests/fixtures/full-project/screenshots.json' with { type: 'json' }; import screenshotTags from '@tests/fixtures/full-project/screenshotTags.json' with { type: 'json' }; +import suggestionsAi from '@tests/fixtures/full-project/suggestionsAi.json' with { type: 'json' }; +import suggestionsMt from '@tests/fixtures/full-project/suggestionsMt.json' with { type: 'json' }; +import suggestionsMtDisabled from '@tests/fixtures/full-project/suggestionsMtDisabled.json' with { type: 'json' }; +import suggestionsTm from '@tests/fixtures/full-project/suggestionsTm.json' with { type: 'json' }; import webhooks from '@tests/fixtures/full-project/webhooks.json' with { type: 'json' }; import webhooksSecret from '@tests/fixtures/full-project/webhooksSecret.json' with { type: 'json' }; import { assertNotNull } from '@tests/support/assert-not-null.js'; @@ -30,6 +34,10 @@ export const serverResponses = { fileDownload, screenshots, screenshotTags, + suggestionsTm, + suggestionsMt, + suggestionsMtDisabled, + suggestionsAi, webhooks, webhooksSecret, resultPostScreenshot: { @@ -41,6 +49,10 @@ export const serverResponses = { resultPut: { result: true, }, + resultSubmitTranslation: { + result: true, + versionId: '_v000000000000000001', + }, resultDelete: { result: true, }, @@ -157,6 +169,45 @@ export const mockResponses = (): void => { ); fetchMock.post(`${baseUrl}/projects/_a0000000000000000001/webhooks`, serverResponses.resultPost); + // suggestions + fetchMock.get( + `${baseUrl}/projects/_a0000000000000000001/keys/_a0000000000000000001/suggestions/mt?extra=1&to=cs`, + serverResponses.suggestionsMt, + ); + fetchMock.get( + `${baseUrl}/projects/_a0000000000000000001/keys/_a0000000000000000001/suggestions/tm?to=cs`, + serverResponses.suggestionsTm, + ); + fetchMock.get( + `${baseUrl}/projects/_a0000000000000000001/keys/_a0000000000000000001/suggestions/mt?to=cs`, + serverResponses.suggestionsMt, + ); + fetchMock.get( + `${baseUrl}/projects/_a0000000000000000001/keys/_a0000000000000000001/suggestions/mt?to=112&from=85`, + serverResponses.suggestionsMtDisabled, + ); + fetchMock.post( + `${baseUrl}/projects/_a0000000000000000001/keys/_a0000000000000000001/suggestions/ai`, + serverResponses.suggestionsAi, + ); + + // translations + fetchMock.post( + `${baseUrl}/projects/_a0000000000000000001/keys/_a0000000000000000001/translations/zh%23Hans`, + serverResponses.resultSubmitTranslation, + ); + fetchMock.post( + `${baseUrl}/projects/_a0000000000000000001/keys/_a0000000000000000001/translations/cs`, + serverResponses.resultSubmitTranslation, + ); + + // tags & priority + fetchMock.put(`${baseUrl}/projects/_a0000000000000000001/keys/tags`, serverResponses.resultPut); + fetchMock.put( + `${baseUrl}/projects/_a0000000000000000001/keys/priority`, + serverResponses.resultPut, + ); + // errors fetchMock.put(`${baseUrl}/projects/_a0000000000000000001/keys/unknown-key-id`, { status: 400, diff --git a/tests/fixtures/full-project/suggestionsAi.json b/tests/fixtures/full-project/suggestionsAi.json new file mode 100644 index 0000000..9eb3e5b --- /dev/null +++ b/tests/fixtures/full-project/suggestionsAi.json @@ -0,0 +1,14 @@ +{ + "enabled": true, + "items": [ + { + "source": "Save changes", + "suggestions": [ + { + "value": "Uložit změny", + "engine": "localazyAi" + } + ] + } + ] +} diff --git a/tests/fixtures/full-project/suggestionsMt.json b/tests/fixtures/full-project/suggestionsMt.json new file mode 100644 index 0000000..c3e4a10 --- /dev/null +++ b/tests/fixtures/full-project/suggestionsMt.json @@ -0,0 +1,22 @@ +{ + "enabled": true, + "allowedEngines": ["google", "deepl"], + "errors": { + "azure": "Engine timed out." + }, + "items": [ + { + "source": "Save changes", + "suggestions": [ + { + "value": "Uložit změny", + "engine": "google" + }, + { + "value": "Uložit úpravy", + "engine": "deepl" + } + ] + } + ] +} diff --git a/tests/fixtures/full-project/suggestionsMtDisabled.json b/tests/fixtures/full-project/suggestionsMtDisabled.json new file mode 100644 index 0000000..548d3e9 --- /dev/null +++ b/tests/fixtures/full-project/suggestionsMtDisabled.json @@ -0,0 +1,4 @@ +{ + "enabled": false, + "items": [] +} diff --git a/tests/fixtures/full-project/suggestionsTm.json b/tests/fixtures/full-project/suggestionsTm.json new file mode 100644 index 0000000..a8044a8 --- /dev/null +++ b/tests/fixtures/full-project/suggestionsTm.json @@ -0,0 +1,20 @@ +{ + "enabled": true, + "items": [ + { + "source": "Save changes", + "suggestions": [ + { + "value": "Uložit změny", + "phraseId": "_e845123154101354564", + "project": { + "id": "_a0000000000000000001", + "name": "My App", + "image": "https://img.localazy.com/project.png", + "url": "/p/my-app" + } + } + ] + } + ] +} diff --git a/tests/specs/suggestions.spec.ts b/tests/specs/suggestions.spec.ts new file mode 100644 index 0000000..56fbf32 --- /dev/null +++ b/tests/specs/suggestions.spec.ts @@ -0,0 +1,169 @@ +import type { + AiSuggestionsResponse, + ApiClient, + MtSuggestionsResponse, + Project, + SuggestionsRequest, + TmSuggestionsResponse, +} from '@/main.js'; +import { fullProject } from '@tests/fixtures/index.js'; +import { assertNotNull } from '@tests/support/assert-not-null.js'; +import { getApiClient, getToken } from '@tests/support/index.js'; +import type { MockInstance } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const KEY_ID: string = '_a0000000000000000001'; + +const jsonHeaders = (): Record => ({ + Accept: 'application/json', + Authorization: `Bearer ${getToken()}`, + 'Content-Type': 'application/json', +}); + +describe('Suggestions', (): void => { + let api: ApiClient; + let project: Project; + + beforeEach(async (): Promise => { + fullProject.mockResponses(); + + api = getApiClient(); + project = await api.projects.first(); + }); + + test('api.suggestions.tm', async (): Promise => { + const request: SuggestionsRequest = { project, key: KEY_ID, to: 'cs' }; + const spy: MockInstance = vi.spyOn(globalThis, 'fetch'); + const response: TmSuggestionsResponse = await api.suggestions.tm(request); + + expect(response.enabled).toBe(true); + const firstItem = assertNotNull(response.items[0]); + expect(firstItem.source).toBe('Save changes'); + const firstSuggestion = assertNotNull(firstItem.suggestions[0]); + expect(firstSuggestion.value).toBe('Uložit změny'); + expect(firstSuggestion.phraseId).toBe('_e845123154101354564'); + expect(firstSuggestion.project.name).toBe('My App'); + + expect(spy).toHaveBeenCalledWith( + `https://api.localazy.com/projects/_a0000000000000000001/keys/${KEY_ID}/suggestions/tm?to=cs`, + { headers: jsonHeaders(), method: 'GET' }, + ); + }); + + test('api.suggestions.mt', async (): Promise => { + const request: SuggestionsRequest = { project, key: KEY_ID, to: 'cs' }; + const spy: MockInstance = vi.spyOn(globalThis, 'fetch'); + const response: MtSuggestionsResponse = await api.suggestions.mt(request); + + expect(response.enabled).toBe(true); + expect(response.allowedEngines).toEqual(['google', 'deepl']); + const firstItem = assertNotNull(response.items[0]); + expect(firstItem.suggestions.map((s): string => s.engine)).toEqual(['google', 'deepl']); + + expect(spy).toHaveBeenCalledWith( + `https://api.localazy.com/projects/_a0000000000000000001/keys/${KEY_ID}/suggestions/mt?to=cs`, + { headers: jsonHeaders(), method: 'GET' }, + ); + }); + + test('api.suggestions.mt surfaces soft errors without throwing', async (): Promise => { + const response: MtSuggestionsResponse = await api.suggestions.mt({ + project, + key: KEY_ID, + to: 'cs', + }); + + // A soft engine failure is reported in `errors`, never as a rejected promise. + expect(response.errors).toEqual({ azure: 'Engine timed out.' }); + expect(response.enabled).toBe(true); + }); + + test('api.suggestions.mt accepts numeric language ids and sends `from`', async (): Promise => { + const request: SuggestionsRequest = { project, key: KEY_ID, to: 112, from: 85 }; + const spy: MockInstance = vi.spyOn(globalThis, 'fetch'); + const response: MtSuggestionsResponse = await api.suggestions.mt(request); + + // `enabled: false` is distinct from "ran but found nothing". + expect(response.enabled).toBe(false); + expect(response.items).toEqual([]); + + expect(spy).toHaveBeenCalledWith( + `https://api.localazy.com/projects/_a0000000000000000001/keys/${KEY_ID}/suggestions/mt?to=112&from=85`, + { headers: jsonHeaders(), method: 'GET' }, + ); + }); + + test('api.suggestions.ai posts the languages in the body', async (): Promise => { + const request: SuggestionsRequest = { project, key: KEY_ID, to: 'cs' }; + const spy: MockInstance = vi.spyOn(globalThis, 'fetch'); + const response: AiSuggestionsResponse = await api.suggestions.ai(request); + + expect(response.enabled).toBe(true); + const firstItem = assertNotNull(response.items[0]); + const firstSuggestion = assertNotNull(firstItem.suggestions[0]); + expect(firstSuggestion.engine).toBe('localazyAi'); + + expect(spy).toHaveBeenCalledWith( + `https://api.localazy.com/projects/_a0000000000000000001/keys/${KEY_ID}/suggestions/ai`, + { body: '{"to":"cs"}', headers: jsonHeaders(), method: 'POST' }, + ); + }); + + test('api.suggestions.ai sends `from` when provided', async (): Promise => { + const spy: MockInstance = vi.spyOn(globalThis, 'fetch'); + await api.suggestions.ai({ project, key: KEY_ID, to: 'cs', from: 'en' }); + + expect(spy).toHaveBeenCalledWith( + `https://api.localazy.com/projects/_a0000000000000000001/keys/${KEY_ID}/suggestions/ai`, + { body: '{"to":"cs","from":"en"}', headers: jsonHeaders(), method: 'POST' }, + ); + }); + + test('api.suggestions.ai omits `from` when not provided', async (): Promise => { + const spy: MockInstance = vi.spyOn(globalThis, 'fetch'); + await api.suggestions.ai({ project, key: KEY_ID, to: 'cs' }); + + // The body must carry `to` only — never `"from":"undefined"`. + expect(spy).toHaveBeenCalledWith( + `https://api.localazy.com/projects/_a0000000000000000001/keys/${KEY_ID}/suggestions/ai`, + { body: '{"to":"cs"}', headers: jsonHeaders(), method: 'POST' }, + ); + }); + + test('api.suggestions.ai omits a null `from` rather than sending "null"', async (): Promise => { + const spy: MockInstance = vi.spyOn(globalThis, 'fetch'); + await api.suggestions.ai({ + project, + key: KEY_ID, + to: 'cs', + from: null as unknown as undefined, + }); + + expect(spy).toHaveBeenCalledWith( + `https://api.localazy.com/projects/_a0000000000000000001/keys/${KEY_ID}/suggestions/ai`, + { body: '{"to":"cs"}', headers: jsonHeaders(), method: 'POST' }, + ); + }); + + test('api.suggestions.mt preserves caller-supplied config.params', async (): Promise => { + const spy: MockInstance = vi.spyOn(globalThis, 'fetch'); + await api.suggestions.mt({ project, key: KEY_ID, to: 'cs' }, { params: { extra: '1' } }); + + // The caller's param must survive alongside the language params. + const calledUrl: string = String(spy.mock.calls.at(-1)?.[0]); + expect(calledUrl).toContain('extra=1'); + expect(calledUrl).toContain('to=cs'); + }); + + test('api.suggestions.mt rejects a missing target language', async (): Promise => { + await expect( + api.suggestions.mt({ project, key: KEY_ID, to: null as unknown as 'cs' }), + ).rejects.toThrow('Invalid to language.'); + }); + + test('api.suggestions.tm rejects an invalid key id', async (): Promise => { + await expect(api.suggestions.tm({ project, key: '', to: 'cs' })).rejects.toThrow( + 'Invalid key ID.', + ); + }); +}); From 93a75927fd111d83582ac7f916b2cb4b69aba34e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=C2=A0Charv=C3=A1t?= Date: Wed, 12 Aug 2026 11:52:45 +0200 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8=20feat(keys):=20submit=20translat?= =?UTF-8?q?ions=20and=20set=20tags=20and=20priority?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Features - Add `keys.submitTranslation` for one key in one target language - Add `keys.setTags` and `keys.setPriority` over the batch routes, both returning `BooleanResult` so a caller can see the API answered — note it reports the request was processed, not that anything changed - URL-escape the locale so script-qualified codes such as `zh#Hans` survive instead of being truncated at the fragment marker - Accept the `@`-prefixed plural form the read API returns and strip it, so a value taken straight from `files.listKeys()` round-trips safely - Derive `PluralClass` and `KeyPriority` from single runtime lists ## Bug fixes - Guard `getIds` against a non-array, and reject a nullish language instead of coercing it to the literal string `"null"` ## Tests - Cover locale escaping, plural prefix stripping, mixed key arrays and the rejection paths Co-Authored-By: Claude Opus 5 (1M context) --- src/api/methods/api-base.ts | 21 ++ src/api/methods/api-keys.ts | 109 ++++++++++ src/enums/key-priority.ts | 7 + src/enums/plural-class.ts | 12 + src/types/boolean-result.ts | 10 + src/types/key-priority.ts | 7 + src/types/key-set-priority-request.ts | 20 ++ src/types/key-set-tags-request.ts | 26 +++ src/types/key-submit-translation-request.ts | 34 +++ src/types/plural-class.ts | 22 ++ src/types/submit-translation-response.ts | 16 ++ src/types/translation-value.ts | 25 +++ src/utils/translation-value-utils.ts | 41 ++++ tests/specs/keys.spec.ts | 230 +++++++++++++++++++- 14 files changed, 578 insertions(+), 2 deletions(-) create mode 100644 src/enums/key-priority.ts create mode 100644 src/enums/plural-class.ts create mode 100644 src/types/boolean-result.ts create mode 100644 src/types/key-priority.ts create mode 100644 src/types/key-set-priority-request.ts create mode 100644 src/types/key-set-tags-request.ts create mode 100644 src/types/key-submit-translation-request.ts create mode 100644 src/types/plural-class.ts create mode 100644 src/types/submit-translation-response.ts create mode 100644 src/types/translation-value.ts create mode 100644 src/utils/translation-value-utils.ts diff --git a/src/api/methods/api-base.ts b/src/api/methods/api-base.ts index 2ff5fc0..df43f8e 100644 --- a/src/api/methods/api-base.ts +++ b/src/api/methods/api-base.ts @@ -16,4 +16,25 @@ export abstract class ApiBase { return id; } + + /** + * Validates a required language and renders it as a string. Guards against a + * nullish value being coerced to the literal `"null"` / `"undefined"`, which + * the API would reject as an unknown language rather than as a missing one. + */ + protected static requireLang(val: string | number | null | undefined, prop: string): string { + if (val === undefined || val === null || String(val).trim() === '') { + throw new Error(`Invalid ${prop} language.`); + } + + return String(val); + } + + protected static getIds(vals: (string | { id: string })[], prop: string): string[] { + if (!Array.isArray(vals)) { + throw new TypeError(`Invalid ${prop} list: an array is required.`); + } + + return vals.map((val: string | { id: string }): string => ApiBase.getId(val, prop)); + } } diff --git a/src/api/methods/api-keys.ts b/src/api/methods/api-keys.ts index 49292ad..8085cbf 100644 --- a/src/api/methods/api-keys.ts +++ b/src/api/methods/api-keys.ts @@ -1,8 +1,14 @@ import { ApiBase } from '@/api/methods/api-base.js'; import type { KeyDeleteRequest } from '@/types/key-delete-request.js'; import type { KeyDeprecateRequest } from '@/types/key-deprecate-request.js'; +import type { KeySetPriorityRequest } from '@/types/key-set-priority-request.js'; +import type { KeySetTagsRequest } from '@/types/key-set-tags-request.js'; +import type { KeySubmitTranslationRequest } from '@/types/key-submit-translation-request.js'; import type { KeyUpdateRequest } from '@/types/key-update-request.js'; import type { RequestConfig } from '@/types/request-config.js'; +import type { BooleanResult } from '@/types/boolean-result.js'; +import { normalizeTranslationValue } from '@/utils/translation-value-utils.js'; +import type { SubmitTranslationResponse } from '@/types/submit-translation-response.js'; export class ApiKeys extends ApiBase { /** @@ -65,4 +71,107 @@ export class ApiKeys extends ApiBase { config, ); } + + /** + * Submit a translation for a single {@link Key key} in one target language. + * + * `value` must match the key's form: a string for a singular key, an array of + * strings for an array key, or an object keyed by CLDR plural class for a + * plural key. Submitting a shape that does not match the key is rejected. + * + * Plural values may use either the plain classes the write API expects + * (`{ one: '1 item' }`) or the `@`-prefixed form the read API returns + * (`{ '@one': '1 item' }`) — the prefix is stripped for you, so a value taken + * straight from `files.listKeys()` round-trips correctly. + * + * `lang` accepts a locale code or Localazy's numeric language id, and is + * URL-escaped, so script-qualified locales such as `zh#Hans` are transmitted + * intact. + * + * **Check `result` on the response.** The API answers HTTP 200 with + * `result: false` and a `message` when a submission is deliberately not + * applied — the target is the project's source language, the project is + * momentarily locked by a running import, or the translation could not be + * stored. None of those reject the promise. + * + * @param request Key submit translation request config. + * @param config Request config. + */ + public async submitTranslation( + request: KeySubmitTranslationRequest, + config?: RequestConfig, + ): Promise { + const { project, key, lang, value }: KeySubmitTranslationRequest = request; + const projectId: string = ApiBase.getId(project, 'project'); + const keyId: string = ApiBase.getId(key, 'key'); + const locale: string = encodeURIComponent(ApiBase.requireLang(lang, 'lang')); + + return (await this.api.client.post( + `/projects/${projectId}/keys/${keyId}/translations/${locale}`, + { value: normalizeTranslationValue(value) }, + config, + )) as SubmitTranslationResponse; + } + + /** + * Add and/or remove tags on {@link Key keys}. + * + * Removal is applied before addition, so a tag name present in both + * `addTags` and `removeTags` ends up added. Tag names that do not exist yet + * are created, subject to the project's 50-tag limit. Applying tags is not + * atomic across tag names. + * + * At most 1000 keys may be passed per call; larger sets are rejected outright + * rather than truncated, and splitting them is the caller's responsibility. + * + * `result` reports that the request was processed, not that it changed + * anything: key ids that do not resolve within the project are skipped + * silently, and a call in which none of them resolve still answers `true`. + * + * @param request Key set tags request config. + * @param config Request config. + * + * @see {@link https://localazy.com/docs/api/source-keys#set-tags-on-multiple-keys Localazy API Docs} + */ + public async setTags(request: KeySetTagsRequest, config?: RequestConfig): Promise { + const { project, keys, ...data }: KeySetTagsRequest = request; + const projectId: string = ApiBase.getId(project, 'project'); + + return (await this.api.client.put( + `/projects/${projectId}/keys/tags`, + { keys: ApiBase.getIds(keys, 'key'), ...data }, + config, + )) as BooleanResult; + } + + /** + * Set the priority level on {@link Key keys}. + * + * `normal` clears any priority currently set. + * + * At most 1000 keys may be passed per call; larger sets are rejected outright + * rather than truncated, and splitting them is the caller's responsibility. + * + * `result` reports that the request was processed, not that it changed + * anything: key ids that do not resolve within the project are skipped + * silently, and a call in which none of them resolve still answers `true`. + * + * @param request Key set priority request config. + * @param config Request config. + * + * @see {@link https://localazy.com/docs/api/source-keys#set-priority-on-multiple-keys Localazy API Docs} + */ + public async setPriority( + request: KeySetPriorityRequest, + config?: RequestConfig, + ): Promise { + const { project, keys, priority }: KeySetPriorityRequest = request; + const projectId: string = ApiBase.getId(project, 'project'); + + return (await this.api.client.put( + `/projects/${projectId}/keys/priority`, + { keys: ApiBase.getIds(keys, 'key'), priority }, + config, + )) as BooleanResult; + } } diff --git a/src/enums/key-priority.ts b/src/enums/key-priority.ts new file mode 100644 index 0000000..21a3f20 --- /dev/null +++ b/src/enums/key-priority.ts @@ -0,0 +1,7 @@ +/** + * Built-in priority levels a source key can be assigned. + * + * Unlike the engine lists this **is** a closed set: the API rejects any other + * value with `invalid_tag`. `normal` clears any priority currently set. + */ +export const KEY_PRIORITIES = ['lowest', 'low', 'normal', 'high', 'highest'] as const; diff --git a/src/enums/plural-class.ts b/src/enums/plural-class.ts new file mode 100644 index 0000000..e9e92c2 --- /dev/null +++ b/src/enums/plural-class.ts @@ -0,0 +1,12 @@ +/** + * The CLDR plural categories. + * + * Single source of truth: the {@link PluralClass} type is derived from this + * array, and the runtime check that recognises a plural value on the wire is + * built from it too — so the type, the validation, and the wire format cannot + * drift apart. + * + * Which subset a language actually uses is defined by CLDR — English uses + * `one`/`other`, Czech `one`/`few`/`many`/`other`. + */ +export const PLURAL_CLASSES = ['zero', 'one', 'two', 'few', 'many', 'other'] as const; diff --git a/src/types/boolean-result.ts b/src/types/boolean-result.ts new file mode 100644 index 0000000..a44cb3b --- /dev/null +++ b/src/types/boolean-result.ts @@ -0,0 +1,10 @@ +export type BooleanResult = { + /** + * Whether the operation was accepted. + * + * Note this reports that the request was processed, not that it changed + * anything: key ids that do not resolve within the project are skipped, and + * a call in which every id is unresolvable still answers `true`. + */ + result: boolean; +}; diff --git a/src/types/key-priority.ts b/src/types/key-priority.ts new file mode 100644 index 0000000..e45289a --- /dev/null +++ b/src/types/key-priority.ts @@ -0,0 +1,7 @@ +import type { KEY_PRIORITIES } from '@/enums/key-priority.js'; + +/** + * A built-in priority level. Closed set — the API rejects any other value with + * `invalid_tag`. + */ +export type KeyPriority = (typeof KEY_PRIORITIES)[number]; diff --git a/src/types/key-set-priority-request.ts b/src/types/key-set-priority-request.ts new file mode 100644 index 0000000..058f7c8 --- /dev/null +++ b/src/types/key-set-priority-request.ts @@ -0,0 +1,20 @@ +import type { KeyPriority } from '@/types/key-priority.js'; +import type { Key } from '@/types/key.js'; +import type { Project } from '@/types/project.js'; + +export type KeySetPriorityRequest = { + /** + * Project object or Project ID. + */ + project: Project | string; + + /** + * Keys to set the priority on. Up to 1000 keys per call. + */ + keys: (Key | Pick | string)[]; + + /** + * The priority level to assign. `normal` clears any priority currently set. + */ + priority: KeyPriority; +}; diff --git a/src/types/key-set-tags-request.ts b/src/types/key-set-tags-request.ts new file mode 100644 index 0000000..83a3ec2 --- /dev/null +++ b/src/types/key-set-tags-request.ts @@ -0,0 +1,26 @@ +import type { Key } from '@/types/key.js'; +import type { Project } from '@/types/project.js'; + +export type KeySetTagsRequest = { + /** + * Project object or Project ID. + */ + project: Project | string; + + /** + * Keys to apply the tag changes to. Up to 1000 keys per call. + */ + keys: (Key | Pick | string)[]; + + /** + * Tag names to add. Names that do not exist yet are created, subject to the + * project's 50-tag limit. + */ + addTags?: string[]; + + /** + * Tag names to remove. Removal is applied before addition, so a name present + * in both `addTags` and `removeTags` ends up added. + */ + removeTags?: string[]; +}; diff --git a/src/types/key-submit-translation-request.ts b/src/types/key-submit-translation-request.ts new file mode 100644 index 0000000..152e206 --- /dev/null +++ b/src/types/key-submit-translation-request.ts @@ -0,0 +1,34 @@ +import type { TranslationValue } from '@/types/translation-value.js'; +import type { Key } from '@/types/key.js'; +import type { Project } from '@/types/project.js'; +import type { Locales } from '@localazy/languages'; + +export type KeySubmitTranslationRequest = { + /** + * Project object or Project ID. + */ + project: Project | string; + + /** + * Key object or Key ID. + */ + key: Key | string; + + /** + * The target language the translation is submitted for, as a locale code + * (e.g. `pt_BR`) or Localazy's numeric language id (e.g. `112`). + */ + lang: `${Locales}` | number; + + /** + * The translation value. Its shape must match the key's form: + * a string for a singular key (`'Save'`), an array of strings for an array + * key (`['First', 'Second']`), or an object keyed by CLDR plural class for a + * plural key (`{ one: '1 item', other: '%d items' }`). + * + * The `@`-prefixed plural form returned by the read API + * (`{ '@one': '1 item' }`) is also accepted — the prefix is stripped before + * the request is sent. + */ + value: TranslationValue; +}; diff --git a/src/types/plural-class.ts b/src/types/plural-class.ts new file mode 100644 index 0000000..0853bde --- /dev/null +++ b/src/types/plural-class.ts @@ -0,0 +1,22 @@ +import type { PLURAL_CLASSES } from '@/enums/plural-class.js'; + +/** + * A CLDR plural category. Derived from {@link PLURAL_CLASSES}. + */ +export type PluralClass = (typeof PLURAL_CLASSES)[number]; + +/** + * A plural value keyed by plain CLDR class, as the **write** API expects: + * `{ one: '1 item', other: '%d items' }`. + */ +export type PluralValue = Partial>; + +/** + * A plural value keyed by `@`-prefixed CLDR class, as the **read** API returns + * and the **import** API expects: `{ '@one': '1 item', '@other': '%d items' }`. + * + * The prefix is not decoration — it is what distinguishes a plural from a + * nested key group. On import, `{ one: '…', other: '…' }` without the prefix + * creates two nested keys `KEY.one` and `KEY.other` rather than one plural key. + */ +export type PrefixedPluralValue = Partial>; diff --git a/src/types/submit-translation-response.ts b/src/types/submit-translation-response.ts new file mode 100644 index 0000000..8c94e71 --- /dev/null +++ b/src/types/submit-translation-response.ts @@ -0,0 +1,16 @@ +export type SubmitTranslationResponse = { + /** + * Whether the translation was accepted. + */ + result: boolean; + + /** + * Identifier of the version created for the submitted translation. + */ + versionId?: string; + + /** + * Additional detail about the outcome. + */ + message?: string; +}; diff --git a/src/types/translation-value.ts b/src/types/translation-value.ts new file mode 100644 index 0000000..996b441 --- /dev/null +++ b/src/types/translation-value.ts @@ -0,0 +1,25 @@ +import type { PluralValue, PrefixedPluralValue } from '@/types/plural-class.js'; +import type { PluralMarker } from '@/types/plural-marker.js'; + +/** + * A translation value accepted by the write API. + * + * The shape must match the key's form: a string for a singular key, an array of + * strings for an array key, or an object keyed by CLDR plural class for a + * plural key. + * + * Both plural spellings are accepted — the plain classes the write API expects + * ({@link PluralValue}) and the `@`-prefixed form the read API returns + * ({@link PrefixedPluralValue}) — because passing a value straight back from + * `files.listKeys()` is the natural thing to do. The client strips the prefix + * before sending. + * + * Prefer `plural()` when authoring a value by hand — it states the intent + * explicitly and renders the correct spelling for whichever endpoint it is + * passed to. + * + * Note that the plural classes are checked only when you pass an object + * *literal*: a value held in a variable typed `Record`, which is + * what `Key.value` is, satisfies this type without inspection. + */ +export type TranslationValue = string | string[] | PluralValue | PrefixedPluralValue | PluralMarker; diff --git a/src/utils/translation-value-utils.ts b/src/utils/translation-value-utils.ts new file mode 100644 index 0000000..bbd3097 --- /dev/null +++ b/src/utils/translation-value-utils.ts @@ -0,0 +1,41 @@ +import type { TranslationValue } from '@/types/translation-value.js'; +import { isPluralMarker } from '@/utils/plural.js'; + +/** + * Normalises a plural translation value for the write API. + * + * The read API returns plural forms keyed with an `@` prefix + * (`{ '@one': '1 item', '@other': '%d items' }`) while the write API expects + * plain CLDR classes (`{ one: '1 item', other: '%d items' }`). Submitting the + * read shape unchanged would store the translation under plural classes named + * `@one` / `@other`, which no CLDR consumer resolves — and the API accepts it + * silently, so nothing surfaces the mistake. + * + * Stripping the prefix is unambiguous here: the target key is identified in the + * URL, so an object value can only be a set of plural classes, and no valid + * CLDR class begins with `@`. The same reasoning does *not* hold on import, + * where the prefix is what separates a plural from a nested key group. + * + * A value produced by `plural()` is unwrapped to its plain forms. + * + * Strings and arrays are returned untouched. + */ +export const normalizeTranslationValue = (value: TranslationValue): TranslationValue => { + if (isPluralMarker(value)) { + return value.forms; + } + + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return value; + } + + const normalized: Record = {}; + + for (const [cls, text] of Object.entries(value)) { + if (typeof text === 'string') { + normalized[cls.startsWith('@') ? cls.slice(1) : cls] = text; + } + } + + return normalized; +}; diff --git a/tests/specs/keys.spec.ts b/tests/specs/keys.spec.ts index 0f5e0af..0f41eb5 100644 --- a/tests/specs/keys.spec.ts +++ b/tests/specs/keys.spec.ts @@ -1,5 +1,17 @@ -import type { ApiClient, File, Key, KeyDeleteRequest, KeyUpdateRequest, Project } from '@/main.js'; -import { Locales } from '@/main.js'; +import type { + ApiClient, + File, + Key, + KeyDeleteRequest, + KeySetPriorityRequest, + KeySetTagsRequest, + KeySubmitTranslationRequest, + KeyUpdateRequest, + Project, + SubmitTranslationResponse, + BooleanResult, +} from '@/main.js'; +import { Locales, plural } from '@/main.js'; import { fullProject } from '@tests/fixtures/index.js'; import { assertNotNull } from '@tests/support/assert-not-null.js'; import { getApiClient, getToken } from '@tests/support/index.js'; @@ -63,4 +75,218 @@ describe('Keys', (): void => { }, ); }); + + test('api.keys.submitTranslation', async (): Promise => { + const request: KeySubmitTranslationRequest = { + project, + key: '_a0000000000000000001', + lang: Locales.CZECH, + value: 'Uložit změny', + }; + const spy: MockInstance = vi.spyOn(globalThis, 'fetch'); + const response: SubmitTranslationResponse = await api.keys.submitTranslation(request); + + expect(response.result).toBe(true); + expect(response.versionId).toBe('_v000000000000000001'); + expect(spy).toHaveBeenCalledWith( + 'https://api.localazy.com/projects/_a0000000000000000001/keys/_a0000000000000000001/translations/cs', + { + body: '{"value":"Uložit změny"}', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${getToken()}`, + 'Content-Type': 'application/json', + }, + method: 'POST', + }, + ); + }); + + test('api.keys.submitTranslation with a plural value', async (): Promise => { + const spy: MockInstance = vi.spyOn(globalThis, 'fetch'); + await api.keys.submitTranslation({ + project, + key: '_a0000000000000000001', + lang: Locales.CZECH, + value: { one: '1 položka', few: '%d položky', other: '%d položek' }, + }); + + expect(spy).toHaveBeenCalledWith( + 'https://api.localazy.com/projects/_a0000000000000000001/keys/_a0000000000000000001/translations/cs', + expect.objectContaining({ + body: '{"value":{"one":"1 položka","few":"%d položky","other":"%d položek"}}', + method: 'POST', + }), + ); + }); + + test('api.keys.setTags', async (): Promise => { + const file: File = await api.files.first({ project }); + const keys: Key[] = await api.files.listKeys({ project, file, lang: Locales.ENGLISH }); + const firstKey = assertNotNull(keys[0]); + const request: KeySetTagsRequest = { + project, + keys: [firstKey], + addTags: ['ui'], + removeTags: ['legacy'], + }; + const spy: MockInstance = vi.spyOn(globalThis, 'fetch'); + const response: BooleanResult = await api.keys.setTags(request); + + expect(response.result).toBe(true); + expect(spy).toHaveBeenCalledWith( + 'https://api.localazy.com/projects/_a0000000000000000001/keys/tags', + { + body: '{"keys":["_a0000000000000000001"],"addTags":["ui"],"removeTags":["legacy"]}', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${getToken()}`, + 'Content-Type': 'application/json', + }, + method: 'PUT', + }, + ); + }); + + test('api.keys.setTags accepts plain key ids', async (): Promise => { + const spy: MockInstance = vi.spyOn(globalThis, 'fetch'); + await api.keys.setTags({ + project, + keys: ['_a0000000000000000001', '_a0000000000000000002'], + addTags: ['ui'], + }); + + expect(spy).toHaveBeenCalledWith( + 'https://api.localazy.com/projects/_a0000000000000000001/keys/tags', + expect.objectContaining({ + body: '{"keys":["_a0000000000000000001","_a0000000000000000002"],"addTags":["ui"]}', + method: 'PUT', + }), + ); + }); + + test('api.keys.setPriority', async (): Promise => { + const request: KeySetPriorityRequest = { + project, + keys: ['_a0000000000000000001'], + priority: 'high', + }; + const spy: MockInstance = vi.spyOn(globalThis, 'fetch'); + const response: BooleanResult = await api.keys.setPriority(request); + + expect(response.result).toBe(true); + expect(spy).toHaveBeenCalledWith( + 'https://api.localazy.com/projects/_a0000000000000000001/keys/priority', + { + body: '{"keys":["_a0000000000000000001"],"priority":"high"}', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${getToken()}`, + 'Content-Type': 'application/json', + }, + method: 'PUT', + }, + ); + }); + + test('api.keys.setPriority rejects an invalid key id', async (): Promise => { + await expect(api.keys.setPriority({ project, keys: [''], priority: 'normal' })).rejects.toThrow( + 'Invalid key ID.', + ); + }); + test('api.keys.submitTranslation escapes a script-qualified locale', async (): Promise => { + const spy: MockInstance = vi.spyOn(globalThis, 'fetch'); + await api.keys.submitTranslation({ + project, + key: '_a0000000000000000001', + lang: 'zh#Hans', + value: '保存', + }); + + // `#` must be percent-encoded, otherwise fetch treats the rest as a URL + // fragment and the backend silently receives the truncated locale `zh`. + const calledUrl: string = String(spy.mock.calls.at(-1)?.[0]); + expect(calledUrl).toContain('/translations/zh%23Hans'); + expect(new URL(calledUrl).pathname).toMatch(/\/translations\/zh%23Hans$/u); + }); + + test('api.keys.submitTranslation strips the read-side @ plural prefix', async (): Promise => { + const spy: MockInstance = vi.spyOn(globalThis, 'fetch'); + // Exactly the shape files.listKeys() returns for a plural key. + await api.keys.submitTranslation({ + project, + key: '_a0000000000000000001', + lang: Locales.CZECH, + value: { '@one': '1 položka', '@few': '%d položky', '@other': '%d položek' }, + }); + + expect(spy).toHaveBeenCalledWith( + 'https://api.localazy.com/projects/_a0000000000000000001/keys/_a0000000000000000001/translations/cs', + expect.objectContaining({ + body: '{"value":{"one":"1 položka","few":"%d položky","other":"%d položek"}}', + method: 'POST', + }), + ); + }); + + test('api.keys.submitTranslation rejects a missing language', async (): Promise => { + await expect( + api.keys.submitTranslation({ + project, + key: '_a0000000000000000001', + lang: null as unknown as 'cs', + value: 'x', + }), + ).rejects.toThrow('Invalid lang language.'); + }); + + test('api.keys.setTags accepts a mixed array of key objects and ids', async (): Promise => { + const file: File = await api.files.first({ project }); + const keys: Key[] = await api.files.listKeys({ project, file, lang: Locales.ENGLISH }); + const firstKey = assertNotNull(keys[0]); + const spy: MockInstance = vi.spyOn(globalThis, 'fetch'); + + // Mixed arrays must compile as well as run. + await api.keys.setTags({ + project, + keys: [firstKey, '_a0000000000000000002'], + addTags: ['ui'], + }); + + expect(spy).toHaveBeenCalledWith( + 'https://api.localazy.com/projects/_a0000000000000000001/keys/tags', + expect.objectContaining({ + body: '{"keys":["_a0000000000000000001","_a0000000000000000002"],"addTags":["ui"]}', + method: 'PUT', + }), + ); + }); + + test('api.keys.setTags rejects a non-array keys value', async (): Promise => { + await expect( + api.keys.setTags({ + project, + keys: '_a0000000000000000001' as unknown as string[], + addTags: ['ui'], + }), + ).rejects.toThrow('Invalid key list: an array is required.'); + }); + test('api.keys.submitTranslation accepts a plural() marker', async (): Promise => { + const spy: MockInstance = vi.spyOn(globalThis, 'fetch'); + await api.keys.submitTranslation({ + project, + key: '_a0000000000000000001', + lang: Locales.CZECH, + value: plural({ one: '1 položka', few: '%d položky', other: '%d položek' }), + }); + + // The marker is unwrapped to plain CLDR classes — no wrapper leaks to the wire. + expect(spy).toHaveBeenCalledWith( + 'https://api.localazy.com/projects/_a0000000000000000001/keys/_a0000000000000000001/translations/cs', + expect.objectContaining({ + body: '{"value":{"one":"1 položka","few":"%d položky","other":"%d položek"}}', + method: 'POST', + }), + ); + }); }); From 25092edbaf0d506d222b3649128d9ef074be7f8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=C2=A0Charv=C3=A1t?= Date: Wed, 12 Aug 2026 11:52:45 +0200 Subject: [PATCH 4/7] =?UTF-8?q?=E2=9C=A8=20feat(plural):=20add=20plural=20?= =?UTF-8?q?helper=20for=20authoring=20plural=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Features - Add `plural()` so a plural value is spelled once and rendered per endpoint: `@`-prefixed on import, plain on `keys.submitTranslation` - Resolve markers before the import payload is chunked, since the chunker recurses into every plain object and would otherwise split one - Leave unmarked objects untouched: without the prefix `{ one, other }` is a legitimate nested key group, so the prefix cannot be inferred ## Tests - Assert the marker never reaches the wire, that unmarked objects stay nested, and that every CLDR class is recognised by iterating the shared list Co-Authored-By: Claude Opus 5 (1M context) --- src/api/methods/api-import.ts | 5 +- src/types/import-json-request.ts | 13 ++++ src/types/plural-marker.ts | 24 ++++++ src/utils/plural.ts | 122 +++++++++++++++++++++++++++++++ tests/specs/plural.spec.ts | 101 +++++++++++++++++++++++++ 5 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 src/types/plural-marker.ts create mode 100644 src/utils/plural.ts create mode 100644 tests/specs/plural.spec.ts diff --git a/src/api/methods/api-import.ts b/src/api/methods/api-import.ts index 522e7c2..eef9cfd 100644 --- a/src/api/methods/api-import.ts +++ b/src/api/methods/api-import.ts @@ -11,6 +11,7 @@ import type { RequestConfig } from '@/types/request-config.js'; import type { UploadSessionStatus } from '@/types/upload-session-status.js'; import { delay } from '@/utils/delay.js'; import { JsonUtils } from '@/utils/json-utils.js'; +import { encodePluralMarkers } from '@/utils/plural.js'; export class ApiImport extends ApiBase { /** @@ -27,7 +28,9 @@ export class ApiImport extends ApiBase { ): Promise> { const { project, json }: ImportJsonRequest = request; const projectId: string = ApiBase.getId(project, 'project'); - const chunks: I18nJson[] = JsonUtils.slice(json); + // Markers must be resolved before chunking: the chunker recurses into every + // plain object, so an unresolved marker would be split and sent verbatim. + const chunks: I18nJson[] = JsonUtils.slice(encodePluralMarkers(json)); const data: ImportData = importDataFactory(request, chunks); const { result }: { result: string } = (await this.api.client.post( diff --git a/src/types/import-json-request.ts b/src/types/import-json-request.ts index d28166d..c8e7ff2 100644 --- a/src/types/import-json-request.ts +++ b/src/types/import-json-request.ts @@ -10,6 +10,19 @@ export type ImportJsonRequest = { */ project: Project | string; + /** + * The content to import, keyed by locale. + * + * **Plural keys need `@`-prefixed CLDR classes** — `{ ITEMS: { '@one': '%d + * item', '@other': '%d items' } }`. The prefix is what distinguishes a plural + * from a nested key group: without it, `{ ITEMS: { one: '…', other: '…' } }` + * silently creates two nested keys `ITEMS.one` and `ITEMS.other` instead, and + * neither the API nor the type system reports a problem, because declaring + * nested keys that way is legitimate. + * + * Note this differs from `keys.submitTranslation`, which takes plain classes + * because the key it targets is already identified in the URL. + */ json: I18nJson; i18nOptions?: ImportI18nOptions; diff --git a/src/types/plural-marker.ts b/src/types/plural-marker.ts new file mode 100644 index 0000000..31f59b8 --- /dev/null +++ b/src/types/plural-marker.ts @@ -0,0 +1,24 @@ +import type { PluralValue } from '@/types/plural-class.js'; + +/** + * Property that brands a {@link PluralMarker}. + * + * Deliberately a plain string rather than a symbol: if a marker ever reached + * the wire unresolved it shows up verbatim in the payload and is rejected, + * whereas a symbol key would silently vanish through `JSON.stringify` and send + * an empty object instead. + */ +export const PLURAL_MARKER = '__localazyPlural' as const; + +/** + * An explicitly-tagged plural value produced by `plural()`. + * + * Carrying the intent rather than inferring it from shape is what lets the + * client render the right spelling per endpoint — `@`-prefixed classes on + * import, plain classes on `keys.submitTranslation` — without having to guess + * whether a bare object meant a plural or a nested key group. + */ +export type PluralMarker = { + readonly [PLURAL_MARKER]: true; + readonly forms: PluralValue; +}; diff --git a/src/utils/plural.ts b/src/utils/plural.ts new file mode 100644 index 0000000..4d012ab --- /dev/null +++ b/src/utils/plural.ts @@ -0,0 +1,122 @@ +import { PLURAL_CLASSES } from '@/enums/plural-class.js'; +import type { PluralValue } from '@/types/plural-class.js'; +import type { PluralMarker } from '@/types/plural-marker.js'; +import { PLURAL_MARKER } from '@/types/plural-marker.js'; +import { isPlainObject } from 'es-toolkit/compat'; + +/** + * Declares a plural value, spelled once and rendered correctly per endpoint. + * + * Plural values have two spellings in the Localazy API: `import.json` and + * `files.listKeys` use `@`-prefixed CLDR classes, while + * `keys.submitTranslation` uses plain ones. On import the prefix is not + * cosmetic — it is the only thing separating a plural from a nested key group, + * so `{ ITEMS: { one: '…', other: '…' } }` silently creates two nested keys + * `ITEMS.one` and `ITEMS.other`, with no error from the API or the compiler. + * + * Wrapping the forms states the intent explicitly, so the client can emit the + * right spelling and that mistake becomes unreachable. + * + * @example + * ```typescript + * // import -> { "ITEMS": { "@one": "%d item", "@other": "%d items" } } + * await api.import.json({ + * project, + * json: { en: { ITEMS: plural({ one: '%d item', other: '%d items' }) } }, + * }); + * + * // submit -> { "value": { "one": "%d élément", "other": "%d éléments" } } + * await api.keys.submitTranslation({ + * project, + * key, + * lang: 'fr', + * value: plural({ one: '%d élément', other: '%d éléments' }), + * }); + * ``` + * + * @param forms Translations keyed by plain CLDR plural class. + */ +const plural = (forms: PluralValue): PluralMarker => ({ + [PLURAL_MARKER]: true, + forms, +}); + +/** + * Whether a value was produced by {@link plural}. + */ +const isPluralMarker = (value: unknown): value is PluralMarker => + isPlainObject(value) && (value as Record)[PLURAL_MARKER] === true; + +/** + * Renders a marker's forms with the `@` prefix the import API uses to + * distinguish plurals from nested keys. + */ +const toPrefixedForms = (marker: PluralMarker): Record => { + const prefixed: Record = {}; + + for (const [cls, text] of Object.entries(marker.forms)) { + if (typeof text === 'string') { + prefixed[cls.startsWith('@') ? cls : `@${cls}`] = text; + } + } + + return prefixed; +}; + +/** + * CLDR plural classes as they appear on the wire, i.e. `@`-prefixed. + * + * Derived from {@link PLURAL_CLASSES} rather than restated, so adding a class + * cannot leave the runtime check behind the type. + */ +const PREFIXED_CLASSES: ReadonlySet = new Set( + PLURAL_CLASSES.map((cls: string): string => `@${cls}`), +); + +/** + * Whether an object carries `@`-prefixed plural classes, i.e. is a plural value + * in wire form rather than a nested key group. + * + * Used by the chunker to keep a plural's classes together: each class would + * otherwise become its own leaf and could be split across two chunks, sending + * one key's forms as two separate files. + */ +const isPrefixedPluralObject = (value: unknown): boolean => + isPlainObject(value) && + Object.keys(value as Record).some((key: string): boolean => + PREFIXED_CLASSES.has(key), + ); + +const encodeNode = (node: unknown): unknown => { + if (isPluralMarker(node)) { + return toPrefixedForms(node); + } + + if (Array.isArray(node)) { + return node.map((item: unknown): unknown => encodeNode(item)); + } + + if (isPlainObject(node)) { + const mapped: Record = {}; + + for (const [key, value] of Object.entries(node as Record)) { + mapped[key] = encodeNode(value); + } + + return mapped; + } + + return node; +}; + +/** + * Replaces every {@link plural} marker in an import payload with its + * `@`-prefixed form, leaving all other content untouched. + * + * Must run before the payload is chunked: the chunker recurses into every plain + * object, so an unresolved marker would be split across chunks and reassembled + * into the request verbatim. + */ +const encodePluralMarkers = (node: T): T => encodeNode(node) as T; + +export { encodePluralMarkers, isPluralMarker, isPrefixedPluralObject, plural }; diff --git a/tests/specs/plural.spec.ts b/tests/specs/plural.spec.ts new file mode 100644 index 0000000..7cae636 --- /dev/null +++ b/tests/specs/plural.spec.ts @@ -0,0 +1,101 @@ +import type { ApiClient, Project } from '@/main.js'; +import { JsonUtils, PLURAL_CLASSES, encodePluralMarkers, plural } from '@/main.js'; +import { fullProject } from '@tests/fixtures/index.js'; +import { getApiClient } from '@tests/support/index.js'; +import type { MockInstance } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +describe('Plural helper', (): void => { + let api: ApiClient; + let project: Project; + + beforeEach(async (): Promise => { + fullProject.mockResponses(); + + api = getApiClient(); + project = await api.projects.first(); + }); + + test('import renders plural() with the @ prefix', async (): Promise => { + const spy: MockInstance = vi.spyOn(globalThis, 'fetch'); + await api.import.json({ + project, + json: { en: { ITEMS: plural({ one: '%d item', other: '%d items' }) } }, + }); + + const body: string = String(spy.mock.calls[0]?.[1]?.body); + // On import the prefix is what marks a plural rather than a nested key group. + expect(body).toContain('"@one":"%d item"'); + expect(body).toContain('"@other":"%d items"'); + // The marker itself must never reach the wire. + expect(body).not.toContain('__localazyPlural'); + expect(body).not.toContain('"forms"'); + }); + + test('import leaves an unmarked object as nested keys', async (): Promise => { + const spy: MockInstance = vi.spyOn(globalThis, 'fetch'); + await api.import.json({ + project, + json: { en: { ITEMS: { one: '%d item', other: '%d items' } } }, + }); + + const body: string = String(spy.mock.calls[0]?.[1]?.body); + // Unmarked objects are legitimate nesting, so the client must not add '@'. + expect(body).toContain('"one":"%d item"'); + expect(body).not.toContain('"@one"'); + }); + + test('import preserves surrounding content and arrays', async (): Promise => { + const spy: MockInstance = vi.spyOn(globalThis, 'fetch'); + await api.import.json({ + project, + json: { + en: { + TITLE: 'Hello', + ROLES: ['Admin', 'Editor'], + ITEMS: plural({ one: '%d item', other: '%d items' }), + }, + }, + }); + + const body: string = String(spy.mock.calls[0]?.[1]?.body); + expect(body).toContain('"TITLE":"Hello"'); + expect(body).toContain('"Admin"'); + expect(body).toContain('"@one":"%d item"'); + }); + test('markers are resolved before the payload is chunked', (): void => { + const json = { + en: { + TITLE: 'Hello', + ROLES: ['Admin', 'Editor'], + ITEMS: plural({ one: '%d item', other: '%d items' }), + CZ: plural({ one: '1', few: '2', many: '3', other: '4' }), + NESTED: { one: 'nested one', other: 'nested other' }, + }, + }; + + // The chunker recurses into every plain object, so an unresolved marker + // would be split across leaves and rebuilt into the request verbatim. + // Encoding must therefore happen first, exactly as api.import.json does it. + const chunks = JsonUtils.slice(encodePluralMarkers(json)); + const en = chunks[0]?.en; + + expect(JSON.stringify(chunks)).not.toContain('__localazyPlural'); + expect(en.ITEMS).toEqual({ '@one': '%d item', '@other': '%d items' }); + // Multi-class plurals survive slicing even though each class is its own leaf. + expect(Object.keys(en.CZ)).toEqual(['@one', '@few', '@many', '@other']); + // Unmarked objects are legitimate nesting and must not gain a prefix. + expect(en.NESTED).toEqual({ one: 'nested one', other: 'nested other' }); + expect(en.ROLES).toEqual(['Admin', 'Editor']); + expect(en.TITLE).toBe('Hello'); + }); + test.each([...PLURAL_CLASSES])('recognises "%s" as a plural class end to end', (cls): void => { + // Iterates PLURAL_CLASSES itself, so a class added to the list is covered + // automatically and cannot be left behind by the runtime check. + const json = encodePluralMarkers({ en: { ITEMS: plural({ [cls]: 'text' }) } }); + const chunks = JsonUtils.slice(json); + + // Prefixed on the wire, and kept whole rather than split per class. + expect(chunks[0]?.en.ITEMS).toEqual({ [`@${cls}`]: 'text' }); + }); +}); From 16ae6cd8c714383f6ecb43f8ba7f29fa25c1b577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=C2=A0Charv=C3=A1t?= Date: Wed, 12 Aug 2026 11:52:57 +0200 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=90=9B=20fix(import-chunking):=20stop?= =?UTF-8?q?=20large=20imports=20crashing=20and=20splitting=20plurals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both defects predate the suggestion work and affect every import, not only plural payloads. They were found while testing the plural import path. ## Bug fixes - Fold chunk leaves with `reduce` instead of spreading them into `merge`: one argument per leaf overflowed the call stack, so any import approaching `CHUNK_LIMIT` threw `RangeError` before a request was ever sent - Treat a plural object as a single leaf, the way `@meta:` keys already were. Emitting one leaf per class let a key's forms land in different chunks and upload as two files with partial forms The second defect was unreachable in practice because the first crashed first, so fixing either alone would have been incomplete. ## Tests - Cover atomicity, a payload at the chunk limit, and a plural sitting on a real chunk boundary Co-Authored-By: Claude Opus 5 (1M context) --- src/utils/json-utils.ts | 12 +++++-- tests/specs/json-utils.spec.ts | 63 ++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 tests/specs/json-utils.spec.ts diff --git a/src/utils/json-utils.ts b/src/utils/json-utils.ts index acd3a85..1e733ae 100644 --- a/src/utils/json-utils.ts +++ b/src/utils/json-utils.ts @@ -1,5 +1,6 @@ import type { I18nJson } from '@/types/i18n-json.js'; import type { Json } from '@/types/json.js'; +import { isPrefixedPluralObject } from '@/utils/plural.js'; import { chunk, isPlainObject, merge, setWith } from 'es-toolkit/compat'; export class JsonUtils { @@ -40,7 +41,7 @@ export class JsonUtils { */ protected static sliceByValue(json: Json, keys: string[] = []): Json[] { return Object.entries(json).reduce((prev: Json[], [key, value]: [string, Json]) => { - if (isPlainObject(value) && !key.startsWith('@meta:')) { + if (isPlainObject(value) && !key.startsWith('@meta:') && !isPrefixedPluralObject(value)) { prev.push(...JsonUtils.sliceByValue(value, [...keys, key])); } else if (keys.length > 1) { prev.push(setWith({}, [...keys, key].join('.'), value, Object)); @@ -51,7 +52,14 @@ export class JsonUtils { }, []); } + /** + * Folds the leaves of one chunk back into a single object. + * + * Deliberately a fold rather than `merge(...values)`: spreading produces one + * argument per leaf, so a payload approaching {@link CHUNK_LIMIT} overflows + * the call stack before any request is sent. + */ protected static mergeChunkValues(values: Json[]): Json { - return merge(...(values as [number, Json])); + return values.reduce((acc: Json, value: Json): Json => merge(acc, value), {}); } } diff --git a/tests/specs/json-utils.spec.ts b/tests/specs/json-utils.spec.ts new file mode 100644 index 0000000..0492310 --- /dev/null +++ b/tests/specs/json-utils.spec.ts @@ -0,0 +1,63 @@ +import { JsonUtils, encodePluralMarkers, plural } from '@/main.js'; +import { describe, expect, test } from 'vitest'; + +describe('JsonUtils chunking', (): void => { + test('keeps a plural key atomic instead of one leaf per class', (): void => { + const json = encodePluralMarkers({ + en: { A: 'x', ITEMS: plural({ one: '%d item', other: '%d items' }), B: 'y' }, + }); + + // Each class used to become its own leaf, which meant a plural could be + // split across two chunks and uploaded as two separate files. + const chunks = JsonUtils.slice(json); + + expect(chunks).toHaveLength(1); + expect(chunks[0]?.en.ITEMS).toEqual({ '@one': '%d item', '@other': '%d items' }); + }); + + test('still recurses into ordinary nested objects', (): void => { + const chunks = JsonUtils.slice({ en: { nested: { deep: { key: 'v' } }, TITLE: 'Hello' } }); + + expect(chunks[0]?.en.nested).toEqual({ deep: { key: 'v' } }); + expect(chunks[0]?.en.TITLE).toBe('Hello'); + }); + + test('folds a payload at the chunk limit without overflowing the stack', (): void => { + const en: Record = {}; + for (let i = 0; i < JsonUtils.CHUNK_LIMIT + 500; i++) { + en[`F${String(i).padStart(6, '0')}`] = `v${i}`; + } + + // mergeChunkValues used to spread one argument per leaf, so a payload of + // this size threw RangeError before any request was sent. + const chunks = JsonUtils.slice({ en }); + const total: number = chunks.reduce( + (n: number, c): number => n + Object.keys(c.en as object).length, + 0, + ); + + expect(chunks.length).toBeGreaterThan(1); + expect(total).toBe(JsonUtils.CHUNK_LIMIT + 500); + }); + + test('keeps a plural intact across a real chunk split', (): void => { + const en: Record = {}; + for (let i = 0; i < JsonUtils.CHUNK_LIMIT + 500; i++) { + en[`F${String(i).padStart(6, '0')}`] = `v${i}`; + } + en[`F${String(JsonUtils.CHUNK_LIMIT - 1).padStart(6, '0')}_PLURAL`] = plural({ + one: '%d item', + other: '%d items', + }); + + const chunks = JsonUtils.slice(encodePluralMarkers({ en })); + const holding = chunks.filter((c) => + Object.keys(c.en as object).some((k: string): boolean => k.endsWith('_PLURAL')), + ); + + expect(holding).toHaveLength(1); + const bucket = holding[0]?.en as Record>; + const key = Object.keys(bucket).find((k: string): boolean => k.endsWith('_PLURAL')) ?? ''; + expect(bucket[key]).toEqual({ '@one': '%d item', '@other': '%d items' }); + }); +}); From 138ca247aeb06b44e47890da562ed240485eeabe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=C2=A0Charv=C3=A1t?= Date: Wed, 12 Aug 2026 11:52:58 +0200 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=93=9A=20docs(api-reference):=20docum?= =?UTF-8?q?ent=20new=20methods=20and=20the=20plural=20conventions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Documentation - Add reference entries for the suggestion, translation, tag and priority methods, each with a runnable example - Add a Plural keys section covering the per-endpoint spelling, `plural()`, and why an unmarked object declares nested keys instead - State what the types check and what they cannot: excess-property checking only applies to object literals - Backfill the missing `keys.deprecate` entry and correct the AI translation link that pointed at a 404 Co-Authored-By: Claude Opus 5 (1M context) --- docs/api-client-reference.md | 353 ++++++++++++++++++++++++++++++++++- 1 file changed, 352 insertions(+), 1 deletion(-) diff --git a/docs/api-client-reference.md b/docs/api-client-reference.md index 574392e..6838fa3 100644 --- a/docs/api-client-reference.md +++ b/docs/api-client-reference.md @@ -17,6 +17,16 @@ - [Keys](#keys) - [keys.update](#keysupdaterequest-config) - [keys.delete](#keysdeleterequest-config) + - [keys.deprecate](#keysdeprecaterequest-config) + - [keys.submitTranslation](#keyssubmittranslationrequest-config) + - [keys.setTags](#keyssettagsrequest-config) + - [keys.setPriority](#keyssetpriorityrequest-config) +- [Suggestions](#suggestions) + - [suggestions.tm](#suggestionstmrequest-config) + - [suggestions.mt](#suggestionsmtrequest-config) + - [suggestions.ai](#suggestionsairequest-config) +- [Plural keys](#plural-keys) + - [plural()](#pluralforms) - [Import](#import) - [import.json](#importjsonrequest-config) - [Export](#export) @@ -67,7 +77,7 @@ Translate provided items from the source language to the target language using L > This endpoint is only available with the Owner's token or a Translation Token. -See: [Localazy API Docs](https://localazy.com/docs/api/ai-translation#translate) +See: [Localazy API Docs](https://localazy.com/docs/api/ai-translation-api#translate) | Arguments | Type | | ----------------- | ------------------------------------------------------------ | @@ -361,12 +371,353 @@ await api.keys.delete({ }); ``` +### keys.deprecate(request[, config]) + +Deprecate [keys](../src/types/key.ts). + +| Arguments | Type | +| ----------------- | -------------------------------------------------------------- | +| request | [`KeyDeprecateRequest`](../src/types/key-deprecate-request.ts) | +| config `optional` | [`RequestConfig`](../src/types/request-config.ts) | + +| Returns | +| --------------- | +| `Promise` | + +```javascript +await api.keys.deprecate({ + project: 'project-id', // or Project object + phrases: ['key-id'], // or Key objects +}); +``` + +### keys.submitTranslation(request[, config]) + +Submit a translation for a single [key](../src/types/key.ts) in one target language. + +`value` must match the key's form: a string for a singular key, an array of strings for an array +key, or an object keyed by CLDR plural class for a plural key. `lang` accepts a locale code or +Localazy's numeric language id, and is URL-escaped, so script-qualified locales such as `zh#Hans` +are transmitted intact. + +Plural values may use either the plain classes the write API expects (`{ one: '1 item' }`) or the +`@`-prefixed form the read API returns (`{ '@one': '1 item' }`) — the prefix is stripped for you, so +a value taken straight from `files.listKeys()` round-trips correctly. + +**Check `result` on the response.** The API answers HTTP 200 with `result: false` and a `message` +when a submission is deliberately not applied — the target is the project's source language, the +project is momentarily locked by a running import, or the translation could not be stored. None of +those reject the promise. + +| Arguments | Type | +| ----------------- | ------------------------------------------------------------------------------- | +| request | [`KeySubmitTranslationRequest`](../src/types/key-submit-translation-request.ts) | +| config `optional` | [`RequestConfig`](../src/types/request-config.ts) | + +| Returns | +| ----------------------------------------------------------------------------------- | +| [`Promise`](../src/types/submit-translation-response.ts) | + +```javascript +// singular key +await api.keys.submitTranslation({ + project: 'project-id', // or Project object + key: 'key-id', // or Key object + lang: 'cs', + value: 'Uložit změny', +}); + +// plural key +await api.keys.submitTranslation({ + project: 'project-id', + key: 'key-id', + lang: 'cs', + value: { one: '1 položka', few: '%d položky', other: '%d položek' }, +}); +``` + +### keys.setTags(request[, config]) + +Add and/or remove tags on [keys](../src/types/key.ts). + +Removal is applied before addition, so a tag name present in both `addTags` and `removeTags` ends up +added. Tag names that do not exist yet are created, subject to the project's 50-tag limit. At most +1000 keys may be passed per call; larger sets are rejected outright rather than truncated, and +splitting them is the caller's responsibility. + +See: [Localazy API Docs](https://localazy.com/docs/api/source-keys#set-tags-on-multiple-keys) + +| Arguments | Type | +| ----------------- | ----------------------------------------------------------- | +| request | [`KeySetTagsRequest`](../src/types/key-set-tags-request.ts) | +| config `optional` | [`RequestConfig`](../src/types/request-config.ts) | + +| Returns | +| ---------------------------------------------------------- | +| [`Promise`](../src/types/boolean-result.ts) | + +`result` reports that the request was processed, not that it changed anything: key ids that do not +resolve within the project are skipped silently, and a call in which none of them resolve still +answers `true`. + +```javascript +const { result } = await api.keys.setTags({ + project: 'project-id', // or Project object + keys: ['key-id'], // or Key objects + addTags: ['ui'], + removeTags: ['legacy'], +}); +``` + +### keys.setPriority(request[, config]) + +Set the priority level on [keys](../src/types/key.ts). + +`normal` clears any priority currently set. At most 1000 keys may be passed per call; larger sets +are rejected outright rather than truncated, and splitting them is the caller's responsibility. + +See: [Localazy API Docs](https://localazy.com/docs/api/source-keys#set-priority-on-multiple-keys) + +| Arguments | Type | +| ----------------- | ------------------------------------------------------------------- | +| request | [`KeySetPriorityRequest`](../src/types/key-set-priority-request.ts) | +| config `optional` | [`RequestConfig`](../src/types/request-config.ts) | + +| Returns | +| ---------------------------------------------------------- | +| [`Promise`](../src/types/boolean-result.ts) | + +`result` reports that the request was processed, not that it changed anything: key ids that do not +resolve within the project are skipped silently, and a call in which none of them resolve still +answers `true`. + +```javascript +const { result } = await api.keys.setPriority({ + project: 'project-id', // or Project object + keys: ['key-id'], // or Key objects + priority: 'high', // lowest | low | normal | high | highest +}); +``` + +## Suggestions + +Per-key translation suggestions. Every response shares the same envelope: + +- `enabled` — whether the family could run at all. `false` means the feature is unavailable for the + project, or the target language is the (possibly overridden) source language. +- `errors` — soft failures keyed by engine name. A soft error never fails the request. The reserved + key `general` covers failures belonging to no single engine, most commonly the key having no value + in the source language. +- `items` — one entry per source form: a singular key yields one entry, a plural or array key one per + form. + +Read those three deliberately: `enabled: true` with empty `items` means "ran, found nothing", which +is a different answer from `enabled: false`. + +In every method `to` is required and `from` is optional, defaulting to the project's source +language. Both accept a locale code (`'pt_BR'`) or Localazy's numeric language id (`112`). + +### suggestions.tm(request[, config]) + +Translation Memory (InTM) suggestions for a single [key](../src/types/key.ts). Free and read-only. + +| Arguments | Type | +| ----------------- | ----------------------------------------------------------- | +| request | [`SuggestionsRequest`](../src/types/suggestions-request.ts) | +| config `optional` | [`RequestConfig`](../src/types/request-config.ts) | + +| Returns | +| --------------------------------------------------------------------------- | +| [`Promise`](../src/types/tm-suggestions-response.ts) | + +```javascript +const response = await api.suggestions.tm({ + project: 'project-id', // or Project object + key: 'key-id', // or Key object + to: 'cs', +}); +``` + +### suggestions.mt(request[, config]) + +Machine Translation suggestions for a single [key](../src/types/key.ts). + +Free to the caller, but a cache miss computes the translations live and meters them against the +organization's machine translation fair-use quota. + +| Arguments | Type | +| ----------------- | ----------------------------------------------------------- | +| request | [`SuggestionsRequest`](../src/types/suggestions-request.ts) | +| config `optional` | [`RequestConfig`](../src/types/request-config.ts) | + +| Returns | +| --------------------------------------------------------------------------- | +| [`Promise`](../src/types/mt-suggestions-response.ts) | + +```javascript +const response = await api.suggestions.mt({ + project: 'project-id', + key: 'key-id', + to: 'cs', + from: 'en', // optional source override +}); +``` + +### suggestions.ai(request[, config]) + +Localazy AI suggestions for a single [key](../src/types/key.ts). + +> **This method spends AI credits** — that is why the underlying endpoint is a `POST`. Not to be +> confused with [`ai.translate`](#aitranslaterequest-config), which translates arbitrary texts you +> supply rather than an existing key. + +`enabled` requires both AI suggestions and Machine Translation to be switched on in the project's +settings; producing results additionally requires an active paid MT tier, so `enabled: true` can +still yield empty `items` with no error. + +| Arguments | Type | +| ----------------- | ----------------------------------------------------------- | +| request | [`SuggestionsRequest`](../src/types/suggestions-request.ts) | +| config `optional` | [`RequestConfig`](../src/types/request-config.ts) | + +| Returns | +| --------------------------------------------------------------------------- | +| [`Promise`](../src/types/ai-suggestions-response.ts) | + +```javascript +const response = await api.suggestions.ai({ + project: 'project-id', + key: 'key-id', + to: 'cs', +}); +``` + +## Plural keys + +Plural values are objects keyed by [CLDR plural class](../src/types/plural-class.ts) (`zero`, `one`, +`two`, `few`, `many`, `other`). Which classes a language uses is defined by CLDR — English uses +`one`/`other`, Czech `one`/`few`/`many`/`other`. + +Two spellings exist, and **which one you need depends on the endpoint**: + +| Surface | Spelling | Example | +| -------------------------------- | ------------------------ | --------------------------------------------- | +| `import.json` (write) | `@`-prefixed | `{ "@one": "%d item", "@other": "%d items" }` | +| `files.listKeys` (read) | `@`-prefixed | `{ "@one": "%d item", "@other": "%d items" }` | +| `keys.submitTranslation` (write) | plain, `@` also accepted | `{ "one": "%d item", "other": "%d items" }` | + +### `plural()` — spell it once + +`plural()` tags a value as a plural explicitly, so the client can render the right spelling for +whichever endpoint receives it. This is the recommended way to author plural values by hand. + +```javascript +import { plural } from '@localazy/api-client'; + +// import -> { "ITEMS": { "@one": "%d item", "@other": "%d items" } } +await api.import.json({ + project, + json: { en: { ITEMS: plural({ one: '%d item', other: '%d items' }) } }, +}); + +// submit -> { "value": { "one": "%d élément", "other": "%d éléments" } } +await api.keys.submitTranslation({ + project, + key, + lang: 'fr', + value: plural({ one: '%d élément', other: '%d éléments' }), +}); +``` + +| Arguments | Type | +| --------- | --------------------------------------------- | +| forms | [`PluralValue`](../src/types/plural-class.ts) | + +| Returns | +| ----------------------------------------------- | +| [`PluralMarker`](../src/types/plural-marker.ts) | + +You always write plain CLDR classes; the `@` prefix is added only where the wire format needs it. +Raw objects keep working unchanged, so nothing existing breaks — `plural()` is opt-in. + +Two things worth knowing: + +- The marker is resolved before the import payload is chunked, so it never reaches the wire. If you + ever see `__localazyPlural` in a request body, a marker escaped unresolved — that is a bug, and it + is deliberately a visible string rather than a symbol so it fails loudly instead of serializing to + an empty object. +- `plural()` only affects values you construct. A value read back from `files.listKeys()` is a plain + `@`-prefixed object, and `keys.submitTranslation` normalises that on its own. + +### The `@` prefix is a disambiguator, not decoration + +On import, the prefix is the _only_ thing separating a plural key from a nested key group. Omitting +it does not fail — it silently creates something else: + +```javascript +// ✅ ONE plural key `ITEMS` with classes one/other +await api.import.json({ + project, + json: { en: { ITEMS: { '@one': '%d item', '@other': '%d items' } } }, +}); + +// ❌ TWO nested singular keys `ITEMS.one` and `ITEMS.other` +await api.import.json({ + project, + json: { en: { ITEMS: { one: '%d item', other: '%d items' } } }, +}); +``` + +Both are valid JSON and valid TypeScript, so nothing catches the second form — it is a legitimate +way to declare nested keys, which is exactly why the client cannot add the prefix for you. Using +[`plural()`](#pluralforms) removes the choice, and with it the mistake. + +### Submitting a plural translation + +`keys.submitTranslation` needs no prefix: the key is identified in the URL, so its form is already +known and an object value can only mean plural classes. The `@`-prefixed form is accepted too and +the prefix is stripped before sending, so a value read from `files.listKeys()` round-trips safely: + +```javascript +const keys = await api.files.listKeys({ project, file, lang: 'en' }); +const key = keys.find((k) => k.key[0] === 'ITEMS'); +// key.value === { '@one': '%d item', '@other': '%d items' } + +await api.keys.submitTranslation({ + project, + key, + lang: 'fr', + value: { one: '%d élément', other: '%d éléments' }, // or the '@'-prefixed form +}); +``` + +### What the types check + +`TranslationValue` uses the real CLDR classes, so a typo is a compile error — but only in an object +_literal_: + +```typescript +value: { one: '1', otehr: 'n' } // ✗ TS2353: 'otehr' does not exist +value: someRecord // ✓ compiles — Key.value is Record +``` + +Excess-property checking does not apply to values held in variables, so a value round-tripped from +the read API is never inspected by the compiler. That path is safe because the client normalises it +at runtime, not because the types verified it. + +This is the gap [`plural()`](#pluralforms) closes on the import side: the compiler cannot tell a +plural from a nested key group, because both are well-typed — but a tagged value carries the intent +regardless of shape. + ## Import ### import.json(request[, config]) Import JSON object as source keys. +Declaring plural keys requires `@`-prefixed CLDR classes — see [Plural keys](#plural-keys). Without +the prefix you get nested keys instead, with no error. + See: [Localazy API Docs](https://localazy.com/docs/api/import#import-content-to-a-project) | Arguments | Type | From 17efacbf57c8fed1b9a24c8bab9d303d4082fa08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=C2=A0Charv=C3=A1t?= Date: Wed, 12 Aug 2026 11:52:58 +0200 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=94=A7=20chore(exports):=20regenerate?= =?UTF-8?q?=20main=20entry=20point?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated by `pnpm run main-ts:build`. Co-Authored-By: Claude Opus 5 (1M context) --- src/main.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/main.ts b/src/main.ts index 1d52b93..92b8b26 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,20 +24,27 @@ export * from '@/api/methods/api-import.js'; export * from '@/api/methods/api-keys.js'; export * from '@/api/methods/api-projects.js'; export * from '@/api/methods/api-screenshots.js'; +export * from '@/api/methods/api-suggestions.js'; export * from '@/api/methods/api-webhooks.js'; export * from '@/enums/i18n-deprecate.js'; +export * from '@/enums/key-priority.js'; +export * from '@/enums/plural-class.js'; export * from '@/enums/project-tone.js'; export * from '@/enums/project-type.js'; +export * from '@/enums/translation-engine.js'; export * from '@/enums/upload-status.js'; export * from '@/enums/user-role.js'; export * from '@/enums/webhook-event.js'; export * from '@/http/fetch-http-adapter.js'; export * from '@/http/i-http-adapter.js'; +export * from '@/types/ai-suggestion.js'; +export * from '@/types/ai-suggestions-response.js'; export * from '@/types/ai-translate-item.js'; export * from '@/types/ai-translate-request.js'; export * from '@/types/ai-translate-response-item.js'; export * from '@/types/ai-translate-response.js'; export * from '@/types/api-client-options.js'; +export * from '@/types/boolean-result.js'; export * from '@/types/export-json-request.js'; export * from '@/types/file-get-contents-request.js'; export * from '@/types/file-list-keys-request.js'; @@ -68,13 +75,22 @@ export * from '@/types/import-progress-request.js'; export * from '@/types/json.js'; export * from '@/types/key-delete-request.js'; export * from '@/types/key-deprecate-request.js'; +export * from '@/types/key-priority.js'; +export * from '@/types/key-set-priority-request.js'; +export * from '@/types/key-set-tags-request.js'; +export * from '@/types/key-submit-translation-request.js'; export * from '@/types/key-update-request.js'; export * from '@/types/key-value.js'; export * from '@/types/key.js'; export * from '@/types/keys-paginated.js'; export * from '@/types/language.js'; +export * from '@/types/localazy-ai-engine-name.js'; export * from '@/types/locales-keys.js'; +export * from '@/types/mt-suggestion.js'; +export * from '@/types/mt-suggestions-response.js'; export * from '@/types/organization.js'; +export * from '@/types/plural-class.js'; +export * from '@/types/plural-marker.js'; export * from '@/types/project.js'; export * from '@/types/projects-list-request.js'; export * from '@/types/request-config.js'; @@ -88,6 +104,12 @@ export * from '@/types/screenshot-update-request.js'; export * from '@/types/screenshot.js'; export * from '@/types/screenshots-list-request.js'; export * from '@/types/screenshots-list-tags-request.js'; +export * from '@/types/submit-translation-response.js'; +export * from '@/types/suggestions-request.js'; +export * from '@/types/tm-suggestion.js'; +export * from '@/types/tm-suggestions-response.js'; +export * from '@/types/translation-engine-name.js'; +export * from '@/types/translation-value.js'; export * from '@/types/upload-session-status.js'; export * from '@/types/webhook.js'; export * from '@/types/webhooks-get-secret-request.js'; @@ -96,6 +118,8 @@ export * from '@/types/webhooks-secret.js'; export * from '@/types/webhooks-update-request.js'; export * from '@/utils/delay.js'; export * from '@/utils/json-utils.js'; +export * from '@/utils/plural.js'; +export * from '@/utils/translation-value-utils.js'; // @end-reexport export { Locales } from '@localazy/languages';