diff --git a/src/api/methods/api-files.ts b/src/api/methods/api-files.ts index b2f25ff..1d70d8a 100644 --- a/src/api/methods/api-files.ts +++ b/src/api/methods/api-files.ts @@ -1,6 +1,10 @@ import { ApiBase } from '@/api/methods/api-base.js'; import type { FileGetContentsRequest } from '@/types/file-get-contents-request.js'; import type { FileListKeysRequest } from '@/types/file-list-keys-request.js'; +import type { + FileListKeysSinceEventRequest, + FileListKeysSinceEventResult, +} from '@/types/file-list-keys-since-event-request.js'; import type { File } from '@/types/file.js'; import type { FilesListRequest } from '@/types/files-list-request.js'; import type { Key } from '@/types/key.js'; @@ -90,6 +94,36 @@ export class ApiFiles extends ApiBase { })) as KeysPaginated; } + /** + * List {@link Key keys} for the language in the {@link File file} with event-based filtering. + * Fetches all keys with `event=true`, computes the maximum event number, and optionally + * filters to only keys changed since a given event cursor. + * + * This method is designed for incremental sync: pass `sinceEvent` from a previous sync + * to receive only keys that have changed since then. + * + * @param request File list keys since event request config. + * @param config Request config. + * @returns An object containing the filtered keys and the maximum event number. + */ + public async listKeysSinceEvent( + request: FileListKeysSinceEventRequest, + config?: RequestConfig, + ): Promise { + const { sinceEvent, ...rest } = request; + const allKeys: Key[] = await this.listKeys({ ...rest, event: true }, config); + + const maxEvent: number | null = + allKeys.length > 0 ? allKeys.reduce((max, key) => Math.max(max, key.event ?? 0), 0) : null; + + const keys: Key[] = + sinceEvent !== null && sinceEvent !== undefined + ? allKeys.filter((key) => (key.event ?? 0) > sinceEvent) + : allKeys; + + return { keys, maxEvent }; + } + /** * Get the contents of the {@link File file}. * diff --git a/src/main.ts b/src/main.ts index 71e04b6..51661d7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -41,6 +41,7 @@ export * from '@/types/api-client-options.js'; export * from '@/types/export-json-request.js'; export * from '@/types/file-get-contents-request.js'; export * from '@/types/file-list-keys-request.js'; +export * from '@/types/file-list-keys-since-event-request.js'; export * from '@/types/file.js'; export * from '@/types/files-list-request.js'; export * from '@/types/format-array-meta.js'; diff --git a/src/types/file-list-keys-request.ts b/src/types/file-list-keys-request.ts index 912edb3..2b03ec5 100644 --- a/src/types/file-list-keys-request.ts +++ b/src/types/file-list-keys-request.ts @@ -42,4 +42,10 @@ export type FileListKeysRequest = { * Receive also metadata for the key. */ metadata?: boolean; + + /** + * When true, the response includes an `event` number on each key + * representing the last modification event. Used for incremental sync. + */ + event?: boolean; }; diff --git a/src/types/file-list-keys-since-event-request.ts b/src/types/file-list-keys-since-event-request.ts new file mode 100644 index 0000000..9a3ecf8 --- /dev/null +++ b/src/types/file-list-keys-since-event-request.ts @@ -0,0 +1,55 @@ +import type { File } from '@/types/file.js'; +import type { Key } from '@/types/key.js'; +import type { Project } from '@/types/project.js'; +import type { Locales } from '@localazy/languages'; + +export type FileListKeysSinceEventRequest = { + /** + * Project object or Project ID. + */ + project: Project | string; + + /** + * File object or File ID. + */ + file: File | string; + + /** + * Locale code. See Locales enum with all available codes. + */ + lang: `${Locales}`; + + /** + * Only return keys with event number greater than this value. + * Pass null or omit to return all keys. + */ + sinceEvent?: number | null; + + /** + * Returns also deprecated keys. + */ + deprecated?: boolean; + + /** + * Receive additional info such as translation note, whether it's hidden etc. + */ + extra_info?: boolean; + + /** + * Receive also metadata for the key. + */ + metadata?: boolean; +}; + +export type FileListKeysSinceEventResult = { + /** + * The filtered keys (only those with event > sinceEvent, or all if sinceEvent is null). + */ + keys: Key[]; + + /** + * The maximum event number found across all returned keys (before filtering). + * Null if no keys were returned. + */ + maxEvent: number | null; +}; diff --git a/src/types/key.ts b/src/types/key.ts index f1da94a..a5f7bb1 100644 --- a/src/types/key.ts +++ b/src/types/key.ts @@ -36,4 +36,10 @@ export type Key = { * Only available when extra_info=true. */ limit?: number; + + /** + * Event number representing the last modification event for this key. + * Only available when event=true in the request. + */ + event?: number; }; diff --git a/tests/fixtures/full-project/fileKeysWithEvents.json b/tests/fixtures/full-project/fileKeysWithEvents.json new file mode 100644 index 0000000..24e133d --- /dev/null +++ b/tests/fixtures/full-project/fileKeysWithEvents.json @@ -0,0 +1,32 @@ +{ + "keys": [ + { + "id": "_a0000000000000000001", + "key": ["app_title"], + "value": "My Application", + "vid": -7407901648464613000, + "event": 100 + }, + { + "id": "_a0000000000000000002", + "key": ["welcome_message"], + "value": "Welcome to My Application!", + "vid": -7407901648464613000, + "event": 200 + }, + { + "id": "_a0000000000000000003", + "key": ["login_button"], + "value": "Log In", + "vid": -7407901648464613000, + "event": 200 + }, + { + "id": "_a0000000000000000004", + "key": ["headers", "role"], + "value": "Project role", + "vid": -7407781050279235000, + "event": 300 + } + ] +} diff --git a/tests/fixtures/full-project/index.ts b/tests/fixtures/full-project/index.ts index ba2da08..7511352 100644 --- a/tests/fixtures/full-project/index.ts +++ b/tests/fixtures/full-project/index.ts @@ -2,6 +2,7 @@ import aiTranslate from '@tests/fixtures/full-project/aiTranslate.json'; import aiTranslatePlurals from '@tests/fixtures/full-project/aiTranslatePlurals.json'; import fileDownload from '@tests/fixtures/full-project/fileDownload.json'; import fileKeys from '@tests/fixtures/full-project/fileKeys.json'; +import fileKeysWithEvents from '@tests/fixtures/full-project/fileKeysWithEvents.json'; import files from '@tests/fixtures/full-project/files.json'; import formats from '@tests/fixtures/full-project/formats.json'; import glossary from '@tests/fixtures/full-project/glossary.json'; @@ -25,6 +26,7 @@ export const serverResponses = { glossary, files, fileKeys, + fileKeysWithEvents, fileDownload, screenshots, screenshotTags, @@ -105,6 +107,10 @@ export const mockResponses = (): void => { `${baseUrl}/projects/_a0000000000000000001/files/_e000000000001/keys/en?next=`, serverResponses.fileKeys, ); + fetchMock.get( + `${baseUrl}/projects/_a0000000000000000001/files/_e000000000001/keys/en?event=true&next=`, + serverResponses.fileKeysWithEvents, + ); fetchMock.put( `${baseUrl}/projects/_a0000000000000000001/keys/_a0000000000000000001`, serverResponses.resultPut, diff --git a/tests/specs/files.spec.ts b/tests/specs/files.spec.ts index 5e45001..2c6f559 100644 --- a/tests/specs/files.spec.ts +++ b/tests/specs/files.spec.ts @@ -3,6 +3,7 @@ import type { File, FileGetContentsRequest, FileListKeysRequest, + FileListKeysSinceEventResult, Key, KeysPaginated, Project, @@ -102,6 +103,47 @@ describe('Files', (): void => { expect(keys.length).toBe(7); }); + test('api.files.listKeysSinceEvent returns all keys and maxEvent when sinceEvent is null', async (): Promise => { + const file: File = await api.files.first({ project }); + const result: FileListKeysSinceEventResult = await api.files.listKeysSinceEvent({ + project, + file, + lang: Locales.ENGLISH, + sinceEvent: null, + }); + + expect(result.keys.length).toBe(4); + expect(result.maxEvent).toBe(300); + }); + + test('api.files.listKeysSinceEvent filters keys by sinceEvent', async (): Promise => { + const file: File = await api.files.first({ project }); + const result: FileListKeysSinceEventResult = await api.files.listKeysSinceEvent({ + project, + file, + lang: Locales.ENGLISH, + sinceEvent: 100, + }); + + // Keys with event 200 and 300 (3 keys), excludes event 100 + expect(result.keys.length).toBe(3); + expect(result.maxEvent).toBe(300); + expect(result.keys.every((k) => (k.event ?? 0) > 100)).toBe(true); + }); + + test('api.files.listKeysSinceEvent returns empty keys when sinceEvent >= maxEvent', async (): Promise => { + const file: File = await api.files.first({ project }); + const result: FileListKeysSinceEventResult = await api.files.listKeysSinceEvent({ + project, + file, + lang: Locales.ENGLISH, + sinceEvent: 300, + }); + + expect(result.keys.length).toBe(0); + expect(result.maxEvent).toBe(300); + }); + test('api.files.getContents', async (): Promise => { // const files: File[] = await api.files.list({ project }); const file: File = await api.files.first({ project });