Skip to content
353 changes: 352 additions & 1 deletion docs/api-client-reference.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/api/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -35,6 +36,8 @@ export class ApiClient {

public screenshots: ApiScreenshots;

public suggestions: ApiSuggestions;

constructor(options: ApiClientOptions) {
this.client = new FetchHttpAdapter(options);

Expand All @@ -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);
}
}
2 changes: 1 addition & 1 deletion src/api/methods/api-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions src/api/methods/api-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
5 changes: 4 additions & 1 deletion src/api/methods/api-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand All @@ -27,7 +28,9 @@ export class ApiImport extends ApiBase {
): Promise<ReturnType<ApiImport['getImportedFile']>> {
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(
Expand Down
109 changes: 109 additions & 0 deletions src/api/methods/api-keys.ts
Original file line number Diff line number Diff line change
@@ -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 {
/**
Expand Down Expand Up @@ -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<SubmitTranslationResponse> {
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<BooleanResult> {
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<BooleanResult> {
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;
}
}
127 changes: 127 additions & 0 deletions src/api/methods/api-suggestions.ts
Original file line number Diff line number Diff line change
@@ -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<TmSuggestionsResponse> {
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<MtSuggestionsResponse> {
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<AiSuggestionsResponse> {
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<string, string>`, 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<string, string> {
const { to, from }: SuggestionsRequest = request;

return {
...config?.params,
to: ApiBase.requireLang(to, 'to'),
...(from === undefined || from === null ? {} : { from: String(from) }),
};
}
}
7 changes: 7 additions & 0 deletions src/enums/key-priority.ts
Original file line number Diff line number Diff line change
@@ -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;
12 changes: 12 additions & 0 deletions src/enums/plural-class.ts
Original file line number Diff line number Diff line change
@@ -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;
28 changes: 28 additions & 0 deletions src/enums/translation-engine.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading