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 | 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-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, 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-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/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/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/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/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/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'; 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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' }); + }); +}); 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', + }), + ); + }); }); 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' }); + }); +}); 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.', + ); + }); +});