UNPKG

lokalise-file-exchange

Version:
1 lines 77.4 kB
{"version":3,"file":"index.mjs","names":["LokaliseApiError"],"sources":["../lib/errors/LokaliseError.ts","../lib/services/LokaliseFileExchange.ts","../lib/services/LokaliseDownload.ts","../lib/services/LokaliseUpload.ts"],"sourcesContent":["import type { LokaliseError as ILokaliseError } from \"../interfaces/LokaliseError.js\";\n\n/**\n * Represents a custom error.\n */\nexport class LokaliseError extends Error implements ILokaliseError {\n\t/**\n\t * The error code representing the type of Lokalise API error.\n\t */\n\tcode?: number | undefined;\n\n\t/**\n\t * Additional details about the error.\n\t */\n\tdetails?: Record<string, string | number | boolean>;\n\n\t/**\n\t * Creates a new instance of LokaliseError.\n\t *\n\t * @param message - The error message.\n\t * @param code - The error code (optional).\n\t * @param details - Optional additional details about the error.\n\t */\n\tconstructor(\n\t\tmessage: string,\n\t\tcode?: number,\n\t\tdetails?: Record<string, string | number | boolean>,\n\t) {\n\t\tsuper(message);\n\t\tthis.code = code;\n\n\t\tif (details) {\n\t\t\tthis.details = details;\n\t\t}\n\t}\n\n\t/**\n\t * Returns a string representation of the error, including code and details.\n\t *\n\t * @returns The formatted error message.\n\t */\n\toverride toString(): string {\n\t\tlet baseMessage = `LokaliseError: ${this.message}`;\n\t\tif (this.code) {\n\t\t\tbaseMessage += ` (Code: ${this.code})`;\n\t\t}\n\t\tif (this.details) {\n\t\t\tconst formattedDetails = Object.entries(this.details)\n\t\t\t\t.map(([key, value]) => `${key}: ${value}`)\n\t\t\t\t.join(\", \");\n\n\t\t\tbaseMessage += ` | Details: ${formattedDetails}`;\n\t\t}\n\t\treturn baseMessage;\n\t}\n}\n","import type { ClientParams, QueuedProcess } from \"@lokalise/node-api\";\nimport {\n\tLokaliseApi,\n\tApiError as LokaliseApiError,\n\tLokaliseApiOAuth,\n} from \"@lokalise/node-api\";\nimport {\n\ttype LogFunction,\n\ttype LogLevel,\n\ttype LogThreshold,\n\tlogWithColor,\n\tlogWithLevel,\n} from \"kliedz\";\nimport { LokaliseError } from \"../errors/LokaliseError.js\";\nimport type {\n\tLokaliseExchangeConfig,\n\tRetryParams,\n} from \"../interfaces/index.js\";\n\n/**\n * A utility class for exchanging files with the Lokalise API.\n */\nexport class LokaliseFileExchange {\n\t/**\n\t * The Lokalise API client instance.\n\t */\n\tprotected readonly apiClient: LokaliseApi;\n\n\t/**\n\t * The ID of the project in Lokalise.\n\t */\n\tprotected readonly projectId: string;\n\n\t/**\n\t * Retry parameters for API requests.\n\t */\n\tprotected readonly retryParams: RetryParams;\n\n\t/**\n\t * Logger function.\n\t */\n\tprotected readonly logger: LogFunction;\n\n\t/**\n\t * Log threshold (do not print messages with severity less than the specified value).\n\t */\n\tprotected readonly logThreshold: LogThreshold;\n\n\t/**\n\t * Default retry parameters for API requests.\n\t */\n\tprivate static readonly defaultRetryParams: Required<RetryParams> = {\n\t\tmaxRetries: 3,\n\t\tinitialSleepTime: 1000,\n\t\tjitterRatio: 0.2,\n\t\trng: Math.random,\n\t};\n\n\tprivate static readonly FINISHED_STATUSES = [\n\t\t\"finished\",\n\t\t\"cancelled\",\n\t\t\"failed\",\n\t] as const;\n\n\tprivate static readonly RETRYABLE_CODES = [408, 429];\n\n\tprotected static readonly maxConcurrentProcesses = 6;\n\n\tprivate static isPendingStatus(status?: string | null): boolean {\n\t\treturn !LokaliseFileExchange.isFinishedStatus(status);\n\t}\n\n\tpublic static isFinishedStatus(status?: string | null): boolean {\n\t\treturn (\n\t\t\tstatus != null &&\n\t\t\t(LokaliseFileExchange.FINISHED_STATUSES as readonly string[]).includes(\n\t\t\t\tstatus,\n\t\t\t)\n\t\t);\n\t}\n\n\t/**\n\t * Creates a new instance of LokaliseFileExchange.\n\t *\n\t * @param clientConfig - Configuration for the Lokalise SDK.\n\t * @param exchangeConfig - The configuration object for file exchange operations.\n\t * @throws {LokaliseError} If the provided configuration is invalid.\n\t */\n\tconstructor(\n\t\tclientConfig: ClientParams,\n\t\t{\n\t\t\tprojectId,\n\t\t\tuseOAuth2 = false,\n\t\t\tretryParams,\n\t\t\tlogThreshold = \"info\",\n\t\t\tlogColor = true,\n\t\t}: LokaliseExchangeConfig,\n\t) {\n\t\tthis.projectId = projectId;\n\t\tthis.logThreshold = logThreshold;\n\t\tthis.logger = this.chooseLogger(logColor);\n\t\tthis.retryParams = this.buildRetryParams(retryParams);\n\n\t\tthis.validateParams();\n\n\t\tconst apiConfig = this.buildLokaliseClientConfig(\n\t\t\tclientConfig,\n\t\t\tlogThreshold,\n\t\t);\n\t\tthis.apiClient = this.createApiClient(apiConfig, useOAuth2);\n\t}\n\n\t/**\n\t * Executes an asynchronous operation with exponential-backoff retry logic.\n\t *\n\t * The operation is attempted multiple times if it throws a retryable\n\t * `LokaliseApiError`. Each retry waits longer than the previous one based on\n\t * exponential backoff parameters (`base`, `factor`, optional jitter).\n\t *\n\t * Behaviour:\n\t * - If the operation succeeds — its result is returned immediately.\n\t * - If it fails with a retryable error — the function waits and retries.\n\t * - If the maximum number of retries is reached — throws a `LokaliseError`\n\t * with the original error details.\n\t * - If the error is non-retryable — it is immediately wrapped into\n\t * `LokaliseError` and rethrown.\n\t * - Any non-Lokalise errors are rethrown as-is.\n\t *\n\t * @param operation - A function that performs the async action to be retried.\n\t * @returns The successful result of the operation.\n\t * @throws LokaliseError After all retries fail or on non-retryable errors.\n\t */\n\tprotected async withExponentialBackoff<T>(\n\t\toperation: () => Promise<T>,\n\t): Promise<T> {\n\t\tconst { maxRetries } = this.retryParams;\n\t\tthis.logMsg(\n\t\t\t\"debug\",\n\t\t\t`Running operation with exponential backoff; max retries: ${maxRetries}`,\n\t\t);\n\n\t\tfor (let attempt = 1; attempt <= maxRetries + 1; attempt++) {\n\t\t\ttry {\n\t\t\t\tthis.logMsg(\"debug\", `Attempt #${attempt}...`);\n\t\t\t\treturn await operation();\n\t\t\t} catch (error: unknown) {\n\t\t\t\tif (error instanceof LokaliseApiError && this.isRetryable(error)) {\n\t\t\t\t\tthis.logMsg(\"debug\", `Retryable error caught: ${error.message}`);\n\n\t\t\t\t\tif (attempt === maxRetries + 1) {\n\t\t\t\t\t\tthrow new LokaliseError(\n\t\t\t\t\t\t\t`Maximum retries reached: ${error.message}`,\n\t\t\t\t\t\t\terror.code,\n\t\t\t\t\t\t\terror.details,\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\n\t\t\t\t\tconst sleepMs = this.calculateSleepMs(this.retryParams, attempt);\n\n\t\t\t\t\tthis.logMsg(\"debug\", `Waiting ${sleepMs}ms before retry...`);\n\t\t\t\t\tawait LokaliseFileExchange.sleep(sleepMs);\n\t\t\t\t} else if (error instanceof LokaliseApiError) {\n\t\t\t\t\tthrow new LokaliseError(error.message, error.code, error.details);\n\t\t\t\t} else {\n\t\t\t\t\tthrow error;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// This line is unreachable but keeps TS happy.\n\t\tthrow new LokaliseError(\"Unexpected error during operation.\", 500);\n\t}\n\t/**\n\t * Polls the status of queued processes until they are marked as \"finished\"\n\t * or until the maximum wait time is exceeded.\n\t *\n\t * Uses batched polling with limited concurrency and exponential backoff-like\n\t * wait times between iterations. Performs an initial status snapshot, then\n\t * repeatedly refreshes pending processes until either:\n\t * - all of them reach a finished state, or\n\t * - the time budget (`maxWaitTime`) is exhausted.\n\t *\n\t * A final refresh is performed for any still-pending processes before returning.\n\t *\n\t * @param processes - List of queued processes to poll.\n\t * @param initialWaitTime - Initial delay (in ms) between polling iterations.\n\t * @param maxWaitTime - Maximum total time (in ms) allowed for polling.\n\t * @param concurrency - Maximum number of processes to refresh per batch.\n\t * @returns A list of processes with their latest known statuses.\n\t */\n\tprotected async pollProcesses(\n\t\tprocesses: QueuedProcess[],\n\t\tinitialWaitTime: number,\n\t\tmaxWaitTime: number,\n\t\tconcurrency = LokaliseFileExchange.maxConcurrentProcesses,\n\t): Promise<QueuedProcess[]> {\n\t\tthis.logMsg(\n\t\t\t\"debug\",\n\t\t\t`Start polling processes. Total processes count: ${processes.length}`,\n\t\t);\n\n\t\tconst startTime = Date.now();\n\n\t\tconst { processMap, pendingProcessIds } =\n\t\t\tthis.initializePollingState(processes);\n\n\t\tawait this.runPollingLoop(\n\t\t\tprocessMap,\n\t\t\tpendingProcessIds,\n\t\t\tstartTime,\n\t\t\tinitialWaitTime,\n\t\t\tmaxWaitTime,\n\t\t\tconcurrency,\n\t\t);\n\n\t\tif (pendingProcessIds.size > 0) {\n\t\t\tawait this.refreshRemainingProcesses(\n\t\t\t\tprocessMap,\n\t\t\t\tpendingProcessIds,\n\t\t\t\tconcurrency,\n\t\t\t);\n\t\t}\n\n\t\treturn Array.from(processMap.values());\n\t}\n\n\t/**\n\t * Builds internal tracking structures for polling: a map of process IDs\n\t * to their last known state and a set of IDs that are still pending.\n\t *\n\t * Also logs the initial status of each process.\n\t *\n\t * @param processes - Initial list of queued processes.\n\t * @returns A map of processes keyed by ID and a set of pending process IDs.\n\t */\n\tprivate initializePollingState(processes: QueuedProcess[]): {\n\t\tprocessMap: Map<string, QueuedProcess>;\n\t\tpendingProcessIds: Set<string>;\n\t} {\n\t\tthis.logMsg(\"debug\", \"Initial processes check...\");\n\n\t\tconst processMap = new Map<string, QueuedProcess>();\n\t\tconst pendingProcessIds = new Set<string>();\n\n\t\tfor (const p of processes) {\n\t\t\tif (p.status) {\n\t\t\t\tthis.logMsg(\n\t\t\t\t\t\"debug\",\n\t\t\t\t\t`Process ID: ${p.process_id}, status: ${p.status}`,\n\t\t\t\t);\n\t\t\t} else {\n\t\t\t\tthis.logMsg(\"debug\", `Process ID: ${p.process_id}, status is missing`);\n\t\t\t}\n\n\t\t\tprocessMap.set(p.process_id, p);\n\n\t\t\tif (LokaliseFileExchange.isPendingStatus(p.status)) {\n\t\t\t\tpendingProcessIds.add(p.process_id);\n\t\t\t}\n\t\t}\n\n\t\treturn { processMap, pendingProcessIds };\n\t}\n\n\t/**\n\t * Runs the main polling loop for the given processes.\n\t *\n\t * Repeatedly fetches updated process statuses in batches while:\n\t * - there are still pending IDs, and\n\t * - the elapsed time is below the configured maximum.\n\t *\n\t * Includes a small \"fast-follow\" recheck when some processes have missing\n\t * status on the first iterations, and uses a growing wait time between\n\t * iterations (capped by the remaining time budget).\n\t *\n\t * @param processMap - Map of process IDs to their last known state.\n\t * @param pendingProcessIds - Set of IDs that are not finished yet.\n\t * @param startTime - Timestamp (ms) when polling started.\n\t * @param initialWaitTime - Initial delay (ms) between polling iterations.\n\t * @param maxWaitTime - Maximum total polling duration (ms).\n\t * @param concurrency - Maximum number of processes to refresh per batch.\n\t */\n\tprivate async runPollingLoop(\n\t\tprocessMap: Map<string, QueuedProcess>,\n\t\tpendingProcessIds: Set<string>,\n\t\tstartTime: number,\n\t\tinitialWaitTime: number,\n\t\tmaxWaitTime: number,\n\t\tconcurrency: number,\n\t): Promise<void> {\n\t\tlet waitTime = initialWaitTime;\n\t\tlet didFastFollow = false;\n\n\t\twhile (pendingProcessIds.size > 0 && Date.now() - startTime < maxWaitTime) {\n\t\t\tthis.logMsg(\"debug\", `Polling... Pending IDs: ${pendingProcessIds.size}`);\n\n\t\t\tif (\n\t\t\t\t!didFastFollow &&\n\t\t\t\t[...processMap.values()].some((p) => p.status == null)\n\t\t\t) {\n\t\t\t\tthis.logMsg(\n\t\t\t\t\t\"debug\",\n\t\t\t\t\t\"Fast-follow: some statuses missing, quick recheck in 200ms\",\n\t\t\t\t);\n\t\t\t\tawait LokaliseFileExchange.sleep(200);\n\t\t\t\tdidFastFollow = true;\n\t\t\t}\n\n\t\t\tconst ids = [...pendingProcessIds];\n\t\t\tconst batch = await this.fetchProcessesBatch(ids, concurrency);\n\n\t\t\tfor (const { id, process } of batch) {\n\t\t\t\tif (!process) continue;\n\t\t\t\tprocessMap.set(id, process);\n\n\t\t\t\tif (LokaliseFileExchange.isFinishedStatus(process.status)) {\n\t\t\t\t\tthis.logMsg(\n\t\t\t\t\t\t\"debug\",\n\t\t\t\t\t\t`Process ${id} completed with status=${process.status}.`,\n\t\t\t\t\t);\n\t\t\t\t\tpendingProcessIds.delete(id);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (pendingProcessIds.size === 0) {\n\t\t\t\tthis.logMsg(\"debug\", \"Finished polling. Pending processes IDs: 0\");\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tconst elapsed = Date.now() - startTime;\n\t\t\tconst remaining = maxWaitTime - elapsed;\n\n\t\t\tif (remaining <= 0) {\n\t\t\t\tthis.logMsg(\n\t\t\t\t\t\"debug\",\n\t\t\t\t\t\"Time budget exhausted, stopping polling without extra sleep.\",\n\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tconst sleepMs = Math.min(waitTime, remaining);\n\t\t\tthis.logMsg(\"debug\", `Waiting ${sleepMs}...`);\n\t\t\tawait LokaliseFileExchange.sleep(sleepMs);\n\n\t\t\twaitTime = Math.min(\n\t\t\t\twaitTime * 2,\n\t\t\t\tMath.max(0, maxWaitTime - (Date.now() - startTime)),\n\t\t\t);\n\t\t}\n\t}\n\n\t/**\n\t * Performs a final status refresh for any processes that are still marked\n\t * as pending after the main polling loop.\n\t *\n\t * This gives one last chance to capture terminal statuses right before\n\t * returning the result to the caller.\n\t *\n\t * @param processMap - Map of process IDs to their last known state.\n\t * @param pendingProcessIds - Set of IDs that are still considered pending.\n\t * @param concurrency - Maximum number of processes to refresh per batch.\n\t */\n\tprivate async refreshRemainingProcesses(\n\t\tprocessMap: Map<string, QueuedProcess>,\n\t\tpendingProcessIds: Set<string>,\n\t\tconcurrency: number,\n\t): Promise<void> {\n\t\tthis.logMsg(\n\t\t\t\"debug\",\n\t\t\t`Final refresh for ${pendingProcessIds.size} pending processes before return...`,\n\t\t);\n\n\t\tconst finalBatch = await this.fetchProcessesBatch(\n\t\t\t[...pendingProcessIds],\n\t\t\tconcurrency,\n\t\t);\n\n\t\tfor (const { id, process } of finalBatch) {\n\t\t\tif (process) {\n\t\t\t\tprocessMap.set(id, process);\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Determines whether the given Lokalise API error should trigger a retry attempt.\n\t *\n\t * An error is considered retryable if its `code` matches one of the predefined\n\t * retryable status codes.\n\t *\n\t * @param error - The `LokaliseApiError` instance to evaluate.\n\t * @returns `true` if the error is retryable, otherwise `false`.\n\t */\n\tprivate isRetryable(error: LokaliseApiError): boolean {\n\t\treturn LokaliseFileExchange.RETRYABLE_CODES.includes(error.code);\n\t}\n\n\t/**\n\t * Logs a message using the configured logger, respecting the current log threshold.\n\t *\n\t * Wraps the raw logger call by attaching metadata such as:\n\t * - `level` — severity of the log entry,\n\t * - `threshold` — active log level threshold used to filter messages,\n\t * - `withTimestamp` — instructs the logger to prepend a timestamp.\n\t *\n\t * All variadic `args` are forwarded directly to the logger.\n\t *\n\t * @param level - Log level of the message being emitted.\n\t * @param args - Additional values to pass to the logger.\n\t */\n\tprotected logMsg(level: LogLevel, ...args: unknown[]): void {\n\t\tthis.logger(\n\t\t\t{ level, threshold: this.logThreshold, withTimestamp: true },\n\t\t\t...args,\n\t\t);\n\t}\n\n\t/**\n\t * Fetches the most recent state of a queued process from the Lokalise API.\n\t *\n\t * Sends a GET request for the process identified by `processId` and logs\n\t * both the request and the received status. Used during polling to refresh\n\t * the status of long-running async operations.\n\t *\n\t * @param processId - The unique identifier of the queued process to retrieve.\n\t * @returns A promise resolving to the updated `QueuedProcess` object.\n\t */\n\tprotected async getUpdatedProcess(processId: string): Promise<QueuedProcess> {\n\t\tthis.logMsg(\"debug\", `Requesting update for process ID: ${processId}`);\n\n\t\tconst updatedProcess = await this.apiClient\n\t\t\t.queuedProcesses()\n\t\t\t.get(processId, { project_id: this.projectId });\n\n\t\tif (updatedProcess.status) {\n\t\t\tthis.logMsg(\n\t\t\t\t\"debug\",\n\t\t\t\t`Process ID: ${updatedProcess.process_id}, status: ${updatedProcess.status}`,\n\t\t\t);\n\t\t} else {\n\t\t\tthis.logMsg(\n\t\t\t\t\"debug\",\n\t\t\t\t`Process ID: ${updatedProcess.process_id}, status is missing`,\n\t\t\t);\n\t\t}\n\n\t\treturn updatedProcess;\n\t}\n\n\t/**\n\t * Validates essential client configuration parameters before any operations run.\n\t *\n\t * Ensures that:\n\t * - `projectId` is present and is a non-empty string,\n\t * - retry settings (`maxRetries`, `initialSleepTime`, `jitterRatio`)\n\t * fall within acceptable ranges.\n\t *\n\t * Throws a `LokaliseError` if any configuration parameter is missing,\n\t * malformed, or outside allowed bounds.\n\t */\n\tprivate validateParams(): void {\n\t\tif (!this.projectId || typeof this.projectId !== \"string\") {\n\t\t\tthrow new LokaliseError(\"Invalid or missing Project ID.\");\n\t\t}\n\n\t\tconst { maxRetries, initialSleepTime, jitterRatio } = this.retryParams;\n\n\t\tif (maxRetries < 0) {\n\t\t\tthrow new LokaliseError(\n\t\t\t\t\"maxRetries must be greater than or equal to zero.\",\n\t\t\t);\n\t\t}\n\t\tif (initialSleepTime <= 0) {\n\t\t\tthrow new LokaliseError(\"initialSleepTime must be a positive value.\");\n\t\t}\n\t\tif (jitterRatio < 0 || jitterRatio > 1)\n\t\t\tthrow new LokaliseError(\"jitterRatio must be between 0 and 1.\");\n\t}\n\n\t/**\n\t * Executes asynchronous work over a list of items with a fixed concurrency limit.\n\t *\n\t * Spawns up to `limit` parallel worker loops. Each loop pulls the next\n\t * unprocessed item index in a thread-safe manner (via shared counter `i`),\n\t * runs the provided async `worker` function for that item, and stores the\n\t * resulting value in the corresponding position of the `results` array.\n\t *\n\t * Processing stops when all items have been consumed. If any worker throws,\n\t * the error propagates and the whole operation rejects.\n\t *\n\t * @param items - The list of items to process.\n\t * @param limit - Maximum number of concurrent async operations.\n\t * @param worker - Async handler executed for each item.\n\t * @returns A promise resolving to an array of results, preserving input order.\n\t */\n\tprotected async runWithConcurrencyLimit<T, R>(\n\t\titems: T[],\n\t\tlimit: number,\n\t\tworker: (item: T, index: number) => Promise<R>,\n\t): Promise<R[]> {\n\t\tconst results = new Array<R>(items.length);\n\t\tlet i = 0;\n\n\t\tconst workers = new Array(Math.min(limit, items.length))\n\t\t\t.fill(null)\n\t\t\t.map(async () => {\n\t\t\t\twhile (true) {\n\t\t\t\t\tconst idx = i++;\n\t\t\t\t\tif (idx >= items.length) break;\n\t\t\t\t\tconst item = items[idx];\n\t\t\t\t\tif (item === undefined) {\n\t\t\t\t\t\tthrow new Error(`Missing item at index ${idx}`);\n\t\t\t\t\t}\n\n\t\t\t\t\tresults[idx] = await worker(item, idx);\n\t\t\t\t}\n\t\t\t});\n\n\t\tawait Promise.all(workers);\n\t\treturn results;\n\t}\n\n\t/**\n\t * Fetches updated process states for a list of process IDs in parallel,\n\t * respecting a maximum concurrency limit.\n\t *\n\t * Each process ID is resolved via `getUpdatedProcess()`. If the fetch\n\t * succeeds, the returned object includes both `id` and the updated\n\t * `process`. If an error occurs, a warning is logged and the result\n\t * contains only the `id`, allowing polling to continue without failing\n\t * the entire batch.\n\t *\n\t * Internally uses `runWithConcurrencyLimit` to enforce controlled parallelism.\n\t *\n\t * @param processIds - The list of queued process IDs to refresh.\n\t * @param concurrency - Maximum number of simultaneous requests.\n\t * @returns A list of objects mapping each ID to its latest fetched state\n\t * (or `undefined` if the fetch failed).\n\t */\n\tprotected async fetchProcessesBatch(\n\t\tprocessIds: string[],\n\t\tconcurrency = LokaliseFileExchange.maxConcurrentProcesses,\n\t): Promise<Array<{ id: string; process?: QueuedProcess }>> {\n\t\treturn this.runWithConcurrencyLimit(processIds, concurrency, async (id) => {\n\t\t\ttry {\n\t\t\t\tconst updated = await this.getUpdatedProcess(id);\n\t\t\t\treturn { id, process: updated };\n\t\t\t} catch (error) {\n\t\t\t\tthis.logMsg(\"warn\", `Failed to fetch process ${id}:`, error);\n\t\t\t\treturn { id };\n\t\t\t}\n\t\t});\n\t}\n\n\t/**\n\t * Delays execution for a given duration.\n\t *\n\t * Creates a Promise that resolves after the specified number of milliseconds,\n\t * allowing async workflows to pause without blocking the event loop.\n\t *\n\t * @param ms - Number of milliseconds to wait.\n\t * @returns A promise that resolves after the delay.\n\t */\n\tprotected static sleep(ms: number): Promise<void> {\n\t\treturn new Promise((resolve) => setTimeout(resolve, ms));\n\t}\n\n\t/**\n\t * Computes the exponential-backoff delay for a retry attempt,\n\t * optionally adding jitter to avoid synchronized retries.\n\t *\n\t * @param retryParams - Backoff settings (initial delay, jitter, RNG).\n\t * @param attempt - Retry attempt number (1-based).\n\t * @returns Calculated sleep time in milliseconds.\n\t */\n\tprotected calculateSleepMs(\n\t\tretryParams: RetryParams,\n\t\tattempt: number,\n\t): number {\n\t\tconst { initialSleepTime, jitterRatio, rng } = retryParams;\n\n\t\tconst base = initialSleepTime * 2 ** (attempt - 1);\n\t\tconst maxJitter = Math.floor(base * jitterRatio);\n\t\tconst jitter = maxJitter > 0 ? Math.floor(rng() * maxJitter) : 0;\n\t\treturn base + jitter;\n\t}\n\n\t/**\n\t * Builds the final Lokalise client configuration,\n\t * enabling silent mode when the log threshold is `\"silent\"`.\n\t *\n\t * @param clientConfig - Base client parameters.\n\t * @param logThreshold - Active logging threshold.\n\t * @returns The adjusted client configuration.\n\t */\n\tprivate buildLokaliseClientConfig(\n\t\tclientConfig: ClientParams,\n\t\tlogThreshold: LogThreshold,\n\t): ClientParams {\n\t\tif (logThreshold === \"silent\") {\n\t\t\treturn {\n\t\t\t\t...clientConfig,\n\t\t\t\tsilent: true,\n\t\t\t};\n\t\t}\n\n\t\treturn { ...clientConfig };\n\t}\n\n\t/**\n\t * Creates the appropriate Lokalise API client instance,\n\t * choosing between OAuth2 and token-based authentication.\n\t *\n\t * @param lokaliseApiConfig - Configuration passed to the client.\n\t * @param useOAuth2 - Whether OAuth2 authentication should be used.\n\t * @returns A Lokalise API client instance.\n\t */\n\tprivate createApiClient(\n\t\tlokaliseApiConfig: ClientParams,\n\t\tuseOAuth2: boolean,\n\t): LokaliseApi | LokaliseApiOAuth {\n\t\tif (useOAuth2) {\n\t\t\tthis.logMsg(\"debug\", \"Using OAuth 2 Lokalise API client\");\n\t\t\treturn new LokaliseApiOAuth(lokaliseApiConfig);\n\t\t}\n\n\t\tthis.logMsg(\"debug\", \"Using regular (token-based) Lokalise API client\");\n\t\treturn new LokaliseApi(lokaliseApiConfig);\n\t}\n\n\t/**\n\t * Merges user-provided retry settings with default retry parameters.\n\t *\n\t * @param retryParams - Optional overrides.\n\t * @returns Fully resolved retry configuration.\n\t */\n\tprivate buildRetryParams(retryParams?: Partial<RetryParams>): RetryParams {\n\t\treturn {\n\t\t\t...LokaliseFileExchange.defaultRetryParams,\n\t\t\t...retryParams,\n\t\t};\n\t}\n\n\t/**\n\t * Selects the logger implementation based on whether color output is enabled.\n\t *\n\t * @param logColor - If true, uses the colorized logger.\n\t * @returns The chosen log function.\n\t */\n\tprivate chooseLogger(logColor: boolean): LogFunction {\n\t\treturn logColor ? logWithColor : logWithLevel;\n\t}\n}\n","import crypto from \"node:crypto\";\nimport fs from \"node:fs\";\nimport os from \"node:os\";\nimport path from \"node:path\";\nimport { pipeline, Readable } from \"node:stream\";\nimport type { ReadableStream as WebReadableStream } from \"node:stream/web\";\nimport { promisify } from \"node:util\";\nimport type {\n\tDownloadBundle,\n\tDownloadedFileProcessDetails,\n\tDownloadFileParams,\n\tQueuedProcess,\n} from \"@lokalise/node-api\";\nimport yauzl from \"yauzl\";\nimport { LokaliseError } from \"../errors/LokaliseError.js\";\nimport type {\n\tDownloadTranslationParams,\n\tProcessDownloadFileParams,\n} from \"../interfaces/index.js\";\nimport { LokaliseFileExchange } from \"./LokaliseFileExchange.js\";\n\n/**\n * Handles downloading and extracting translation files from Lokalise.\n */\nexport class LokaliseDownload extends LokaliseFileExchange {\n\tprivate static readonly defaultProcessParams: Required<ProcessDownloadFileParams> =\n\t\t{\n\t\t\tasyncDownload: false,\n\t\t\tpollInitialWaitTime: 1000,\n\t\t\tpollMaximumWaitTime: 120_000,\n\t\t\tbundleDownloadTimeout: 0,\n\t\t};\n\n\tprivate readonly streamPipeline = promisify(pipeline);\n\n\t/**\n\t * Downloads translations from Lokalise, optionally using async polling, and extracts them to disk.\n\t *\n\t * @param downloadTranslationParams - Full configuration for the download process, extraction destination, and optional polling or timeout settings.\n\t * @throws {LokaliseError} If the download, polling, or extraction fails.\n\t */\n\tasync downloadTranslations({\n\t\tdownloadFileParams,\n\t\textractParams = {},\n\t\tprocessDownloadFileParams,\n\t}: DownloadTranslationParams): Promise<void> {\n\t\tthis.logMsg(\"debug\", \"Downloading translations from Lokalise...\");\n\n\t\tconst processParams = this.buildProcessParams(processDownloadFileParams);\n\n\t\tconst translationsBundleURL = await this.fetchTranslationBundleURL(\n\t\t\tdownloadFileParams,\n\t\t\tprocessParams,\n\t\t);\n\n\t\tconst zipFilePath = await this.downloadZip(\n\t\t\ttranslationsBundleURL,\n\t\t\tprocessParams.bundleDownloadTimeout,\n\t\t);\n\n\t\tawait this.processZip(\n\t\t\tzipFilePath,\n\t\t\tpath.resolve(extractParams.outputDir ?? \"./\"),\n\t\t);\n\t}\n\n\t/**\n\t * Unpacks a ZIP file into the specified directory.\n\t *\n\t * @param zipFilePath - Path to the ZIP file.\n\t * @param outputDir - Directory to extract the files into.\n\t * @throws {LokaliseError} If extraction fails or malicious paths are detected.\n\t */\n\tprotected async unpackZip(\n\t\tzipFilePath: string,\n\t\toutputDir: string,\n\t): Promise<void> {\n\t\treturn new Promise((resolve, reject) => {\n\t\t\tyauzl.open(zipFilePath, { lazyEntries: true }, (err, zipfile) => {\n\t\t\t\tif (err) {\n\t\t\t\t\treturn reject(\n\t\t\t\t\t\tnew LokaliseError(\n\t\t\t\t\t\t\t`Failed to open ZIP file at ${zipFilePath}: ${err.message}`,\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\tzipfile.readEntry();\n\n\t\t\t\tzipfile.on(\"entry\", (entry) => {\n\t\t\t\t\tthis.handleZipEntry(entry, zipfile, outputDir)\n\t\t\t\t\t\t.then(() => zipfile.readEntry())\n\t\t\t\t\t\t.catch(reject);\n\t\t\t\t});\n\n\t\t\t\tzipfile.on(\"end\", resolve);\n\t\t\t\tzipfile.on(\"error\", reject);\n\t\t\t});\n\t\t});\n\t}\n\n\t/**\n\t * Downloads a ZIP file from the given URL and stores it as a temporary file.\n\t *\n\t * Performs URL validation, optional timeout handling, fetch request execution,\n\t * response integrity checks, and writes the ZIP stream to disk.\n\t *\n\t * @param url - Direct URL to the ZIP bundle provided by Lokalise.\n\t * @param downloadTimeout - Optional timeout (in ms) for the HTTP request. `0` disables timeouts.\n\t * @returns Absolute path to the temporary ZIP file on disk.\n\t */\n\tprotected async downloadZip(\n\t\turl: string,\n\t\tdownloadTimeout = 0,\n\t): Promise<string> {\n\t\tthis.logMsg(\"debug\", \"Downloading translation bundle...\");\n\n\t\tconst bundleURL = this.assertHttpUrl(url);\n\t\tconst tempZipPath = this.buildTempZipPath();\n\n\t\tconst signal = this.buildAbortSignal(downloadTimeout);\n\t\tconst response = await this.fetchZipResponse(\n\t\t\tbundleURL,\n\t\t\tsignal,\n\t\t\tdownloadTimeout,\n\t\t);\n\n\t\tconst body = this.getZipResponseBody(response, url);\n\n\t\tawait this.writeZipToDisk(body, tempZipPath);\n\n\t\treturn tempZipPath;\n\t}\n\n\t/**\n\t * Builds a unique temporary file path for storing the downloaded ZIP bundle.\n\t *\n\t * Uses a UUID when available or falls back to a combination of PID, timestamp, and random bytes.\n\t *\n\t * @returns A full path to a temporary ZIP file in the OS temp directory.\n\t */\n\tprotected buildTempZipPath(): string {\n\t\tconst uid =\n\t\t\tcrypto.randomUUID?.() ??\n\t\t\t`${process.pid}-${Date.now()}-${crypto.randomBytes(8).toString(\"hex\")}`;\n\n\t\treturn path.join(os.tmpdir(), `lokalise-${uid}.zip`);\n\t}\n\n\t/**\n\t * Creates an optional AbortSignal for enforcing request timeouts.\n\t *\n\t * Returns `undefined` when no timeout is configured, disabling abort handling.\n\t *\n\t * @param downloadTimeout - Timeout in milliseconds. `0` or negative disables the signal.\n\t * @returns An AbortSignal if timeout is enabled, otherwise `undefined`.\n\t */\n\tprivate buildAbortSignal(downloadTimeout: number): AbortSignal | undefined {\n\t\tif (downloadTimeout <= 0) {\n\t\t\treturn undefined;\n\t\t}\n\n\t\treturn AbortSignal.timeout(downloadTimeout);\n\t}\n\n\t/**\n\t * Executes a fetch request for the ZIP bundle URL with optional timeout handling.\n\t *\n\t * Wraps network failures, timeouts, and unexpected fetch errors into `LokaliseError`\n\t * so higher-level logic receives consistent exceptions.\n\t *\n\t * @param bundleURL - Parsed URL pointing to the ZIP file.\n\t * @param signal - Optional `AbortSignal` used to enforce request timeouts.\n\t * @param downloadTimeout - Timeout duration (ms) used for error messaging.\n\t * @returns The raw `Response` object returned by `fetch` if the request succeeds.\n\t */\n\tprotected async fetchZipResponse(\n\t\tbundleURL: URL,\n\t\tsignal: AbortSignal | undefined,\n\t\tdownloadTimeout: number,\n\t): Promise<Response> {\n\t\ttry {\n\t\t\treturn await fetch(bundleURL, signal ? { signal } : {});\n\t\t} catch (err) {\n\t\t\tif (err instanceof Error) {\n\t\t\t\tif (err.name === \"TimeoutError\") {\n\t\t\t\t\tthrow new LokaliseError(\n\t\t\t\t\t\t`Request timed out after ${downloadTimeout}ms`,\n\t\t\t\t\t\t408,\n\t\t\t\t\t\t{ reason: \"timeout\" },\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\tthrow new LokaliseError(err.message, 500, {\n\t\t\t\t\treason: \"network or fetch error\",\n\t\t\t\t});\n\t\t\t}\n\n\t\t\t// This should never happen in production\n\t\t\t// as realistically fetch always raises Error,\n\t\t\t// unless some black magic has been involved.\n\t\t\t/* v8 ignore start */\n\t\t\tthrow new LokaliseError(\n\t\t\t\t\"An unknown error occurred. This might indicate a bug.\",\n\t\t\t\t500,\n\t\t\t\t{\n\t\t\t\t\treason: String(err),\n\t\t\t\t},\n\t\t\t);\n\t\t\t/* v8 ignore end */\n\t\t}\n\t}\n\n\t/**\n\t * Validates and extracts the readable body stream from a fetch response.\n\t *\n\t * Ensures the response is OK and has a non-null body before returning it.\n\t *\n\t * @param response - The HTTP response returned by `fetch`.\n\t * @param originalUrl - Original URL used for error diagnostics.\n\t * @returns A web ReadableStream of the ZIP file contents.\n\t * @throws {LokaliseError} If the response is not OK or body is missing.\n\t */\n\tprivate getZipResponseBody(\n\t\tresponse: Response,\n\t\toriginalUrl: string,\n\t): WebReadableStream<Uint8Array> {\n\t\tif (!response.ok) {\n\t\t\tthrow new LokaliseError(\n\t\t\t\t`Failed to download ZIP file: ${response.statusText} (${response.status})`,\n\t\t\t);\n\t\t}\n\n\t\tconst body = response.body as WebReadableStream<Uint8Array> | null;\n\n\t\tif (!body) {\n\t\t\tthrow new LokaliseError(\n\t\t\t\t`Response body is null. Cannot download ZIP file from URL: ${originalUrl}`,\n\t\t\t);\n\t\t}\n\n\t\treturn body;\n\t}\n\n\t/**\n\t * Streams the ZIP response body to a temporary file on disk.\n\t *\n\t * Cleans up the temporary file if the streaming pipeline fails.\n\t *\n\t * @param body - Web readable stream of the ZIP content.\n\t * @param tempZipPath - Path where the ZIP should be written.\n\t * @returns A promise that resolves once the file is fully written.\n\t * @throws {Error} Re-throws any pipeline errors after attempting cleanup.\n\t */\n\tprivate async writeZipToDisk(\n\t\tbody: WebReadableStream<Uint8Array>,\n\t\ttempZipPath: string,\n\t): Promise<void> {\n\t\ttry {\n\t\t\tconst nodeReadable = Readable.fromWeb(body);\n\t\t\tawait this.streamPipeline(\n\t\t\t\tnodeReadable,\n\t\t\t\tfs.createWriteStream(tempZipPath),\n\t\t\t);\n\t\t} catch (e) {\n\t\t\ttry {\n\t\t\t\tawait fs.promises.unlink(tempZipPath);\n\t\t\t} catch {\n\t\t\t\tthis.logMsg(\n\t\t\t\t\t\"debug\",\n\t\t\t\t\t`Stream pipeline failed and unable to remove temp path ${tempZipPath}`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tthrow e;\n\t\t}\n\t}\n\n\t/**\n\t * Retrieves a translation bundle from Lokalise with retries and exponential backoff.\n\t *\n\t * @param downloadFileParams - Parameters for Lokalise API file download.\n\t * @returns The downloaded bundle metadata.\n\t * @throws {LokaliseError} If retries are exhausted or an API error occurs.\n\t */\n\tprotected async getTranslationsBundle(\n\t\tdownloadFileParams: DownloadFileParams,\n\t): Promise<DownloadBundle> {\n\t\treturn this.withExponentialBackoff(() =>\n\t\t\tthis.apiClient.files().download(this.projectId, downloadFileParams),\n\t\t);\n\t}\n\n\t/**\n\t * Retrieves a translation bundle from Lokalise with retries and exponential backoff.\n\t *\n\t * @param downloadFileParams - Parameters for Lokalise API file download.\n\t * @returns The queued process.\n\t * @throws {LokaliseError} If retries are exhausted or an API error occurs.\n\t */\n\tprotected async getTranslationsBundleAsync(\n\t\tdownloadFileParams: DownloadFileParams,\n\t): Promise<QueuedProcess> {\n\t\treturn this.withExponentialBackoff(() =>\n\t\t\tthis.apiClient.files().async_download(this.projectId, downloadFileParams),\n\t\t);\n\t}\n\n\t/**\n\t * Extracts a single entry from a ZIP archive to the specified output directory.\n\t *\n\t * Creates necessary directories and streams the file content to disk.\n\t *\n\t * @param entry - The ZIP entry to extract.\n\t * @param zipfile - The open ZIP file instance.\n\t * @param outputDir - The directory where the entry should be written.\n\t * @returns A promise that resolves when the entry is fully written.\n\t */\n\tprotected async handleZipEntry(\n\t\tentry: yauzl.Entry,\n\t\tzipfile: yauzl.ZipFile,\n\t\toutputDir: string,\n\t): Promise<void> {\n\t\tconst fullPath = this.processZipEntryPath(outputDir, entry.fileName);\n\n\t\tif (entry.fileName.endsWith(\"/\")) {\n\t\t\t// it's a directory\n\t\t\tawait this.createDir(fullPath);\n\t\t\treturn;\n\t\t}\n\n\t\tawait this.createDir(path.dirname(fullPath));\n\n\t\treturn new Promise((response, reject) => {\n\t\t\tzipfile.openReadStream(entry, (readErr, readStream) => {\n\t\t\t\tif (readErr || !readStream) {\n\t\t\t\t\treturn reject(\n\t\t\t\t\t\tnew LokaliseError(`Failed to read ZIP entry: ${entry.fileName}`),\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\tconst writeStream = fs.createWriteStream(fullPath);\n\t\t\t\treadStream.pipe(writeStream);\n\t\t\t\twriteStream.on(\"finish\", response);\n\t\t\t\twriteStream.on(\"error\", reject);\n\t\t\t\treadStream.on(\"error\", reject);\n\t\t\t});\n\t\t});\n\t}\n\n\t/**\n\t * Creates a directory and all necessary parent directories.\n\t *\n\t * @param dir - The directory path to create.\n\t * @returns A promise that resolves when the directory is created.\n\t */\n\tprivate async createDir(dir: string): Promise<void> {\n\t\tawait fs.promises.mkdir(dir, { recursive: true });\n\t}\n\n\t/**\n\t * Resolves and validates the full output path for a ZIP entry.\n\t *\n\t * Prevents path traversal attacks by ensuring the resolved path stays within the output directory.\n\t *\n\t * @param outputDir - The base output directory.\n\t * @param entryFilename - The filename of the ZIP entry.\n\t * @returns The absolute and safe path to write the entry.\n\t * @throws {LokaliseError} If the entry path is detected as malicious.\n\t */\n\tprotected processZipEntryPath(\n\t\toutputDir: string,\n\t\tentryFilename: string,\n\t): string {\n\t\t// Validate paths to avoid path traversal issues\n\t\tconst fullPath = path.resolve(outputDir, entryFilename);\n\t\tconst relative = path.relative(outputDir, fullPath);\n\t\tif (relative.startsWith(\"..\") || path.isAbsolute(relative)) {\n\t\t\tthrow new LokaliseError(`Malicious ZIP entry detected: ${entryFilename}`);\n\t\t}\n\n\t\treturn fullPath;\n\t}\n\n\t/**\n\t * Parses and validates a URL string, ensuring it uses HTTP or HTTPS protocol.\n\t *\n\t * @param value - The URL string to validate.\n\t * @returns A parsed `URL` object if valid.\n\t * @throws {LokaliseError} If the URL is invalid or uses an unsupported protocol.\n\t */\n\tprivate assertHttpUrl(value: string): URL {\n\t\tlet parsed: URL;\n\t\ttry {\n\t\t\tparsed = new URL(value);\n\t\t} catch {\n\t\t\tthrow new LokaliseError(`Invalid URL: ${value}`);\n\t\t}\n\n\t\tif (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") {\n\t\t\tthrow new LokaliseError(`Unsupported protocol in URL: ${value}`);\n\t\t}\n\n\t\treturn parsed;\n\t}\n\n\t/**\n\t * Builds effective process parameters for the download workflow.\n\t *\n\t * Merges caller-provided overrides with the default settings.\n\t *\n\t * @param overrides - Partial process configuration to override defaults.\n\t * @returns Fully resolved process parameters.\n\t */\n\tprivate buildProcessParams(\n\t\toverrides?: Partial<ProcessDownloadFileParams>,\n\t): Required<ProcessDownloadFileParams> {\n\t\treturn {\n\t\t\t...LokaliseDownload.defaultProcessParams,\n\t\t\t...overrides,\n\t\t};\n\t}\n\n\t/**\n\t * Unpacks the downloaded ZIP archive into the target directory and\n\t * removes the temporary archive file afterwards.\n\t *\n\t * Logs progress and always attempts to delete the temporary file.\n\t *\n\t * @param zipFilePath - Path to the temporary ZIP file.\n\t * @param unpackTo - Destination directory for extracted files.\n\t */\n\tprivate async processZip(\n\t\tzipFilePath: string,\n\t\tunpackTo: string,\n\t): Promise<void> {\n\t\tthis.logMsg(\n\t\t\t\"debug\",\n\t\t\t`Unpacking translations from ${zipFilePath} to ${unpackTo}`,\n\t\t);\n\n\t\ttry {\n\t\t\tawait this.unpackZip(zipFilePath, unpackTo);\n\n\t\t\tthis.logMsg(\"debug\", \"Translations unpacked!\");\n\t\t\tthis.logMsg(\"debug\", \"Download successful!\");\n\t\t} finally {\n\t\t\tthis.logMsg(\"debug\", `Removing temp archive from ${zipFilePath}`);\n\t\t\tawait fs.promises.unlink(zipFilePath);\n\t\t}\n\t}\n\n\t/**\n\t * Fetches the direct bundle URL in synchronous (non-async) mode.\n\t *\n\t * Calls the standard download endpoint without polling.\n\t *\n\t * @param downloadFileParams - Parameters for Lokalise API file download.\n\t * @returns Direct bundle URL returned by Lokalise.\n\t */\n\tprivate async fetchBundleURLSync(\n\t\tdownloadFileParams: DownloadFileParams,\n\t): Promise<string> {\n\t\tthis.logMsg(\"debug\", \"Async download mode disabled.\");\n\n\t\tconst translationsBundle =\n\t\t\tawait this.getTranslationsBundle(downloadFileParams);\n\t\treturn translationsBundle.bundle_url;\n\t}\n\n\t/**\n\t * Polls an async download process until it completes or the maximum wait time is reached.\n\t *\n\t * Validates the final status and throws if the process did not finish properly.\n\t *\n\t * @param downloadProcess - The initially queued async process.\n\t * @param initialWait - Initial interval in ms before the first poll.\n\t * @param maxWait - Maximum total wait time in ms.\n\t * @returns The completed process object.\n\t * @throws {LokaliseError} If the process is not found or does not finish successfully.\n\t */\n\tprotected async pollAsyncDownload(\n\t\tdownloadProcess: QueuedProcess,\n\t\tinitialWait: number,\n\t\tmaxWait: number,\n\t): Promise<QueuedProcess> {\n\t\tthis.logMsg(\n\t\t\t\"debug\",\n\t\t\t`Waiting for download process ID ${downloadProcess.process_id} to complete...`,\n\t\t);\n\t\tthis.logMsg(\n\t\t\t\"debug\",\n\t\t\t`Effective waits: initial=${initialWait}ms, max=${maxWait}ms`,\n\t\t);\n\n\t\tconst results = await this.pollProcesses(\n\t\t\t[downloadProcess],\n\t\t\tinitialWait,\n\t\t\tmaxWait,\n\t\t);\n\n\t\tconst completedProcess = results.find(\n\t\t\t(p) => p.process_id === downloadProcess.process_id,\n\t\t);\n\n\t\tif (!completedProcess) {\n\t\t\tthrow new LokaliseError(\n\t\t\t\t`Process ${downloadProcess.process_id} not found after polling`,\n\t\t\t\t500,\n\t\t\t);\n\t\t}\n\n\t\tif (!LokaliseFileExchange.isFinishedStatus(completedProcess.status)) {\n\t\t\tthrow new LokaliseError(\n\t\t\t\t`Download process did not finish within ${maxWait}ms` +\n\t\t\t\t\t`${completedProcess.status ? ` (last status=${completedProcess.status})` : \" (status missing)\"}`,\n\t\t\t\t504,\n\t\t\t);\n\t\t}\n\n\t\treturn completedProcess;\n\t}\n\n\t/**\n\t * Resolves the bundle download URL using either async or sync strategy.\n\t *\n\t * Delegates to `fetchBundleURLAsync` or `fetchBundleURLSync`\n\t * based on the `asyncDownload` flag.\n\t *\n\t * @param downloadFileParams - Parameters for Lokalise API file download.\n\t * @param processParams - Effective process parameters controlling async behavior and polling.\n\t * @returns Direct bundle URL to download.\n\t */\n\tprivate fetchTranslationBundleURL(\n\t\tdownloadFileParams: DownloadFileParams,\n\t\tprocessParams: Required<ProcessDownloadFileParams>,\n\t): Promise<string> {\n\t\treturn processParams.asyncDownload\n\t\t\t? this.fetchBundleURLAsync(downloadFileParams, processParams)\n\t\t\t: this.fetchBundleURLSync(downloadFileParams);\n\t}\n\n\t/**\n\t * Extracts and verifies the download URL from a finished async process.\n\t *\n\t * Ensures `details.download_url` is present and is a string.\n\t *\n\t * @param completedProcess - Process object with status `finished`.\n\t * @returns Valid download URL string.\n\t * @throws {LokaliseError} If the URL is missing or invalid.\n\t */\n\tprivate handleFinishedAsyncProcess(completedProcess: QueuedProcess): string {\n\t\tconst details = completedProcess.details as\n\t\t\t| (DownloadedFileProcessDetails & { download_url?: string })\n\t\t\t| undefined;\n\n\t\tconst url = details?.download_url;\n\t\tif (!url || typeof url !== \"string\") {\n\t\t\tthis.logMsg(\n\t\t\t\t\"warn\",\n\t\t\t\t\"Process finished but details.download_url is missing or invalid\",\n\t\t\t\tdetails,\n\t\t\t);\n\t\t\tthrow new LokaliseError(\n\t\t\t\t\"Lokalise returned finished process without a valid download_url\",\n\t\t\t\t502,\n\t\t\t);\n\t\t}\n\n\t\treturn url;\n\t}\n\n\t/**\n\t * Handles a failed or cancelled async process by throwing an error with context.\n\t *\n\t * Includes the process status and optional message from Lokalise.\n\t *\n\t * @param completedProcess - Process object with status `failed` or `cancelled`.\n\t * @throws {LokaliseError} Always throws, as the process did not succeed.\n\t */\n\tprivate handleFailedAsyncProcess(completedProcess: QueuedProcess): never {\n\t\tconst msg = completedProcess.message?.trim();\n\t\tthrow new LokaliseError(\n\t\t\t`Process ${completedProcess.process_id} ended with status=${completedProcess.status}` +\n\t\t\t\t(msg ? `: ${msg}` : \"\"),\n\t\t\t502,\n\t\t);\n\t}\n\n\t/**\n\t * Handles an unexpected async process outcome when it did not finish in time.\n\t *\n\t * Logs a warning and throws an error indicating that finalization took too long.\n\t *\n\t * @param completedProcess - Process object with unexpected status.\n\t * @param maxWait - Effective maximum wait time used during polling.\n\t * @throws {LokaliseError} Always throws to signal an unexpected async outcome.\n\t */\n\tprivate handleUnexpectedAsyncProcess(\n\t\tcompletedProcess: QueuedProcess,\n\t\tmaxWait: number,\n\t): never {\n\t\tthis.logMsg(\"warn\", `Process ended with status=${completedProcess.status}`);\n\t\tthrow new LokaliseError(\n\t\t\t`Download process took too long to finalize; effective=${maxWait}ms`,\n\t\t\t500,\n\t\t);\n\t}\n\n\t/**\n\t * Runs the async download flow: queues the download, polls its status,\n\t * and returns the final bundle URL once the process completes.\n\t *\n\t * Handles finished, failed/cancelled, and unexpected statuses separately.\n\t *\n\t * @param downloadFileParams - Parameters for Lokalise API async file download.\n\t * @param processParams - Effective process parameters controlling polling behavior.\n\t * @returns Direct URL to the generated ZIP bundle.\n\t * @throws {LokaliseError} If the process fails, is cancelled, or does not finalize properly.\n\t */\n\tprotected async fetchBundleURLAsync(\n\t\tdownloadFileParams: DownloadFileParams,\n\t\tprocessParams: Required<ProcessDownloadFileParams>,\n\t): Promise<string> {\n\t\tthis.logMsg(\"debug\", \"Async download mode enabled.\");\n\n\t\tconst downloadProcess =\n\t\t\tawait this.getTranslationsBundleAsync(downloadFileParams);\n\n\t\tconst { pollInitialWaitTime, pollMaximumWaitTime } = processParams;\n\n\t\tconst completedProcess = await this.pollAsyncDownload(\n\t\t\tdownloadProcess,\n\t\t\tpollInitialWaitTime,\n\t\t\tpollMaximumWaitTime,\n\t\t);\n\n\t\tthis.logMsg(\n\t\t\t\"debug\",\n\t\t\t`Download process status is ${completedProcess.status}`,\n\t\t);\n\n\t\tif (completedProcess.status === \"finished\") {\n\t\t\treturn this.handleFinishedAsyncProcess(completedProcess);\n\t\t}\n\n\t\tif (\n\t\t\tcompletedProcess.status === \"failed\" ||\n\t\t\tcompletedProcess.status === \"cancelled\"\n\t\t) {\n\t\t\tthis.handleFailedAsyncProcess(completedProcess);\n\t\t}\n\n\t\tthis.handleUnexpectedAsyncProcess(completedProcess, pollMaximumWaitTime);\n\t}\n}\n","import fs from \"node:fs\";\nimport path from \"node:path\";\nimport type { QueuedProcess, UploadFileParams } from \"@lokalise/node-api\";\nimport type {\n\tCollectFileParams,\n\tFileUploadError,\n\tPartialUploadFileParams,\n\tProcessedFile,\n\tProcessUploadFileParams,\n\tQueuedUploadProcessesWithErrors,\n\tUploadTranslationParams,\n} from \"../interfaces/index.js\";\nimport { LokaliseFileExchange } from \"./LokaliseFileExchange.js\";\n\n/**\n * Handles uploading translation files to Lokalise.\n */\nexport class LokaliseUpload extends LokaliseFileExchange {\n\tprivate static readonly defaultPollingParams = {\n\t\tpollStatuses: false,\n\t\tpollInitialWaitTime: 1000,\n\t\tpollMaximumWaitTime: 120_000,\n\t};\n\n\t/**\n\t * Collects files, uploads them to Lokalise, and optionally polls for process completion, returning both processes and errors.\n\t *\n\t * @param {UploadTranslationParams} uploadTranslationParams - Parameters for collecting and uploading files.\n\t * @returns {Promise<{ processes: QueuedProcess[]; errors: FileUploadError[] }>} A promise resolving with successful processes and upload errors.\n\t */\n\tasync uploadTranslations({\n\t\tuploadFileParams,\n\t\tcollectFileParams,\n\t\tprocessUploadFileParams,\n\t}: UploadTranslationParams = {}): Promise<QueuedUploadProcessesWithErrors> {\n\t\tthis.logMsg(\"debug\", \"Uploading translations to Lokalise...\");\n\n\t\tconst { pollStatuses, pollInitialWaitTime, pollMaximumWaitTime } = {\n\t\t\t...LokaliseUpload.defaultPollingParams,\n\t\t\t...processUploadFileParams,\n\t\t};\n\n\t\tthis.logMsg(\"debug\", \"Collecting files to upload...\");\n\t\tconst collectedFiles = await this.collectFiles(collectFileParams);\n\t\tthis.logMsg(\"debug\", \"Collected files:\", collectedFiles);\n\n\t\tthis.logMsg(\"debug\", \"Performing parallel upload...\");\n\t\tconst { processes, errors } = await this.parallelUpload(\n\t\t\tcollectedFiles,\n\t\t\tuploadFileParams,\n\t\t\tprocessUploadFileParams,\n\t\t);\n\n\t\tlet completedProcesses = processes;\n\t\tthis.logMsg(\n\t\t\t\"debug\",\n\t\t\t\"File uploading queued! IDs:\",\n\t\t\tcompletedProcesses.map((p) => p.process_id),\n\t\t);\n\n\t\tif (pollStatuses) {\n\t\t\tthis.logMsg(\"debug\", \"Polling queued processes...\");\n\n\t\t\tcompletedProcesses = await this.pollProcesses(\n\t\t\t\tprocesses,\n\t\t\t\tpollInitialWaitTime,\n\t\t\t\tpollMaximumWaitTime,\n\t\t\t);\n\n\t\t\tthis.logMsg(\"debug\", \"Polling completed!\");\n\t\t}\n\n\t\tthis.logMsg(\"debug\", \"Upload successful!\");\n\n\t\treturn { processes: completedProcesses, errors };\n\t}\n\n\t/**\n\t * Collects files from the filesystem based on the given parameters.\n\t *\n\t * @param {CollectFileParams} collectFileParams - Parameters for file collection, including directories, extensions, and patterns.\n\t * @returns {Promise<string[]>} A promise resolving with the list of collected file paths.\n\t */\n\tprotected async collectFiles({\n\t\tinputDirs = [\"./locales\"],\n\t\textensions = [\".*\"],\n\t\texcludePatterns = [],\n\t\trecursive = true,\n\t\tfileNamePattern = \".*\",\n\t}: CollectFileParams = {}): Promise<string[]> {\n\t\tconst queue = this.makeQueue(inputDirs);\n\t\tconst normalizedExtensions = this.normalizeExtensions(extensions);\n\t\tconst fileNameRegex = this.makeFilenameRegexp(fileNamePattern);\n\t\tconst excludeRegexes = this.makeExcludeRegExes(excludePatterns);\n\n\t\tconst files = await this.processCollectionQueue(\n\t\t\tqueue,\n\t\t\tnormalizedExtensions,\n\t\t\tfileNameRegex,\n\t\t\texcludeRegexes,\n\t\t\trecursive,\n\t\t);\n\n\t\treturn files.sort();\n\t}\n\n\t/**\n\t * Uploads a single file to Lokalise.\n\t *\n\t * @param {UploadFileParams} uploadParams - Parameters for uploading the file.\n\t * @returns {Promise<QueuedProcess>} A promise resolving with the upload process details.\n\t */\n\tprotected async uploadSingleFile(\n\t\tuploadParams: UploadFileParams,\n\t): Promise<QueuedProcess> {\n\t\treturn this.withExponentialBackoff(() =>\n\t\t\tthis.apiClient.files().upload(this.projectId, uploadParams),\n\t\t);\n\t}\n\n\t/**\n\t * Processes a file to prepare it for upload, converting it to base64 and extracting its language code.\n\t *\n\t * @param file - The absolute path to the file.\n\t * @param projectRoot - The root directory of the project.\n\t * @param processParams - Optional processing settings including inferers.\n\t * @returns A promise resolving with the processed file details, including base64 content, relative path, and language code.\n\t */\n\tprotected async processFile(\n\t\tfile: string,\n\t\tprojectRoot: string,\n\t\tprocessParams?: ProcessUploadFileParams,\n\t): P