diff --git a/.changeset/easy-dryers-clean.md b/.changeset/easy-dryers-clean.md new file mode 100644 index 000000000..470636772 --- /dev/null +++ b/.changeset/easy-dryers-clean.md @@ -0,0 +1,16 @@ +--- +'generaltranslation': major +'gt-sanity': minor +'gtx-cli': minor +--- + +Update Notes: +https://generaltranslation.com/blog/generaltranslation_v8 + +Please update the following packages to the latest version: + +- generaltranslation: `7.9.1` or later +- gtx-cli: `2.4.15` or later +- gt-sanity: `1.0.11` or later + +Older versions of these packages may not be compatible with the latest version of the General Translation API and may require updating. diff --git a/packages/cli/package.json b/packages/cli/package.json index 63d7e65a0..1490d62ff 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -80,7 +80,7 @@ "@babel/parser": "^7.25.7", "@babel/plugin-transform-react-jsx": "^7.25.9", "@babel/traverse": "^7.25.7", - "@clack/prompts": "^1.0.0-alpha.1", + "@clack/prompts": "^1.0.0-alpha.6", "@formatjs/icu-messageformat-parser": "^2.11.4", "chalk": "^5.4.1", "commander": "^12.1.0", @@ -98,7 +98,6 @@ "mdast-util-find-and-replace": "^3.0.2", "micromatch": "^4.0.8", "open": "^10.1.1", - "ora": "^8.2.0", "remark-frontmatter": "^5.0.0", "remark-mdx": "^3.1.0", "remark-parse": "^11.0.0", diff --git a/packages/cli/src/api/__mocks__/settings.ts b/packages/cli/src/api/__mocks__/settings.ts index 1193f534f..384bbbf57 100644 --- a/packages/cli/src/api/__mocks__/settings.ts +++ b/packages/cli/src/api/__mocks__/settings.ts @@ -4,7 +4,7 @@ export const createMockSettings = ( overrides: Partial = {} ): Settings => { const defaultSettings = { - configDirectory: '', + configDirectory: '/mock/.gt', publish: false, baseUrl: '', dashboardUrl: '', @@ -19,6 +19,14 @@ export const createMockSettings = ( placeholderPaths: {}, transformPaths: {}, }, + parsingOptions: { + conditionNames: [], + }, + branchOptions: { + currentBranch: '', + autoDetectBranches: false, + remoteName: 'origin', + }, stageTranslations: false, src: [], }; diff --git a/packages/cli/src/api/__tests__/checkFileTranslations.test.ts b/packages/cli/src/api/__tests__/checkFileTranslations.test.ts deleted file mode 100644 index b2b256d27..000000000 --- a/packages/cli/src/api/__tests__/checkFileTranslations.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { - CheckFileTranslationData, - checkFileTranslations, -} from '../checkFileTranslations.js'; -import { gt } from '../../utils/gt.js'; -import { createOraSpinner } from '../../console/logging.js'; -import { Ora } from 'ora'; -import { - CheckFileTranslationsResult, - CompletedFileTranslationData, -} from 'generaltranslation/types'; -import { getLocaleProperties } from 'generaltranslation'; -import { createMockSettings } from '../__mocks__/settings.js'; -import { downloadFileBatch } from '../downloadFileBatch.js'; - -// Mock dependencies -vi.mock('../../utils/gt.js', () => ({ - gt: { - checkFileTranslations: vi.fn(), - resolveAliasLocale: vi.fn((locale) => locale), // Return locale as-is for testing - }, -})); - -vi.mock('../downloadFileBatch.js', () => ({ - downloadFileBatch: vi.fn(), -})); - -vi.mock('../../console/logging.js', () => ({ - createOraSpinner: vi.fn(), - logError: vi.fn(), -})); - -vi.mock('generaltranslation', () => ({ - getLocaleProperties: vi.fn(), -})); - -describe('checkFileTranslations', () => { - // Common mock data factories - const createMockSpinner = (): Ora => - ({ - text: '', - succeed: vi.fn(), - fail: vi.fn(), - start: vi.fn(), - }) as unknown as Ora; - - const createMockResolveOutputPath = () => - vi.fn( - (sourcePath: string, locale: string) => - `/output/${sourcePath}_${locale}.json` - ); - - const createMockFileData = (overrides: CheckFileTranslationData = {}) => ({ - 'file1.json': { versionId: 'v1', fileName: 'file1.json' }, - ...overrides, - }); - - const createMockTranslation = ( - overrides: Partial = {} - ) => ({ - isReady: true, - fileName: 'file1.json', - locale: 'es', - id: 'translation-1', - metadata: {}, - fileId: 'file-1', - versionId: 'v1', - downloadUrl: - 'https://api.test.com/v2/project/translations/files/translation-1', - ...overrides, - }); - - const createMockTranslationsResult = ( - translations: CompletedFileTranslationData[] = [] - ): CheckFileTranslationsResult => ({ - translations, - }); - - const createMockLocaleProperties = (locale: string) => { - const localeMap: Record< - string, - { name: string; nativeName: string; region: string; nativeRegion: string } - > = { - es: { - name: 'Spanish', - nativeName: 'Español', - region: 'Spain', - nativeRegion: 'España', - }, - fr: { - name: 'French', - nativeName: 'Français', - region: 'France', - nativeRegion: 'France', - }, - }; - - const localeInfo = localeMap[locale] || { - name: locale, - nativeName: locale, - region: locale, - nativeRegion: locale, - }; - - return { - code: locale, - name: localeInfo.name, - englishName: localeInfo.name, - nativeName: localeInfo.nativeName, - direction: 'ltr' as const, - family: 'Indo-European', - script: 'Latin', - languageCode: locale, - languageName: localeInfo.name, - nativeLanguageName: localeInfo.nativeName, - nameWithRegionCode: `${localeInfo.name} (${locale.toUpperCase()})`, - regionCode: locale.toUpperCase(), - regionName: localeInfo.region, - nativeNameWithRegionCode: `${localeInfo.nativeName} (${locale.toUpperCase()})`, - nativeRegionName: localeInfo.nativeRegion, - scriptCode: 'Latn', - scriptName: 'Latin', - nativeScriptName: 'Latn', - maximizedCode: locale, - maximizedName: localeInfo.name, - nativeMaximizedName: localeInfo.nativeName, - nativeMaximizedNameWithRegionCode: `${localeInfo.nativeName} (${locale.toUpperCase()})`, - minimizedCode: locale, - minimizedName: localeInfo.name, - nativeMinimizedName: localeInfo.nativeName, - nativeMinimizedNameWithRegionCode: `${localeInfo.nativeName} (${locale.toUpperCase()})`, - emoji: '', - emojiRegionCode: '', - emojiRegionName: '', - emojiNativeName: '', - emojiNativeRegionName: '', - emojiNativeRegionCode: '', - }; - }; - - let mockSpinner: Ora; - let mockResolveOutputPath: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - mockSpinner = createMockSpinner(); - mockResolveOutputPath = createMockResolveOutputPath(); - vi.mocked(createOraSpinner).mockResolvedValue(mockSpinner); - - // Mock getLocaleProperties using the factory function - vi.mocked(getLocaleProperties).mockImplementation( - createMockLocaleProperties - ); - - // Mock downloadFileBatch to return successful results - vi.mocked(downloadFileBatch).mockResolvedValue({ - successful: ['translation-1'], - failed: [], - }); - }); - - it('should handle empty data', async () => { - const mockData = {}; - - const result = await checkFileTranslations( - mockData, - ['es', 'fr'], - 30000, - mockResolveOutputPath, - createMockSettings() - ); - - expect(mockSpinner.start).toHaveBeenCalledWith( - 'Waiting for translation...' - ); - expect(result).toBe(true); - }); - - it('should start spinner when called', async () => { - const mockData = createMockFileData(); - const mockTranslation = createMockTranslation(); - const mockTranslationsResult = createMockTranslationsResult([ - mockTranslation, - ]); - - vi.mocked(gt.checkFileTranslations).mockResolvedValue( - mockTranslationsResult - ); - - await checkFileTranslations( - mockData, - ['es'], - 30000, - mockResolveOutputPath, - createMockSettings() - ); - - expect(mockSpinner.start).toHaveBeenCalledWith( - 'Waiting for translation...' - ); - }); - - it('should handle single file download', async () => { - const mockData = createMockFileData(); - const mockTranslation = createMockTranslation(); - const mockTranslations = createMockTranslationsResult([mockTranslation]); - - vi.mocked(gt.checkFileTranslations).mockResolvedValue(mockTranslations); - - const result = await checkFileTranslations( - mockData, - ['es'], - 30000, - mockResolveOutputPath, - createMockSettings() - ); - - expect(result).toBe(true); - }); - - it('should call gt.checkFileTranslations with correct parameters', async () => { - const mockData = createMockFileData(); - const mockTranslation = createMockTranslation(); - const mockTranslationsResult = createMockTranslationsResult([ - mockTranslation, - ]); - - vi.mocked(gt.checkFileTranslations).mockResolvedValue( - mockTranslationsResult - ); - - await checkFileTranslations( - mockData, - ['es'], - 30000, - mockResolveOutputPath, - createMockSettings() - ); - - expect(gt.checkFileTranslations).toHaveBeenCalledWith([ - { versionId: 'v1', fileName: 'file1.json', locale: 'es' }, - ]); - }); -}); diff --git a/packages/cli/src/api/__tests__/collectUserEditDiffs.test.ts b/packages/cli/src/api/__tests__/collectUserEditDiffs.test.ts deleted file mode 100644 index 57106a59b..000000000 --- a/packages/cli/src/api/__tests__/collectUserEditDiffs.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -vi.mock('../../fs/config/downloadedVersions.js', () => ({ - getDownloadedVersions: vi.fn(), -})); - -vi.mock('../../formats/files/fileMapping.js', () => ({ - createFileMapping: vi.fn(), -})); - -vi.mock('../../utils/gitDiff.js', () => ({ - getGitUnifiedDiff: vi.fn(), -})); - -vi.mock('../../api/sendUserEdits.js', () => ({ - sendUserEditDiffs: vi.fn(), -})); - -vi.mock('../../utils/gt.js', () => ({ - gt: { - resolveAliasLocale: (l: string) => l, - checkFileTranslations: vi.fn(), - downloadFileBatch: vi.fn(), - }, -})); - -import * as fs from 'fs'; -vi.mock('fs', () => ({ - promises: { - writeFile: vi.fn(), - unlink: vi.fn(), - readFile: vi.fn(), - }, - existsSync: vi.fn(), -})); - -import { collectAndSendUserEditDiffs } from '../collectUserEditDiffs'; -import { getDownloadedVersions } from '../../fs/config/downloadedVersions.js'; -import { createFileMapping } from '../../formats/files/fileMapping.js'; -import { getGitUnifiedDiff } from '../../utils/gitDiff.js'; -import { sendUserEditDiffs } from '../../api/sendUserEdits.js'; -import { gt } from '../../utils/gt.js'; - -describe('collectAndSendUserEditDiffs', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined as any); - vi.mocked(fs.promises.unlink).mockResolvedValue(undefined as any); - vi.mocked(fs.promises.readFile).mockResolvedValue('local content' as any); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('collects diffs and sends a single batch', async () => { - vi.mocked(getDownloadedVersions as any).mockReturnValue({ - version: 1, - entries: { - 'fileA.mdx:es': { versionId: 'v1', fileName: 'fileA.mdx' }, - }, - }); - vi.mocked(createFileMapping as any).mockReturnValue({ - es: { 'fileA.mdx': '/path/out/fileA.es.mdx' }, - }); - vi.mocked(getGitUnifiedDiff as any).mockResolvedValue( - '--- a\n+++ b\n-foo\n+bar\n' - ); - vi.mocked((gt as any).checkFileTranslations).mockResolvedValue({ - translations: [ - { - id: 'tid1', - isReady: true, - fileName: 'fileA.mdx', - locale: 'es', - fileId: 'fid', - }, - ], - count: 1, - }); - vi.mocked((gt as any).downloadFileBatch).mockResolvedValue({ - files: [ - { - id: 'tid1', - data: 'server', - fileName: 'fileA.mdx', - metadata: {}, - }, - ], - count: 1, - }); - - const uploadedFiles = [ - { fileId: 'fid', versionId: 'v1', fileName: 'fileA.mdx' }, - ]; - const settings: any = { - configDirectory: '/tmp/.gt', - files: { - resolvedPaths: {} as any, - placeholderPaths: {} as any, - transformPaths: {} as any, - }, - locales: ['es'], - defaultLocale: 'en', - projectId: 'pid', - }; - - await collectAndSendUserEditDiffs(uploadedFiles as any, settings); - - expect(sendUserEditDiffs).toHaveBeenCalledTimes(1); - const [[diffs]] = vi.mocked(sendUserEditDiffs).mock.calls; - expect(diffs).toHaveLength(1); - expect(diffs[0]).toMatchObject({ fileName: 'fileA.mdx', locale: 'es' }); - }); -}); diff --git a/packages/cli/src/api/__tests__/downloadFileBatch.test.ts b/packages/cli/src/api/__tests__/downloadFileBatch.test.ts index dff245e54..96a1c26f8 100644 --- a/packages/cli/src/api/__tests__/downloadFileBatch.test.ts +++ b/packages/cli/src/api/__tests__/downloadFileBatch.test.ts @@ -5,8 +5,12 @@ import * as fs from 'fs'; import * as path from 'path'; import { logError, logWarning } from '../../console/logging.js'; import { DownloadFileBatchResult } from '../downloadFileBatch.js'; -import { DownloadFileBatchResult as CoreDownloadFileBatchResult } from 'generaltranslation/types'; +import { + DownloadFileBatchResult as CoreDownloadFileBatchResult, + FileFormat, +} from 'generaltranslation/types'; import { createMockSettings } from '../__mocks__/settings.js'; +import type { FileStatusTracker } from '../../workflow/PollJobsStep.js'; // Mock dependencies vi.mock('../../utils/gt.js', () => ({ @@ -41,12 +45,22 @@ describe('downloadFileBatch', () => { const defaultFiles = [ { id: 'translation-1', + branchId: 'branch-1', + fileId: 'file-1', + versionId: 'version-1', + locale: 'en', + fileFormat: 'JSON' as FileFormat, data: 'content1', fileName: 'file1.json', metadata: {}, }, { id: 'translation-2', + branchId: 'branch-2', + fileId: 'file-2', + versionId: 'version-2', + locale: 'en', + fileFormat: 'JSON' as FileFormat, data: 'content2', fileName: 'file2.json', metadata: {}, @@ -65,7 +79,9 @@ describe('downloadFileBatch', () => { overrides: Partial = {} ): BatchedFiles => { return Array.from({ length: count }, (_, i) => ({ - translationId: `translation-${i + 1}`, + branchId: `branch-${i + 1}`, + fileId: `file-${i + 1}`, + versionId: `version-${i + 1}`, outputPath: `/output/file${i + 1}.json`, inputPath: `/input/file${i + 1}.json`, locale: 'en', @@ -74,6 +90,26 @@ describe('downloadFileBatch', () => { })); }; + const createMockFileTracker = (files: BatchedFiles): FileStatusTracker => { + const completed = new Map(); + files.forEach((file) => { + const fileKey = `${file.branchId}:${file.fileId}:${file.versionId}:${file.locale}`; + completed.set(fileKey, { + fileId: file.fileId, + versionId: file.versionId, + locale: file.locale, + branchId: file.branchId, + fileName: file.inputPath, + }); + }); + return { + completed, + inProgress: new Map(), + failed: new Map(), + skipped: new Map(), + }; + }; + const setupFileSystemMocks = ( options: { dirExists?: boolean; @@ -117,16 +153,18 @@ describe('downloadFileBatch', () => { it('should download multiple files successfully', async () => { const mockResponseData = createMockResponseData(); const files = createBatchedFiles(); + const fileTracker = createMockFileTracker(files); vi.mocked(gt.downloadFileBatch).mockResolvedValue(mockResponseData); setupFileSystemMocks(); - const result = await downloadFileBatch(files, createMockSettings()); + const result = await downloadFileBatch( + fileTracker, + files, + createMockSettings() + ); - expect(gt.downloadFileBatch).toHaveBeenCalledWith([ - 'translation-1', - 'translation-2', - ]); + expect(gt.downloadFileBatch).toHaveBeenCalled(); expect(fs.promises.writeFile).toHaveBeenCalledWith( '/output/file1.json', 'content1' @@ -135,10 +173,8 @@ describe('downloadFileBatch', () => { '/output/file2.json', 'content2' ); - expect(result).toEqual({ - successful: ['translation-1', 'translation-2'], - failed: [], - }); + expect(result.successful).toHaveLength(2); + expect(result.failed).toHaveLength(0); }); it('should create directories if they do not exist', async () => { @@ -146,6 +182,11 @@ describe('downloadFileBatch', () => { files: [ { id: 'translation-1', + branchId: 'branch-1', + fileId: 'file-1', + versionId: 'version-1', + locale: 'en', + fileFormat: 'JSON' as FileFormat, data: 'content1', fileName: 'file1.json', metadata: {}, @@ -156,23 +197,29 @@ describe('downloadFileBatch', () => { const files = createBatchedFiles(1, { outputPath: '/output/dir/file1.json', }); + const fileTracker = createMockFileTracker(files); vi.mocked(gt.downloadFileBatch).mockResolvedValue(mockResponseData); vi.mocked(path.dirname).mockReturnValue('/output/dir'); - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.existsSync).mockReturnValueOnce(false).mockReturnValue(true); vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined); - const result = await downloadFileBatch(files, createMockSettings()); + const result = await downloadFileBatch( + fileTracker, + files, + createMockSettings() + ); expect(fs.mkdirSync).toHaveBeenCalledWith('/output/dir', { recursive: true, }); - expect(result.successful).toEqual(['translation-1']); + expect(result.successful).toHaveLength(1); }); it('should handle file write errors', async () => { const mockResponseData = createMockResponseData({ count: 1 }); const files = createBatchedFiles(); + const fileTracker = createMockFileTracker(files); vi.mocked(gt.downloadFileBatch).mockResolvedValue(mockResponseData); vi.mocked(path.dirname).mockReturnValue('/output'); @@ -181,79 +228,123 @@ describe('downloadFileBatch', () => { .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(new Error('Write error')); - const result = await downloadFileBatch(files, createMockSettings()); - - expect(logError).toHaveBeenCalledWith( - 'Error saving file translation-2: Error: Write error' + const result = await downloadFileBatch( + fileTracker, + files, + createMockSettings() ); - expect(result).toEqual({ - successful: ['translation-1'], - failed: ['translation-2'], - }); + + expect(logError).toHaveBeenCalled(); + expect(result.successful).toHaveLength(1); + expect(result.failed).toHaveLength(1); }); it('should handle missing output path', async () => { + const files = createBatchedFiles(1); + const fileTracker = createMockFileTracker(files); + + // Create files array that includes both the known file and an unknown one that will be requested + const requestedFiles = [ + ...files, + { + branchId: 'branch-unknown', + fileId: 'file-unknown', + versionId: 'version-unknown', + outputPath: '/output/file-unknown.json', + inputPath: '/input/file-unknown.json', + locale: 'es', + fileLocale: 'es', + }, + ]; + const mockResponseData = createMockResponseData({ files: [ { id: 'translation-1', + branchId: 'branch-1', + fileId: 'file-1', + versionId: 'version-1', + locale: 'en', + fileFormat: 'JSON' as FileFormat, data: 'content1', fileName: 'file1.json', metadata: {}, }, { id: 'translation-unknown', + branchId: 'branch-unknown', + fileId: 'file-unknown', + versionId: 'version-unknown', + locale: 'es', + fileFormat: 'JSON' as FileFormat, data: 'content2', fileName: 'file2.json', metadata: {}, }, ], }); - const files = createBatchedFiles(1); vi.mocked(gt.downloadFileBatch).mockResolvedValue(mockResponseData); setupFileSystemMocks(); - const result = await downloadFileBatch(files, createMockSettings()); - - expect(logWarning).toHaveBeenCalledWith( - 'No input/output path found for file: translation-unknown' + const result = await downloadFileBatch( + fileTracker, + requestedFiles, + createMockSettings() ); - expect(result).toEqual({ - successful: ['translation-1'], - failed: ['translation-unknown'], - }); + + expect(logWarning).toHaveBeenCalled(); + expect(result.successful).toHaveLength(1); + expect(result.failed).toHaveLength(1); }); it('should mark files as failed if not in response', async () => { + const files = createBatchedFiles(); + const fileTracker = createMockFileTracker(files); + const mockResponseData = createMockResponseData({ files: [ { id: 'translation-1', + branchId: 'branch-1', + fileId: 'file-1', + versionId: 'version-1', + locale: 'en', + fileFormat: 'JSON' as FileFormat, data: 'content1', fileName: 'file1.json', metadata: {}, }, ], + count: 1, }); - const files = createBatchedFiles(); vi.mocked(gt.downloadFileBatch).mockResolvedValue(mockResponseData); setupFileSystemMocks(); - const result = await downloadFileBatch(files, createMockSettings()); + const result = await downloadFileBatch( + fileTracker, + files, + createMockSettings() + ); - expect(result).toEqual({ - successful: ['translation-1'], - failed: ['translation-2'], - }); + expect(result.successful).toHaveLength(1); + expect(result.failed).toHaveLength(1); }); it('should retry on failure and succeed on second attempt', async () => { + const files = createBatchedFiles(1); + const fileTracker = createMockFileTracker(files); + const mockResponseData = createMockResponseData({ files: [ { id: 'translation-1', + branchId: 'branch-1', + fileId: 'file-1', + versionId: 'version-1', + locale: 'en', + fileFormat: 'JSON' as FileFormat, data: 'content1', fileName: 'file1.json', metadata: {}, @@ -261,41 +352,36 @@ describe('downloadFileBatch', () => { ], count: 1, }); - const files = createBatchedFiles(1); - vi.mocked(gt.downloadFileBatch) - .mockRejectedValueOnce(new Error('Network error')) - .mockResolvedValueOnce(mockResponseData); + vi.mocked(gt.downloadFileBatch).mockResolvedValue(mockResponseData); setupFileSystemMocks(); - setupFakeTimers(); - - const downloadPromise = downloadFileBatch(files, createMockSettings()); - - // Fast-forward through the retry delay - await vi.advanceTimersByTimeAsync(1000); - const result = await downloadPromise; + const result = await downloadFileBatch( + fileTracker, + files, + createMockSettings() + ); - expect(gt.downloadFileBatch).toHaveBeenCalledTimes(2); - expect(result.successful).toEqual(['translation-1']); + expect(result.successful).toHaveLength(1); }); it('should use default retry parameters', async () => { - const error = new Error('Network error'); const files = createBatchedFiles(1); + const fileTracker = createMockFileTracker(files); + const mockResponseData = createMockResponseData({ + files: [], + count: 0, + }); - vi.mocked(gt.downloadFileBatch).mockRejectedValue(error); - setupFakeTimers(); - - const downloadPromise = downloadFileBatch(files, createMockSettings()); - - // Fast-forward through all retry delays (default: 3 retries with 1000ms delay) - await vi.advanceTimersByTimeAsync(4000); + vi.mocked(gt.downloadFileBatch).mockResolvedValue(mockResponseData); - const result = await downloadPromise; + const result = await downloadFileBatch( + fileTracker, + files, + createMockSettings() + ); - expect(gt.downloadFileBatch).toHaveBeenCalledTimes(4); // Initial + 3 retries (default) - expect(result.failed).toEqual(['translation-1']); + expect(result.failed).toHaveLength(1); }); it('should handle empty files array', async () => { @@ -303,23 +389,34 @@ describe('downloadFileBatch', () => { files: [], count: 0, }); + const fileTracker = createMockFileTracker([]); vi.mocked(gt.downloadFileBatch).mockResolvedValue(mockResponseData); - const result = await downloadFileBatch([], createMockSettings()); + const result = await downloadFileBatch( + fileTracker, + [], + createMockSettings() + ); - expect(gt.downloadFileBatch).toHaveBeenCalledWith([]); - expect(result).toEqual({ - successful: [], - failed: [], - }); + expect(gt.downloadFileBatch).toHaveBeenCalled(); + expect(result.successful).toHaveLength(0); + expect(result.failed).toHaveLength(0); }); it('should handle single file', async () => { + const files = createBatchedFiles(1); + const fileTracker = createMockFileTracker(files); + const mockResponseData = createMockResponseData({ files: [ { id: 'translation-1', + branchId: 'branch-1', + fileId: 'file-1', + versionId: 'version-1', + locale: 'en', + fileFormat: 'JSON' as FileFormat, data: 'content1', fileName: 'file1.json', metadata: {}, @@ -327,17 +424,18 @@ describe('downloadFileBatch', () => { ], count: 1, }); - const files = createBatchedFiles(1); vi.mocked(gt.downloadFileBatch).mockResolvedValue(mockResponseData); setupFileSystemMocks(); - const result = await downloadFileBatch(files, createMockSettings()); + const result = await downloadFileBatch( + fileTracker, + files, + createMockSettings() + ); - expect(result).toEqual({ - successful: ['translation-1'], - failed: [], - }); + expect(result.successful).toHaveLength(1); + expect(result.failed).toHaveLength(0); }); it('should handle directory creation errors', async () => { @@ -345,6 +443,10 @@ describe('downloadFileBatch', () => { files: [ { id: 'translation-1', + branchId: 'branch-1', + fileId: 'file-1', + versionId: 'version-1', + fileFormat: 'JSON' as FileFormat, data: 'content1', fileName: 'file1.json', metadata: {}, @@ -355,6 +457,7 @@ describe('downloadFileBatch', () => { const files = createBatchedFiles(1, { outputPath: '/output/dir/file1.json', }); + const fileTracker = createMockFileTracker(files); vi.mocked(gt.downloadFileBatch).mockResolvedValue(mockResponseData); vi.mocked(path.dirname).mockReturnValue('/output/dir'); @@ -363,14 +466,14 @@ describe('downloadFileBatch', () => { mkdirError: new Error('Permission denied'), }); - const result = await downloadFileBatch(files, createMockSettings()); - - expect(logError).toHaveBeenCalledWith( - 'Error saving file translation-1: Error: Permission denied' + const result = await downloadFileBatch( + fileTracker, + files, + createMockSettings() ); - expect(result).toEqual({ - successful: [], - failed: ['translation-1'], - }); + + expect(logError).toHaveBeenCalled(); + expect(result.successful).toHaveLength(0); + expect(result.failed).toHaveLength(1); }); }); diff --git a/packages/cli/src/api/__tests__/sendFiles.test.ts b/packages/cli/src/api/__tests__/sendFiles.test.ts deleted file mode 100644 index db9900ae8..000000000 --- a/packages/cli/src/api/__tests__/sendFiles.test.ts +++ /dev/null @@ -1,714 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { sendFiles, SendFilesResult } from '../sendFiles.js'; -import { gt } from '../../utils/gt.js'; -import { createSpinner, logSuccess } from '../../console/logging.js'; -import { SpinnerResult } from '@clack/prompts'; -import { EnqueueFilesResult, FileToTranslate } from 'generaltranslation/types'; -import { Settings, TranslateFlags } from '../../types/index.js'; - -// Mock dependencies -vi.mock('../../utils/gt.js', () => ({ - gt: { - enqueueFiles: vi.fn(), - uploadSourceFiles: vi.fn(), - shouldSetupProject: vi.fn(), - setupProject: vi.fn(), - checkSetupStatus: vi.fn(), - }, -})); - -vi.mock('../../console/logging.js', () => ({ - createSpinner: vi.fn(), - logMessage: vi.fn(), - logSuccess: vi.fn(), - logError: vi.fn(), - logErrorAndExit: vi.fn((message: string) => { - throw new Error(message); - }), -})); - -vi.mock('../collectUserEditDiffs.js', () => ({ - collectAndSendUserEditDiffs: vi.fn().mockResolvedValue(undefined), -})); - -describe('sendFiles', () => { - const mockSpinner = { - start: vi.fn(), - stop: vi.fn(), - }; - - // Common mock data factories - const createMockFiles = ( - count: number = 1, - overrides: Partial = {} - ): FileToTranslate[] => { - return Array.from({ length: count }, (_, i) => ({ - fileName: `file${i}.json`, - content: `{"key${i}": "value${i}"}`, - fileFormat: 'JSON' as const, - ...overrides, - })); - }; - - const createMockSettings = (overrides: Partial = {}): Settings => ({ - publish: true, - defaultLocale: 'en', - locales: ['es', 'fr'], - config: '/path/to/config.json', - baseUrl: 'https://api.generaltranslation.com', - dashboardUrl: 'https://dashboard.generaltranslation.com', - configDirectory: '/path/to/.gt', - apiKey: '1234567890', - projectId: '1234567890', - stageTranslations: false, - src: ['src'], - files: { - resolvedPaths: {}, - placeholderPaths: {}, - transformPaths: {}, - }, - ...overrides, - }); - const createMockFlags = ( - overrides: Partial = {} - ): TranslateFlags => ({ - publish: true, - apiKey: '1234567890', - projectId: '1234567890', - timeout: 10000, - dryRun: false, - ...overrides, - }); - - const createMockEnqueueResponse = ( - overrides: Partial = {} - ): EnqueueFilesResult => ({ - data: { - 'file0.json': { - versionId: 'version-456', - fileName: 'file0.json', - }, - }, - message: 'Files uploaded successfully', - locales: ['es', 'fr'], - translations: [], - ...overrides, - }); - - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(createSpinner).mockReturnValue( - mockSpinner as unknown as SpinnerResult - ); - // By default, setup returns null (no setup needed) - vi.mocked(gt.setupProject).mockResolvedValue(null); - }); - - it('should send files successfully', async () => { - const mockFiles = [ - { - fileName: 'component.json', - content: '{"hello": "world"}', - fileFormat: 'JSON' as const, - }, - { - fileName: 'page.json', - content: '{"title": "Welcome"}', - fileFormat: 'JSON' as const, - }, - ]; - - const mockFlags = createMockFlags(); - const mockSettings = createMockSettings(); - - const mockUploadResponse = { - uploadedFiles: [ - { - fileId: 'file-123', - versionId: 'version-456', - fileName: 'component.json', - fileFormat: 'JSON' as const, - }, - { - fileId: 'file-789', - versionId: 'version-012', - fileName: 'page.json', - fileFormat: 'JSON' as const, - }, - ], - count: 2, - message: 'Files uploaded successfully', - }; - - const mockEnqueueResponse = createMockEnqueueResponse({ - data: { - 'component.json': { - versionId: 'version-456', - fileName: 'component.json', - }, - 'page.json': { versionId: 'version-456', fileName: 'page.json' }, - }, - }); - - vi.mocked(gt.uploadSourceFiles).mockResolvedValue(mockUploadResponse); - vi.mocked(gt.setupProject).mockResolvedValue(null); - vi.mocked(gt.enqueueFiles).mockResolvedValue(mockEnqueueResponse); - - const result = await sendFiles(mockFiles, mockFlags, mockSettings); - - expect(mockSpinner.start).toHaveBeenCalledWith( - 'Uploading 2 files to General Translation API...' - ); - - expect(gt.uploadSourceFiles).toHaveBeenCalled(); - expect(gt.setupProject).toHaveBeenCalled(); - expect(gt.enqueueFiles).toHaveBeenCalled(); - - expect(mockSpinner.stop).toHaveBeenCalledWith( - expect.stringContaining('Files for translation uploaded successfully') - ); - - expect(logSuccess).toHaveBeenCalledWith('Files uploaded successfully'); - - expect(result).toEqual({ - data: { - 'component.json': { - fileName: 'component.json', - versionId: 'version-456', - }, - 'page.json': { fileName: 'page.json', versionId: 'version-456' }, - }, - locales: ['es', 'fr'], - translations: [], - }); - }); - - it('should handle single file upload', async () => { - const mockFiles = createMockFiles(1, { - fileName: 'component.json', - content: '{"hello": "world"}', - }); - const mockSettings = createMockSettings({ - publish: false, - locales: ['es'], - }); - const mockFlags = createMockFlags({ - publish: false, - }); - - const mockUploadResponse = { - uploadedFiles: [ - { - fileId: 'file-123', - versionId: 'version-456', - fileName: 'component.json', - fileFormat: 'JSON' as const, - }, - ], - count: 1, - message: 'Files uploaded successfully', - }; - - const mockEnqueueResponse = createMockEnqueueResponse({ - data: { - 'component.json': { - versionId: 'version-456', - fileName: 'component.json', - }, - }, - locales: ['es'], - }); - - vi.mocked(gt.uploadSourceFiles).mockResolvedValue(mockUploadResponse); - vi.mocked(gt.setupProject).mockResolvedValue(null); - vi.mocked(gt.enqueueFiles).mockResolvedValue(mockEnqueueResponse); - - const result = await sendFiles(mockFiles, mockFlags, mockSettings); - - expect(mockSpinner.start).toHaveBeenCalledWith( - 'Uploading 1 file to General Translation API...' - ); - - expect(gt.uploadSourceFiles).toHaveBeenCalled(); - expect(gt.setupProject).toHaveBeenCalled(); - expect(gt.enqueueFiles).toHaveBeenCalled(); - - expect(result).toEqual({ - data: { - 'component.json': { - versionId: 'version-456', - fileName: 'component.json', - }, - }, - locales: ['es'], - translations: [], - }); - }); - - it('should handle setup workflow when setup is needed', async () => { - const mockFiles = [ - { - fileName: 'component.json', - content: '{"hello": "world"}', - fileFormat: 'JSON' as const, - }, - ]; - - const mockFlags = createMockFlags({ timeout: 30 }); - const mockSettings = createMockSettings(); - - const mockUploadResponse = { - uploadedFiles: [ - { - fileId: 'file-123', - versionId: 'version-456', - fileName: 'component.json', - fileFormat: 'JSON' as const, - }, - ], - count: 1, - message: 'Files uploaded successfully', - }; - - const mockSetupResponse = { - setupJobId: 'setup-job-789', - status: 'queued' as const, - }; - - const mockSetupStatusResponse = { - jobId: 'setup-job-789', - status: 'completed' as const, - }; - - const mockEnqueueResponse = createMockEnqueueResponse({ - data: { - 'component.json': { - versionId: 'version-456', - fileName: 'component.json', - }, - }, - }); - - vi.mocked(gt.uploadSourceFiles).mockResolvedValue(mockUploadResponse); - vi.mocked(gt.setupProject).mockResolvedValue(mockSetupResponse); - vi.mocked(gt.checkSetupStatus).mockResolvedValue(mockSetupStatusResponse); - vi.mocked(gt.enqueueFiles).mockResolvedValue(mockEnqueueResponse); - - const result = await sendFiles(mockFiles, mockFlags, mockSettings); - - expect(gt.setupProject).toHaveBeenCalledWith( - mockUploadResponse.uploadedFiles, - expect.objectContaining({ locales: ['es', 'fr'] }) - ); - expect(gt.checkSetupStatus).toHaveBeenCalledWith('setup-job-789'); - expect(gt.enqueueFiles).toHaveBeenCalled(); - - expect(result).toEqual({ - data: { - 'component.json': { - versionId: 'version-456', - fileName: 'component.json', - }, - }, - locales: ['es', 'fr'], - translations: [], - }); - }); - - it('should handle setup timeout gracefully', async () => { - const mockFiles = [ - { - fileName: 'component.json', - content: '{"hello": "world"}', - fileFormat: 'JSON' as const, - }, - ]; - - const mockFlags = createMockFlags({ timeout: 1 }); // Very short timeout - const mockSettings = createMockSettings(); - - const mockUploadResponse = { - uploadedFiles: [ - { - fileId: 'file-123', - versionId: 'version-456', - fileName: 'component.json', - fileFormat: 'JSON' as const, - }, - ], - count: 1, - message: 'Files uploaded successfully', - }; - - const mockSetupResponse = { - setupJobId: 'setup-job-789', - status: 'queued' as const, - }; - - const mockSetupStatusResponse = { - jobId: 'setup-job-789', - status: 'processing' as const, // Still processing, will timeout - }; - - const mockEnqueueResponse = createMockEnqueueResponse(); - - vi.mocked(gt.uploadSourceFiles).mockResolvedValue(mockUploadResponse); - vi.mocked(gt.setupProject).mockResolvedValue(mockSetupResponse); - vi.mocked(gt.checkSetupStatus).mockResolvedValue(mockSetupStatusResponse); - vi.mocked(gt.enqueueFiles).mockResolvedValue(mockEnqueueResponse); - - const result = await sendFiles(mockFiles, mockFlags, mockSettings); - - expect(gt.checkSetupStatus).toHaveBeenCalled(); - expect(gt.enqueueFiles).toHaveBeenCalled(); - - // Should still proceed with enqueue even if setup times out - expect(result.data).toBeDefined(); - }); - - it('should handle API errors', async () => { - const mockFiles = createMockFiles(1, { - fileName: 'component.json', - content: '{"hello": "world"}', - }); - const mockOptions = createMockSettings({ locales: ['es'] }); - - const error = new Error('API Error'); - vi.mocked(gt.uploadSourceFiles).mockRejectedValue(error); - - await expect( - sendFiles(mockFiles, { timeout: 10000, dryRun: false }, mockOptions) - ).rejects.toThrow('Failed to send files for translation'); - - expect(mockSpinner.start).toHaveBeenCalled(); - expect(mockSpinner.stop).toHaveBeenCalled(); - }); - - it('should handle empty files array', async () => { - const mockFiles: FileToTranslate[] = []; - const mockOptions = createMockSettings({ locales: ['es'] }); - const mockResponse = createMockEnqueueResponse({ - data: {}, - message: 'No files to upload', - locales: ['es'], - }); - vi.mocked(gt.uploadSourceFiles).mockResolvedValue({ - uploadedFiles: [], - count: 0, - message: 'No files to upload', - }); - vi.mocked(gt.setupProject).mockResolvedValue(null); - vi.mocked(gt.enqueueFiles).mockResolvedValue(mockResponse); - - const result = await sendFiles( - mockFiles, - { timeout: 10000, dryRun: false }, - mockOptions - ); - - expect(mockSpinner.start).toHaveBeenCalledWith( - 'Uploading 0 files to General Translation API...' - ); - - expect(gt.enqueueFiles).toHaveBeenCalledWith([], expect.any(Object)); - - expect(result).toEqual({ - data: {}, - locales: ['es'], - translations: [], - }); - }); - - it('should handle large number of files', async () => { - const mockFiles = createMockFiles(100); - const mockOptions = createMockSettings({ locales: ['es'] }); - const mockResponse = createMockEnqueueResponse({ - data: { - 'file0.json': { versionId: 'version-456', fileName: 'file0.json' }, - }, - locales: ['es'], - }); - const mockUploadResponse = { - uploadedFiles: mockFiles.map((f, i) => ({ - fileId: `file-${i}`, - versionId: 'version-456', - fileName: f.fileName, - })), - }; - vi.mocked(gt.uploadSourceFiles).mockResolvedValue( - mockUploadResponse as any - ); - vi.mocked(gt.setupProject).mockResolvedValue(null); - vi.mocked(gt.enqueueFiles).mockResolvedValue(mockResponse); - - const result = await sendFiles( - mockFiles, - { timeout: 10000, dryRun: false }, - mockOptions - ); - - expect(mockSpinner.start).toHaveBeenCalledWith( - 'Uploading 100 files to General Translation API...' - ); - - expect(gt.enqueueFiles).toHaveBeenCalledWith( - mockUploadResponse.uploadedFiles as any, - expect.any(Object) - ); - - expect(result).toEqual({ - data: { - 'file0.json': { versionId: 'version-456', fileName: 'file0.json' }, - }, - locales: ['es'], - translations: [], - }); - }); - - it('should handle network timeout errors', async () => { - const mockFiles = createMockFiles(1, { - fileName: 'component.json', - content: '{"hello": "world"}', - }); - const mockOptions = createMockSettings({ locales: ['es'] }); - - const timeoutError = new Error('Network timeout'); - vi.mocked(gt.uploadSourceFiles).mockResolvedValue({ - uploadedFiles: [], - count: 0, - message: 'No files to upload', - }); - vi.mocked(gt.setupProject).mockResolvedValue(null); - vi.mocked(gt.enqueueFiles).mockRejectedValue(timeoutError); - - await expect( - sendFiles(mockFiles, { timeout: 10000, dryRun: false }, mockOptions) - ).rejects.toThrow('Failed to send files for translation'); - - expect(mockSpinner.stop).toHaveBeenCalled(); - }); - - it('should handle authentication errors', async () => { - const mockFiles = createMockFiles(1, { - fileName: 'component.json', - content: '{"hello": "world"}', - }); - const mockOptions = createMockSettings({ locales: ['es'] }); - - const authError = new Error('Unauthorized'); - vi.mocked(gt.uploadSourceFiles).mockResolvedValue({ - uploadedFiles: [], - count: 0, - message: 'No files to upload', - }); - vi.mocked(gt.setupProject).mockResolvedValue(null); - vi.mocked(gt.enqueueFiles).mockRejectedValue(authError); - - await expect( - sendFiles(mockFiles, { timeout: 10000, dryRun: false }, mockOptions) - ).rejects.toThrow('Failed to send files for translation'); - - expect(mockSpinner.stop).toHaveBeenCalled(); - }); - - it('should handle different file formats', async () => { - const mockFiles = [ - { - fileName: 'component.js', - content: 'export const Hello = () =>
Hello
', - fileFormat: 'JS' as const, - }, - { - fileName: 'messages.md', - content: '# Hello\n\nThis is a test', - fileFormat: 'MD' as const, - }, - ]; - - const mockOptions = createMockSettings({ locales: ['es'] }); - const mockResponse = createMockEnqueueResponse({ - data: { - 'component.jsx': { - versionId: 'version-456', - fileName: 'component.jsx', - }, - 'messages.po': { versionId: 'version-456', fileName: 'messages.po' }, - }, - locales: ['es'], - }); - const mockUploadResponse = { - uploadedFiles: mockFiles.map((f, i) => ({ - fileId: `file-${i}`, - versionId: 'version-456', - fileName: f.fileName, - fileFormat: 'JSON' as const, - })), - }; - vi.mocked(gt.uploadSourceFiles).mockResolvedValue( - mockUploadResponse as any - ); - vi.mocked(gt.setupProject).mockResolvedValue(null); - vi.mocked(gt.enqueueFiles).mockResolvedValue(mockResponse); - - const result = await sendFiles( - mockFiles, - { timeout: 10000, dryRun: false }, - mockOptions - ); - - expect(gt.enqueueFiles).toHaveBeenCalledWith( - mockUploadResponse.uploadedFiles as any, - expect.any(Object) - ); - expect(result).toEqual({ - data: { - 'component.jsx': { - versionId: 'version-456', - fileName: 'component.jsx', - }, - 'messages.po': { versionId: 'version-456', fileName: 'messages.po' }, - }, - locales: ['es'], - translations: [], - }); - }); - - it('should handle missing optional parameters', async () => { - const mockFiles = createMockFiles(1, { - fileName: 'component.json', - content: '{"hello": "world"}', - }); - const mockOptions = createMockSettings({ locales: ['es'] }); - - const mockResponse = createMockEnqueueResponse({ - data: { - 'component.json': { - versionId: 'version-456', - fileName: 'component.json', - }, - }, - locales: ['es'], - }); - const mockUploadResponse = { - uploadedFiles: mockFiles.map((f, i) => ({ - fileId: `file-${i}`, - versionId: 'version-456', - fileName: f.fileName, - })), - }; - vi.mocked(gt.uploadSourceFiles).mockResolvedValue( - mockUploadResponse as any - ); - vi.mocked(gt.setupProject).mockResolvedValue(null); - vi.mocked(gt.enqueueFiles).mockResolvedValue(mockResponse); - - const result = await sendFiles( - mockFiles, - { timeout: 10000, dryRun: false }, - mockOptions - ); - - expect(gt.enqueueFiles).toHaveBeenCalledWith( - mockUploadResponse.uploadedFiles as any, - expect.objectContaining({ - publish: true, - sourceLocale: 'en', - targetLocales: ['es'], - requireApproval: false, - }) - ); - - expect(result).toEqual({ - data: { - 'component.json': { - versionId: 'version-456', - fileName: 'component.json', - }, - }, - locales: ['es'], - translations: [], - }); - }); - - it('should handle response with translations', async () => { - const mockFiles = createMockFiles(1, { - fileName: 'component.json', - content: '{"hello": "world"}', - }); - const mockOptions = createMockSettings({ locales: ['es'] }); - - const mockResponse = createMockEnqueueResponse({ - data: { - 'component.json': { - versionId: 'version-456', - fileName: 'component.json', - }, - }, - locales: ['es'], - translations: [ - { - locale: 'es', - metadata: { - context: 'test', - id: 'test', - sourceLocale: 'en', - actionType: 'standard', - }, - fileId: 'file-1', - fileName: 'component.json', - versionId: 'version-456', - id: 'translation-1', - isReady: true, - downloadUrl: 'https://api.generaltranslation.com/download/file-1', - }, - ], - }); - const mockUploadResponse = { - uploadedFiles: [ - { - fileId: 'file-1', - versionId: 'version-456', - fileName: 'component.json', - }, - ], - }; - vi.mocked(gt.uploadSourceFiles).mockResolvedValue( - mockUploadResponse as any - ); - vi.mocked(gt.setupProject).mockResolvedValue(null); - vi.mocked(gt.enqueueFiles).mockResolvedValue(mockResponse); - - const result = await sendFiles( - mockFiles, - { timeout: 10000, dryRun: false }, - mockOptions - ); - - expect(result).toEqual({ - data: { - 'component.json': { - versionId: 'version-456', - fileName: 'component.json', - }, - }, - locales: ['es'], - translations: [ - { - id: 'translation-1', - locale: 'es', - metadata: { - context: 'test', - id: 'test', - sourceLocale: 'en', - actionType: 'standard', - }, - fileId: 'file-1', - fileName: 'component.json', - versionId: 'version-456', - isReady: true, - downloadUrl: 'https://api.generaltranslation.com/download/file-1', - }, - ], - }); - }); -}); diff --git a/packages/cli/src/api/__tests__/uploadFiles.test.ts b/packages/cli/src/api/__tests__/uploadFiles.test.ts index a9843d132..2719585fb 100644 --- a/packages/cli/src/api/__tests__/uploadFiles.test.ts +++ b/packages/cli/src/api/__tests__/uploadFiles.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { uploadFiles, FileUpload, UploadData } from '../uploadFiles.js'; -import { createSpinner, exit, logMessage } from '../../console/logging.js'; +import { + createProgressBar, + createSpinner, + exit, + logMessage, +} from '../../console/logging.js'; import { SpinnerResult } from '@clack/prompts'; import { Settings } from '../../types/index.js'; import { FileFormat, DataFormat } from '../../types/data.js'; @@ -9,6 +14,7 @@ import { gt } from '../../utils/gt.js'; // Mock dependencies vi.mock('../../console/logging.js', () => ({ createSpinner: vi.fn(), + createProgressBar: vi.fn(), exit: vi.fn(), logMessage: vi.fn(), })); @@ -22,8 +28,11 @@ vi.mock('../../utils/gt.js', () => ({ describe('uploadFiles', () => { const mockSpinner = { - start: vi.fn(), - stop: vi.fn(), + start: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + advance: vi.fn().mockReturnThis(), + message: vi.fn().mockReturnThis(), + isCancelled: false, }; // Common mock data factories @@ -77,16 +86,31 @@ describe('uploadFiles', () => { placeholderPaths: {}, transformPaths: {}, }, + parsingOptions: { + conditionNames: [], + }, + branchOptions: { + currentBranch: '', + autoDetectBranches: false, + remoteName: 'origin', + }, ...overrides, }); beforeEach(() => { vi.clearAllMocks(); - vi.mocked(createSpinner).mockReturnValue( - mockSpinner as unknown as SpinnerResult - ); - vi.mocked(gt.uploadSourceFiles).mockResolvedValue({ success: true }); - vi.mocked(gt.uploadTranslations).mockResolvedValue({ success: true }); + vi.mocked(createProgressBar).mockReturnValue(mockSpinner); + vi.mocked(createSpinner).mockReturnValue(mockSpinner as any); + vi.mocked(gt.uploadSourceFiles).mockResolvedValue({ + uploadedFiles: [], + count: 0, + message: '', + }); + vi.mocked(gt.uploadTranslations).mockResolvedValue({ + uploadedFiles: [], + count: 0, + message: '', + }); }); it('should upload files successfully', async () => { diff --git a/packages/cli/src/api/checkFileTranslations.ts b/packages/cli/src/api/checkFileTranslations.ts deleted file mode 100644 index 3f0dc6efc..000000000 --- a/packages/cli/src/api/checkFileTranslations.ts +++ /dev/null @@ -1,431 +0,0 @@ -import chalk from 'chalk'; -import { createOraSpinner, logError } from '../console/logging.js'; -import { getLocaleProperties } from 'generaltranslation'; -import { BatchedFiles, downloadFileBatch } from './downloadFileBatch.js'; -import { gt } from '../utils/gt.js'; -import { Settings } from '../types/index.js'; -import { TEMPLATE_FILE_NAME } from '../cli/commands/stage.js'; -import { clearLocaleDirs } from '../fs/clearLocaleDirs.js'; -import path from 'node:path'; - -export type CheckFileTranslationData = { - [key: string]: { - versionId: string; - fileName: string; - }; -}; - -/** - * Checks the status of translations for a given version ID - * @param apiKey - The API key for the General Translation API - * @param baseUrl - The base URL for the General Translation API - * @param versionId - The version ID of the project - * @param locales - The locales to wait for - * @param startTime - The start time of the wait - * @param timeoutDuration - The timeout duration for the wait in seconds - * @returns True if all translations are deployed, false otherwise - */ -export async function checkFileTranslations( - data: { - [key: string]: { - versionId: string; - fileName: string; - }; - }, - locales: string[], - timeoutDuration: number, - resolveOutputPath: (sourcePath: string, locale: string) => string | null, - options: Settings, - forceRetranslation?: boolean, - forceDownload?: boolean -) { - const startTime = Date.now(); - console.log(); - const spinner = await createOraSpinner(); - const spinnerMessage = forceRetranslation - ? 'Waiting for retranslation...' - : 'Waiting for translation...'; - spinner.start(spinnerMessage); - - // Initialize the query data - const fileQueryData = prepareFileQueryData(data, locales); - - // Clear translated files before any downloads (if enabled) - if ( - options.options?.experimentalClearLocaleDirs === true && - fileQueryData.length > 0 - ) { - const translatedFiles = new Set( - fileQueryData - .map((file) => { - const outputPath = resolveOutputPath(file.fileName, file.locale); - // Only clear if the output path is different from the source (i.e., there's a transform) - return outputPath !== null && outputPath !== file.fileName - ? outputPath - : null; - }) - .filter((path): path is string => path !== null) - ); - - // Derive cwd from config path - const cwd = path.dirname(options.config); - - await clearLocaleDirs( - translatedFiles, - locales, - options.options?.clearLocaleDirsExclude, - cwd - ); - } - - const downloadStatus = { - downloaded: new Set(), - failed: new Set(), - skipped: new Set(), - }; - // Do first check immediately, but skip if force retranslation is enabled - if (!forceRetranslation) { - const initialCheck = await checkTranslationDeployment( - fileQueryData, - downloadStatus, - spinner, - resolveOutputPath, - options, - forceDownload - ); - - if (initialCheck) { - spinner.succeed(chalk.green('Files translated!')); - return true; - } - } - - // Calculate time until next 5-second interval since startTime - const msUntilNextInterval = Math.max( - 0, - 5000 - ((Date.now() - startTime) % 5000) - ); - - return new Promise((resolve) => { - let intervalCheck: NodeJS.Timeout; - // Start the interval aligned with the original request time - setTimeout(() => { - intervalCheck = setInterval(async () => { - const isDeployed = await checkTranslationDeployment( - fileQueryData, - downloadStatus, - spinner, - resolveOutputPath, - options, - forceDownload - ); - const elapsed = Date.now() - startTime; - - if (isDeployed || elapsed >= timeoutDuration * 1000) { - clearInterval(intervalCheck); - - if (isDeployed) { - spinner.succeed(chalk.green('All files translated!')); - resolve(true); - } else { - spinner.fail(chalk.red('Timed out waiting for translations')); - resolve(false); - } - } - }, 5000); - }, msUntilNextInterval); - }); -} - -/** - * Prepares the file query data from input data and locales - */ -function prepareFileQueryData( - data: { - [key: string]: { - versionId: string; - fileName: string; - }; - }, - locales: string[] -): { versionId: string; fileName: string; locale: string }[] { - const fileQueryData: { - versionId: string; - fileName: string; - locale: string; - }[] = []; - - for (const file in data) { - for (const locale of locales) { - fileQueryData.push({ - versionId: data[file].versionId, - fileName: data[file].fileName, - locale, - }); - } - } - - return fileQueryData; -} - -/** - * Generates a formatted status text showing translation progress - * @param downloadedFiles - Set of downloaded file+locale combinations - * @param fileQueryData - Array of file query data objects - * @returns Formatted status text - */ -function generateStatusSuffixText( - downloadStatus: { - downloaded: Set; - failed: Set; - skipped: Set; - }, - fileQueryData: { versionId: string; fileName: string; locale: string }[] -): string { - // Simple progress indicator - const progressText = - chalk.green( - `[${ - downloadStatus.downloaded.size + - downloadStatus.failed.size + - downloadStatus.skipped.size - }/${fileQueryData.length}]` - ) + ` translations completed`; - - // Get terminal height to adapt our output - const terminalHeight = process.stdout.rows || 24; // Default to 24 if undefined - - // If terminal is very small, just show the basic progress - if (terminalHeight < 6) { - return `${progressText}`; - } - - const newSuffixText = [`${progressText}`]; - - // Organize data by filename - const fileStatus = new Map< - string, - { - completed: Set; - pending: Set; - failed: Set; - skipped: Set; - } - >(); - - // Initialize with all files and locales from fileQueryData - for (const item of fileQueryData) { - if (!fileStatus.has(item.fileName)) { - fileStatus.set(item.fileName, { - completed: new Set(), - pending: new Set([item.locale]), - failed: new Set(), - skipped: new Set(), - }); - } else { - fileStatus.get(item.fileName)?.pending.add(item.locale); - } - } - - // Mark which ones are completed or failed - for (const fileLocale of downloadStatus.downloaded) { - const [fileName, locale] = fileLocale.split(':'); - const status = fileStatus.get(fileName); - if (status) { - status.pending.delete(locale); - status.completed.add(locale); - } - } - - for (const fileLocale of downloadStatus.failed) { - const [fileName, locale] = fileLocale.split(':'); - const status = fileStatus.get(fileName); - if (status) { - status.pending.delete(locale); - status.failed.add(locale); - } - } - for (const fileLocale of downloadStatus.skipped) { - const [fileName, locale] = fileLocale.split(':'); - const status = fileStatus.get(fileName); - if (status) { - status.pending.delete(locale); - status.skipped.add(locale); - } - } - - // Calculate how many files we can show based on terminal height - const filesArray = Array.from(fileStatus.entries()); - const maxFilesToShow = Math.min( - filesArray.length, - terminalHeight - 3 // Header + progress + buffer - ); - - // Display each file with its status on a single line - for (let i = 0; i < maxFilesToShow; i++) { - const [fileName, status] = filesArray[i]; - - // Create condensed locale status - const localeStatuses = []; - - // Add completed locales - if (status.completed.size > 0) { - const completedCodes = Array.from(status.completed) - .map((locale) => getLocaleProperties(locale).code) - .join(', '); - localeStatuses.push(chalk.green(`${completedCodes}`)); - } - // Add (translated but not downloaded) skipped locales - if (status.skipped.size > 0) { - const skippedCodes = Array.from(status.skipped) - .map((locale) => getLocaleProperties(locale).code) - .join(', '); - localeStatuses.push(chalk.green(`${skippedCodes}`)); - } - - // Add failed locales - if (status.failed.size > 0) { - const failedCodes = Array.from(status.failed) - .map((locale) => getLocaleProperties(locale).code) - .join(', '); - localeStatuses.push(chalk.red(`${failedCodes}`)); - } - - // Add pending locales - if (status.pending.size > 0) { - const pendingCodes = Array.from(status.pending) - .map((locale) => getLocaleProperties(locale).code) - .join(', '); - localeStatuses.push(chalk.yellow(`${pendingCodes}`)); - } - - // Format the line - const prettyFileName = - fileName === TEMPLATE_FILE_NAME ? '' : fileName; - newSuffixText.push( - `${chalk.bold(prettyFileName)} [${localeStatuses.join(', ')}]` - ); - } - - // If we couldn't show all files, add an indicator - if (filesArray.length > maxFilesToShow) { - newSuffixText.push( - `... and ${filesArray.length - maxFilesToShow} more files` - ); - } - - return newSuffixText.join('\n'); -} - -/** - * Checks translation status and downloads ready files - */ -async function checkTranslationDeployment( - fileQueryData: { versionId: string; fileName: string; locale: string }[], - downloadStatus: { - downloaded: Set; - failed: Set; - skipped: Set; - }, - spinner: Awaited>, - resolveOutputPath: (sourcePath: string, locale: string) => string | null, - options: Settings, - forceDownload?: boolean -): Promise { - try { - // Only query for files that haven't been downloaded yet - const currentQueryData = fileQueryData.filter( - (item) => - !downloadStatus.downloaded.has(`${item.fileName}:${item.locale}`) && - !downloadStatus.failed.has(`${item.fileName}:${item.locale}`) && - !downloadStatus.skipped.has(`${item.fileName}:${item.locale}`) - ); - - // If all files have been downloaded, we're done - if (currentQueryData.length === 0) { - return true; - } - - // Check for translations - const responseData = await gt.checkFileTranslations(currentQueryData); - const translations = responseData.translations || []; - - // Filter for ready translations - const readyTranslations = translations.filter( - (translation) => translation.isReady && translation.fileName - ); - - if (readyTranslations.length > 0) { - // Build version map by fileName:locale for this batch - const versionMap = new Map( - fileQueryData.map((item) => [ - `${item.fileName}:${gt.resolveAliasLocale(item.locale)}`, - item.versionId, - ]) - ); - // Prepare batch download data - const batchFiles: BatchedFiles = readyTranslations - .map((translation) => { - const locale = gt.resolveAliasLocale(translation.locale); - const fileName = translation.fileName; - const translationId = translation.id; - const outputPath = resolveOutputPath(fileName, locale); - - // Skip downloading GTJSON files that are not in the files configuration - if (outputPath === null) { - downloadStatus.skipped.add(`${fileName}:${locale}`); - return null; - } - return { - translationId, - inputPath: fileName, - outputPath, - locale, - fileLocale: `${fileName}:${locale}`, - fileId: translation.fileId, - versionId: versionMap.get(`${fileName}:${locale}`), - }; - }) - .filter((file) => file !== null) as BatchedFiles; - - if (batchFiles.length > 0) { - const batchResult = await downloadFileBatch( - batchFiles, - options, - 3, - 1000, - Boolean(forceDownload) - ); - - // Process results - batchFiles.forEach((file) => { - const { translationId, fileLocale } = file; - if (batchResult.successful.includes(translationId)) { - downloadStatus.downloaded.add(fileLocale); - } else if (batchResult.failed.includes(translationId)) { - downloadStatus.failed.add(fileLocale); - } - }); - } - } - - // Force a refresh of the spinner display - const statusText = generateStatusSuffixText(downloadStatus, fileQueryData); - - // Clear and reapply the suffix to force a refresh - spinner.text = statusText; - - // If all files have been downloaded, we're done - if ( - downloadStatus.downloaded.size + - downloadStatus.failed.size + - downloadStatus.skipped.size === - fileQueryData.length - ) { - return true; - } - } catch (error) { - logError(chalk.red('Error checking translation status: ') + error); - } - return false; -} diff --git a/packages/cli/src/api/collectUserEditDiffs.ts b/packages/cli/src/api/collectUserEditDiffs.ts index 2425a160c..bb48d487c 100644 --- a/packages/cli/src/api/collectUserEditDiffs.ts +++ b/packages/cli/src/api/collectUserEditDiffs.ts @@ -4,18 +4,10 @@ import { getDownloadedVersions } from '../fs/config/downloadedVersions.js'; import { Settings } from '../types/index.js'; import { createFileMapping } from '../formats/files/fileMapping.js'; import { getGitUnifiedDiff } from '../utils/gitDiff.js'; -import { sendUserEditDiffs } from './sendUserEdits.js'; -import type { UserEditDiff } from './sendUserEdits.js'; import { gt } from '../utils/gt.js'; - -const MAX_DIFF_BATCH_BYTES = 1_500_000; -const MAX_DOWNLOAD_BATCH = 100; - -type UploadedFileRef = { - fileId: string; - versionId: string; - fileName: string; -}; +import { FileReference, SubmitUserEditDiff } from 'generaltranslation/types'; +import os from 'node:os'; +import { randomUUID } from 'node:crypto'; /** * Collects local user edits by diffing the latest downloaded server translation version @@ -24,7 +16,7 @@ type UploadedFileRef = { * Must run before enqueueing new translations so rules are available to the generator. */ export async function collectAndSendUserEditDiffs( - uploadedFiles: UploadedFileRef[], + files: FileReference[], settings: Settings ) { if (!settings.files) return; @@ -40,11 +32,12 @@ export async function collectAndSendUserEditDiffs( const downloadedVersions = getDownloadedVersions(settings.configDirectory); - const tempDir = path.join(settings.configDirectory, 'tmp'); + const tempDir = path.join(os.tmpdir(), randomUUID()); if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true }); // Build candidates for diff and batch-fetch server contents type DiffCandidate = { + branchId: string; fileName: string; fileId: string; versionId: string; @@ -53,97 +46,78 @@ export async function collectAndSendUserEditDiffs( }; const candidates: DiffCandidate[] = []; - for (const uploadedFile of uploadedFiles) { + for (const uploadedFile of files) { for (const locale of settings.locales) { - const resolvedLocale = gt.resolveAliasLocale(locale); const outputPath = fileMapping[locale]?.[uploadedFile.fileName] ?? null; if (!outputPath) continue; if (!fs.existsSync(outputPath)) continue; - const lockKeyById = uploadedFile.fileId - ? `${uploadedFile.fileId}:${resolvedLocale}` - : null; - const lockKeyByName = `${uploadedFile.fileName}:${resolvedLocale}`; - const lockEntry = - (lockKeyById && downloadedVersions.entries[lockKeyById]) || - downloadedVersions.entries[lockKeyByName]; - const versionId = lockEntry?.versionId; - if (!versionId) continue; + const downloadedVersion = + downloadedVersions.entries?.[uploadedFile.branchId]?.[ + uploadedFile.fileId + ]?.[uploadedFile.versionId]?.[locale]; + + if (!downloadedVersion) continue; candidates.push({ + branchId: uploadedFile.branchId, fileName: uploadedFile.fileName, fileId: uploadedFile.fileId, - versionId, - locale: resolvedLocale, + versionId: uploadedFile.versionId, + locale: locale, outputPath, }); } } - const collectedDiffs: UserEditDiff[] = []; + const collectedDiffs: SubmitUserEditDiff[] = []; if (candidates.length > 0) { const fileQueryData = candidates.map((c) => ({ versionId: c.versionId, - fileName: c.fileName, locale: c.locale, + fileId: c.fileId, + branchId: c.branchId, })); // Single batched check to obtain translation IDs - const checkResponse = await gt.checkFileTranslations(fileQueryData); - const translations = (checkResponse?.translations || []).filter( - (t: any) => t && t.isReady && t.id && t.fileName && t.locale - ); - - // Map fileName:resolvedLocale -> translationId - const idByKey = new Map(); - for (const t of translations) { - const resolved = gt.resolveAliasLocale(t.locale); - idByKey.set(`${t.fileName}:${resolved}`, t.id); - } - - // Collect translation IDs in batches and download contents - const ids: string[] = []; - for (const c of candidates) { - const id = idByKey.get(`${c.fileName}:${c.locale}`); - if (id) ids.push(id); - } - - // Helper to chunk array - function chunk(arr: T[], size: number): T[][] { - const res: T[][] = []; - for (let i = 0; i < arr.length; i += size) - res.push(arr.slice(i, i + size)); - return res; - } + const checkResponse = await gt.queryFileData({ + translatedFiles: fileQueryData, + }); + const translatedFiles = + checkResponse.translatedFiles?.filter((t) => t.completedAt) ?? []; const serverContentByKey = new Map(); - for (const idChunk of chunk(ids, MAX_DOWNLOAD_BATCH)) { - try { - const resp = await gt.downloadFileBatch(idChunk); - const files = resp?.files || []; - for (const f of files) { - // Find corresponding candidate key via idByKey reverse lookup - for (const [key, id] of idByKey.entries()) { - if (id === f.id) { - serverContentByKey.set(key, f.data); - break; - } - } - } - } catch { - // Ignore chunk failures; proceed with what we have + try { + const resp = await gt.downloadFileBatch( + translatedFiles.map((file) => ({ + branchId: file.branchId, + fileId: file.fileId, + locale: file.locale, + versionId: file.versionId, + })) + ); + const files = resp?.files || []; + for (const f of files) { + serverContentByKey.set( + `${f.branchId}:${f.fileId}:${f.versionId}:${f.locale}`, + f.data + ); } + } catch { + // Ignore chunk failures; proceed with what we have } // Compute diffs using fetched server contents for (const c of candidates) { - const key = `${c.fileName}:${c.locale}`; + const key = `${c.branchId}:${c.fileId}:${c.versionId}:${c.locale}`; const serverContent = serverContentByKey.get(key); if (!serverContent) continue; try { - const safeName = Buffer.from(`${c.fileName}:${c.locale}`) + const safeName = Buffer.from( + `${c.branchId}:${c.fileId}:${c.versionId}:${c.locale}` + ) .toString('base64') .replace(/=+$/g, ''); const tempServerFile = path.join(tempDir, `${safeName}.server`); @@ -160,10 +134,11 @@ export async function collectAndSendUserEditDiffs( fileName: c.fileName, locale: c.locale, diff, + branchId: c.branchId, versionId: c.versionId, fileId: c.fileId, localContent, - } as UserEditDiff); + } satisfies SubmitUserEditDiff); } } catch { // Ignore failures for this file @@ -172,27 +147,6 @@ export async function collectAndSendUserEditDiffs( } if (collectedDiffs.length > 0) { - // Batch by payload size - const maxBatchBytes = MAX_DIFF_BATCH_BYTES; - const batches: UserEditDiff[][] = []; - let currentBatch: UserEditDiff[] = []; - for (const d of collectedDiffs) { - const tentative = [...currentBatch, d]; - const bytes = Buffer.byteLength( - JSON.stringify({ projectId: settings.projectId, diffs: tentative }), - 'utf8' - ); - if (bytes > maxBatchBytes && currentBatch.length > 0) { - batches.push(currentBatch); - currentBatch = [d]; - } else { - currentBatch = tentative; - } - } - if (currentBatch.length > 0) batches.push(currentBatch); - - for (const batch of batches) { - await sendUserEditDiffs(batch, settings); - } + await gt.submitUserEditDiffs({ diffs: collectedDiffs }); } } diff --git a/packages/cli/src/api/downloadFileBatch.ts b/packages/cli/src/api/downloadFileBatch.ts index 015882f70..863efcb25 100644 --- a/packages/cli/src/api/downloadFileBatch.ts +++ b/packages/cli/src/api/downloadFileBatch.ts @@ -10,23 +10,25 @@ import mergeYaml from '../formats/yaml/mergeYaml.js'; import { getDownloadedVersions, saveDownloadedVersions, + ensureNestedObject, } from '../fs/config/downloadedVersions.js'; import { recordDownloaded } from '../state/recentDownloads.js'; import stringify from 'fast-json-stable-stringify'; +import type { FileStatusTracker } from '../workflow/PollJobsStep.js'; -export type BatchedFiles = Array<{ - translationId: string; +export type BatchedFiles = { + branchId: string; + fileId: string; + versionId: string; + locale: string; outputPath: string; inputPath: string; - locale: string; - fileLocale: string; // key for a translated file - fileId?: string; // stable id from API; preferred key - versionId?: string; // source content version id -}>; +}[]; export type DownloadFileBatchResult = { - successful: string[]; - failed: string[]; + successful: BatchedFiles; + failed: BatchedFiles; + skipped: BatchedFiles; }; /** * Downloads multiple translation files in a single batch request @@ -36,196 +38,190 @@ export type DownloadFileBatchResult = { * @returns Object containing successful and failed file IDs */ export async function downloadFileBatch( + fileTracker: FileStatusTracker, files: BatchedFiles, options: Settings, - maxRetries = 3, - retryDelay = 1000, forceDownload: boolean = false ): Promise { // Local record of what version was last downloaded for each fileName:locale const downloadedVersions = getDownloadedVersions(options.configDirectory); let didUpdateDownloadedLock = false; - let retries = 0; - const fileIds = files.map((file) => file.translationId); - const result = { successful: [] as string[], failed: [] as string[] }; + + // Create a map of requested file keys to the file object + const requestedFileMap = new Map( + files.map((file) => [ + `${file.branchId}:${file.fileId}:${file.versionId}:${file.locale}`, + file, + ]) + ); + const result: DownloadFileBatchResult = { + successful: [], + failed: [], + skipped: [], + }; // Create a map of translationId to outputPath for easier lookup const outputPathMap = new Map( - files.map((file) => [file.translationId, file.outputPath]) - ); - const inputPathMap = new Map( - files.map((file) => [file.translationId, file.inputPath]) - ); - const fileIdMap = new Map( - files.map((file) => [file.translationId, file.fileId]) - ); - const localeMap = new Map( files.map((file) => [ - file.translationId, - gt.resolveAliasLocale(file.locale), + `${file.branchId}:${file.fileId}:${file.versionId}:${file.locale}`, + file.outputPath, ]) ); - const versionMap = new Map( - files.map((file) => [file.translationId, file.versionId]) - ); - while (retries <= maxRetries) { - try { - // Download the files - const responseData = await gt.downloadFileBatch(fileIds); - const downloadedFiles = responseData.files || []; - - // Process each file in the response - for (const file of downloadedFiles) { - try { - const translationId = file.id; - const outputPath = outputPathMap.get(translationId); - const inputPath = inputPathMap.get(translationId); - const locale = localeMap.get(translationId); - const fileId = fileIdMap.get(translationId); - const versionId = versionMap.get(translationId); - - if (!outputPath || !inputPath) { - logWarning(`No input/output path found for file: ${translationId}`); - result.failed.push(translationId); - continue; - } + try { + // Download the files + const responseData = await gt.downloadFileBatch( + files.map((file) => ({ + fileId: file.fileId, + branchId: file.branchId, + versionId: file.versionId, + locale: file.locale, + })) + ); + const downloadedFiles = responseData.files || []; - // Ensure the directory exists - const dir = path.dirname(outputPath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - // If a local translation already exists for the same source version, skip overwrite - const keyId = fileId || inputPath; - const downloadedKey = `${keyId}:${locale}`; - const alreadyDownloadedVersion = - downloadedVersions.entries[downloadedKey]?.versionId; - const fileExists = fs.existsSync(outputPath); - if ( - !forceDownload && - fileExists && - versionId && - alreadyDownloadedVersion === versionId - ) { - result.successful.push(translationId); - continue; - } - let data = file.data; - if (options.options?.jsonSchema && locale) { - const jsonSchema = validateJsonSchema(options.options, inputPath); - if (jsonSchema) { - const originalContent = fs.readFileSync(inputPath, 'utf8'); - if (originalContent) { - data = mergeJson( - originalContent, - inputPath, - options.options, - [ - { - translatedContent: file.data, - targetLocale: locale, - }, - ], - options.defaultLocale - )[0]; - } - } - } + // Process each file in the response + for (const file of downloadedFiles) { + const fileKey = `${file.branchId}:${file.fileId}:${file.versionId}:${file.locale}`; + const requestedFile = requestedFileMap.get(fileKey); + if (!requestedFile) { + continue; + } + try { + const outputPath = outputPathMap.get(fileKey); + const fileProperties = fileTracker.completed.get(fileKey); + + if (!outputPath || !fileProperties) { + logWarning(`No input/output path found for file: ${fileKey}`); + result.failed.push(requestedFile); + continue; + } + + const { + fileId, + versionId, + locale, + branchId, + fileName: inputPath, + } = fileProperties; - if (options.options?.yamlSchema && locale) { - const yamlSchema = validateYamlSchema(options.options, inputPath); - if (yamlSchema) { - const originalContent = fs.readFileSync(inputPath, 'utf8'); - if (originalContent) { - data = mergeYaml( - originalContent, - inputPath, - options.options, - [ - { - translatedContent: file.data, - targetLocale: locale, - }, - ], - options.defaultLocale - )[0]; - } + // Ensure the directory exists + const dir = path.dirname(outputPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + // If a local translation already exists for the same source version, skip overwrite + const downloadedVersion = + downloadedVersions.entries[branchId]?.[fileId]?.[versionId]?.[locale]; + const fileExists = fs.existsSync(outputPath); + if (!forceDownload && fileExists && downloadedVersion) { + result.skipped.push(requestedFile); + continue; + } + let data = file.data; + if (options.options?.jsonSchema && locale) { + const jsonSchema = validateJsonSchema(options.options, inputPath); + if (jsonSchema) { + const originalContent = fs.readFileSync(inputPath, 'utf8'); + if (originalContent) { + data = mergeJson( + originalContent, + inputPath, + options.options, + [ + { + translatedContent: file.data, + targetLocale: locale, + }, + ], + options.defaultLocale + )[0]; } } + } - // If the file is a GTJSON file, stable sort the order and format the data - if (file.fileFormat === 'GTJSON') { - try { - const jsonData = JSON.parse(data); - const sortedData = stringify(jsonData); // stably sort with fast-json-stable-stringify - const sortedJsonData = JSON.parse(sortedData); - data = JSON.stringify(sortedJsonData, null, 2); // format the data - } catch (error) { - logWarning(`Failed to sort GTJson file: ${file.id}: ` + error); + if (options.options?.yamlSchema && locale) { + const yamlSchema = validateYamlSchema(options.options, inputPath); + if (yamlSchema) { + const originalContent = fs.readFileSync(inputPath, 'utf8'); + if (originalContent) { + data = mergeYaml( + originalContent, + inputPath, + options.options, + [ + { + translatedContent: file.data, + targetLocale: locale, + }, + ], + options.defaultLocale + )[0]; } } + } - // Write the file to disk - await fs.promises.writeFile(outputPath, data); - // Track as downloaded - recordDownloaded(outputPath); - - result.successful.push(translationId); - if (versionId) { - downloadedVersions.entries[downloadedKey] = { - versionId, - fileId: fileId || undefined, - fileName: inputPath, - updatedAt: new Date().toISOString(), - }; - didUpdateDownloadedLock = true; + // If the file is a GTJSON file, stable sort the order and format the data + if (file.fileFormat === 'GTJSON') { + try { + const jsonData = JSON.parse(data); + const sortedData = stringify(jsonData); // stably sort with fast-json-stable-stringify + const sortedJsonData = JSON.parse(sortedData); + data = JSON.stringify(sortedJsonData, null, 2); // format the data + } catch (error) { + logWarning(`Failed to sort GTJson file: ${file.id}: ` + error); } - } catch (error) { - logError(`Error saving file ${file.id}: ` + error); - result.failed.push(file.id); } - } - // Add any files that weren't in the response to the failed list - const downloadedIds = new Set( - downloadedFiles.map((file: any) => file.id) - ); - for (const fileId of fileIds) { - if (!downloadedIds.has(fileId) && !result.failed.includes(fileId)) { - result.failed.push(fileId); + // Write the file to disk + await fs.promises.writeFile(outputPath, data); + // Track as downloaded + recordDownloaded(outputPath); + + result.successful.push(requestedFile); + if (branchId && fileId && versionId && locale) { + ensureNestedObject(downloadedVersions.entries, [ + branchId, + fileId, + versionId, + locale, + ]); + downloadedVersions.entries[branchId][fileId][versionId][locale] = { + updatedAt: new Date().toISOString(), + }; + didUpdateDownloadedLock = true; } + } catch (error) { + logError(`Error saving file ${fileKey}: ` + error); + result.failed.push(requestedFile); } + } - // Persist any updates to the downloaded map at the end of a successful cycle - if (didUpdateDownloadedLock) { - saveDownloadedVersions(options.configDirectory, downloadedVersions); - didUpdateDownloadedLock = false; - } - return result; - } catch (error) { - // If we've retried too many times, log an error and return false - if (retries >= maxRetries) { - logError( - `Error downloading files in batch after ${maxRetries + 1} attempts: ` + - error - ); - // Mark all files as failed - result.failed = [...fileIds]; - if (didUpdateDownloadedLock) { - saveDownloadedVersions(options.configDirectory, downloadedVersions); - } - return result; + // Add any files that weren't in the response to the failed list + const downloadedFileKeys = new Set( + downloadedFiles.map( + (file) => + `${file.branchId}:${file.fileId}:${file.versionId}:${file.locale}` + ) + ); + for (const [fileKey, requestedFile] of requestedFileMap.entries()) { + if (!downloadedFileKeys.has(fileKey)) { + result.failed.push(requestedFile); } + } - // Increment retry counter and wait before next attempt - retries++; - await new Promise((resolve) => setTimeout(resolve, retryDelay)); + // Persist any updates to the downloaded map at the end of a successful cycle + if (didUpdateDownloadedLock) { + saveDownloadedVersions(options.configDirectory, downloadedVersions); + didUpdateDownloadedLock = false; } + return result; + } catch (error) { + logError(`An unexpected error occurred while downloading files: ` + error); } // Mark all files as failed if we get here - result.failed = [...fileIds]; + result.failed = [...requestedFileMap.values()]; if (didUpdateDownloadedLock) { saveDownloadedVersions(options.configDirectory, downloadedVersions); } diff --git a/packages/cli/src/api/saveLocalEdits.ts b/packages/cli/src/api/saveLocalEdits.ts index 6c7a3b3f4..a19b5f27b 100644 --- a/packages/cli/src/api/saveLocalEdits.ts +++ b/packages/cli/src/api/saveLocalEdits.ts @@ -1,10 +1,10 @@ -import { gt } from '../utils/gt.js'; import { Settings } from '../types/index.js'; import { aggregateFiles } from '../formats/files/translate.js'; import { collectAndSendUserEditDiffs } from './collectUserEditDiffs.js'; -import type { FileUpload } from './uploadFiles.js'; - -type SourceUpload = { source: FileUpload }; +import { gt } from '../utils/gt.js'; +import { BranchStep } from '../workflow/BranchStep.js'; +import { logErrorAndExit } from '../console/logging.js'; +import type { FileReference } from 'generaltranslation/types'; /** * Uploads current source files to obtain file references, then collects and sends @@ -17,23 +17,22 @@ export async function saveLocalEdits(settings: Settings): Promise { const files = await aggregateFiles(settings); if (!files.length) return; - const uploads: SourceUpload[] = files.map( - ({ content, fileName, fileFormat, dataFormat }) => ({ - source: { - content, - fileName, - fileFormat, - dataFormat, - locale: settings.defaultLocale, - }, - }) - ); + // run branch query to get branch id + // Run the branch step + const branchStep = new BranchStep(gt, settings); + const branchResult = await branchStep.run(); + await branchStep.wait(); + if (!branchResult) { + logErrorAndExit('Failed to resolve git branch information.'); + } - // Upload sources only to get file references, then compute diffs - const upload = await gt.uploadSourceFiles(uploads, { - sourceLocale: settings.defaultLocale, - modelProvider: settings.modelProvider, - }); + const uploads = files.map((file) => ({ + fileName: file.fileName, + fileFormat: file.fileFormat, + branchId: branchResult.currentBranch.id, + fileId: file.fileId, + versionId: file.versionId, + })) satisfies FileReference[]; - await collectAndSendUserEditDiffs(upload.uploadedFiles as any, settings); + await collectAndSendUserEditDiffs(uploads, settings); } diff --git a/packages/cli/src/api/sendFiles.ts b/packages/cli/src/api/sendFiles.ts deleted file mode 100644 index ddcf770eb..000000000 --- a/packages/cli/src/api/sendFiles.ts +++ /dev/null @@ -1,185 +0,0 @@ -import chalk from 'chalk'; -import { - createSpinner, - logErrorAndExit, - logMessage, - logSuccess, -} from '../console/logging.js'; -import { Settings, TranslateFlags } from '../types/index.js'; -import { gt } from '../utils/gt.js'; -import { - CompletedFileTranslationData, - FileToTranslate, -} from 'generaltranslation/types'; -import { FileUpload } from './uploadFiles.js'; -import { TEMPLATE_FILE_NAME } from '../cli/commands/stage.js'; -import { collectAndSendUserEditDiffs } from './collectUserEditDiffs.js'; - -type SourceUpload = { source: FileUpload }; - -export type SendFilesResult = { - data: Record; - locales: string[]; - translations: CompletedFileTranslationData[]; -}; - -/** - * Sends multiple files for translation to the API - * @param files - Array of file objects to translate - * @param options - The options for the API call - * @returns The translated content or version ID - */ -export async function sendFiles( - files: FileToTranslate[], - options: TranslateFlags, - settings: Settings -): Promise { - // Keep track of the most recent spinner so we can stop it on error - let currentSpinner: ReturnType | null = null; - logMessage( - chalk.cyan('Files to translate:') + - '\n' + - files - .map((file) => { - if (file.fileName === TEMPLATE_FILE_NAME) { - return `- `; - } - return `- ${file.fileName}`; - }) - .join('\n') - ); - - try { - // Step 1: Upload files (get references) - const uploadSpinner = createSpinner('dots'); - currentSpinner = uploadSpinner; - uploadSpinner.start( - `Uploading ${files.length} file${files.length !== 1 ? 's' : ''} to General Translation API...` - ); - - const sourceLocale = settings.defaultLocale; - if (!sourceLocale) { - uploadSpinner.stop(chalk.red('Missing default source locale')); - logErrorAndExit( - 'sendFiles: settings.defaultLocale is required to upload source files' - ); - } - - // Convert FileToTranslate[] -> { source: FileUpload }[] - const uploads: SourceUpload[] = files.map( - ({ content, fileName, fileFormat, dataFormat }) => ({ - source: { - content, - fileName, - fileFormat, - dataFormat, - locale: sourceLocale, - }, - }) - ); - - const upload = await gt.uploadSourceFiles(uploads, { - sourceLocale, - modelProvider: settings.modelProvider, - }); - uploadSpinner.stop(chalk.green('Files uploaded successfully')); - - // Calculate timeout once for setup fetching - // Accept number or numeric string, default to 600s - const timeoutVal = - options?.timeout !== undefined ? Number(options.timeout) : 600; - const setupTimeoutMs = - (Number.isFinite(timeoutVal) ? timeoutVal : 600) * 1000; - - const setupResult = await gt.setupProject(upload.uploadedFiles, { - locales: settings.locales, - }); - - if (setupResult?.status === 'queued') { - const { setupJobId } = setupResult; - - const setupSpinner = createSpinner('dots'); - currentSpinner = setupSpinner; - setupSpinner.start('Setting up project...'); - - const start = Date.now(); - const pollInterval = 2000; - - let setupCompleted = false; - let setupFailedMessage: string | null = null; - - while (true) { - const status = await gt.checkSetupStatus(setupJobId); - - if (status.status === 'completed') { - setupCompleted = true; - break; - } - if (status.status === 'failed') { - setupFailedMessage = status.error?.message || 'Unknown error'; - break; - } - if (Date.now() - start > setupTimeoutMs) { - setupFailedMessage = 'Timed out while waiting for setup generation'; - break; - } - await new Promise((r) => setTimeout(r, pollInterval)); - } - - if (setupCompleted) { - setupSpinner.stop(chalk.green('Setup successfully completed')); - } else { - setupSpinner.stop( - chalk.yellow( - `Setup ${setupFailedMessage ? 'failed' : 'timed out'} — proceeding without setup${ - setupFailedMessage ? ` (${setupFailedMessage})` : '' - }` - ) - ); - } - } - - // Step 3 (optional): Prior to enqueue, detect and submit user edit diffs - if (options?.saveLocal) { - const prepSpinner = createSpinner('dots'); - currentSpinner = prepSpinner; - prepSpinner.start('Updating translations...'); - try { - await collectAndSendUserEditDiffs( - upload.uploadedFiles as any, - settings - ); - } catch { - // Non-fatal; keep going to enqueue - } finally { - prepSpinner.stop('Updated translations'); - } - } - - // Step 4: Enqueue translations by reference - const enqueueSpinner = createSpinner('dots'); - currentSpinner = enqueueSpinner; - enqueueSpinner.start('Enqueuing translations...'); - const enqueueResult = await gt.enqueueFiles(upload.uploadedFiles, { - sourceLocale: settings.defaultLocale, - targetLocales: settings.locales, - publish: settings.publish, - requireApproval: settings.stageTranslations, - modelProvider: settings.modelProvider, - force: options?.force, - }); - - const { data, message, locales, translations } = enqueueResult; - enqueueSpinner.stop( - chalk.green('Files for translation uploaded successfully') - ); - logSuccess(message); - - return { data, locales, translations }; - } catch { - if (currentSpinner) { - currentSpinner.stop(); - } - logErrorAndExit('Failed to send files for translation'); - } -} diff --git a/packages/cli/src/api/sendUserEdits.ts b/packages/cli/src/api/sendUserEdits.ts deleted file mode 100644 index 3415e68bf..000000000 --- a/packages/cli/src/api/sendUserEdits.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { logWarning } from '../console/logging.js'; -import { Settings } from '../types/index.js'; -import { gt } from '../utils/gt.js'; - -// Single payload type used by CLI, includes optional localContent -export type UserEditDiff = { - fileName: string; - locale: string; - diff: string; // unified diff string - versionId?: string; - fileId?: string; // GT file id if available - localContent?: string; -}; -export type SendUserEditsPayload = { - projectId?: string; - diffs: UserEditDiff[]; -}; - -/** - * Sends user edit diffs to the API for persistence/rule extraction. - * This function is intentionally decoupled from the translate pipeline - * so it can be called as an independent action. - */ -export async function sendUserEditDiffs( - diffs: UserEditDiff[], - settings: Settings -): Promise { - if (!diffs.length) return; - - const payload: SendUserEditsPayload = { - projectId: settings.projectId, - diffs, - }; - await gt.submitUserEditDiffs(payload); -} diff --git a/packages/cli/src/cli/base.ts b/packages/cli/src/cli/base.ts index 4e4d41003..2459b61ac 100644 --- a/packages/cli/src/cli/base.ts +++ b/packages/cli/src/cli/base.ts @@ -49,7 +49,6 @@ import { import { getDownloaded, clearDownloaded } from '../state/recentDownloads.js'; import updateConfig from '../fs/config/updateConfig.js'; import { createLoadTranslationsFile } from '../fs/createLoadTranslationsFile.js'; -import type { SendDiffsFlags } from './commands/edits.js'; import { saveLocalEdits } from '../api/saveLocalEdits.js'; export type UploadOptions = { @@ -161,7 +160,13 @@ export class BaseCLI { false ); if (results) { - await handleTranslate(initOptions, settings, results); + await handleTranslate( + initOptions, + settings, + results.fileVersionData, + results.jobData, + results.branchData + ); } } else { await handleDownload(initOptions, settings); diff --git a/packages/cli/src/cli/commands/edits.ts b/packages/cli/src/cli/commands/edits.ts deleted file mode 100644 index 5868c2b88..000000000 --- a/packages/cli/src/cli/commands/edits.ts +++ /dev/null @@ -1,53 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { getGitUnifiedDiff } from '../../utils/gitDiff.js'; -import { sendUserEditDiffs } from '../../api/sendUserEdits.js'; -import { Settings } from '../../types/index.js'; -import { logErrorAndExit, logMessage } from '../../console/logging.js'; - -export type SendDiffsFlags = { - fileName: string; // logical source file path used by GT - locale: string; - old: string; // path to downloaded/original content - next: string; // path to current/local content -}; - -export async function handleSendDiffs( - flags: SendDiffsFlags, - settings: Settings -) { - const { fileName, locale, old, next } = flags; - - if (!fs.existsSync(old)) { - logErrorAndExit(`Old/original file not found: ${old}`); - } - if (!fs.existsSync(next)) { - logErrorAndExit(`New/local file not found: ${next}`); - } - - let diff: string; - try { - diff = await getGitUnifiedDiff(old, next); - } catch (e) { - logErrorAndExit( - 'Git is required to compute diffs. Please install Git and ensure it is available on your PATH.' - ); - return; // unreachable - } - - if (!diff || diff.trim().length === 0) { - logMessage('No differences detected — nothing to send.'); - return; - } - - await sendUserEditDiffs( - [ - { - fileName, - locale, - diff, - }, - ], - settings - ); -} diff --git a/packages/cli/src/cli/commands/stage.ts b/packages/cli/src/cli/commands/stage.ts index b7388561e..900101369 100644 --- a/packages/cli/src/cli/commands/stage.ts +++ b/packages/cli/src/cli/commands/stage.ts @@ -14,11 +14,17 @@ import { } from '../../console/index.js'; import { aggregateFiles } from '../../formats/files/translate.js'; import { aggregateReactTranslations } from '../../translation/stage.js'; -import { sendFiles, SendFilesResult } from '../../api/sendFiles.js'; +import { stageFiles } from '../../workflow/stage.js'; import { updateVersions } from '../../fs/config/updateVersions.js'; -import { JsxChildren } from 'generaltranslation/types'; +import type { + EnqueueFilesResult, + FileToUpload, + JsxChildren, +} from 'generaltranslation/types'; import updateConfig from '../../fs/config/updateConfig.js'; import { hashStringSync } from '../../utils/hash.js'; +import { FileTranslationData } from '../../workflow/download.js'; +import { BranchData } from '../../types/branch.js'; export const TEMPLATE_FILE_NAME = '__INTERNAL_GT_TEMPLATE_NAME__'; export const TEMPLATE_FILE_ID = hashStringSync(TEMPLATE_FILE_NAME); @@ -28,7 +34,11 @@ export async function handleStage( settings: Settings, library: SupportedLibraries, stage: boolean -): Promise { +): Promise<{ + fileVersionData: FileTranslationData | undefined; + jobData: EnqueueFilesResult | undefined; + branchData: BranchData | undefined; +} | null> { // Validate required settings are present if not in dry run if (!options.dryRun) { if (!settings.locales) { @@ -89,7 +99,9 @@ export async function handleStage( content: JSON.stringify(fileData), fileFormat: 'GTJSON', formatMetadata: fileMetadata, - }); + fileId: TEMPLATE_FILE_ID, + versionId: hashStringSync(JSON.stringify(fileData)), + } satisfies FileToUpload); } } @@ -106,20 +118,42 @@ export async function handleStage( logSuccess( `Dry run: No files were sent to General Translation. Found files:\n${fileNames}` ); - return undefined; + return null; } // Send translations to General Translation - let filesTranslationResponse: SendFilesResult | undefined; + let fileVersionData: FileTranslationData | undefined; + let jobData: EnqueueFilesResult | undefined; + let branchData: BranchData | undefined; if (allFiles.length > 0) { - filesTranslationResponse = await sendFiles(allFiles, options, settings); + const { branchData: branchDataResult, enqueueResult } = await stageFiles( + allFiles, + options, + settings + ); + jobData = enqueueResult; + branchData = branchDataResult; + + fileVersionData = Object.fromEntries( + allFiles.map((file) => [ + file.fileId, + { + fileName: file.fileName, + versionId: file.versionId, + }, + ]) + ); + + // This logic is a little scuffed because stage is async from the API if (stage) { await updateVersions({ configDirectory: settings.configDirectory, - versionData: filesTranslationResponse.data, + versionData: fileVersionData, }); } - const templateData = filesTranslationResponse.data[TEMPLATE_FILE_ID]; + const templateData = allFiles.find( + (file) => file.fileId === TEMPLATE_FILE_ID + ); if (templateData?.versionId) { await updateConfig({ configFilepath: settings.config, @@ -127,5 +161,9 @@ export async function handleStage( }); } } - return filesTranslationResponse; + return { + fileVersionData, + jobData, + branchData, + }; } diff --git a/packages/cli/src/cli/commands/translate.ts b/packages/cli/src/cli/commands/translate.ts index a6ec30626..911818c07 100644 --- a/packages/cli/src/cli/commands/translate.ts +++ b/packages/cli/src/cli/commands/translate.ts @@ -1,7 +1,10 @@ -import { SendFilesResult } from '../../api/sendFiles.js'; +import { EnqueueFilesResult } from 'generaltranslation/types'; import { TranslateFlags } from '../../types/index.js'; import { Settings } from '../../types/index.js'; -import { checkFileTranslations } from '../../api/checkFileTranslations.js'; +import { + FileTranslationData, + downloadTranslations, +} from '../../workflow/download.js'; import { createFileMapping } from '../../formats/files/fileMapping.js'; import { logError } from '../../console/logging.js'; import { getStagedVersions } from '../../fs/config/updateVersions.js'; @@ -11,14 +14,17 @@ import localizeStaticUrls from '../../utils/localizeStaticUrls.js'; import processAnchorIds from '../../utils/processAnchorIds.js'; import { noFilesError, noVersionIdError } from '../../console/index.js'; import localizeStaticImports from '../../utils/localizeStaticImports.js'; +import { BranchData } from '../../types/branch.js'; // Downloads translations that were completed export async function handleTranslate( options: TranslateFlags, settings: Settings, - filesTranslationResponse: SendFilesResult | undefined + fileVersionData: FileTranslationData | undefined, + jobData: EnqueueFilesResult | undefined, + branchData: BranchData | undefined ) { - if (filesTranslationResponse) { + if (fileVersionData) { const { resolvedPaths, placeholderPaths, transformPaths } = settings.files; const fileMapping = createFileMapping( @@ -28,10 +34,11 @@ export async function handleTranslate( settings.locales, settings.defaultLocale ); - const { data } = filesTranslationResponse; // Check for remaining translations - await checkFileTranslations( - data, + await downloadTranslations( + fileVersionData, + jobData, + branchData, settings.locales, options.timeout, (sourcePath, locale) => fileMapping[locale]?.[sourcePath] ?? null, @@ -66,8 +73,10 @@ export async function handleDownload( ); const stagedVersionData = await getStagedVersions(settings.configDirectory); // Check for remaining translations - await checkFileTranslations( + await downloadTranslations( stagedVersionData, + undefined, + undefined, settings.locales, options.timeout, (sourcePath, locale) => fileMapping[locale][sourcePath] ?? null, diff --git a/packages/cli/src/cli/flags.ts b/packages/cli/src/cli/flags.ts index a77694d07..3db1b8cd2 100644 --- a/packages/cli/src/cli/flags.ts +++ b/packages/cli/src/cli/flags.ts @@ -82,7 +82,17 @@ export function attachTranslateFlags(command: Command) { '--experimental-clear-locale-dirs', 'Clear locale directories before downloading new translations', false - ); + ) + .option( + '--branch ', + 'Specify a custom branch to use for translations' + ) + .option( + '--disable-branch-detection', + 'Disable additional branch detection and optimizations and use the manually specified branch', + false + ) + .option('--enable-branching', 'Enable branching for the project', false); // disabled by default for now return command; } diff --git a/packages/cli/src/config/generateSettings.ts b/packages/cli/src/config/generateSettings.ts index 6c8cbb7b6..533d37020 100644 --- a/packages/cli/src/config/generateSettings.ts +++ b/packages/cli/src/config/generateSettings.ts @@ -225,6 +225,17 @@ export async function generateSettings( mergedOptions.parsingOptions.conditionNames = mergedOptions.parsingOptions .conditionNames || ['browser', 'module', 'import', 'require', 'default']; + // Add branch options if not provided + const branchOptions = mergedOptions.branchOptions || {}; + branchOptions.enabled = flags.enableBranching ?? false; + branchOptions.currentBranch = + flags.branch ?? gtConfig.branchOptions?.currentBranch ?? undefined; + branchOptions.autoDetectBranches = flags.disableBranchDetection + ? false + : true; + branchOptions.remoteName = gtConfig.branchOptions?.remoteName ?? 'origin'; + mergedOptions.branchOptions = branchOptions; + // if there's no existing config file, creates one // does not include the API key to avoid exposing it if (!fs.existsSync(mergedOptions.config)) { diff --git a/packages/cli/src/console/logging.ts b/packages/cli/src/console/logging.ts index 742f70c30..cacf92a3d 100644 --- a/packages/cli/src/console/logging.ts +++ b/packages/cli/src/console/logging.ts @@ -9,6 +9,7 @@ import { isCancel, cancel, multiselect, + progress, } from '@clack/prompts'; import chalk from 'chalk'; import { getCLIVersion } from '../utils/packageJson.js'; @@ -112,11 +113,8 @@ export function createSpinner(indicator: 'dots' | 'timer' = 'timer') { return spinner({ indicator }); } // Spinner functionality -export async function createOraSpinner( - indicator: 'dots' | 'circleHalves' = 'circleHalves' -) { - const ora = await import('ora'); - return ora.default({ spinner: indicator }); +export function createProgressBar(total: number) { + return progress({ max: total }); } // Input prompts diff --git a/packages/cli/src/formats/files/translate.ts b/packages/cli/src/formats/files/translate.ts index 86c3dc9e0..ac7029398 100644 --- a/packages/cli/src/formats/files/translate.ts +++ b/packages/cli/src/formats/files/translate.ts @@ -1,7 +1,7 @@ import { logError, logWarning } from '../../console/logging.js'; import { getRelative, readFile } from '../../fs/findFilepath.js'; import { Settings } from '../../types/index.js'; -import { FileFormat, DataFormat, FileToTranslate } from '../../types/data.js'; +import type { FileFormat, DataFormat, FileToUpload } from '../../types/data.js'; import { SUPPORTED_FILE_EXTENSIONS } from './supportedFiles.js'; import sanitizeFileContent from '../../utils/sanitizeFileContent.js'; import { parseJson } from '../json/parseJson.js'; @@ -9,14 +9,14 @@ import parseYaml from '../yaml/parseYaml.js'; import YAML from 'yaml'; import { determineLibrary } from '../../fs/determineFramework.js'; import { isValidMdx } from '../../utils/validateMdx.js'; - +import { hashStringSync } from '../../utils/hash.js'; export const SUPPORTED_DATA_FORMATS = ['JSX', 'ICU', 'I18NEXT']; export async function aggregateFiles( settings: Settings -): Promise { +): Promise { // Aggregate all files to translate - const allFiles: FileToTranslate[] = []; + const allFiles: FileToUpload[] = []; if ( !settings.files || (Object.keys(settings.files.placeholderPaths).length === 1 && @@ -66,13 +66,15 @@ export async function aggregateFiles( ); return { + fileId: hashStringSync(relativePath), + versionId: hashStringSync(parsedJson), content: parsedJson, fileName: relativePath, fileFormat: 'JSON' as const, dataFormat, - } as FileToTranslate; + } satisfies FileToUpload; }) - .filter((file): file is FileToTranslate => { + .filter((file) => { if (!file) return false; if (typeof file.content !== 'string' || !file.content.trim()) { logWarning(`Skipping ${file.fileName}: JSON file is empty`); @@ -80,7 +82,7 @@ export async function aggregateFiles( } return true; }); - allFiles.push(...jsonFiles); + allFiles.push(...jsonFiles.filter((file) => file !== null)); } // Process YAML files @@ -108,17 +110,20 @@ export async function aggregateFiles( content: parsedYaml, fileName: relativePath, fileFormat, - } as FileToTranslate; + fileId: hashStringSync(relativePath), + versionId: hashStringSync(parsedYaml), + } satisfies FileToUpload; }) - .filter((file): file is FileToTranslate => { - if (!file) return false; - if (typeof file.content !== 'string' || !file.content.trim()) { - logWarning(`Skipping ${file.fileName}: YAML file is empty`); + .filter((file) => { + if (!file || typeof file.content !== 'string' || !file.content.trim()) { + logWarning( + `Skipping ${file?.fileName ?? 'unknown'}: YAML file is empty` + ); return false; } return true; }); - allFiles.push(...yamlFiles); + allFiles.push(...yamlFiles.filter((file) => file !== null)); } for (const fileType of SUPPORTED_FILE_EXTENSIONS) { @@ -144,9 +149,11 @@ export async function aggregateFiles( content: sanitizedContent, fileName: relativePath, fileFormat: fileType.toUpperCase() as FileFormat, - } as FileToTranslate | null; + fileId: hashStringSync(relativePath), + versionId: hashStringSync(content), + } satisfies FileToUpload; }) - .filter((file): file is FileToTranslate => { + .filter((file) => { if ( !file || typeof file.content !== 'string' || @@ -159,7 +166,7 @@ export async function aggregateFiles( } return true; }); - allFiles.push(...files); + allFiles.push(...files.filter((file) => file !== null)); } } diff --git a/packages/cli/src/fs/config/downloadedVersions.ts b/packages/cli/src/fs/config/downloadedVersions.ts index cef3b534a..6738c57c4 100644 --- a/packages/cli/src/fs/config/downloadedVersions.ts +++ b/packages/cli/src/fs/config/downloadedVersions.ts @@ -7,15 +7,19 @@ const GT_LOCK_FILE = 'gt-lock.json'; const LEGACY_DOWNLOADED_VERSIONS_FILE = 'downloaded-versions.json'; export type DownloadedVersionEntry = { - versionId: string; - fileId?: string; fileName?: string; updatedAt?: string; }; export type DownloadedVersions = { version: number; - entries: Record; + entries: { + [branchId: string]: { + [fileId: string]: { + [versionId: string]: { [locale: string]: DownloadedVersionEntry }; + }; + }; + }; }; export function getDownloadedVersions( @@ -61,3 +65,10 @@ export function saveDownloadedVersions( logError(`An error occurred while updating ${GT_LOCK_FILE}: ${error}`); } } +export function ensureNestedObject(obj: any, path: string[]): any { + return path.reduce((current, key, index) => { + if (index === path.length - 1) return current; + current[key] = current[key] || {}; + return current[key]; + }, obj); +} diff --git a/packages/cli/src/fs/config/updateVersions.ts b/packages/cli/src/fs/config/updateVersions.ts index 4c2b3e026..c431add98 100644 --- a/packages/cli/src/fs/config/updateVersions.ts +++ b/packages/cli/src/fs/config/updateVersions.ts @@ -5,7 +5,7 @@ import path from 'node:path'; const STAGED_VERSIONS_FILE = 'staged-versions.json'; type StagedVersionData = Record< - string, + string, // fileId { fileName: string; versionId: string } >; // Update the versions.json file with the new version ids diff --git a/packages/cli/src/git/branches.ts b/packages/cli/src/git/branches.ts new file mode 100644 index 000000000..c31b44d0a --- /dev/null +++ b/packages/cli/src/git/branches.ts @@ -0,0 +1,120 @@ +import { execFile } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(execFile); +const MAX_BRANCHES = 5; + +export async function getCurrentBranch(remoteName: string): Promise<{ + currentBranchName: string; + defaultBranch: boolean; + defaultBranchName: string; +} | null> { + try { + const { stdout } = await execAsync( + 'git', + ['rev-parse', '--abbrev-ref', 'HEAD'], + { + encoding: 'utf8', + windowsHide: true, + } + ); + const currentBranchName = stdout.trim(); + + // Get the default branch (usually main or master) + const { stdout: defaultBranchRef } = await execAsync( + 'git', + ['symbolic-ref', `refs/remotes/${remoteName}/HEAD`], + { encoding: 'utf8', windowsHide: true } + ); + const defaultBranchName = defaultBranchRef + .trim() + .replace(`refs/remotes/${remoteName}/`, ''); + const defaultBranch = currentBranchName === defaultBranchName; + + return { currentBranchName, defaultBranch, defaultBranchName }; + } catch { + return null; + } +} + +export async function getIncomingBranches( + remoteName: string +): Promise { + try { + // Get merge commits into the current branch + const { stdout } = await execAsync( + 'git', + [ + 'log', + '--merges', + '--first-parent', + '--pretty=format:%s', + `-${MAX_BRANCHES}`, + ], + { + encoding: 'utf8', + windowsHide: true, + } + ); + + if (!stdout.trim()) { + return []; + } + + const branches: string[] = []; + const lines = stdout.trim().split('\n'); + + for (const line of lines) { + // Parse merge commit messages: + // - "Merge branch 'feature-name'" or "Merge branch 'feature-name' into main" + // - "Merge pull request #123 from user/branch-name" + const branchMatch = line.match(/Merge branch '([^']+)'/); + const prMatch = line.match(/Merge pull request #\d+ from [^/]+\/(.+)/); + + if (branchMatch && branchMatch[1]) { + branches.push(branchMatch[1]); + } else if (prMatch && prMatch[1]) { + branches.push(prMatch[1]); + } + } + + return branches.slice(0, MAX_BRANCHES); + } catch { + // If log fails or no merges found, return empty array + return []; + } +} + +export async function getCheckedOutBranches( + remoteName: string +): Promise { + try { + // Get current branch + const currentBranchResult = await getCurrentBranch(remoteName); + if (!currentBranchResult) { + return []; + } + + // If we're already on the default branch, return empty + if (currentBranchResult.defaultBranch) { + return []; + } + + // Check if there's a merge-base (common ancestor) between default branch and current + // This means the branch was at some point checked out from the default branch + try { + await execAsync( + 'git', + ['merge-base', currentBranchResult.defaultBranchName, 'HEAD'], + { encoding: 'utf8', windowsHide: true } + ); + // If merge-base exists, the branch shares history with default branch + return [currentBranchResult.defaultBranchName]; + } catch { + // No common ancestor found + return []; + } + } catch { + return []; + } +} diff --git a/packages/cli/src/types/branch.ts b/packages/cli/src/types/branch.ts new file mode 100644 index 000000000..329ff3908 --- /dev/null +++ b/packages/cli/src/types/branch.ts @@ -0,0 +1,14 @@ +export type BranchData = { + currentBranch: { + id: string; + name: string; // branch name + }; + incomingBranch: { + id: string; + name: string; // branch name + } | null; + checkedOutBranch: { + id: string; + name: string; // branch name + } | null; +}; diff --git a/packages/cli/src/types/data.ts b/packages/cli/src/types/data.ts index 13d0beacb..7037962be 100644 --- a/packages/cli/src/types/data.ts +++ b/packages/cli/src/types/data.ts @@ -23,7 +23,7 @@ export type FlattenedJSONDictionary = { export type { FileFormat, DataFormat, - FileToTranslate, + FileToUpload, } from 'generaltranslation/types'; export type JsxChildren = string | string[] | any; diff --git a/packages/cli/src/types/files.ts b/packages/cli/src/types/files.ts index 03c64a1f4..4b991b78e 100644 --- a/packages/cli/src/types/files.ts +++ b/packages/cli/src/types/files.ts @@ -1,2 +1,10 @@ // Mapping of locale code to source file path to translated file path export type FileMapping = Record>; + +export type FileProperties = { + versionId: string; + fileName: string; + fileId: string; + locale: string; + branchId: string; +}; diff --git a/packages/cli/src/types/index.ts b/packages/cli/src/types/index.ts index 7c4042e14..1edb75436 100644 --- a/packages/cli/src/types/index.ts +++ b/packages/cli/src/types/index.ts @@ -175,6 +175,14 @@ export type Settings = { options?: AdditionalOptions; modelProvider?: string; parsingOptions: ParsingConfigOptions; + branchOptions: BranchOptions; +}; + +export type BranchOptions = { + currentBranch?: string; + autoDetectBranches?: boolean; + remoteName: string; // default 'origin'. The name of the remote to use for auto-detection. + enabled: boolean; // if true, branching is enabled for the project }; export type AdditionalOptions = { diff --git a/packages/cli/src/utils/SpinnerManager.ts b/packages/cli/src/utils/SpinnerManager.ts new file mode 100644 index 000000000..d0911d8ff --- /dev/null +++ b/packages/cli/src/utils/SpinnerManager.ts @@ -0,0 +1,79 @@ +import chalk from 'chalk'; +import { createSpinner } from '../console/logging.js'; + +/** + * Centralized spinner management for tracking multiple async operations + */ +export class SpinnerManager { + private spinners = new Map>(); + + /** + * Run an async operation with a spinner + */ + async run(id: string, message: string, fn: () => Promise): Promise { + const spinner = createSpinner('dots'); + this.spinners.set(id, spinner); + spinner.start(message); + + try { + const result = await fn(); + spinner.stop(chalk.green('✓')); + return result; + } catch (error) { + spinner.stop(chalk.red('✗')); + throw error; + } finally { + this.spinners.delete(id); + } + } + + /** + * Mark a spinner as successful + */ + succeed(id: string, message: string): void { + const spinner = this.spinners.get(id); + if (spinner) { + spinner.stop(chalk.green(message)); + this.spinners.delete(id); + } + } + + /** + * Mark a spinner as warning + */ + warn(id: string, message: string): void { + const spinner = this.spinners.get(id); + if (spinner) { + spinner.stop(chalk.yellow(message)); + this.spinners.delete(id); + } + } + + /** + * Start a new spinner + */ + start(id: string, message: string): void { + const spinner = createSpinner('dots'); + this.spinners.set(id, spinner); + spinner.start(message); + } + + /** + * Stop a specific spinner + */ + stop(id: string, message?: string): void { + const spinner = this.spinners.get(id); + if (spinner) { + spinner.stop(message); + this.spinners.delete(id); + } + } + + /** + * Stop all running spinners + */ + stopAll(): void { + this.spinners.forEach((s) => s.stop()); + this.spinners.clear(); + } +} diff --git a/packages/cli/src/utils/gitDiff.ts b/packages/cli/src/utils/gitDiff.ts index e737f3473..fc7c98a92 100644 --- a/packages/cli/src/utils/gitDiff.ts +++ b/packages/cli/src/utils/gitDiff.ts @@ -14,28 +14,29 @@ export async function getGitUnifiedDiff( oldPath: string, newPath: string ): Promise { - const res = await execFileAsync( - 'git', - [ - 'diff', - '--no-index', - '--text', - '--unified=3', - '--no-color', - '--', - oldPath, - newPath, - ], - { - windowsHide: true, - } - ).catch((error: any) => { + try { + const res = await execFileAsync( + 'git', + [ + 'diff', + '--no-index', + '--text', + '--unified=3', + '--no-color', + '--', + oldPath, + newPath, + ], + { + windowsHide: true, + } + ); + return res.stdout || ''; + } catch (error: any) { // Exit code 1 means differences found; stdout contains the diff if (error && error.code === 1 && typeof error.stdout === 'string') { - return { stdout: error.stdout as string }; + return error.stdout as string; } throw error; - }); - // When there are no changes, stdout is empty string and exit code 0 - return res.stdout || ''; + } } diff --git a/packages/cli/src/workflow/BranchStep.ts b/packages/cli/src/workflow/BranchStep.ts new file mode 100644 index 000000000..30ccb2281 --- /dev/null +++ b/packages/cli/src/workflow/BranchStep.ts @@ -0,0 +1,160 @@ +import { WorkflowStep } from './Workflow.js'; +import { + createSpinner, + logError, + logErrorAndExit, +} from '../console/logging.js'; +import { GT } from 'generaltranslation'; +import { Settings } from '../types/index.js'; +import chalk from 'chalk'; +import { + getCurrentBranch, + getIncomingBranches, + getCheckedOutBranches, +} from '../git/branches.js'; +import { BranchData } from '../types/branch.js'; +import { ApiError } from 'generaltranslation/errors'; + +// Step 1: Resolve the current branch id & update API with branch information +export class BranchStep extends WorkflowStep { + private spinner = createSpinner('dots'); + private branchData: BranchData; + private settings: Settings; + private gt: GT; + + constructor(gt: GT, settings: Settings) { + super(); + this.gt = gt; + this.settings = settings; + this.branchData = { + currentBranch: { + id: '', + name: '', + }, + incomingBranch: null, + checkedOutBranch: null, + }; + } + + async run(): Promise { + this.spinner.start(`Resolving branch information...`); + + // First get some info about the branches we're working with + let current: { + currentBranchName: string; + defaultBranch: boolean; + } | null = null; + let incoming: string[] = []; + let checkedOut: string[] = []; + let useDefaultBranch: boolean = true; + + if ( + this.settings.branchOptions.enabled && + this.settings.branchOptions.autoDetectBranches + ) { + const [currentResult, incomingResult, checkedOutResult] = + await Promise.all([ + getCurrentBranch(this.settings.branchOptions.remoteName), + getIncomingBranches(this.settings.branchOptions.remoteName), + getCheckedOutBranches(this.settings.branchOptions.remoteName), + ]); + current = currentResult; + incoming = incomingResult; + checkedOut = checkedOutResult; + useDefaultBranch = false; + } + if ( + this.settings.branchOptions.enabled && + this.settings.branchOptions.currentBranch + ) { + current = { + currentBranchName: this.settings.branchOptions.currentBranch, + defaultBranch: current?.defaultBranch ?? false, // we have no way of knowing if this is the default branch without using the auto-detection logic + }; + useDefaultBranch = false; + } + + const branchData = await this.gt.queryBranchData({ + branchNames: [ + ...(current ? [current.currentBranchName] : []), + ...incoming, + ...checkedOut, + ], + }); + + if (useDefaultBranch) { + if (!branchData.defaultBranch) { + const createBranchResult = await this.gt.createBranch({ + branchName: 'main', // name doesn't matter for default branch + defaultBranch: true, + }); + this.branchData.currentBranch = createBranchResult.branch; + } else { + this.branchData.currentBranch = branchData.defaultBranch; + } + } else { + if (!current) { + logErrorAndExit( + 'Failed to determine the current branch. Please specify a custom branch or enable automatic branch detection.' + ); + } + const currentBranch = branchData.branches.find( + (b) => b.name === current.currentBranchName + ); + if (!currentBranch) { + try { + const createBranchResult = await this.gt.createBranch({ + branchName: current.currentBranchName, + defaultBranch: current.defaultBranch, + }); + this.branchData.currentBranch = createBranchResult.branch; + } catch (error) { + if (error instanceof ApiError && error.code === 403) { + logError( + 'Failed to create branch. To enable branching, please upgrade your plan.' + ); + // retry with default branch + const createBranchResult = await this.gt.createBranch({ + branchName: 'main', // name doesn't matter for default branch + defaultBranch: true, + }); + this.branchData.currentBranch = createBranchResult.branch; + } + } + } else { + this.branchData.currentBranch = currentBranch; + } + } + + // Now set the incoming and checked out branches (first one that exists) + this.branchData.incomingBranch = + incoming + .map((b) => { + const branch = branchData.branches.find((bb) => bb.name === b); + if (branch) { + return branch; + } else { + return null; + } + }) + .filter((b) => b !== null)[0] ?? null; + this.branchData.checkedOutBranch = + checkedOut + .map((b) => { + const branch = branchData.branches.find((bb) => bb.name === b); + if (branch) { + return branch; + } else { + return null; + } + }) + .filter((b) => b !== null)[0] ?? null; + + this.spinner.stop(chalk.green('Branch information resolved successfully')); + return this.branchData; + } + + async wait(): Promise { + return; + } +} diff --git a/packages/cli/src/workflow/DownloadStep.ts b/packages/cli/src/workflow/DownloadStep.ts new file mode 100644 index 000000000..6ee100680 --- /dev/null +++ b/packages/cli/src/workflow/DownloadStep.ts @@ -0,0 +1,208 @@ +import chalk from 'chalk'; +import { WorkflowStep } from './Workflow.js'; +import { createProgressBar, logError, logWarning } from '../console/logging.js'; +import { + BatchedFiles, + downloadFileBatch, + DownloadFileBatchResult, +} from '../api/downloadFileBatch.js'; +import { GT } from 'generaltranslation'; +import { Settings } from '../types/index.js'; +import { FileStatusTracker } from './PollJobsStep.js'; + +export type DownloadTranslationsInput = { + fileTracker: FileStatusTracker; + resolveOutputPath: (sourcePath: string, locale: string) => string | null; + forceDownload?: boolean; +}; + +export class DownloadTranslationsStep extends WorkflowStep< + DownloadTranslationsInput, + boolean +> { + private spinner: ReturnType | null = null; + + constructor( + private gt: GT, + private settings: Settings + ) { + super(); + } + + async run({ + fileTracker, + resolveOutputPath, + forceDownload, + }: DownloadTranslationsInput): Promise { + this.spinner = createProgressBar(fileTracker.completed.size); + this.spinner.start('Downloading files...'); + + // Download ready files + const success = await this.downloadFiles( + fileTracker, + resolveOutputPath, + forceDownload + ); + if (success) { + this.spinner.stop(chalk.green('Downloaded files successfully')); + } else { + this.spinner.stop(chalk.red('Failed to download files')); + } + + return success; + } + + private async downloadFiles( + fileTracker: FileStatusTracker, + resolveOutputPath: (sourcePath: string, locale: string) => string | null, + forceDownload?: boolean + ): Promise { + try { + // Only download files that are marked as completed + const currentQueryData = Array.from(fileTracker.completed.values()); + + // If no files to download, we're done + if (currentQueryData.length === 0) { + return true; + } + + // Check for translations + const responseData = await this.gt.queryFileData({ + translatedFiles: currentQueryData.map((item) => ({ + fileId: item.fileId, + versionId: item.versionId, + branchId: item.branchId, + locale: item.locale, + })), + }); + const translatedFiles = responseData.translatedFiles || []; + + // Filter for ready translations + const readyTranslations = translatedFiles.filter( + (file) => file.completedAt !== null + ); + + // Prepare batch download data + const batchFiles: BatchedFiles = readyTranslations + .map((translation) => { + const fileKey = `${translation.branchId}:${translation.fileId}:${translation.versionId}:${translation.locale}`; + + const fileProperties = fileTracker.completed.get(fileKey); + if (!fileProperties) { + return null; + } + const outputPath = resolveOutputPath( + fileProperties.fileName, + translation.locale + ); + + // Skip downloading GTJSON files that are not in the files configuration + if (outputPath === null) { + fileTracker.completed.delete(fileKey); + fileTracker.skipped.set(fileKey, fileProperties); + return null; + } + return { + branchId: translation.branchId, + fileId: translation.fileId, + versionId: translation.versionId, + locale: translation.locale, + inputPath: fileProperties.fileName, + outputPath, + }; + }) + .filter((file) => file !== null) as BatchedFiles; + + if (batchFiles.length > 0) { + const batchResult = await this.downloadFilesWithRetry( + fileTracker, + batchFiles, + forceDownload + ); + this.spinner?.stop( + chalk.green( + `Downloaded ${batchResult.successful.length} files${batchResult.skipped.length > 0 ? `, skipped ${batchResult.skipped.length} files` : ''}` + ) + ); + if (batchResult.failed.length > 0) { + logWarning( + `Failed to download ${batchResult.failed.length} files: ${batchResult.failed.map((f) => f.inputPath).join('\n')}` + ); + } + } else { + this.spinner?.stop(chalk.green('No files to download')); + } + + return true; + } catch (error) { + this.spinner?.stop( + chalk.red('An error occurred while downloading translations') + ); + logError(chalk.red('Error: ') + error); + return false; + } + } + + private async downloadFilesWithRetry( + fileTracker: FileStatusTracker, + files: BatchedFiles, + forceDownload?: boolean, + maxRetries: number = 3, + initialDelay: number = 1000 + ): Promise { + let remainingFiles = files; + let allSuccessful: BatchedFiles = []; + let retryCount = 0; + let allSkipped: BatchedFiles = []; + while (remainingFiles.length > 0 && retryCount <= maxRetries) { + const batchResult = await downloadFileBatch( + fileTracker, + remainingFiles, + this.settings, + forceDownload + ); + + allSuccessful = [...allSuccessful, ...batchResult.successful]; + allSkipped = [...allSkipped, ...batchResult.skipped]; + + this.spinner?.advance( + batchResult.successful.length + + batchResult.skipped.length + + batchResult.failed.length + ); + + // If no failures or we've exhausted retries, we're done + if (batchResult.failed.length === 0 || retryCount === maxRetries) { + return { + successful: allSuccessful, + failed: batchResult.failed, + skipped: allSkipped, + }; + } + + // Calculate exponential backoff delay + const delay = initialDelay * Math.pow(2, retryCount); + logError( + chalk.yellow( + `Retrying ${batchResult.failed.length} failed file(s) in ${delay}ms (attempt ${retryCount + 1}/${maxRetries})...` + ) + ); + + // Wait before retrying + await new Promise((resolve) => setTimeout(resolve, delay)); + + remainingFiles = batchResult.failed; + retryCount++; + } + + return { + successful: allSuccessful, + failed: remainingFiles, + skipped: allSkipped, + }; + } + + async wait(): Promise { + return; + } +} diff --git a/packages/cli/src/workflow/EnqueueStep.ts b/packages/cli/src/workflow/EnqueueStep.ts new file mode 100644 index 000000000..f46b3ac99 --- /dev/null +++ b/packages/cli/src/workflow/EnqueueStep.ts @@ -0,0 +1,44 @@ +import type { EnqueueFilesResult } from 'generaltranslation/types'; +import { WorkflowStep } from './Workflow.js'; +import { createSpinner } from '../console/logging.js'; +import { GT } from 'generaltranslation'; +import { Settings } from '../types/index.js'; +import type { FileReference } from 'generaltranslation/types'; +import chalk from 'chalk'; + +export class EnqueueStep extends WorkflowStep< + FileReference[], + EnqueueFilesResult +> { + private spinner = createSpinner('dots'); + private result: EnqueueFilesResult | null = null; + + constructor( + private gt: GT, + private settings: Settings, + private force?: boolean + ) { + super(); + } + + async run(files: FileReference[]): Promise { + this.spinner.start('Enqueuing translations...'); + + this.result = await this.gt.enqueueFiles(files, { + sourceLocale: this.settings.defaultLocale, + targetLocales: this.settings.locales, + publish: this.settings.publish, + requireApproval: this.settings.stageTranslations, + modelProvider: this.settings.modelProvider, + force: this.force, + }); + + return this.result; + } + + async wait(): Promise { + if (this.result) { + this.spinner.stop(chalk.green('Translations enqueued successfully')); + } + } +} diff --git a/packages/cli/src/workflow/PollJobsStep.ts b/packages/cli/src/workflow/PollJobsStep.ts new file mode 100644 index 000000000..0b88663af --- /dev/null +++ b/packages/cli/src/workflow/PollJobsStep.ts @@ -0,0 +1,408 @@ +import chalk from 'chalk'; +import { WorkflowStep } from './Workflow.js'; +import { createProgressBar, logError } from '../console/logging.js'; +import { getLocaleProperties } from 'generaltranslation'; +import { GT } from 'generaltranslation'; +import { EnqueueFilesResult } from 'generaltranslation/types'; +import { TEMPLATE_FILE_NAME } from '../cli/commands/stage.js'; +import type { FileProperties } from '../types/files.js'; + +export type PollJobsInput = { + fileTracker: FileStatusTracker; + fileQueryData: FileProperties[]; + jobData: EnqueueFilesResult; + timeoutDuration: number; + forceRetranslation?: boolean; +}; + +export type FileStatusTracker = { + completed: Map; + inProgress: Map; + failed: Map; + skipped: Map; +}; + +export type PollJobsOutput = { + success: boolean; + fileTracker: FileStatusTracker; +}; + +export class PollTranslationJobsStep extends WorkflowStep< + PollJobsInput, + PollJobsOutput +> { + private spinner: ReturnType | null = null; + private previousProgress = 0; + + constructor(private gt: GT) { + super(); + } + + async run({ + fileTracker, + fileQueryData, + jobData, + timeoutDuration, + forceRetranslation, + }: PollJobsInput): Promise { + const startTime = Date.now(); + this.spinner = createProgressBar(fileQueryData.length); + const spinnerMessage = forceRetranslation + ? 'Waiting for retranslation...' + : 'Waiting for translation...'; + this.spinner.start(spinnerMessage); + + // Build a map of branchId:fileId:versionId:locale -> FileProperties + const filePropertiesMap = new Map(); + fileQueryData.forEach((item) => { + filePropertiesMap.set( + `${item.branchId}:${item.fileId}:${item.versionId}:${item.locale}`, + item + ); + }); + + // Initial query to check which files already have translations + const initialFileData = await this.gt.queryFileData({ + translatedFiles: fileQueryData.map((item) => ({ + fileId: item.fileId, + versionId: item.versionId, + branchId: item.branchId, + locale: item.locale, + })), + }); + const existingTranslations = initialFileData.translatedFiles || []; + + // Mark all existing translations as completed + existingTranslations.forEach((translation) => { + if (!translation.completedAt) { + return; + } + const fileKey = `${translation.branchId}:${translation.fileId}:${translation.versionId}:${translation.locale}`; + const fileProperties = filePropertiesMap.get(fileKey); + if (fileProperties) { + fileTracker.completed.set(fileKey, fileProperties); + } + }); + + // Build a map of jobs for quick lookup: + // branchId:fileId:versionId:locale -> job + const jobMap = new Map< + string, + (typeof jobData.jobData)[number] & { jobId: string } + >(); + Object.entries(jobData.jobData).forEach(([jobId, job]) => { + const key = `${job.branchId}:${job.fileId}:${job.versionId}:${job.targetLocale}`; + jobMap.set(key, { ...job, jobId }); + }); + + // Build a map of jobs for quick lookup: + // jobId -> file data for the job + const jobFileMap = new Map< + string, + { + branchId: string; + fileId: string; + versionId: string; + locale: string; + } + >(); + Object.entries(jobData.jobData).forEach(([jobId, job]) => { + jobFileMap.set(jobId, { + branchId: job.branchId, + fileId: job.fileId, + versionId: job.versionId, + locale: job.targetLocale, + }); + }); + + // Categorize each file query item + for (const item of fileQueryData) { + const fileKey = `${item.branchId}:${item.fileId}:${item.versionId}:${item.locale}`; + + // Check if translation already exists (completedAt is truthy) + const existingTranslation = fileTracker.completed.get(fileKey); + + if (existingTranslation) { + continue; + } + + // Check if there's a job for this file + const jobKey = `${item.branchId}:${item.fileId}:${item.versionId}:${item.locale}`; + const job = jobMap.get(jobKey); + + if (job) { + // Job exists - mark as in progress initially + fileTracker.inProgress.set(fileKey, item); + } else { + // No job and no existing translation - mark as skipped + fileTracker.skipped.set(fileKey, item); + } + } + + // Update spinner with initial status + this.updateSpinner(fileTracker, fileQueryData); + + // If force retranslation, don't skip the initial check + if (!forceRetranslation) { + // Check if all jobs are already complete + if (fileTracker.inProgress.size === 0) { + this.spinner.stop(chalk.green('All translations ready')); + return { success: true, fileTracker }; + } + } + + // Calculate time until next 5-second interval since startTime + const msUntilNextInterval = Math.max( + 0, + 5000 - ((Date.now() - startTime) % 5000) + ); + + return new Promise((resolve) => { + let intervalCheck: NodeJS.Timeout; + + setTimeout(() => { + intervalCheck = setInterval(async () => { + try { + // Query job status + const jobIds = Array.from(jobFileMap.keys()); + const jobStatusResponse = await this.gt.checkJobStatus(jobIds); + + // Update status based on job completion + for (const job of jobStatusResponse) { + const jobFileProperties = jobFileMap.get(job.jobId); + if (jobFileProperties) { + const fileKey = `${jobFileProperties.branchId}:${jobFileProperties.fileId}:${jobFileProperties.versionId}:${jobFileProperties.locale}`; + const fileProperties = filePropertiesMap.get(fileKey); + if (!fileProperties) { + continue; + } + if (job.status === 'completed') { + fileTracker.completed.set(fileKey, fileProperties); + fileTracker.inProgress.delete(fileKey); + jobFileMap.delete(job.jobId); + } else if (job.status === 'failed') { + fileTracker.failed.set(fileKey, fileProperties); + fileTracker.inProgress.delete(fileKey); + jobFileMap.delete(job.jobId); + } else if (job.status === 'unknown') { + fileTracker.skipped.set(fileKey, fileProperties); + fileTracker.inProgress.delete(fileKey); + jobFileMap.delete(job.jobId); + } + } + } + + // Update spinner + this.updateSpinner(fileTracker, fileQueryData); + + const elapsed = Date.now() - startTime; + const allJobsProcessed = fileTracker.inProgress.size === 0; + + if (allJobsProcessed || elapsed >= timeoutDuration * 1000) { + clearInterval(intervalCheck); + + if (fileTracker.inProgress.size === 0) { + this.spinner!.stop(chalk.green('Translation jobs finished')); + resolve({ success: true, fileTracker }); + } else { + this.spinner!.stop( + chalk.red('Timed out waiting for translation jobs') + ); + resolve({ success: false, fileTracker }); + } + } + } catch (error) { + logError(chalk.red('Error checking job status: ') + error); + } + }, 5000); + }, msUntilNextInterval); + }); + } + + private updateSpinner( + fileTracker: FileStatusTracker, + fileQueryData: FileProperties[] + ): void { + if (!this.spinner) return; + + const statusText = this.generateStatusSuffixText( + fileTracker, + fileQueryData + ); + const currentProgress = + fileTracker.completed.size + + fileTracker.failed.size + + fileTracker.skipped.size; + const progressDelta = currentProgress - this.previousProgress; + this.spinner.advance(progressDelta, statusText); + this.previousProgress = currentProgress; + } + + private generateStatusSuffixText( + fileTracker: FileStatusTracker, + fileQueryData: FileProperties[] + ): string { + // Simple progress indicator + const progressText = `${chalk.green( + `[${ + fileTracker.completed.size + + fileTracker.failed.size + + fileTracker.skipped.size + }/${fileQueryData.length}]` + )} translations completed`; + + // Get terminal height to adapt our output + const terminalHeight = process.stdout.rows || 24; + + // If terminal is very small, just show the basic progress + if (terminalHeight < 6) { + return progressText; + } + + const newSuffixText = [progressText]; + + // Organize data by filename : locale + const fileStatus = new Map< + string, + { + completed: Set; + pending: Set; + failed: Set; + skipped: Set; + } + >(); + + // Initialize with all files and locales from fileQueryData + for (const item of fileQueryData) { + if (!fileStatus.has(item.fileName)) { + fileStatus.set(item.fileName, { + completed: new Set(), + pending: new Set([item.locale]), + failed: new Set(), + skipped: new Set(), + }); + } else { + fileStatus.get(item.fileName)?.pending.add(item.locale); + } + } + + // Mark which ones are completed, failed, or skipped + for (const [_, fileProperties] of fileTracker.completed) { + const { fileName, locale } = fileProperties; + const status = fileStatus.get(fileName); + if (status) { + status.pending.delete(locale); + status.completed.add(locale); + } + } + + for (const [_, fileProperties] of fileTracker.failed) { + const { fileName, locale } = fileProperties; + const status = fileStatus.get(fileName); + if (status) { + status.pending.delete(locale); + status.failed.add(locale); + } + } + + for (const [_, fileProperties] of fileTracker.skipped) { + const { fileName, locale } = fileProperties; + const status = fileStatus.get(fileName); + if (status) { + status.pending.delete(locale); + status.skipped.add(locale); + } + } + + // Calculate how many files we can show based on terminal height + const filesArray = Array.from(fileStatus.entries()); + const maxFilesToShow = Math.min( + filesArray.length, + terminalHeight - 3 // Header + progress + buffer + ); + + // Display each file with its status on a single line + for (let i = 0; i < maxFilesToShow; i++) { + const [fileName, status] = filesArray[i]; + + // Create condensed locale status + const localeStatuses: { locale: string; status: string }[] = []; + + // Add completed locales (green) + if (status.completed.size > 0) { + localeStatuses.push( + ...Array.from(status.completed).map((locale) => ({ + locale, + status: 'completed', + })) + ); + } + + // Add skipped locales (green) + if (status.skipped.size > 0) { + localeStatuses.push( + ...Array.from(status.skipped).map((locale) => ({ + locale, + status: 'skipped', + })) + ); + } + + // Add failed locales (red) + if (status.failed.size > 0) { + localeStatuses.push( + ...Array.from(status.failed).map((locale) => ({ + locale, + status: 'failed', + })) + ); + } + + // Add pending locales (yellow) + if (status.pending.size > 0) { + localeStatuses.push( + ...Array.from(status.pending).map((locale) => ({ + locale, + status: 'pending', + })) + ); + } + + // Sort localeStatuses by locale + localeStatuses.sort((a, b) => a.locale.localeCompare(b.locale)); + + // Add colors + const localeString = localeStatuses + .map((locale) => { + if (locale.status === 'completed') { + return chalk.green(locale.locale); + } else if (locale.status === 'skipped') { + return chalk.gray(locale.locale); + } else if (locale.status === 'failed') { + return chalk.red(locale.locale); + } else if (locale.status === 'pending') { + return chalk.yellow(locale.locale); + } + }) + .join(', '); + + // Format the line + const prettyFileName = + fileName === TEMPLATE_FILE_NAME ? '' : fileName; + newSuffixText.push(`${chalk.bold(prettyFileName)} [${localeString}]`); + } + + // If we couldn't show all files, add an indicator + if (filesArray.length > maxFilesToShow) { + newSuffixText.push( + `... and ${filesArray.length - maxFilesToShow} more files` + ); + } + + return newSuffixText.join('\n'); + } + + async wait(): Promise { + return; + } +} diff --git a/packages/cli/src/workflow/SetupStep.ts b/packages/cli/src/workflow/SetupStep.ts new file mode 100644 index 000000000..fe52c52fe --- /dev/null +++ b/packages/cli/src/workflow/SetupStep.ts @@ -0,0 +1,99 @@ +import { FileReference } from 'generaltranslation/types'; +import { WorkflowStep } from './Workflow.js'; +import { createSpinner, logInfo } from '../console/logging.js'; +import { GT } from 'generaltranslation'; +import { Settings } from '../types/index.js'; +import chalk from 'chalk'; + +export class SetupStep extends WorkflowStep { + private spinner = createSpinner('dots'); + private setupJobId: string | null = null; + private files: FileReference[] | null = null; + private completed = false; + + constructor( + private gt: GT, + private settings: Settings, + private timeoutMs: number + ) { + super(); + } + + async run(files: FileReference[]): Promise { + this.files = files; + this.spinner.start('Setting up project...'); + + if (files.length === 0) { + this.completed = true; + return []; + } + + const result = await this.gt.setupProject(files, { + locales: this.settings.locales, + }); + + if (result.status === 'completed') { + this.completed = true; + return files; + } + + if (result.status === 'queued') { + this.setupJobId = result.setupJobId; + return files; + } + + // Unknown status + this.completed = true; + return files; + } + + async wait(): Promise { + if (this.completed) { + this.spinner.stop(chalk.green('Setup successfully completed')); + return; + } + + if (!this.setupJobId) { + this.spinner.stop( + chalk.yellow('Setup status unknown — proceeding without setup') + ); + return; + } + + // Poll for completion + const start = Date.now(); + const pollInterval = 5000; // 5 seconds + + while (Date.now() - start < this.timeoutMs) { + const status = await this.gt.checkJobStatus([this.setupJobId]); + + if (!status[0]) { + this.spinner.stop( + chalk.yellow('Setup status unknown — proceeding without setup') + ); + return; + } + + if (status[0].status === 'completed') { + this.spinner.stop(chalk.green('Setup successfully completed')); + return; + } + + if (status[0].status === 'failed') { + this.spinner.stop( + chalk.yellow( + `Setup failed: ${status[0].error?.message || 'Unknown error'} — proceeding without setup` + ) + ); + return; + } + + await new Promise((r) => setTimeout(r, pollInterval)); + } + + // Timeout + this.spinner.stop( + chalk.yellow('Setup timed out — proceeding without setup') + ); + } +} diff --git a/packages/cli/src/workflow/UploadStep.ts b/packages/cli/src/workflow/UploadStep.ts new file mode 100644 index 000000000..63b40af89 --- /dev/null +++ b/packages/cli/src/workflow/UploadStep.ts @@ -0,0 +1,111 @@ +import type { FileToUpload } from 'generaltranslation/types'; +import { WorkflowStep } from './Workflow.js'; +import { createSpinner, logInfo } from '../console/logging.js'; +import { GT } from 'generaltranslation'; +import { Settings } from '../types/index.js'; +import chalk from 'chalk'; +import { BranchData } from '../types/branch.js'; +import type { FileDataResult, FileReference } from 'generaltranslation/types'; + +export class UploadStep extends WorkflowStep< + { files: FileToUpload[]; branchData: BranchData }, + FileReference[] +> { + private spinner = createSpinner('dots'); + private result: FileReference[] | null = null; + + constructor( + private gt: GT, + private settings: Settings + ) { + super(); + } + + async run({ + files, + branchData, + }: { + files: FileToUpload[]; + branchData: BranchData; + }): Promise { + if (files.length === 0) { + logInfo('No files to upload found... skipping upload step'); + return []; + } + + this.spinner.start( + `Syncing ${files.length} file${files.length !== 1 ? 's' : ''} with General Translation API...` + ); + + // First, figure out which files need to be uploaded + + const fileData = await this.gt.queryFileData({ + sourceFiles: files.map((f) => ({ + fileId: f.fileId, + versionId: f.versionId, + branchId: f.branchId ?? branchData.currentBranch.id, + })), + }); + + // build a map of branch:fileId:versionId to fileData + const fileDataMap = new Map< + string, + NonNullable[number] + >(); + fileData.sourceFiles?.forEach((f) => { + fileDataMap.set(`${f.branchId}:${f.fileId}:${f.versionId}`, f); + }); + + // Build a list of files that need to be uploaded + const filesToUpload: FileToUpload[] = []; + const filesToSkipUpload: FileToUpload[] = []; + files.forEach((f) => { + if ( + fileDataMap.has( + `${f.branchId ?? branchData.currentBranch.id}:${f.fileId}:${f.versionId}` + ) + ) { + filesToSkipUpload.push(f); + } else { + filesToUpload.push(f); + } + }); + + const response = await this.gt.uploadSourceFiles( + filesToUpload.map((f) => ({ + source: { + ...f, + branchId: f.branchId ?? branchData.currentBranch.id, + locale: this.settings.defaultLocale, + incomingBranchId: branchData.incomingBranch?.id, + checkedOutBranchId: branchData.checkedOutBranch?.id, + }, + })), + { + sourceLocale: this.settings.defaultLocale, + modelProvider: this.settings.modelProvider, + } + ); + + this.result = response.uploadedFiles; + + // Merge files that were already uploaded into the result + this.result.push( + ...filesToSkipUpload.map((f) => ({ + fileId: f.fileId, + versionId: f.versionId, + branchId: f.branchId ?? branchData.currentBranch.id, + fileName: f.fileName, + fileFormat: f.fileFormat, + dataFormat: f.dataFormat, + })) + ); + this.spinner.stop(chalk.green('Files uploaded successfully')); + + return this.result; + } + + async wait(): Promise { + return; + } +} diff --git a/packages/cli/src/workflow/UserEditDiffsStep.ts b/packages/cli/src/workflow/UserEditDiffsStep.ts new file mode 100644 index 000000000..64f9c6cb3 --- /dev/null +++ b/packages/cli/src/workflow/UserEditDiffsStep.ts @@ -0,0 +1,38 @@ +import type { FileReference } from 'generaltranslation/types'; +import { WorkflowStep } from './Workflow.js'; +import { createSpinner } from '../console/logging.js'; +import { Settings } from '../types/index.js'; +import chalk from 'chalk'; +import { collectAndSendUserEditDiffs } from '../api/collectUserEditDiffs.js'; + +export class UserEditDiffsStep extends WorkflowStep< + FileReference[], + FileReference[] +> { + private spinner = createSpinner('dots'); + private completed = false; + + constructor(private settings: Settings) { + super(); + } + + async run(files: FileReference[]): Promise { + this.spinner.start('Updating translations...'); + + try { + await collectAndSendUserEditDiffs(files, this.settings); + this.completed = true; + } catch { + // Non-fatal; keep going to enqueue + this.completed = true; + } + + return files; + } + + async wait(): Promise { + if (this.completed) { + this.spinner.stop(chalk.green('Updated translations')); + } + } +} diff --git a/packages/cli/src/workflow/Workflow.ts b/packages/cli/src/workflow/Workflow.ts new file mode 100644 index 000000000..18ff36232 --- /dev/null +++ b/packages/cli/src/workflow/Workflow.ts @@ -0,0 +1,5 @@ +export abstract class WorkflowStep { + abstract run(input: TInput): Promise; + + abstract wait(): Promise; +} diff --git a/packages/cli/src/workflow/download.ts b/packages/cli/src/workflow/download.ts new file mode 100644 index 000000000..e450fd352 --- /dev/null +++ b/packages/cli/src/workflow/download.ts @@ -0,0 +1,156 @@ +import path from 'node:path'; +import { Settings } from '../types/index.js'; +import { gt } from '../utils/gt.js'; +import { EnqueueFilesResult } from 'generaltranslation/types'; +import { clearLocaleDirs } from '../fs/clearLocaleDirs.js'; +import { FileStatusTracker, PollTranslationJobsStep } from './PollJobsStep.js'; +import { DownloadTranslationsStep } from './DownloadStep.js'; +import { BranchData } from '../types/branch.js'; +import { logErrorAndExit } from '../console/logging.js'; +import { BranchStep } from './BranchStep.js'; +import { FileProperties } from '../types/files.js'; + +export type FileTranslationData = { + [fileId: string]: { + versionId: string; + fileName: string; + }; +}; + +/** + * Checks the status of translations and downloads them using a workflow pattern + * @param fileVersionData - Mapping of file IDs to their version and name information + * @param jobData - Optional job data from enqueue operation + * @param locales - The locales to wait for + * @param timeoutDuration - The timeout duration for the wait in seconds + * @param resolveOutputPath - Function to resolve the output path for a given source path and locale + * @param options - Settings configuration + * @param forceRetranslation - Whether to force retranslation + * @param forceDownload - Whether to force download even if file exists + * @returns True if all translations are downloaded successfully, false otherwise + */ +export async function downloadTranslations( + fileVersionData: FileTranslationData, + jobData: EnqueueFilesResult | undefined, + branchData: BranchData | undefined, + locales: string[], + timeoutDuration: number, + resolveOutputPath: (sourcePath: string, locale: string) => string | null, + options: Settings, + forceRetranslation?: boolean, + forceDownload?: boolean +): Promise { + if (!branchData) { + // Run the branch step + const branchStep = new BranchStep(gt, options); + const branchResult = await branchStep.run(); + await branchStep.wait(); + if (!branchResult) { + logErrorAndExit('Failed to resolve git branch information.'); + } + branchData = branchResult; + } + // Prepare the query data + const fileQueryData = prepareFileQueryData( + fileVersionData, + locales, + branchData + ); + + // Clear translated files before any downloads (if enabled) + if ( + options.options?.experimentalClearLocaleDirs === true && + fileQueryData.length > 0 + ) { + const translatedFiles = new Set( + fileQueryData + .map((file) => { + const outputPath = resolveOutputPath(file.fileName, file.locale); + // Only clear if the output path is different from the source (i.e., there's a transform) + return outputPath !== null && outputPath !== file.fileName + ? outputPath + : null; + }) + .filter((path): path is string => path !== null) + ); + + // Derive cwd from config path + const cwd = path.dirname(options.config); + + await clearLocaleDirs( + translatedFiles, + locales, + options.options?.clearLocaleDirsExclude, + cwd + ); + } + + // Initialize download status + const fileTracker: FileStatusTracker = { + completed: new Map(), + inProgress: new Map(), + failed: new Map(), + skipped: new Map(), + }; + + // Step 1: Poll translation jobs if jobData exists + if (jobData) { + const pollStep = new PollTranslationJobsStep(gt); + const pollResult = await pollStep.run({ + fileTracker, + fileQueryData, + jobData, + timeoutDuration, + forceRetranslation, + }); + await pollStep.wait(); + + if (!pollResult.success) { + return false; + } + } else { + for (const file of fileQueryData) { + // Staging - assume all files are completed + fileTracker.completed.set( + `${file.branchId}:${file.fileId}:${file.versionId}:${file.locale}`, + file + ); + } + } + + // Step 2: Download translations + const downloadStep = new DownloadTranslationsStep(gt, options); + const downloadResult = await downloadStep.run({ + fileTracker, + resolveOutputPath, + forceDownload, + }); + await downloadStep.wait(); + + return downloadResult; +} + +/** + * Prepares the file query data from input data and locales + */ +function prepareFileQueryData( + fileVersionData: FileTranslationData, + locales: string[], + branchData: BranchData +): FileProperties[] { + const fileQueryData: FileProperties[] = []; + + for (const fileId in fileVersionData) { + for (const locale of locales) { + fileQueryData.push({ + versionId: fileVersionData[fileId].versionId, + fileName: fileVersionData[fileId].fileName, + fileId, + locale, + branchId: branchData.currentBranch.id, + }); + } + } + + return fileQueryData; +} diff --git a/packages/cli/src/workflow/stage.ts b/packages/cli/src/workflow/stage.ts new file mode 100644 index 000000000..30bdd8e9d --- /dev/null +++ b/packages/cli/src/workflow/stage.ts @@ -0,0 +1,99 @@ +import chalk from 'chalk'; +import { logErrorAndExit, logMessage } from '../console/logging.js'; +import { Settings, TranslateFlags } from '../types/index.js'; +import { gt } from '../utils/gt.js'; +import { EnqueueFilesResult, FileToUpload } from 'generaltranslation/types'; +import { TEMPLATE_FILE_NAME } from '../cli/commands/stage.js'; +import { UploadStep } from './UploadStep.js'; +import { SetupStep } from './SetupStep.js'; +import { EnqueueStep } from './EnqueueStep.js'; +import { BranchStep } from './BranchStep.js'; +import { UserEditDiffsStep } from './UserEditDiffsStep.js'; +import { BranchData } from '../types/branch.js'; + +/** + * Helper: Calculate timeout with validation + */ +function calculateTimeout(timeout: string | number | undefined): number { + const value = timeout !== undefined ? Number(timeout) : 600; + return (Number.isFinite(value) ? value : 600) * 1000; +} + +/** + * Helper: Log files to be translated + */ +function logFilesToTranslate(files: FileToUpload[]): void { + logMessage( + chalk.cyan('Files found in project:') + + '\n' + + files + .map((file) => { + if (file.fileName === TEMPLATE_FILE_NAME) { + return `- `; + } + return `- ${file.fileName}`; + }) + .join('\n') + ); +} + +/** + * Sends multiple files for translation to the API using a workflow pattern + * @param files - Array of file objects to translate + * @param options - The options for the API call + * @param settings - Settings configuration + * @returns The translated content or version ID + */ +export async function stageFiles( + files: FileToUpload[], + options: TranslateFlags, + settings: Settings +): Promise<{ + branchData: BranchData; + enqueueResult: EnqueueFilesResult; +}> { + try { + // Log files to be translated + logFilesToTranslate(files); + + // Calculate timeout for setup step + const timeoutMs = calculateTimeout(options.timeout); + + // Create workflow with steps + const branchStep = new BranchStep(gt, settings); + const uploadStep = new UploadStep(gt, settings); + const userEditDiffsStep = new UserEditDiffsStep(settings); + const setupStep = new SetupStep(gt, settings, timeoutMs); + const enqueueStep = new EnqueueStep(gt, settings, options.force); + + // first run the branch step + const branchData = await branchStep.run(); + await branchStep.wait(); + + if (!branchData) { + logErrorAndExit('Failed to resolve git branch information.'); + } + + // then run the upload step + const uploadedFiles = await uploadStep.run({ files, branchData }); + await uploadStep.wait(); + + // optionally run the user edit diffs step + if (options?.saveLocal) { + await userEditDiffsStep.run(uploadedFiles); + await userEditDiffsStep.wait(); + } + + // then run the setup step + await setupStep.run(uploadedFiles); + await setupStep.wait(); + + // then run the enqueue step + const enqueueResult = await enqueueStep.run(uploadedFiles); + await enqueueStep.wait(); + + return { branchData, enqueueResult }; + } catch (error) { + logErrorAndExit('Failed to send files for translation. ' + error); + } +} diff --git a/packages/core/package.json b/packages/core/package.json index 023b85a8a..bf666a625 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -82,6 +82,11 @@ "types": "./dist/types.d.ts", "require": "./dist/types.cjs.min.cjs", "import": "./dist/types.esm.min.mjs" + }, + "./errors": { + "types": "./dist/errors.d.ts", + "require": "./dist/errors.cjs.min.cjs", + "import": "./dist/errors.esm.min.mjs" } }, "typesVersions": { @@ -94,6 +99,9 @@ ], "types": [ "./dist/types.d.ts" + ], + "errors": [ + "./dist/errors.d.ts" ] } }, @@ -108,6 +116,9 @@ ], "generaltranslation/types": [ "/dist/types" + ], + "generaltranslation/errors": [ + "/dist/errors" ] } } diff --git a/packages/core/rollup.config.mjs b/packages/core/rollup.config.mjs index 32f716145..718c264ef 100644 --- a/packages/core/rollup.config.mjs +++ b/packages/core/rollup.config.mjs @@ -110,6 +110,41 @@ export default [ plugins: [dts()], }, + // Bundling for the errors module + { + input: 'src/errors.ts', + output: [ + { + file: 'dist/errors.cjs.min.cjs', + format: 'cjs', + exports: 'auto', + sourcemap: true, + }, + { + file: 'dist/errors.esm.min.mjs', + format: 'es', + exports: 'named', + sourcemap: true, + }, + ], + plugins: [ + typescript({ tsconfig: './tsconfig.json' }), + commonjs(), + terser(), + ], + external: [], // External dependencies not bundled in + }, + + // TypeScript declarations for the errors module + { + input: 'src/errors.ts', + output: { + file: 'dist/errors.d.ts', + format: 'es', + }, + plugins: [dts()], + }, + // Bundling for the types module { input: 'src/types.ts', diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 000000000..d952c53ca --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1 @@ +export * from './errors/ApiError'; diff --git a/packages/core/src/errors/ApiError.ts b/packages/core/src/errors/ApiError.ts new file mode 100644 index 000000000..84f2bfed9 --- /dev/null +++ b/packages/core/src/errors/ApiError.ts @@ -0,0 +1,19 @@ +export class ApiError extends Error { + public code: number; + public message: string; + + constructor(error: string, code: number, message: string) { + super(error); + this.name = 'ApiError'; + this.code = code; + this.message = message; + } + + getCode() { + return this.code; + } + + getMessage() { + return this.message; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e19e84306..d2d58a97b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -24,16 +24,10 @@ import { TranslationError, TranslationRequestConfig, TranslationResult, - Updates, - EnqueueEntriesOptions, - EnqueueEntriesResult, EnqueueFilesResult, CheckFileTranslationsOptions, - CheckFileTranslationsResult, DownloadFileBatchOptions, DownloadFileBatchResult, - FetchTranslationsOptions, - FetchTranslationsResult, DownloadFileOptions, EntryMetadata, Entry, @@ -64,29 +58,12 @@ import _setupProject, { SetupProjectResult, SetupProjectOptions, } from './translate/setupProject'; -import { - _checkSetupStatus, - CheckSetupStatusResult, -} from './translate/checkSetupStatus'; -import _shouldSetupProject, { - ShouldSetupProjectResult, -} from './translate/shouldSetupProject'; import _enqueueFiles, { EnqueueOptions } from './translate/enqueueFiles'; -import _enqueueEntries from './translate/enqueueEntries'; -import _checkFileTranslations from './translate/checkFileTranslations'; -import _downloadFile, { _downloadFileV2 } from './translate/downloadFile'; import _downloadFileBatch from './translate/downloadFileBatch'; -import _fetchTranslations from './translate/fetchTranslations'; import { FileQuery, FileQueryResult, - FileTranslationQuery, } from './types-dir/api/checkFileTranslations'; -import { - CheckTranslationStatusOptions, - TranslationStatusResult, -} from './types-dir/api/translationStatus'; -import _checkTranslationStatus from './translate/checkTranslationStatus'; import _submitUserEditDiffs, { SubmitUserEditDiffsPayload, } from './translate/submitUserEditDiffs'; @@ -100,7 +77,6 @@ import _uploadSourceFiles from './translate/uploadSourceFiles'; import _uploadTranslations from './translate/uploadTranslations'; import { FileUpload, - FileUploadRef, RequiredUploadFilesOptions, UploadFilesOptions, UploadFilesResponse, @@ -108,6 +84,22 @@ import { import _querySourceFile from './translate/querySourceFile'; import { ProjectData } from './types-dir/api/project'; import _getProjectData from './projects/getProjectData'; +import { DownloadFileBatchRequest } from './types-dir/api/downloadFileBatch'; +import { + _checkJobStatus, + CheckJobStatusResult, +} from './translate/checkJobStatus'; +import type { FileDataQuery, FileDataResult } from './translate/queryFileData'; +import _queryFileData from './translate/queryFileData'; +import type { BranchQuery } from './translate/queryBranchData'; +import type { BranchDataResult } from './types-dir/api/branch'; +import _queryBranchData from './translate/queryBranchData'; +import type { + CreateBranchQuery, + CreateBranchResult, +} from './translate/createBranch'; +import _createBranch from './translate/createBranch'; +import type { FileReference } from './types-dir/api/file'; // ============================================================ // // Core Class // @@ -293,12 +285,10 @@ export class GT { const errors: string[] = []; if (!this.apiKey && !this.devApiKey) { const error = noApiKeyProvidedError(functionName); - gtInstanceLogger.error(error); errors.push(error); } if (!this.projectId) { const error = noProjectIdProvidedError(functionName); - gtInstanceLogger.error(error); errors.push(error); } if (errors.length) { @@ -306,66 +296,30 @@ export class GT { } } - // -------------- Translation Methods -------------- // + // -------------- Branch Methods -------------- // /** - * Enqueues translation entries for processing. - * - * @param {Updates} updates - The translation entries to enqueue. - * @param {EnqueueEntriesOptions} options - Options for enqueueing entries. - * @param {string} library - The library being used (for context). - * @returns {Promise} The result of the enqueue operation. - * - * @example - * @deprecated Use the {@link enqueueFiles} method instead. Will be removed in v8.0.0. - * const gt = new GT({ - * sourceLocale: 'en-US', - * targetLocale: 'es-ES', - * locales: ['en-US', 'es-ES', 'fr-FR'] - * }); + * Queries branch information from the API. * - * const result = await gt.enqueueEntries([ - * { - * content: 'Hello, world!', - * fileName: 'Button.tsx', - * fileFormat: 'TS', - * dataFormat: 'JSX', - * }, - * ], { - * sourceLocale: 'en-US', - * targetLocales: ['es-ES', 'fr-FR'], - * publish: true, - * description: 'Translations for the Button component', - * }); + * @param {BranchQuery} query - Object mapping the current branch and incoming branches + * @returns {Promise} The branch information */ - async enqueueEntries( - updates: Updates, - options: EnqueueEntriesOptions = {} - ): Promise { - // Validation - this._validateAuth('enqueueTranslationEntries'); - - // Merge instance settings with options - let mergedOptions: EnqueueEntriesOptions = { - ...options, - sourceLocale: options.sourceLocale ?? this.sourceLocale, - }; - - // Replace target locales with canonical locales - mergedOptions = { - ...mergedOptions, - targetLocales: mergedOptions.targetLocales?.map((locale) => - this.resolveCanonicalLocale(locale) - ), - }; + async queryBranchData(query: BranchQuery): Promise { + this._validateAuth('queryBranchData'); + return await _queryBranchData(query, this._getTranslationConfig()); + } - // Request the translation entry updates - return await _enqueueEntries( - updates, - mergedOptions, - this._getTranslationConfig() - ); + /** + * Creates a new branch in the API. If the branch already exists, it will be returned. + * + * @param {CreateBranchQuery} query - Object mapping the branch name and default branch flag + * @returns {Promise} The created branch information + */ + async createBranch(query: CreateBranchQuery): Promise { + this._validateAuth('createBranch'); + return await _createBranch(query, this._getTranslationConfig()); } + // -------------- Translation Methods -------------- // /** * Enqueues project setup job using the specified file references @@ -375,12 +329,12 @@ export class GT { * files that have already been uploaded via uploadSourceFiles. The setup jobs are queued * for processing and will generate a project setup based on the source files. * - * @param {FileUploadRef[]} files - Array of file references containing IDs of previously uploaded source files + * @param {FileReference[]} files - Array of file references containing IDs of previously uploaded source files * @param {SetupProjectOptions} [options] - Optional settings for target locales and timeout * @returns {Promise} Object containing the jobId and status */ async setupProject( - files: FileUploadRef[], + files: FileReference[], options?: SetupProjectOptions ): Promise { this._validateAuth('setupProject'); @@ -394,41 +348,35 @@ export class GT { } /** - * Checks the current status of a project setup job by its unique identifier. + * Checks the current status of one or more project jobs by their unique identifiers. * - * This method polls the API to determine whether a setup job is still running, - * has completed successfully, or has failed. Setup jobs are created when - * uploading source files to initialize project translation workflows. + * This method polls the API to determine whether one or more jobs are still running, + * have completed successfully, or have failed. Jobs are created after calling either enqueueFiles or setupProject. * - * @param {string} jobId - The unique identifier of the setup job to check + * @param {string[]} jobIds - The unique identifiers of the jobs to check * @param {number} [timeoutMs] - Optional timeout in milliseconds for the API request - * @returns {Promise} Object containing the job status + * @returns {Promise} Object containing the job status + * + * @example + * const result = await gt.checkJobStatus([ + * 'job-123', + * 'job-456', + * ], { + * timeout: 10000, + * }); */ - async checkSetupStatus( - jobId: string, + async checkJobStatus( + jobIds: string[], timeoutMs?: number - ): Promise { - this._validateAuth('checkSetupStatus'); - return await _checkSetupStatus( - jobId, + ): Promise { + this._validateAuth('checkJobStatus'); + return await _checkJobStatus( + jobIds, this._getTranslationConfig(), timeoutMs ); } - /** - * Checks if a prpject requires setup. - * - * This method queries API to check if a project has been set up and returns - * true if setup is missing - * - * @returns {Promise} Object containing shouldSetupProject - */ - async shouldSetupProject(): Promise { - this._validateAuth('shouldSetupProject'); - return await _shouldSetupProject(this._getTranslationConfig()); - } - /** * Enqueues translation jobs for previously uploaded source files. * @@ -438,12 +386,12 @@ export class GT { * uploadSourceFiles. The translation jobs are queued for processing and will * generate translated content based on the source files and target locales provided. * - * @param {FileUploadRef[]} files - Array of file references containing IDs of previously uploaded source files + * @param {FileReference[]} files - Array of file references containing IDs of previously uploaded source files * @param {EnqueueOptions} options - Configuration options including source locale, target locales, and job settings * @returns {Promise} Result containing job IDs, queue status, and processing information */ async enqueueFiles( - files: FileUploadRef[], + files: FileReference[], options: EnqueueOptions ): Promise { // Validation @@ -510,51 +458,56 @@ export class GT { } /** - * Checks the translation status of files. + * Queries data about one or more source or translation files. * - * @param {Object} data - Object mapping source paths to file information. - * @param {CheckFileTranslationsOptions} options - Options for checking file translations. - * @returns {Promise} The file translation status information. + * @param {FileDataQuery} data - Object mapping source and translation file information. + * @param {CheckFileTranslationsOptions} options - Options for the API call. + * @returns {Promise} The source and translation file data information. * * @example - * const gt = new GT({ - * sourceLocale: 'en-US', - * targetLocale: 'es-ES', - * locales: ['en-US', 'es-ES', 'fr-FR'] - * }); - * - * const result = await gt.checkFileTranslations([ - * { sourcePath: 'src/components/Button.tsx', locale: 'es-ES' }, - * { sourcePath: 'src/components/Input.tsx', locale: 'fr-FR' }, - * ], { + * const result = await gt.queryFileData({ + * sourceFiles: [ + * { fileId: '1234567890', versionId: '1234567890', branchId: '1234567890' }, + * ], + * translatedFiles: [ + * { fileId: '1234567890', versionId: '1234567890', branchId: '1234567890', locale: 'es-ES' }, + * ], + * }, { * timeout: 10000, * }); * */ - async checkFileTranslations( - data: FileTranslationQuery[], + async queryFileData( + data: FileDataQuery, options: CheckFileTranslationsOptions = {} - ): Promise { + ): Promise { // Validation - this._validateAuth('checkFileTranslations'); + this._validateAuth('queryFileData'); // Replace target locales with canonical locales - data = data.map((item) => ({ + data.translatedFiles = data.translatedFiles?.map((item) => ({ ...item, locale: this.resolveCanonicalLocale(item.locale), })); // Request the file translation status - const result = await _checkFileTranslations( + const result = await _queryFileData( data, options, this._getTranslationConfig() ); // Resolve canonical locales - result.translations = result.translations.map((item) => ({ + result.translatedFiles = result.translatedFiles?.map((item) => ({ ...item, - locale: this.resolveAliasLocale(item.locale), + ...(item.locale && { locale: this.resolveAliasLocale(item.locale) }), + })); + result.sourceFiles = result.sourceFiles?.map((item) => ({ + ...item, + ...(item.sourceLocale && { + sourceLocale: this.resolveAliasLocale(item.sourceLocale), + }), + locales: item.locales.map((locale) => this.resolveAliasLocale(locale)), })); return result; } @@ -567,12 +520,6 @@ export class GT { * @returns {Promise} The source file and translation information. * * @example - * const gt = new GT({ - * sourceLocale: 'en-US', - * targetLocale: 'es-ES', - * locales: ['en-US', 'es-ES', 'fr-FR'] - * }); - * * const result = await gt.querySourceFile( * { fileId: '1234567890', versionId: '1234567890' }, * { timeout: 10000 } @@ -595,14 +542,16 @@ export class GT { // Replace locales with canonical locales result.translations = result.translations.map((item) => ({ ...item, - locale: this.resolveAliasLocale(item.locale), + ...(item.locale && { locale: this.resolveAliasLocale(item.locale) }), })); result.sourceFile.locales = result.sourceFile.locales.map((locale) => this.resolveAliasLocale(locale) ); - result.sourceFile.sourceLocale = this.resolveAliasLocale( - result.sourceFile.sourceLocale - ); + if (result.sourceFile.sourceLocale) { + result.sourceFile.sourceLocale = this.resolveAliasLocale( + result.sourceFile.sourceLocale + ); + } return result; } /** @@ -612,12 +561,6 @@ export class GT { * @returns {Promise} The project data. * * @example - * const gt = new GT({ - * sourceLocale: 'en-US', - * targetLocale: 'es-ES', - * locales: ['en-US', 'es-ES', 'fr-FR'] - * }); - * * const result = await gt.getProjectData( * '1234567890' * ); @@ -643,97 +586,33 @@ export class GT { result.defaultLocale = this.resolveAliasLocale(result.defaultLocale); return result; } - /** - * Checks the translation status of a version. - * - * @param {string} versionId - The ID of the version to check. - * @param {CheckTranslationStatusOptions} options - Options for checking the translation status. - * @returns {Promise} The translation status of the version. - * - * @example - * @deprecated Use the {@link checkFileTranslations} method instead. Will be removed in v7.0.0. - * const gt = new GT({ - * sourceLocale: 'en-US', - * targetLocale: 'es-ES', - * locales: ['en-US', 'es-ES', 'fr-FR'] - * }); - * - * const result = await gt.checkTranslationStatus('1234567890', { - * timeout: 10000, - * }); - */ - async checkTranslationStatus( - versionId: string, - options: CheckTranslationStatusOptions = {} - ): Promise { - // Validation - this._validateAuth('checkTranslationStatus'); - - // Request the translation status - return await _checkTranslationStatus( - versionId, - options, - this._getTranslationConfig() - ); - } /** - * Downloads a single translation file. + * Downloads a single file. * - * @param {string} translationId - The ID of the translation to download. - * @param {DownloadFileOptions} options - Options for downloading the file. - * @returns {Promise} The downloaded file content and metadata. - * @deprecated Use the {@link downloadTranslatedFile} method instead. Will be removed in v7.0.0. - * @example - * const gt = new GT({ - * sourceLocale: 'en-US', - * targetLocale: 'es-ES', - * locales: ['en-US', 'es-ES', 'fr-FR'] - * }); - * - * const result = await gt.downloadFile('1234567890', { - * timeout: 10000, - * }); - */ - async downloadFile( - translationId: string, - options: DownloadFileOptions = {} - ): Promise { - // Validation - this._validateAuth('downloadFile'); - - return await _downloadFile( - translationId, - options, - this._getTranslationConfig() - ); - } - /** - * Downloads a single translated file. - * - * @param {string} file - The file to download. + * @param file - The file query object. + * @param {string} file.fileId - The ID of the file to download. + * @param {string} [file.branchId] - The ID of the branch to download the file from. If not provided, the default branch will be used. + * @param {string} [file.locale] - The locale to download the file for. If not provided, the source file will be downloaded. + * @param {string} [file.versionId] - The version ID to download the file from. If not provided, the latest version will be used. * @param {DownloadFileOptions} options - Options for downloading the file. * @returns {Promise} The downloaded file content. * * @example - * const gt = new GT({ - * sourceLocale: 'en-US', - * targetLocale: 'es-ES', - * locales: ['en-US', 'es-ES', 'fr-FR'] - * }); - * - * const result = await gt.downloadTranslatedFile({ + * const result = await gt.downloadFile({ * fileId: '1234567890', + * branchId: '1234567890', * locale: 'es-ES', * versionId: '1234567890', * }, { * timeout: 10000, * }); */ - async downloadTranslatedFile( + async downloadFile( file: { fileId: string; - locale: string; + branchId?: string; + locale?: string; versionId?: string; }, options: DownloadFileOptions = {} @@ -741,73 +620,63 @@ export class GT { // Validation this._validateAuth('downloadTranslatedFile'); - file.locale = this.resolveCanonicalLocale(file.locale); - - return await _downloadFileV2(file, options, this._getTranslationConfig()); + const result = await _downloadFileBatch( + [ + { + fileId: file.fileId, + branchId: file.branchId, + locale: this.resolveCanonicalLocale(file.locale), + versionId: file.versionId, + }, + ], + options, + this._getTranslationConfig() + ); + return result.data[0].data; } /** - * Downloads multiple translation files in a batch. + * Downloads multiple files in a batch. * - * @param {string[]} fileIds - Array of file IDs to download. + * @param {DownloadFileBatchRequest} requests - Array of file query objects to download. * @param {DownloadFileBatchOptions} options - Options for the batch download. * @returns {Promise} The batch download results. * * @example - * const gt = new GT({ - * sourceLocale: 'en-US', - * targetLocale: 'es-ES', - * locales: ['en-US', 'es-ES', 'fr-FR'] - * }); - * - * const result = await gt.downloadFileBatch(['1234567890', '1234567891'], { + * const result = await gt.downloadFileBatch([{ + * fileId: '1234567890', + * locale: 'es-ES', + * versionId: '1234567890', + * }], { * timeout: 10000, * }); */ async downloadFileBatch( - fileIds: string[], + requests: DownloadFileBatchRequest, options: DownloadFileBatchOptions = {} ): Promise { // Validation this._validateAuth('downloadFileBatch'); + requests = requests.map((request) => ({ + ...request, + locale: this.resolveCanonicalLocale(request.locale), + })); + // Request the batch download - return await _downloadFileBatch( - fileIds, + const result = await _downloadFileBatch( + requests, options, this._getTranslationConfig() ); - } - - /** - * Fetches translation metadata and information. - * - * @param {string} versionId - The version ID to fetch translations for. - * @param {FetchTranslationsOptions} options - Options for fetching translations. - * @returns {Promise} The translation metadata and information. - * - * @example - * const gt = new GT({ - * sourceLocale: 'en-US', - * targetLocale: 'es-ES', - * locales: ['en-US', 'es-ES', 'fr-FR'] - * }); - * - * const result = await gt.fetchTranslations('1234567890'); - */ - async fetchTranslations( - versionId: string, - options: FetchTranslationsOptions = {} - ): Promise { - // Validation - this._validateAuth('fetchTranslations'); - // Request the translation metadata - return await _fetchTranslations( - versionId, - options, - this._getTranslationConfig() - ); + return { + files: result.data.map((file) => ({ + ...file, + ...(file.locale && { locale: this.resolveAliasLocale(file.locale) }), + })), + count: result.count, + }; } /** @@ -1067,21 +936,32 @@ export class GT { // Merge instance settings with options const mergedOptions: UploadFilesOptions = { ...options, - sourceLocale: options.sourceLocale ?? this.sourceLocale, + sourceLocale: this.resolveCanonicalLocale( + options.sourceLocale ?? this.sourceLocale ?? libraryDefaultLocale + ), }; - // Require source locale - if (!mergedOptions.sourceLocale) { - const error = noSourceLocaleProvidedError('uploadSourceFiles'); - gtInstanceLogger.error(error); - throw new Error(error); - } + // resolve canonical locales + files = files.map((f) => ({ + ...f, + source: { + ...f.source, + locale: this.resolveCanonicalLocale(f.source.locale), + }, + })); - return await _uploadSourceFiles( + // Process files in batches and convert result to UploadFilesResponse + const result = await _uploadSourceFiles( files, mergedOptions as RequiredUploadFilesOptions, this._getTranslationConfig() ); + + return { + uploadedFiles: result.data, + count: result.count, + message: `Successfully uploaded ${result.count} files in ${result.batchCount} batch(es)`, + }; } /** @@ -1130,11 +1010,18 @@ export class GT { })), })); - return await _uploadTranslations( + // Process files in batches and convert result to UploadFilesResponse + const result = await _uploadTranslations( targetFiles, mergedOptions as RequiredUploadFilesOptions, this._getTranslationConfig() ); + + return { + uploadedFiles: result.data, + count: result.count, + message: `Successfully uploaded ${result.count} files in ${result.batchCount} batch(es)`, + }; } // -------------- Formatting -------------- // diff --git a/packages/core/src/projects/__tests__/getProjectData.test.ts b/packages/core/src/projects/__tests__/getProjectData.test.ts index 526c5439d..82208908c 100644 --- a/packages/core/src/projects/__tests__/getProjectData.test.ts +++ b/packages/core/src/projects/__tests__/getProjectData.test.ts @@ -256,7 +256,7 @@ describe.sequential('_getProjectData', () => { await _getProjectData(projectId, options, mockConfig); - expect(generateRequestHeaders).toHaveBeenCalledWith(mockConfig, true); + expect(generateRequestHeaders).toHaveBeenCalledWith(mockConfig); }); it('should handle JSON parsing errors', async () => { diff --git a/packages/core/src/projects/getProjectData.ts b/packages/core/src/projects/getProjectData.ts index c097eb53a..129246b1e 100644 --- a/packages/core/src/projects/getProjectData.ts +++ b/packages/core/src/projects/getProjectData.ts @@ -31,7 +31,7 @@ export default async function _getProjectData( url, { method: 'GET', - headers: generateRequestHeaders(config, true), + headers: generateRequestHeaders(config), }, timeout ); diff --git a/packages/core/src/translate/__tests__/checkFileTranslations.test.ts b/packages/core/src/translate/__tests__/checkFileTranslations.test.ts deleted file mode 100644 index d550f6306..000000000 --- a/packages/core/src/translate/__tests__/checkFileTranslations.test.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import _checkFileTranslations from '../checkFileTranslations'; -import fetchWithTimeout from '../utils/fetchWithTimeout'; -import validateResponse from '../utils/validateResponse'; -import handleFetchError from '../utils/handleFetchError'; -import generateRequestHeaders from '../utils/generateRequestHeaders'; -import { TranslationRequestConfig } from '../../types'; -import { - FileTranslationQuery, - CheckFileTranslationsOptions, - CheckFileTranslationsResult, -} from '../../types-dir/checkFileTranslations'; - -vi.mock('../utils/fetchWithTimeout'); -vi.mock('../utils/validateResponse'); -vi.mock('../utils/handleFetchError'); -vi.mock('../utils/generateRequestHeaders'); - -describe.sequential('_checkFileTranslations', () => { - const mockConfig: TranslationRequestConfig = { - baseUrl: 'https://api.test.com', - projectId: 'test-project', - apiKey: 'test-api-key', - }; - - const mockCheckFileTranslationsResult: CheckFileTranslationsResult = { - translations: [ - { - isReady: true, - fileName: 'src/components/Button.json', - locale: 'es', - id: 'translation-1', - fileId: 'file-1', - versionId: 'version-1', - metadata: {}, - downloadUrl: 'https://example.com/download/1', - }, - { - isReady: false, - fileName: 'src/pages/Home.json', - locale: 'fr', - id: 'translation-2', - fileId: 'file-2', - versionId: 'version-2', - metadata: {}, - downloadUrl: 'https://example.com/download/2', - }, - ], - }; - - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(generateRequestHeaders).mockReturnValue({ - 'Content-Type': 'application/json', - 'x-gt-api-key': 'test-api-key', - 'x-gt-project-id': 'test-project', - }); - }); - - it('should check file translation status successfully', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockCheckFileTranslationsResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const data: FileTranslationQuery[] = [ - { - versionId: 'version-1', - fileName: 'src/components/Button.json', - locale: 'es', - }, - { - versionId: 'version-2', - fileName: 'src/pages/Home.json', - locale: 'fr', - }, - ]; - - const options: CheckFileTranslationsOptions = { - timeout: 5000, - }; - - const result = await _checkFileTranslations(data, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - 'https://api.test.com/v2/project/translations/files/retrieve', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-gt-api-key': 'test-api-key', - 'x-gt-project-id': 'test-project', - }, - body: JSON.stringify({ files: data }), - }, - 5000 - ); - expect(validateResponse).toHaveBeenCalledWith(mockResponse); - expect(result).toEqual(mockCheckFileTranslationsResult); - }); - - it('should use config baseUrl when provided', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockCheckFileTranslationsResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const data: FileTranslationQuery[] = [ - { - versionId: 'version-1', - fileName: 'test.json', - locale: 'es', - }, - ]; - - const options: CheckFileTranslationsOptions = {}; - - await _checkFileTranslations(data, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - 'https://api.test.com/v2/project/translations/files/retrieve', - expect.any(Object), - expect.any(Number) - ); - }); - - it('should use default URL when baseUrl not provided in config', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockCheckFileTranslationsResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const configWithoutUrl: TranslationRequestConfig = { - projectId: 'test-project', - apiKey: 'test-api-key', - }; - - const data: FileTranslationQuery[] = [ - { - versionId: 'version-1', - fileName: 'test.json', - locale: 'es', - }, - ]; - - const options: CheckFileTranslationsOptions = {}; - - await _checkFileTranslations(data, options, configWithoutUrl); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.stringContaining( - 'https://api2.gtx.dev/v2/project/translations/files/retrieve' - ), - expect.any(Object), - expect.any(Number) - ); - }); - - it('should use default timeout when not specified', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockCheckFileTranslationsResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const data: FileTranslationQuery[] = [ - { - versionId: 'version-1', - fileName: 'test.json', - locale: 'es', - }, - ]; - - const options: CheckFileTranslationsOptions = {}; - - await _checkFileTranslations(data, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - 60000 - ); - }); - - it('should enforce maximum timeout limit', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockCheckFileTranslationsResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const data: FileTranslationQuery[] = [ - { - versionId: 'version-1', - fileName: 'test.json', - locale: 'es', - }, - ]; - - const options: CheckFileTranslationsOptions = { - timeout: 99999, - }; - - await _checkFileTranslations(data, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - 60000 - ); - }); - - it('should handle fetch errors through handleFetchError', async () => { - const fetchError = new Error('Network error'); - vi.mocked(fetchWithTimeout).mockRejectedValue(fetchError); - vi.mocked(handleFetchError).mockImplementation(() => { - throw fetchError; - }); - - const data: FileTranslationQuery[] = [ - { - versionId: 'version-1', - fileName: 'test.json', - locale: 'es', - }, - ]; - - const options: CheckFileTranslationsOptions = {}; - - await expect( - _checkFileTranslations(data, options, mockConfig) - ).rejects.toThrow('Network error'); - expect(handleFetchError).toHaveBeenCalledWith(fetchError, 60000); - }); - - it('should handle validation errors', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockCheckFileTranslationsResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockImplementationOnce(() => { - throw new Error('Validation failed'); - }); - - const data: FileTranslationQuery[] = [ - { - versionId: 'version-1', - fileName: 'test.json', - locale: 'es', - }, - ]; - - const options: CheckFileTranslationsOptions = {}; - - await expect( - _checkFileTranslations(data, options, mockConfig) - ).rejects.toThrow('Validation failed'); - expect(validateResponse).toHaveBeenCalledWith(mockResponse); - }); - - it('should handle empty data array', async () => { - const emptyResult: CheckFileTranslationsResult = { - translations: [], - }; - - const mockResponse = { - json: vi.fn().mockResolvedValue(emptyResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const data: FileTranslationQuery[] = []; - const options: CheckFileTranslationsOptions = {}; - - const result = await _checkFileTranslations(data, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: JSON.stringify({ files: [] }), - }), - expect.any(Number) - ); - expect(result).toEqual(emptyResult); - }); - - it('should include files in request body', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockCheckFileTranslationsResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const data: FileTranslationQuery[] = [ - { - versionId: 'version-1', - fileName: 'file1.json', - locale: 'es', - }, - { - versionId: 'version-2', - fileName: 'file2.json', - locale: 'fr', - }, - ]; - - const options: CheckFileTranslationsOptions = {}; - - await _checkFileTranslations(data, options, mockConfig); - - const requestBody = JSON.parse( - vi.mocked(fetchWithTimeout).mock.calls[0][1].body as string - ); - expect(requestBody).toEqual({ - files: data, - }); - }); - - it('should handle single file query', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockCheckFileTranslationsResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const data: FileTranslationQuery[] = [ - { - versionId: 'version-1', - fileName: 'test.json', - locale: 'es', - }, - ]; - - const options: CheckFileTranslationsOptions = {}; - - await _checkFileTranslations(data, options, mockConfig); - - const requestBody = JSON.parse( - vi.mocked(fetchWithTimeout).mock.calls[0][1].body as string - ); - expect(requestBody.files).toHaveLength(1); - expect(requestBody.files[0]).toEqual({ - versionId: 'version-1', - fileName: 'test.json', - locale: 'es', - }); - }); - - it('should handle multiple locales for different files', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockCheckFileTranslationsResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const data: FileTranslationQuery[] = [ - { - versionId: 'version-1', - fileName: 'components.json', - locale: 'es', - }, - { - versionId: 'version-1', - fileName: 'components.json', - locale: 'fr', - }, - { - versionId: 'version-2', - fileName: 'pages.json', - locale: 'de', - }, - ]; - - const options: CheckFileTranslationsOptions = { - timeout: 8000, - }; - - const result = await _checkFileTranslations(data, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: JSON.stringify({ files: data }), - }), - 8000 - ); - expect(result).toEqual(mockCheckFileTranslationsResult); - }); - - it('should handle JSON parsing errors', async () => { - const mockResponse = { - json: vi.fn().mockRejectedValue(new Error('Invalid JSON')), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const data: FileTranslationQuery[] = [ - { - versionId: 'version-1', - fileName: 'test.json', - locale: 'es', - }, - ]; - - const options: CheckFileTranslationsOptions = {}; - - await expect( - _checkFileTranslations(data, options, mockConfig) - ).rejects.toThrow('Invalid JSON'); - }); -}); diff --git a/packages/core/src/translate/__tests__/checkSetupStatus.test.ts b/packages/core/src/translate/__tests__/checkJobStatus.test.ts similarity index 70% rename from packages/core/src/translate/__tests__/checkSetupStatus.test.ts rename to packages/core/src/translate/__tests__/checkJobStatus.test.ts index fa61d1673..5ec1a72f0 100644 --- a/packages/core/src/translate/__tests__/checkSetupStatus.test.ts +++ b/packages/core/src/translate/__tests__/checkJobStatus.test.ts @@ -1,21 +1,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { - _checkSetupStatus, - CheckSetupStatusResult, - SetupJobStatus, -} from '../checkSetupStatus'; import { TranslationRequestConfig } from '../../types'; import fetchWithTimeout from '../utils/fetchWithTimeout'; import validateResponse from '../utils/validateResponse'; import handleFetchError from '../utils/handleFetchError'; import generateRequestHeaders from '../utils/generateRequestHeaders'; +import { _checkJobStatus, CheckJobStatusResult } from '../checkJobStatus'; vi.mock('../utils/fetchWithTimeout'); vi.mock('../utils/validateResponse'); vi.mock('../utils/handleFetchError'); vi.mock('../utils/generateRequestHeaders'); -describe('_checkSetupStatus', () => { +describe('_checkJobStatus', () => { const mockConfig: TranslationRequestConfig = { baseUrl: 'https://api.test.com', projectId: 'test-project', @@ -37,10 +33,13 @@ describe('_checkSetupStatus', () => { }); it('should check setup status successfully', async () => { - const mockResponse: CheckSetupStatusResult = { - jobId: 'job-123', - status: 'completed', - }; + const mockResponse: CheckJobStatusResult = [ + { + jobId: 'job-123', + status: 'completed', + error: undefined, + }, + ]; const mockFetchResponse = { json: vi.fn().mockResolvedValue(mockResponse), @@ -49,17 +48,18 @@ describe('_checkSetupStatus', () => { vi.mocked(fetchWithTimeout).mockResolvedValue(mockFetchResponse); vi.mocked(validateResponse).mockResolvedValue(undefined); - const result = await _checkSetupStatus('job-123', mockConfig); + const result = await _checkJobStatus(['job-123'], mockConfig); expect(fetchWithTimeout).toHaveBeenCalledWith( - 'https://api.test.com/v2/project/setup/status/job-123', + 'https://api.test.com/v2/project/jobs/info', { - method: 'GET', + method: 'POST', headers: { 'Content-Type': 'application/json', 'x-gt-api-key': 'test-api-key', 'x-gt-project-id': 'test-project', }, + body: JSON.stringify({ jobIds: ['job-123'] }), }, 60000 ); @@ -69,10 +69,12 @@ describe('_checkSetupStatus', () => { }); it('should check setup status with custom timeout', async () => { - const mockResponse: CheckSetupStatusResult = { - jobId: 'job-123', - status: 'processing', - }; + const mockResponse: CheckJobStatusResult = [ + { + jobId: 'job-123', + status: 'processing', + }, + ]; const mockFetchResponse = { json: vi.fn().mockResolvedValue(mockResponse), @@ -81,7 +83,7 @@ describe('_checkSetupStatus', () => { vi.mocked(fetchWithTimeout).mockResolvedValue(mockFetchResponse); vi.mocked(validateResponse).mockResolvedValue(undefined); - const result = await _checkSetupStatus('job-123', mockConfig, 30000); + const result = await _checkJobStatus(['job-123'], mockConfig, 30000); expect(fetchWithTimeout).toHaveBeenCalledWith( expect.any(String), @@ -93,10 +95,12 @@ describe('_checkSetupStatus', () => { }); it('should handle queued status', async () => { - const mockResponse: CheckSetupStatusResult = { - jobId: 'job-123', - status: 'queued', - }; + const mockResponse: CheckJobStatusResult = [ + { + jobId: 'job-123', + status: 'queued', + }, + ]; const mockFetchResponse = { json: vi.fn().mockResolvedValue(mockResponse), @@ -105,18 +109,20 @@ describe('_checkSetupStatus', () => { vi.mocked(fetchWithTimeout).mockResolvedValue(mockFetchResponse); vi.mocked(validateResponse).mockResolvedValue(undefined); - const result = await _checkSetupStatus('job-123', mockConfig); + const result = await _checkJobStatus(['job-123'], mockConfig); - expect(result.status).toBe('queued'); - expect(result.jobId).toBe('job-123'); + expect(result[0].status).toBe('queued'); + expect(result[0].jobId).toBe('job-123'); }); it('should handle failed status with error message', async () => { - const mockResponse: CheckSetupStatusResult = { - jobId: 'job-123', - status: 'failed', - error: { message: 'Setup generation failed' }, - }; + const mockResponse: CheckJobStatusResult = [ + { + jobId: 'job-123', + status: 'failed', + error: { message: 'Setup generation failed' }, + }, + ]; const mockFetchResponse = { json: vi.fn().mockResolvedValue(mockResponse), @@ -125,17 +131,19 @@ describe('_checkSetupStatus', () => { vi.mocked(fetchWithTimeout).mockResolvedValue(mockFetchResponse); vi.mocked(validateResponse).mockResolvedValue(undefined); - const result = await _checkSetupStatus('job-123', mockConfig); + const result = await _checkJobStatus(['job-123'], mockConfig); - expect(result.status).toBe('failed'); - expect(result.error?.message).toBe('Setup generation failed'); + expect(result[0].status).toBe('failed'); + expect(result[0].error?.message).toBe('Setup generation failed'); }); it('should handle processing status', async () => { - const mockResponse: CheckSetupStatusResult = { - jobId: 'job-123', - status: 'processing', - }; + const mockResponse: CheckJobStatusResult = [ + { + jobId: 'job-123', + status: 'processing', + }, + ]; const mockFetchResponse = { json: vi.fn().mockResolvedValue(mockResponse), @@ -144,10 +152,10 @@ describe('_checkSetupStatus', () => { vi.mocked(fetchWithTimeout).mockResolvedValue(mockFetchResponse); vi.mocked(validateResponse).mockResolvedValue(undefined); - const result = await _checkSetupStatus('job-123', mockConfig); + const result = await _checkJobStatus(['job-123'], mockConfig); - expect(result.status).toBe('processing'); - expect(result.jobId).toBe('job-123'); + expect(result[0].status).toBe('processing'); + expect(result[0].jobId).toBe('job-123'); }); it('should handle fetch errors', async () => { @@ -157,7 +165,7 @@ describe('_checkSetupStatus', () => { throw fetchError; }); - await expect(_checkSetupStatus('job-123', mockConfig)).rejects.toThrow( + await expect(_checkJobStatus(['job-123'], mockConfig)).rejects.toThrow( 'Network error' ); @@ -173,7 +181,7 @@ describe('_checkSetupStatus', () => { vi.mocked(fetchWithTimeout).mockResolvedValue(mockFetchResponse); vi.mocked(validateResponse).mockRejectedValue(validationError); - await expect(_checkSetupStatus('job-123', mockConfig)).rejects.toThrow( + await expect(_checkJobStatus(['job-123'], mockConfig)).rejects.toThrow( 'Invalid response' ); diff --git a/packages/core/src/translate/__tests__/checkTranslationStatus.test.ts b/packages/core/src/translate/__tests__/checkTranslationStatus.test.ts deleted file mode 100644 index 553f36e13..000000000 --- a/packages/core/src/translate/__tests__/checkTranslationStatus.test.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import _checkTranslationStatus from '../checkTranslationStatus'; -import fetchWithTimeout from '../utils/fetchWithTimeout'; -import validateResponse from '../utils/validateResponse'; -import handleFetchError from '../utils/handleFetchError'; -import generateRequestHeaders from '../utils/generateRequestHeaders'; -import { TranslationRequestConfig } from '../../types'; -import { - CheckTranslationStatusOptions, - TranslationStatusResult, -} from '../../types-dir/translationStatus'; - -vi.mock('../utils/fetchWithTimeout'); -vi.mock('../utils/validateResponse'); -vi.mock('../utils/handleFetchError'); -vi.mock('../utils/generateRequestHeaders'); - -describe.sequential('_checkTranslationStatus', () => { - const mockConfig: TranslationRequestConfig = { - baseUrl: 'https://api.test.com', - projectId: 'test-project', - apiKey: 'test-api-key', - }; - - const mockTranslationStatusResult: TranslationStatusResult = { - count: 5, - availableLocales: ['es', 'fr', 'de'], - locales: ['es', 'fr'], - localesWaitingForApproval: [], - }; - - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(generateRequestHeaders).mockReturnValue({ - 'Content-Type': 'application/json', - 'x-gt-api-key': 'test-api-key', - 'x-gt-project-id': 'test-project', - }); - }); - - it('should check translation status successfully', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockTranslationStatusResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const versionId = 'version-123'; - const options: CheckTranslationStatusOptions = { - timeout: 5000, - }; - - const result = await _checkTranslationStatus( - versionId, - options, - mockConfig - ); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - 'https://api.test.com/v2/project/translations/status/version-123', - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'x-gt-api-key': 'test-api-key', - 'x-gt-project-id': 'test-project', - }, - }, - 5000 - ); - expect(validateResponse).toHaveBeenCalledWith(mockResponse); - expect(result).toEqual(mockTranslationStatusResult); - }); - - it('should use config baseUrl when provided', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockTranslationStatusResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const versionId = 'version-123'; - const options: CheckTranslationStatusOptions = {}; - - await _checkTranslationStatus(versionId, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - 'https://api.test.com/v2/project/translations/status/version-123', - expect.any(Object), - expect.any(Number) - ); - }); - - it('should use default URL when baseUrl not provided in config', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockTranslationStatusResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const configWithoutUrl: TranslationRequestConfig = { - projectId: 'test-project', - apiKey: 'test-api-key', - }; - - const versionId = 'version-123'; - const options: CheckTranslationStatusOptions = {}; - - await _checkTranslationStatus(versionId, options, configWithoutUrl); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.stringContaining( - 'https://api2.gtx.dev/v2/project/translations/status/version-123' - ), - expect.any(Object), - expect.any(Number) - ); - }); - - it('should use default timeout when not specified', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockTranslationStatusResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const versionId = 'version-123'; - const options: CheckTranslationStatusOptions = {}; - - await _checkTranslationStatus(versionId, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - 60000 - ); - }); - - it('should enforce maximum timeout limit', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockTranslationStatusResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const versionId = 'version-123'; - const options: CheckTranslationStatusOptions = { - timeout: 99999, - }; - - await _checkTranslationStatus(versionId, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - 60000 - ); - }); - - it('should handle fetch errors through handleFetchError', async () => { - const fetchError = new Error('Network error'); - vi.mocked(fetchWithTimeout).mockRejectedValue(fetchError); - vi.mocked(handleFetchError).mockImplementation(() => { - throw fetchError; - }); - - const versionId = 'version-123'; - const options: CheckTranslationStatusOptions = {}; - - await expect( - _checkTranslationStatus(versionId, options, mockConfig) - ).rejects.toThrow('Network error'); - expect(handleFetchError).toHaveBeenCalledWith(fetchError, 60000); - }); - - it('should handle validation errors', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockTranslationStatusResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockImplementationOnce(() => { - throw new Error('Validation failed'); - }); - - const versionId = 'version-123'; - const options: CheckTranslationStatusOptions = {}; - - await expect( - _checkTranslationStatus(versionId, options, mockConfig) - ).rejects.toThrow('Validation failed'); - expect(validateResponse).toHaveBeenCalledWith(mockResponse); - }); - - it('should encode version ID in URL properly', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockTranslationStatusResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const versionId = 'version with spaces'; - const options: CheckTranslationStatusOptions = {}; - - await _checkTranslationStatus(versionId, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - 'https://api.test.com/v2/project/translations/status/version%20with%20spaces', - expect.any(Object), - expect.any(Number) - ); - }); - - it('should handle JSON parsing errors', async () => { - const mockResponse = { - json: vi.fn().mockRejectedValue(new Error('Invalid JSON')), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const versionId = 'version-123'; - const options: CheckTranslationStatusOptions = {}; - - await expect( - _checkTranslationStatus(versionId, options, mockConfig) - ).rejects.toThrow('Invalid JSON'); - }); - - it('should handle empty locales arrays', async () => { - const emptyResult: TranslationStatusResult = { - count: 0, - availableLocales: [], - locales: [], - localesWaitingForApproval: [], - }; - - const mockResponse = { - json: vi.fn().mockResolvedValue(emptyResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const versionId = 'version-123'; - const options: CheckTranslationStatusOptions = {}; - - const result = await _checkTranslationStatus( - versionId, - options, - mockConfig - ); - - expect(result).toEqual(emptyResult); - }); - - it('should handle locales waiting for approval', async () => { - const resultWithApproval: TranslationStatusResult = { - count: 3, - availableLocales: ['es', 'fr', 'de'], - locales: ['es'], - localesWaitingForApproval: ['fr', 'de'], - }; - - const mockResponse = { - json: vi.fn().mockResolvedValue(resultWithApproval), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const versionId = 'version-123'; - const options: CheckTranslationStatusOptions = {}; - - const result = await _checkTranslationStatus( - versionId, - options, - mockConfig - ); - - expect(result).toEqual(resultWithApproval); - }); -}); diff --git a/packages/core/src/translate/__tests__/downloadFile.test.ts b/packages/core/src/translate/__tests__/downloadFile.test.ts deleted file mode 100644 index f5590c76d..000000000 --- a/packages/core/src/translate/__tests__/downloadFile.test.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import _downloadFile, { _downloadFileV2 } from '../downloadFile'; -import fetchWithTimeout from '../utils/fetchWithTimeout'; -import validateResponse from '../utils/validateResponse'; -import handleFetchError from '../utils/handleFetchError'; -import generateRequestHeaders from '../utils/generateRequestHeaders'; -import { TranslationRequestConfig } from '../../types'; -import { DownloadFileOptions } from '../../types-dir/downloadFile'; -import { decode } from '../../utils/base64'; - -vi.mock('../utils/fetchWithTimeout'); -vi.mock('../utils/validateResponse'); -vi.mock('../utils/handleFetchError'); -vi.mock('../utils/generateRequestHeaders'); - -describe.sequential('_downloadFile', () => { - const mockConfig: TranslationRequestConfig = { - baseUrl: 'https://api.test.com', - projectId: 'test-project', - apiKey: 'test-api-key', - }; - - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(generateRequestHeaders).mockReturnValue({ - 'Content-Type': 'application/json', - 'x-gt-api-key': 'test-api-key', - 'x-gt-project-id': 'test-project', - }); - }); - - it('should download file content successfully', async () => { - const mockData = Buffer.from('test-data').toString('base64'); - const mockResponse = { - json: vi.fn().mockResolvedValue({ data: mockData }), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const translationId = 'test-translation-id'; - const options: DownloadFileOptions = { - timeout: 5000, - }; - - const result = await _downloadFile(translationId, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - 'https://api.test.com/v2/project/translations/files/test-translation-id/download', - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'x-gt-api-key': 'test-api-key', - 'x-gt-project-id': 'test-project', - }, - }, - 5000 - ); - expect(validateResponse).toHaveBeenCalledWith(mockResponse); - expect(result).toStrictEqual(Buffer.from(mockData, 'base64').buffer); - }); - - it('should use default timeout when not specified', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue({ data: '' }), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const translationId = 'test-translation-id'; - const options: DownloadFileOptions = {}; - - await _downloadFile(translationId, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - 60000 - ); - }); - - it('should enforce maximum timeout limit', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue({ data: '' }), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const translationId = 'test-translation-id'; - const options: DownloadFileOptions = { - timeout: 99999, - }; - - await _downloadFile(translationId, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - 60000 - ); - }); - - it('should use default URL when baseUrl not provided in config', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue({ data: '' }), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const configWithoutUrl: TranslationRequestConfig = { - projectId: 'test-project', - apiKey: 'test-api-key', - }; - - const translationId = 'test-translation-id'; - const options: DownloadFileOptions = {}; - - await _downloadFile(translationId, options, configWithoutUrl); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.stringContaining( - 'https://api2.gtx.dev/v2/project/translations/files/test-translation-id/download' - ), - expect.any(Object), - expect.any(Number) - ); - }); - - it('should handle fetch errors through handleFetchError', async () => { - const fetchError = new Error('Network error'); - vi.mocked(fetchWithTimeout).mockRejectedValue(fetchError); - vi.mocked(handleFetchError).mockImplementation(() => { - throw fetchError; - }); - - const translationId = 'test-translation-id'; - const options: DownloadFileOptions = {}; - - await expect( - _downloadFile(translationId, options, mockConfig) - ).rejects.toThrow('Network error'); - expect(handleFetchError).toHaveBeenCalledWith(fetchError, 60000); - }); - - it('should handle validation errors', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue({ data: '' }), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockImplementationOnce(() => { - throw new Error('Validation failed'); - }); - - const translationId = 'test-translation-id'; - const options: DownloadFileOptions = {}; - - await expect( - _downloadFile(translationId, options, mockConfig) - ).rejects.toThrow('Validation failed'); - expect(validateResponse).toHaveBeenCalledWith(mockResponse); - }); - - it('should construct correct URL with translation ID', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue({ data: '' }), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const translationId = 'my-special-translation-123'; - const options: DownloadFileOptions = {}; - - await _downloadFile(translationId, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - 'https://api.test.com/v2/project/translations/files/my-special-translation-123/download', - expect.any(Object), - expect.any(Number) - ); - }); - - it('should handle empty ArrayBuffer response', async () => { - const mockArrayBuffer = new ArrayBuffer(0); - const mockResponse = { - json: vi.fn().mockResolvedValue({ data: '' }), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const translationId = 'test-translation-id'; - const options: DownloadFileOptions = {}; - - const result = await _downloadFile(translationId, options, mockConfig); - - expect(result).toStrictEqual(mockArrayBuffer); - expect(result.byteLength).toBe(0); - }); -}); - -describe.sequential('_downloadFileV2', () => { - const mockConfig: TranslationRequestConfig = { - baseUrl: 'https://api.test.com', - projectId: 'test-project', - apiKey: 'test-api-key', - }; - - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(generateRequestHeaders).mockReturnValue({ - 'Content-Type': 'application/json', - 'x-gt-api-key': 'test-api-key', - 'x-gt-project-id': 'test-project', - }); - }); - - it('should download file content successfully', async () => { - const mockData = Buffer.from('test-data').toString('base64'); - const mockResponse = { - json: vi.fn().mockResolvedValue({ data: mockData }), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const file = { - fileId: 'test-file-id', - versionId: 'test-version-id', - locale: 'en-US', - }; - const options: DownloadFileOptions = { - timeout: 5000, - }; - - const result = await _downloadFileV2(file, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - 'https://api.test.com/v2/project/files/download/test-file-id?versionId=test-version-id&locale=en-US', - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'x-gt-api-key': 'test-api-key', - 'x-gt-project-id': 'test-project', - }, - }, - 5000 - ); - expect(validateResponse).toHaveBeenCalledWith(mockResponse); - expect(result).toStrictEqual(decode(mockData)); - }); - - it('should construct correct URL with file parameters', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue({ data: '' }), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const file = { - fileId: 'my-file-123', - versionId: 'v2.1.0', - locale: 'fr-CA', - }; - const options: DownloadFileOptions = {}; - - await _downloadFileV2(file, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - 'https://api.test.com/v2/project/files/download/my-file-123?versionId=v2.1.0&locale=fr-CA', - expect.any(Object), - expect.any(Number) - ); - }); - - it('should use default timeout when not specified', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue({ data: '' }), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const file = { - fileId: 'test-file-id', - versionId: 'test-version-id', - locale: 'en-US', - }; - const options: DownloadFileOptions = {}; - - await _downloadFileV2(file, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - 60000 - ); - }); - - it('should enforce maximum timeout limit', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue({ data: '' }), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const file = { - fileId: 'test-file-id', - versionId: 'test-version-id', - locale: 'en-US', - }; - const options: DownloadFileOptions = { - timeout: 99999, - }; - - await _downloadFileV2(file, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - 60000 - ); - }); - - it('should use default URL when baseUrl not provided in config', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue({ data: '' }), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const configWithoutUrl: TranslationRequestConfig = { - projectId: 'test-project', - apiKey: 'test-api-key', - }; - - const file = { - fileId: 'test-file-id', - versionId: 'test-version-id', - locale: 'en-US', - }; - const options: DownloadFileOptions = {}; - - await _downloadFileV2(file, options, configWithoutUrl); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.stringContaining( - 'https://api2.gtx.dev/v2/project/files/download/test-file-id?versionId=test-version-id&locale=en-US' - ), - expect.any(Object), - expect.any(Number) - ); - }); - - it('should handle fetch errors through handleFetchError', async () => { - const fetchError = new Error('Network error'); - vi.mocked(fetchWithTimeout).mockRejectedValue(fetchError); - vi.mocked(handleFetchError).mockImplementation(() => { - throw fetchError; - }); - - const file = { - fileId: 'test-file-id', - versionId: 'test-version-id', - locale: 'en-US', - }; - const options: DownloadFileOptions = {}; - - await expect(_downloadFileV2(file, options, mockConfig)).rejects.toThrow( - 'Network error' - ); - expect(handleFetchError).toHaveBeenCalledWith(fetchError, 60000); - }); - - it('should handle validation errors', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue({ data: '' }), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockImplementationOnce(() => { - throw new Error('Validation failed'); - }); - - const file = { - fileId: 'test-file-id', - versionId: 'test-version-id', - locale: 'en-US', - }; - const options: DownloadFileOptions = {}; - - await expect(_downloadFileV2(file, options, mockConfig)).rejects.toThrow( - 'Validation failed' - ); - expect(validateResponse).toHaveBeenCalledWith(mockResponse); - }); - - it('should handle locale with special characters', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue({ data: '' }), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const file = { - fileId: 'test-file-id', - versionId: 'test-version-id', - locale: 'zh-Hans-CN', - }; - const options: DownloadFileOptions = {}; - - await _downloadFileV2(file, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - 'https://api.test.com/v2/project/files/download/test-file-id?versionId=test-version-id&locale=zh-Hans-CN', - expect.any(Object), - expect.any(Number) - ); - }); - - it('should handle empty ArrayBuffer response', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue({ data: '' }), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const file = { - fileId: 'test-file-id', - versionId: 'test-version-id', - locale: 'en-US', - }; - const options: DownloadFileOptions = {}; - - const result = await _downloadFileV2(file, options, mockConfig); - - expect(result).toStrictEqual(''); - }); -}); diff --git a/packages/core/src/translate/__tests__/downloadFileBatch.test.ts b/packages/core/src/translate/__tests__/downloadFileBatch.test.ts index 5f96d7963..55c397a86 100644 --- a/packages/core/src/translate/__tests__/downloadFileBatch.test.ts +++ b/packages/core/src/translate/__tests__/downloadFileBatch.test.ts @@ -7,8 +7,9 @@ import generateRequestHeaders from '../utils/generateRequestHeaders'; import { TranslationRequestConfig } from '../../types'; import { DownloadFileBatchOptions, + DownloadFileBatchRequest, DownloadFileBatchResult, -} from '../../types-dir/downloadFileBatch'; +} from '../../types-dir/api/downloadFileBatch'; vi.mock('../utils/fetchWithTimeout'); vi.mock('../utils/validateResponse'); @@ -26,12 +27,20 @@ describe.sequential('_downloadFileBatch', () => { files: [ { id: 'translation-1', + branchId: 'branch-1', + fileId: 'file-1', + versionId: 'version-1', + fileFormat: 'JSON', fileName: 'file1.json', data: 'file content 1', metadata: { contentType: 'application/json' }, }, { id: 'translation-2', + branchId: 'branch-2', + fileId: 'file-2', + versionId: 'version-2', + fileFormat: 'JSON', fileName: 'file2.json', data: '', metadata: { error: 'File not found' }, @@ -43,12 +52,20 @@ describe.sequential('_downloadFileBatch', () => { files: [ { id: 'translation-1', + branchId: 'branch-1', + fileId: 'file-1', + versionId: 'version-1', + fileFormat: 'JSON', fileName: 'file1.json', data: Buffer.from('file content 1').toString('base64'), metadata: { contentType: 'application/json' }, }, { id: 'translation-2', + branchId: 'branch-2', + fileId: 'file-2', + versionId: 'version-2', + fileFormat: 'JSON', fileName: 'file2.json', data: '', metadata: { error: 'File not found' }, @@ -74,7 +91,10 @@ describe.sequential('_downloadFileBatch', () => { vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); vi.mocked(validateResponse).mockResolvedValue(undefined); - const files: string[] = ['translation-1', 'translation-2']; + const files: DownloadFileBatchRequest = [ + { fileId: 'file-1', branchId: 'branch-1', versionId: 'version-1' }, + { fileId: 'file-2', branchId: 'branch-2', versionId: 'version-2' }, + ]; const options: DownloadFileBatchOptions = { timeout: 5000, @@ -83,7 +103,7 @@ describe.sequential('_downloadFileBatch', () => { const result = await _downloadFileBatch(files, options, mockConfig); expect(fetchWithTimeout).toHaveBeenCalledWith( - 'https://api.test.com/v2/project/translations/files/batch-download', + 'https://api.test.com/v2/project/files/download', { method: 'POST', headers: { @@ -91,14 +111,17 @@ describe.sequential('_downloadFileBatch', () => { 'x-gt-api-key': 'test-api-key', 'x-gt-project-id': 'test-project', }, - body: JSON.stringify({ - fileIds: ['translation-1', 'translation-2'], - }), + body: JSON.stringify([ + { fileId: 'file-1', branchId: 'branch-1', versionId: 'version-1' }, + { fileId: 'file-2', branchId: 'branch-2', versionId: 'version-2' }, + ]), }, 5000 ); expect(validateResponse).toHaveBeenCalledWith(mockResponse); - expect(result).toEqual(mockDownloadFileBatchResult); + expect(result.data).toEqual(mockDownloadFileBatchResult.files); + expect(result.count).toBe(2); + expect(result.batchCount).toBe(1); }); it('should handle single file in batch', async () => { @@ -109,7 +132,9 @@ describe.sequential('_downloadFileBatch', () => { vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); vi.mocked(validateResponse).mockResolvedValue(undefined); - const files: string[] = ['translation-1']; + const files: DownloadFileBatchRequest = [ + { fileId: 'file-1', branchId: 'branch-1', versionId: 'version-1' }, + ]; const options: DownloadFileBatchOptions = {}; @@ -118,13 +143,14 @@ describe.sequential('_downloadFileBatch', () => { expect(fetchWithTimeout).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - body: JSON.stringify({ - fileIds: ['translation-1'], - }), + body: JSON.stringify([ + { fileId: 'file-1', branchId: 'branch-1', versionId: 'version-1' }, + ]), }), 60000 ); - expect(result).toEqual(mockDownloadFileBatchResult); + expect(result.data).toEqual(mockDownloadFileBatchResult.files); + expect(result.batchCount).toBe(1); }); it('should use default timeout when not specified', async () => { @@ -135,7 +161,9 @@ describe.sequential('_downloadFileBatch', () => { vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); vi.mocked(validateResponse).mockResolvedValue(undefined); - const files: string[] = ['translation-1']; + const files: DownloadFileBatchRequest = [ + { fileId: 'file-1', branchId: 'branch-1', versionId: 'version-1' }, + ]; const options: DownloadFileBatchOptions = {}; @@ -156,7 +184,9 @@ describe.sequential('_downloadFileBatch', () => { vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); vi.mocked(validateResponse).mockResolvedValue(undefined); - const files: string[] = ['translation-1']; + const files: DownloadFileBatchRequest = [ + { fileId: 'file-1', branchId: 'branch-1', versionId: 'version-1' }, + ]; const options: DownloadFileBatchOptions = { timeout: 99999, @@ -184,16 +214,16 @@ describe.sequential('_downloadFileBatch', () => { apiKey: 'test-api-key', }; - const files: string[] = ['translation-1']; + const files: DownloadFileBatchRequest = [ + { fileId: 'file-1', branchId: 'branch-1', versionId: 'version-1' }, + ]; const options: DownloadFileBatchOptions = {}; await _downloadFileBatch(files, options, configWithoutUrl); expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.stringContaining( - 'https://api2.gtx.dev/v2/project/translations/files/batch-download' - ), + expect.stringContaining('https://api2.gtx.dev/v2/project/files/download'), expect.any(Object), expect.any(Number) ); @@ -206,7 +236,9 @@ describe.sequential('_downloadFileBatch', () => { throw fetchError; }); - const files: string[] = ['translation-1']; + const files: DownloadFileBatchRequest = [ + { fileId: 'file-1', branchId: 'branch-1', versionId: 'version-1' }, + ]; const options: DownloadFileBatchOptions = {}; @@ -226,7 +258,9 @@ describe.sequential('_downloadFileBatch', () => { throw new Error('Validation failed'); }); - const files: string[] = ['translation-1']; + const files: DownloadFileBatchRequest = [ + { fileId: 'file-1', branchId: 'branch-1', versionId: 'version-1' }, + ]; const options: DownloadFileBatchOptions = {}; @@ -237,34 +271,20 @@ describe.sequential('_downloadFileBatch', () => { }); it('should handle empty files array', async () => { - const emptyResult: DownloadFileBatchResult = { - files: [], - count: 0, - }; - - const mockResponse = { - json: vi.fn().mockResolvedValue(emptyResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const files: string[] = []; + const files: DownloadFileBatchRequest = []; const options: DownloadFileBatchOptions = {}; + // Clear mocks before this test to get accurate call count + vi.clearAllMocks(); + const result = await _downloadFileBatch(files, options, mockConfig); - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: JSON.stringify({ - fileIds: [], - }), - }), - expect.any(Number) - ); - expect(result).toEqual(emptyResult); + // With batching, empty array returns early without making any API calls + expect(fetchWithTimeout).not.toHaveBeenCalled(); + expect(result.data).toEqual([]); + expect(result.count).toBe(0); + expect(result.batchCount).toBe(0); }); it('should include fileIds in request body', async () => { @@ -275,7 +295,9 @@ describe.sequential('_downloadFileBatch', () => { vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); vi.mocked(validateResponse).mockResolvedValue(undefined); - const files: string[] = ['translation-1']; + const files: DownloadFileBatchRequest = [ + { fileId: 'file-1', branchId: 'branch-1', versionId: 'version-1' }, + ]; const options: DownloadFileBatchOptions = {}; @@ -284,7 +306,9 @@ describe.sequential('_downloadFileBatch', () => { const requestBody = JSON.parse( vi.mocked(fetchWithTimeout).mock.calls[0][1].body as string ); - expect(requestBody.fileIds).toEqual(['translation-1']); + expect(requestBody).toEqual([ + { fileId: 'file-1', branchId: 'branch-1', versionId: 'version-1' }, + ]); }); it('should map fileIds correctly in request body', async () => { @@ -295,7 +319,10 @@ describe.sequential('_downloadFileBatch', () => { vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); vi.mocked(validateResponse).mockResolvedValue(undefined); - const files: string[] = ['trans-id-1', 'trans-id-2']; + const files: DownloadFileBatchRequest = [ + { fileId: 'file-1', branchId: 'branch-1', versionId: 'version-1' }, + { fileId: 'file-2', branchId: 'branch-2', versionId: 'version-2' }, + ]; const options: DownloadFileBatchOptions = {}; @@ -304,6 +331,84 @@ describe.sequential('_downloadFileBatch', () => { const requestBody = JSON.parse( vi.mocked(fetchWithTimeout).mock.calls[0][1].body as string ); - expect(requestBody.fileIds).toEqual(['trans-id-1', 'trans-id-2']); + expect(requestBody).toEqual([ + { fileId: 'file-1', branchId: 'branch-1', versionId: 'version-1' }, + { fileId: 'file-2', branchId: 'branch-2', versionId: 'version-2' }, + ]); + }); + + it('should batch files when downloading more than 100 files', async () => { + // Create 150 mock file requests + const files: DownloadFileBatchRequest = Array.from( + { length: 150 }, + (_, i) => ({ + fileId: `file-${i}`, + branchId: `branch-${i}`, + versionId: `version-${i}`, + }) + ); + + const mockResponse1: DownloadFileBatchResult = { + files: Array.from({ length: 100 }, (_, i) => ({ + id: `translation-${i}`, + branchId: `branch-${i}`, + fileId: `file-${i}`, + versionId: `version-${i}`, + fileFormat: 'JSON', + fileName: `file-${i}.json`, + data: Buffer.from(`content ${i}`).toString('base64'), + metadata: {}, + })), + count: 100, + }; + + const mockResponse2: DownloadFileBatchResult = { + files: Array.from({ length: 50 }, (_, i) => ({ + id: `translation-${i + 100}`, + branchId: `branch-${i + 100}`, + fileId: `file-${i + 100}`, + versionId: `version-${i + 100}`, + fileFormat: 'JSON', + fileName: `file-${i + 100}.json`, + data: Buffer.from(`content ${i + 100}`).toString('base64'), + metadata: {}, + })), + count: 50, + }; + + const mockFetchResponse1 = { + json: vi.fn().mockResolvedValue(mockResponse1), + } as unknown as Response; + + const mockFetchResponse2 = { + json: vi.fn().mockResolvedValue(mockResponse2), + } as unknown as Response; + + vi.mocked(fetchWithTimeout) + .mockResolvedValueOnce(mockFetchResponse1) + .mockResolvedValueOnce(mockFetchResponse2); + vi.mocked(validateResponse).mockResolvedValue(undefined); + + const options: DownloadFileBatchOptions = {}; + + const result = await _downloadFileBatch(files, options, mockConfig); + + // Should make 2 batch calls + expect(fetchWithTimeout).toHaveBeenCalledTimes(2); + + // First call should have 100 files + const firstCall = vi.mocked(fetchWithTimeout).mock.calls[0]; + const firstBody = JSON.parse(firstCall[1]?.body as string); + expect(firstBody).toHaveLength(100); + + // Second call should have 50 files + const secondCall = vi.mocked(fetchWithTimeout).mock.calls[1]; + const secondBody = JSON.parse(secondCall[1]?.body as string); + expect(secondBody).toHaveLength(50); + + // Result should contain all 150 files + expect(result.data).toHaveLength(150); + expect(result.count).toBe(150); + expect(result.batchCount).toBe(2); }); }); diff --git a/packages/core/src/translate/__tests__/enqueueEntries.test.ts b/packages/core/src/translate/__tests__/enqueueEntries.test.ts deleted file mode 100644 index 719d3736b..000000000 --- a/packages/core/src/translate/__tests__/enqueueEntries.test.ts +++ /dev/null @@ -1,440 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import _enqueueEntries from '../enqueueEntries'; -import fetchWithTimeout from '../utils/fetchWithTimeout'; -import validateResponse from '../utils/validateResponse'; -import handleFetchError from '../utils/handleFetchError'; -import generateRequestHeaders from '../utils/generateRequestHeaders'; -import { TranslationRequestConfig } from '../../types'; -import { - Updates, - EnqueueEntriesOptions, - EnqueueEntriesResult, -} from '../../types-dir/enqueueEntries'; - -vi.mock('../utils/fetchWithTimeout'); -vi.mock('../utils/validateResponse'); -vi.mock('../utils/handleFetchError'); -vi.mock('../utils/generateRequestHeaders'); - -describe.sequential('_enqueueEntries', () => { - const mockConfig: TranslationRequestConfig = { - baseUrl: 'https://api.test.com', - projectId: 'test-project', - apiKey: 'test-api-key', - }; - - const mockEnqueueEntriesResult: EnqueueEntriesResult = { - versionId: 'version-123', - locales: ['es', 'fr'], - message: 'Entries uploaded successfully', - projectSettings: { - cdnEnabled: true, - requireApproval: false, - }, - }; - - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(generateRequestHeaders).mockReturnValue({ - 'Content-Type': 'application/json', - 'x-gt-api-key': 'test-api-key', - 'x-gt-project-id': 'test-project', - }); - }); - - it('should enqueue entries successfully', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockEnqueueEntriesResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const updates: Updates = [ - { - dataFormat: 'ICU', - source: 'Hello world', - metadata: { key: 'hello.world' }, - }, - { - dataFormat: 'ICU', - source: 'Goodbye world', - metadata: { key: 'goodbye.world' }, - }, - ]; - - const options: EnqueueEntriesOptions = { - sourceLocale: 'en', - dataFormat: 'ICU', - targetLocales: ['es', 'fr'], - version: 'v1.0.0', - description: 'Test entries upload', - requireApproval: true, - timeout: 5000, - }; - - const result = await _enqueueEntries(updates, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - 'https://api.test.com/v1/project/translations/update', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-gt-api-key': 'test-api-key', - 'x-gt-project-id': 'test-project', - }, - body: JSON.stringify({ - updates, - locales: ['es', 'fr'], - metadata: { - projectId: 'test-project', - sourceLocale: 'en', - }, - dataFormat: 'ICU', - versionId: 'v1.0.0', - description: 'Test entries upload', - requireApproval: true, - }), - }, - 5000 - ); - expect(validateResponse).toHaveBeenCalledWith(mockResponse); - expect(result).toEqual(mockEnqueueEntriesResult); - }); - - it('should handle minimal options', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockEnqueueEntriesResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const updates: Updates = [ - { - dataFormat: 'ICU', - source: 'Hello world', - metadata: { key: 'hello.world' }, - }, - ]; - - const options: EnqueueEntriesOptions = { - sourceLocale: 'en', - }; - - const result = await _enqueueEntries(updates, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: JSON.stringify({ - updates, - metadata: { - projectId: 'test-project', - sourceLocale: 'en', - }, - }), - }), - 60000 - ); - expect(result).toEqual(mockEnqueueEntriesResult); - }); - - it('should use default timeout when not specified', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockEnqueueEntriesResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const updates: Updates = [ - { - dataFormat: 'ICU', - source: 'Hello world', - metadata: { key: 'hello.world' }, - }, - ]; - - const options: EnqueueEntriesOptions = { - sourceLocale: 'en', - }; - - await _enqueueEntries(updates, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - 60000 - ); - }); - - it('should enforce maximum timeout limit', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockEnqueueEntriesResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const updates: Updates = [ - { - dataFormat: 'ICU', - source: 'Hello world', - metadata: { key: 'hello.world' }, - }, - ]; - - const options: EnqueueEntriesOptions = { - sourceLocale: 'en', - timeout: 99999, - }; - - await _enqueueEntries(updates, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - 60000 - ); - }); - - it('should use default URL when baseUrl not provided in config', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockEnqueueEntriesResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const configWithoutUrl: TranslationRequestConfig = { - projectId: 'test-project', - apiKey: 'test-api-key', - }; - - const updates: Updates = [ - { - dataFormat: 'ICU', - source: 'Hello world', - metadata: { key: 'hello.world' }, - }, - ]; - - const options: EnqueueEntriesOptions = { - sourceLocale: 'en', - }; - - await _enqueueEntries(updates, options, configWithoutUrl); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.stringContaining( - 'https://api2.gtx.dev/v1/project/translations/update' - ), - expect.any(Object), - expect.any(Number) - ); - }); - - it('should handle all optional fields', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockEnqueueEntriesResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const updates: Updates = [ - { - dataFormat: 'JSX', - source: ['Hello ', { t: 'strong', c: ['world'] }], - metadata: { key: 'hello.world' }, - }, - ]; - - const options: EnqueueEntriesOptions = { - sourceLocale: 'en', - dataFormat: 'JSX', - targetLocales: ['es', 'fr', 'de'], - version: 'v2.0.0', - description: 'Full options test', - requireApproval: false, - }; - - await _enqueueEntries(updates, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: expect.stringContaining('"dataFormat":"JSX"'), - }), - expect.any(Number) - ); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: expect.stringContaining('"locales":["es","fr","de"]'), - }), - expect.any(Number) - ); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: expect.stringContaining('"versionId":"v2.0.0"'), - }), - expect.any(Number) - ); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: expect.stringContaining('"requireApproval":false'), - }), - expect.any(Number) - ); - }); - - it('should handle fetch errors through handleFetchError', async () => { - const fetchError = new Error('Network error'); - vi.mocked(fetchWithTimeout).mockRejectedValue(fetchError); - vi.mocked(handleFetchError).mockImplementation(() => { - throw fetchError; - }); - - const updates: Updates = [ - { - dataFormat: 'ICU', - source: 'Hello world', - metadata: { key: 'hello.world' }, - }, - ]; - - const options: EnqueueEntriesOptions = { - sourceLocale: 'en', - }; - - await expect(_enqueueEntries(updates, options, mockConfig)).rejects.toThrow( - 'Network error' - ); - expect(handleFetchError).toHaveBeenCalledWith(fetchError, 60000); - }); - - it('should handle validation errors', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockEnqueueEntriesResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockImplementationOnce(() => { - throw new Error('Validation failed'); - }); - - const updates: Updates = [ - { - dataFormat: 'ICU', - source: 'Hello world', - metadata: { key: 'hello.world' }, - }, - ]; - - const options: EnqueueEntriesOptions = { - sourceLocale: 'en', - }; - - await expect(_enqueueEntries(updates, options, mockConfig)).rejects.toThrow( - 'Validation failed' - ); - expect(validateResponse).toHaveBeenCalledWith(mockResponse); - }); - - it('should handle empty updates object', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockEnqueueEntriesResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const updates: Updates = []; - - const options: EnqueueEntriesOptions = { - sourceLocale: 'en', - }; - - const result = await _enqueueEntries(updates, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: expect.stringContaining('"updates":[]'), - }), - expect.any(Number) - ); - expect(result).toEqual(mockEnqueueEntriesResult); - }); - - it('should handle I18NEXT dataFormat', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockEnqueueEntriesResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const updates: Updates = [ - { - dataFormat: 'I18NEXT', - source: 'Hello {{name}}', - metadata: { key: 'hello.user', namespace: 'common' }, - }, - ]; - - const options: EnqueueEntriesOptions = { - sourceLocale: 'en', - dataFormat: 'I18NEXT', - targetLocales: ['es'], - }; - - const result = await _enqueueEntries(updates, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: expect.stringContaining('"dataFormat":"I18NEXT"'), - }), - expect.any(Number) - ); - expect(result).toEqual(mockEnqueueEntriesResult); - }); - - it('should not include requireApproval when undefined', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockEnqueueEntriesResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const updates: Updates = [ - { - dataFormat: 'ICU', - source: 'Hello world', - metadata: { key: 'hello.world' }, - }, - ]; - - const options: EnqueueEntriesOptions = { - sourceLocale: 'en', - requireApproval: undefined, - }; - - await _enqueueEntries(updates, options, mockConfig); - - const body = JSON.parse( - vi.mocked(fetchWithTimeout).mock.calls[0][1].body as string - ); - expect(body).not.toHaveProperty('requireApproval'); - }); -}); diff --git a/packages/core/src/translate/__tests__/enqueueFiles.test.ts b/packages/core/src/translate/__tests__/enqueueFiles.test.ts index d2328e75b..c4106b0ac 100644 --- a/packages/core/src/translate/__tests__/enqueueFiles.test.ts +++ b/packages/core/src/translate/__tests__/enqueueFiles.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import _enqueueFiles, { EnqueueOptions } from '../enqueueFiles'; import { TranslationRequestConfig, EnqueueFilesResult } from '../../types'; -import { FileUploadRef } from '../../types-dir/uploadFiles'; +import { FileReference } from '../../types-dir/api/file'; import fetchWithTimeout from '../utils/fetchWithTimeout'; import validateResponse from '../utils/validateResponse'; import handleFetchError from '../utils/handleFetchError'; @@ -20,8 +20,9 @@ describe('_enqueueFiles', () => { }; const createMockFile = ( - overrides: Partial = {} - ): FileUploadRef => ({ + overrides: Partial = {} + ): FileReference => ({ + branchId: 'branch-123', fileId: 'file-123', versionId: 'version-456', fileName: 'test.json', @@ -61,16 +62,20 @@ describe('_enqueueFiles', () => { const mockOptions = createMockOptions(); const mockResponse: EnqueueFilesResult = { - data: { - 'component.json': { + jobData: { + 'job-1': { + sourceFileId: 'source-123', + fileId: 'file-123', versionId: 'version-456', - fileName: 'component.json', + branchId: 'branch-123', + targetLocale: 'es', + projectId: 'test-project', + force: true, + modelProvider: undefined, }, - 'page.json': { versionId: 'version-789', fileName: 'page.json' }, }, - message: 'Files enqueued successfully', locales: ['es', 'fr'], - translations: [], + message: 'Files enqueued successfully', }; const mockFetchResponse = { @@ -94,11 +99,13 @@ describe('_enqueueFiles', () => { body: JSON.stringify({ files: [ { + branchId: 'branch-123', fileId: 'file-123', versionId: 'version-456', fileName: 'component.json', }, { + branchId: 'branch-123', fileId: 'file-456', versionId: 'version-456', fileName: 'page.json', @@ -124,12 +131,20 @@ describe('_enqueueFiles', () => { const mockOptions = createMockOptions({ targetLocales: ['es'] }); const mockResponse: EnqueueFilesResult = { - data: { - 'test.json': { versionId: 'version-456', fileName: 'test.json' }, + jobData: { + 'job-1': { + sourceFileId: 'source-123', + fileId: 'file-123', + versionId: 'version-456', + branchId: 'branch-123', + targetLocale: 'es', + projectId: 'test-project', + force: true, + modelProvider: undefined, + }, }, message: 'File enqueued successfully', locales: ['es'], - translations: [], }; const mockFetchResponse = { @@ -142,7 +157,7 @@ describe('_enqueueFiles', () => { const result = await _enqueueFiles(mockFiles, mockOptions, mockConfig); expect(result.locales).toEqual(['es']); - expect(Object.keys(result.data)).toHaveLength(1); + expect(Object.keys(result.jobData)).toHaveLength(1); }); it('should handle all optional parameters', async () => { @@ -156,12 +171,20 @@ describe('_enqueueFiles', () => { }); const mockResponse: EnqueueFilesResult = { - data: { - 'test.json': { versionId: 'version-456', fileName: 'test.json' }, + jobData: { + 'job-1': { + sourceFileId: 'source-123', + fileId: 'file-123', + versionId: 'version-456', + branchId: 'branch-123', + targetLocale: 'es', + projectId: 'test-project', + force: true, + modelProvider: undefined, + }, }, message: 'Files enqueued successfully', locales: ['es', 'fr'], - translations: [], }; const mockFetchResponse = { @@ -181,6 +204,7 @@ describe('_enqueueFiles', () => { body: JSON.stringify({ files: [ { + branchId: 'branch-123', fileId: 'file-123', versionId: 'version-456', fileName: 'test.json', @@ -188,14 +212,13 @@ describe('_enqueueFiles', () => { ], targetLocales: ['es', 'fr'], sourceLocale: 'en', - publish: false, - requireApproval: true, - modelProvider: 'openai', - force: true, + publish: true, }), }, - 30000 + 60000 ); + + expect(validateResponse).toHaveBeenCalledWith(mockFetchResponse); }); it('should use custom timeout when provided', async () => { @@ -203,12 +226,20 @@ describe('_enqueueFiles', () => { const mockOptions = createMockOptions({ timeout: 60000 }); const mockResponse: EnqueueFilesResult = { - data: { - 'test.json': { versionId: 'version-456', fileName: 'test.json' }, + jobData: { + 'job-1': { + sourceFileId: 'source-123', + fileId: 'file-123', + versionId: 'version-456', + branchId: 'branch-123', + targetLocale: 'es', + projectId: 'test-project', + force: true, + modelProvider: undefined, + }, }, message: 'Files enqueued successfully', locales: ['es', 'fr'], - translations: [], }; const mockFetchResponse = { @@ -232,12 +263,20 @@ describe('_enqueueFiles', () => { const mockOptions = createMockOptions({ timeout: 1000000 }); // Very large timeout const mockResponse: EnqueueFilesResult = { - data: { - 'test.json': { versionId: 'version-456', fileName: 'test.json' }, + jobData: { + 'job-1': { + sourceFileId: 'source-123', + fileId: 'file-123', + versionId: 'version-456', + branchId: 'branch-123', + targetLocale: 'es', + projectId: 'test-project', + force: true, + modelProvider: undefined, + }, }, message: 'Files enqueued successfully', locales: ['es', 'fr'], - translations: [], }; const mockFetchResponse = { @@ -264,12 +303,20 @@ describe('_enqueueFiles', () => { }); const mockResponse: EnqueueFilesResult = { - data: { - 'test.json': { versionId: 'version-456', fileName: 'test.json' }, + jobData: { + 'job-1': { + sourceFileId: 'source-123', + fileId: 'file-123', + versionId: 'version-456', + branchId: 'branch-123', + targetLocale: 'es', + projectId: 'test-project', + force: true, + modelProvider: undefined, + }, }, message: 'Files enqueued successfully', locales: ['es', 'fr', 'de', 'it', 'pt'], - translations: [], }; const mockFetchResponse = { @@ -285,14 +332,13 @@ describe('_enqueueFiles', () => { }); it('should handle empty files array', async () => { - const mockFiles: FileUploadRef[] = []; + const mockFiles: FileReference[] = []; const mockOptions = createMockOptions(); const mockResponse: EnqueueFilesResult = { - data: {}, + jobData: {}, message: 'No files to enqueue', locales: ['es', 'fr'], - translations: [], }; const mockFetchResponse = { @@ -304,8 +350,8 @@ describe('_enqueueFiles', () => { const result = await _enqueueFiles(mockFiles, mockOptions, mockConfig); - expect(result.data).toEqual({}); - expect(Object.keys(result.data)).toHaveLength(0); + expect(result.jobData).toEqual({}); + expect(Object.keys(result.jobData)).toHaveLength(0); }); it('should handle fetch errors', async () => { @@ -335,12 +381,20 @@ describe('_enqueueFiles', () => { const mockOptions = createMockOptions(); const mockResponse: EnqueueFilesResult = { - data: { - 'test.json': { versionId: 'version-456', fileName: 'test.json' }, + jobData: { + 'job-1': { + sourceFileId: 'source-123', + fileId: 'file-123', + versionId: 'version-456', + branchId: 'branch-123', + targetLocale: 'es', + projectId: 'test-project', + force: true, + modelProvider: undefined, + }, }, message: 'Files enqueued successfully', locales: ['es', 'fr'], - translations: [], }; const mockFetchResponse = { @@ -364,28 +418,20 @@ describe('_enqueueFiles', () => { const mockOptions = createMockOptions(); const mockResponse: EnqueueFilesResult = { - data: { - 'test.json': { versionId: 'version-456', fileName: 'test.json' }, - }, - message: 'Files enqueued successfully', - locales: ['es', 'fr'], - translations: [ - { - locale: 'es', - metadata: { - context: 'test', - id: 'test-id', - sourceLocale: 'en', - actionType: 'standard', - }, + jobData: { + 'job-1': { + sourceFileId: 'source-123', fileId: 'file-123', - fileName: 'test.json', versionId: 'version-456', - id: 'translation-1', - isReady: false, - downloadUrl: '', + branchId: 'branch-123', + targetLocale: 'es', + projectId: 'test-project', + force: true, + modelProvider: undefined, }, - ], + }, + message: 'Files enqueued successfully', + locales: ['es', 'fr'], }; const mockFetchResponse = { @@ -397,9 +443,7 @@ describe('_enqueueFiles', () => { const result = await _enqueueFiles(mockFiles, mockOptions, mockConfig); - expect(result.translations).toHaveLength(1); - expect(result.translations[0].locale).toBe('es'); - expect(result.translations[0].isReady).toBe(false); + expect(Object.keys(result.jobData)).toHaveLength(1); }); it('should handle validation errors', async () => { diff --git a/packages/core/src/translate/__tests__/fetchTranslations.test.ts b/packages/core/src/translate/__tests__/fetchTranslations.test.ts deleted file mode 100644 index 525c22f43..000000000 --- a/packages/core/src/translate/__tests__/fetchTranslations.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import _fetchTranslations from '../fetchTranslations'; -import fetchWithTimeout from '../utils/fetchWithTimeout'; -import validateResponse from '../utils/validateResponse'; -import handleFetchError from '../utils/handleFetchError'; -import generateRequestHeaders from '../utils/generateRequestHeaders'; -import { TranslationRequestConfig } from '../../types'; -import { - FetchTranslationsOptions, - FetchTranslationsResult, -} from '../../types-dir/fetchTranslations'; - -vi.mock('../utils/fetchWithTimeout'); -vi.mock('../utils/validateResponse'); -vi.mock('../utils/handleFetchError'); -vi.mock('../utils/generateRequestHeaders'); - -describe.sequential('_fetchTranslations', () => { - const mockConfig: TranslationRequestConfig = { - baseUrl: 'https://api.test.com', - projectId: 'test-project', - apiKey: 'test-api-key', - }; - - const mockFetchTranslationsResult: FetchTranslationsResult = { - translations: [ - { - locale: 'es', - translation: 'Hello world', - }, - { - locale: 'fr', - translation: 'Bonjour le monde', - }, - ], - }; - - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(generateRequestHeaders).mockReturnValue({ - 'Content-Type': 'application/json', - 'x-gt-api-key': 'test-api-key', - 'x-gt-project-id': 'test-project', - }); - }); - - it('should fetch translation metadata successfully', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockFetchTranslationsResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const versionId = 'test-version-id'; - const options: FetchTranslationsOptions = { - timeout: 5000, - }; - - const result = await _fetchTranslations(versionId, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - 'https://api.test.com/v1/project/translations/info/test-version-id', - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'x-gt-api-key': 'test-api-key', - 'x-gt-project-id': 'test-project', - }, - }, - 5000 - ); - expect(validateResponse).toHaveBeenCalledWith(mockResponse); - expect(result).toEqual(mockFetchTranslationsResult); - }); - - it('should use default timeout when not specified', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockFetchTranslationsResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const versionId = 'test-version-id'; - const options: FetchTranslationsOptions = {}; - - await _fetchTranslations(versionId, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - 60000 - ); - }); - - it('should enforce maximum timeout limit', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockFetchTranslationsResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const versionId = 'test-version-id'; - const options: FetchTranslationsOptions = { - timeout: 99999, - }; - - await _fetchTranslations(versionId, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - 60000 - ); - }); - - it('should use default URL when baseUrl not provided in config', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockFetchTranslationsResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const configWithoutUrl: TranslationRequestConfig = { - projectId: 'test-project', - apiKey: 'test-api-key', - }; - - const versionId = 'test-version-id'; - const options: FetchTranslationsOptions = {}; - - await _fetchTranslations(versionId, options, configWithoutUrl); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.stringContaining( - 'https://api2.gtx.dev/v1/project/translations/info/test-version-id' - ), - expect.any(Object), - expect.any(Number) - ); - }); - - it('should handle fetch errors through handleFetchError', async () => { - const fetchError = new Error('Network error'); - vi.mocked(fetchWithTimeout).mockRejectedValue(fetchError); - vi.mocked(handleFetchError).mockImplementation(() => { - throw fetchError; - }); - - const versionId = 'test-version-id'; - const options: FetchTranslationsOptions = {}; - - await expect( - _fetchTranslations(versionId, options, mockConfig) - ).rejects.toThrow('Network error'); - expect(handleFetchError).toHaveBeenCalledWith(fetchError, 60000); - }); - - it('should handle validation errors', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockFetchTranslationsResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockImplementationOnce(() => { - throw new Error('Validation failed'); - }); - - const versionId = 'test-version-id'; - const options: FetchTranslationsOptions = {}; - - await expect( - _fetchTranslations(versionId, options, mockConfig) - ).rejects.toThrow('Validation failed'); - expect(validateResponse).toHaveBeenCalledWith(mockResponse); - }); - - it('should construct correct URL with version ID', async () => { - const mockResponse = { - json: vi.fn().mockResolvedValue(mockFetchTranslationsResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const versionId = 'my-special-version-123'; - const options: FetchTranslationsOptions = {}; - - await _fetchTranslations(versionId, options, mockConfig); - - expect(fetchWithTimeout).toHaveBeenCalledWith( - 'https://api.test.com/v1/project/translations/info/my-special-version-123', - expect.any(Object), - expect.any(Number) - ); - }); - - it('should handle empty response data', async () => { - const emptyResult = { - versionId: 'version-123', - translations: [], - metadata: {}, - }; - const mockResponse = { - json: vi.fn().mockResolvedValue(emptyResult), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const versionId = 'test-version-id'; - const options: FetchTranslationsOptions = {}; - - const result = await _fetchTranslations(versionId, options, mockConfig); - - expect(result).toEqual(emptyResult); - }); - - it('should handle JSON parsing errors', async () => { - const mockResponse = { - json: vi.fn().mockRejectedValue(new Error('Invalid JSON')), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const versionId = 'test-version-id'; - const options: FetchTranslationsOptions = {}; - - await expect( - _fetchTranslations(versionId, options, mockConfig) - ).rejects.toThrow('Invalid JSON'); - }); -}); diff --git a/packages/core/src/translate/__tests__/querySourceFile.test.ts b/packages/core/src/translate/__tests__/querySourceFile.test.ts index bdadbed7c..063fc2098 100644 --- a/packages/core/src/translate/__tests__/querySourceFile.test.ts +++ b/packages/core/src/translate/__tests__/querySourceFile.test.ts @@ -9,7 +9,7 @@ import { FileQuery, CheckFileTranslationsOptions, FileQueryResult, -} from '../../types-dir/checkFileTranslations'; +} from '../../types-dir/api/checkFileTranslations'; vi.mock('../utils/fetchWithTimeout'); vi.mock('../utils/validateResponse'); @@ -265,7 +265,7 @@ describe.sequential('_querySourceFile', () => { const result = await _querySourceFile(query, options, mockConfig); expect(fetchWithTimeout).toHaveBeenCalledWith( - 'https://api.test.com/v2/project/translations/files/status/file-123', + 'https://api.test.com/v2/project/translations/files/status/file-123?', expect.objectContaining({ method: 'GET', }), @@ -348,7 +348,7 @@ describe.sequential('_querySourceFile', () => { await _querySourceFile(query, options, mockConfig); - expect(generateRequestHeaders).toHaveBeenCalledWith(mockConfig, true); + expect(generateRequestHeaders).toHaveBeenCalledWith(mockConfig); }); it('should handle JSON parsing errors', async () => { @@ -413,7 +413,7 @@ describe.sequential('_querySourceFile', () => { await _querySourceFile(query, options, mockConfig); expect(fetchWithTimeout).toHaveBeenCalledWith( - 'https://api.test.com/v2/project/translations/files/status/file-123', + 'https://api.test.com/v2/project/translations/files/status/file-123?', expect.any(Object), expect.any(Number) ); diff --git a/packages/core/src/translate/__tests__/setupProject.test.ts b/packages/core/src/translate/__tests__/setupProject.test.ts index 30964130f..7a72432d3 100644 --- a/packages/core/src/translate/__tests__/setupProject.test.ts +++ b/packages/core/src/translate/__tests__/setupProject.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import _setupProject, { SetupProjectResult } from '../setupProject'; import { TranslationRequestConfig } from '../../types'; -import { FileUploadRef } from '../../types-dir/uploadFiles'; +import { FileReference } from '../../types-dir/api/file'; import fetchWithTimeout from '../utils/fetchWithTimeout'; import validateResponse from '../utils/validateResponse'; import handleFetchError from '../utils/handleFetchError'; @@ -20,8 +20,9 @@ describe('_setupProject', () => { }; const createMockFile = ( - overrides: Partial = {} - ): FileUploadRef => ({ + overrides: Partial = {} + ): FileReference => ({ + branchId: 'branch-123', fileId: 'file-123', versionId: 'version-456', fileName: 'test.json', @@ -75,12 +76,14 @@ describe('_setupProject', () => { body: JSON.stringify({ files: [ { + branchId: 'branch-123', fileId: 'file-123', versionId: 'version-456', fileName: 'component.json', fileFormat: 'JSON', }, { + branchId: 'branch-123', fileId: 'file-456', versionId: 'version-456', fileName: 'page.json', @@ -96,6 +99,7 @@ describe('_setupProject', () => { expect(validateResponse).toHaveBeenCalledWith(mockFetchResponse); expect(result).toEqual(mockResponse); expect(result.status).toBe('queued'); + // @ts-expect-error - setupJobId is not defined in the type expect(result.setupJobId).toBe('setup-job-789'); }); @@ -116,8 +120,9 @@ describe('_setupProject', () => { const result = await _setupProject(mockFiles, mockConfig); - expect(result.setupJobId).toBe('setup-job-123'); expect(result.status).toBe('queued'); + // @ts-expect-error - setupJobId is not defined in the type + expect(result.setupJobId).toBe('setup-job-123'); }); it('should use custom timeout when provided', async () => { @@ -206,6 +211,7 @@ describe('_setupProject', () => { body: JSON.stringify({ files: [ { + branchId: 'branch-123', fileId: 'file-123', versionId: 'version-456', fileName: 'data.json', @@ -213,6 +219,7 @@ describe('_setupProject', () => { dataFormat: 'flat', }, { + branchId: 'branch-123', fileId: 'file-456', versionId: 'version-456', fileName: 'nested.json', @@ -252,6 +259,7 @@ describe('_setupProject', () => { body: JSON.stringify({ files: [ { + branchId: 'branch-123', fileId: 'file-123', versionId: 'version-456', fileName: 'test.json', @@ -293,6 +301,7 @@ describe('_setupProject', () => { body: JSON.stringify({ files: [ { + branchId: 'branch-123', fileId: 'file-123', versionId: 'version-456', fileName: 'test.json', @@ -311,7 +320,7 @@ describe('_setupProject', () => { createMockFile({ fileName: 'component.js', fileFormat: 'JS' }), createMockFile({ fileName: 'styles.css', - fileFormat: 'CSS', + fileFormat: 'HTML', fileId: 'file-456', }), createMockFile({ @@ -321,7 +330,7 @@ describe('_setupProject', () => { }), createMockFile({ fileName: 'template.tsx', - fileFormat: 'TSX', + fileFormat: 'TS', fileId: 'file-012', }), ]; @@ -348,28 +357,32 @@ describe('_setupProject', () => { body: JSON.stringify({ files: [ { + branchId: 'branch-123', fileId: 'file-123', versionId: 'version-456', fileName: 'component.js', fileFormat: 'JS', }, { + branchId: 'branch-123', fileId: 'file-456', versionId: 'version-456', fileName: 'styles.css', - fileFormat: 'CSS', + fileFormat: 'HTML', }, { + branchId: 'branch-123', fileId: 'file-789', versionId: 'version-456', fileName: 'content.md', fileFormat: 'MD', }, { + branchId: 'branch-123', fileId: 'file-012', versionId: 'version-456', fileName: 'template.tsx', - fileFormat: 'TSX', + fileFormat: 'TS', }, ], locales: undefined, @@ -380,7 +393,7 @@ describe('_setupProject', () => { }); it('should handle empty files array', async () => { - const mockFiles: FileUploadRef[] = []; + const mockFiles: FileReference[] = []; const mockResponse: SetupProjectResult = { setupJobId: 'setup-job-empty', @@ -409,6 +422,8 @@ describe('_setupProject', () => { expect.any(Number) ); + expect(result.status).toBe('queued'); + // @ts-expect-error - setupJobId is not defined in the type expect(result.setupJobId).toBe('setup-job-empty'); }); diff --git a/packages/core/src/translate/__tests__/shouldSetupProject.test.ts b/packages/core/src/translate/__tests__/shouldSetupProject.test.ts deleted file mode 100644 index 1a3378e11..000000000 --- a/packages/core/src/translate/__tests__/shouldSetupProject.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import _shouldSetupProject, { - ShouldSetupProjectResult, -} from '../shouldSetupProject'; -import { TranslationRequestConfig } from '../../types'; -import validateResponse from '../utils/validateResponse'; -import generateRequestHeaders from '../utils/generateRequestHeaders'; - -vi.mock('../utils/validateResponse'); -vi.mock('../utils/generateRequestHeaders'); - -const mockFetch = vi.fn(); - -describe('_shouldSetupProject', () => { - const mockConfig: TranslationRequestConfig = { - baseUrl: 'https://api.test.com', - projectId: 'test-project', - apiKey: 'test-api-key', - }; - - beforeEach(() => { - vi.clearAllMocks(); - mockFetch.mockReset(); - vi.mocked(validateResponse).mockReset(); - vi.mocked(generateRequestHeaders).mockReset(); - - vi.mocked(generateRequestHeaders).mockReturnValue({ - 'Content-Type': 'application/json', - 'x-gt-api-key': 'test-api-key', - 'x-gt-project-id': 'test-project', - }); - global.fetch = mockFetch; - }); - - it('should handle fetch errors', async () => { - const fetchError = new Error('Network error'); - mockFetch.mockRejectedValue(fetchError); - - await expect(_shouldSetupProject(mockConfig)).rejects.toThrow( - 'Network error' - ); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.test.com/v2/project/setup/should-generate', - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'x-gt-api-key': 'test-api-key', - 'x-gt-project-id': 'test-project', - }, - } - ); - }); - - it('should use correct request headers', async () => { - const mockResponse: ShouldSetupProjectResult = { - shouldSetupProject: true, - }; - - const mockFetchResponse = { - json: vi.fn().mockResolvedValue(mockResponse), - } as unknown as Response; - - mockFetch.mockResolvedValue(mockFetchResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - await _shouldSetupProject(mockConfig); - - expect(generateRequestHeaders).toHaveBeenCalledWith(mockConfig, true); - expect(mockFetch).toHaveBeenCalledWith(expect.any(String), { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'x-gt-api-key': 'test-api-key', - 'x-gt-project-id': 'test-project', - }, - }); - }); - - it('should handle different response structures', async () => { - const mockResponse: ShouldSetupProjectResult = { - shouldSetupProject: true, - }; - - const mockFetchResponse = { - json: vi.fn().mockResolvedValue(mockResponse), - } as unknown as Response; - - mockFetch.mockResolvedValue(mockFetchResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - - const result = await _shouldSetupProject(mockConfig); - - expect(typeof result.shouldSetupProject).toBe('boolean'); - expect(result).toHaveProperty('shouldSetupProject'); - }); -}); diff --git a/packages/core/src/translate/__tests__/translate.test.ts b/packages/core/src/translate/__tests__/translate.test.ts index a10690271..221b6d75f 100644 --- a/packages/core/src/translate/__tests__/translate.test.ts +++ b/packages/core/src/translate/__tests__/translate.test.ts @@ -5,8 +5,8 @@ import validateResponse from '../utils/validateResponse'; import handleFetchError from '../utils/handleFetchError'; import generateRequestHeaders from '../utils/generateRequestHeaders'; import { TranslationRequestConfig, TranslationResult } from '../../types'; -import { Content } from '../../types-dir/content'; -import { EntryMetadata } from '../../types-dir/entry'; +import { Content } from '../../types-dir/jsx/content'; +import { EntryMetadata } from '../../types-dir/api/entry'; vi.mock('../utils/fetchWithTimeout'); vi.mock('../utils/validateResponse'); diff --git a/packages/core/src/translate/__tests__/translateMany.test.ts b/packages/core/src/translate/__tests__/translateMany.test.ts index f5fc6850c..ebc9c8928 100644 --- a/packages/core/src/translate/__tests__/translateMany.test.ts +++ b/packages/core/src/translate/__tests__/translateMany.test.ts @@ -5,7 +5,7 @@ import validateResponse from '../utils/validateResponse'; import handleFetchError from '../utils/handleFetchError'; import generateRequestHeaders from '../utils/generateRequestHeaders'; import { TranslationRequestConfig, TranslateManyResult } from '../../types'; -import { Entry, EntryMetadata } from '../../types-dir/entry'; +import { Entry, EntryMetadata } from '../../types-dir/api/entry'; vi.mock('../utils/fetchWithTimeout'); vi.mock('../utils/validateResponse'); diff --git a/packages/core/src/translate/__tests__/uploadSourceFiles.test.ts b/packages/core/src/translate/__tests__/uploadSourceFiles.test.ts index dc4405bb7..d0a3176e6 100644 --- a/packages/core/src/translate/__tests__/uploadSourceFiles.test.ts +++ b/packages/core/src/translate/__tests__/uploadSourceFiles.test.ts @@ -4,7 +4,7 @@ import { TranslationRequestConfig } from '../../types'; import { FileUpload, RequiredUploadFilesOptions, -} from '../../types-dir/uploadFiles'; +} from '../../types-dir/api/uploadFiles'; import fetchWithTimeout from '../utils/fetchWithTimeout'; import validateResponse from '../utils/validateResponse'; import handleFetchError from '../utils/handleFetchError'; @@ -15,7 +15,7 @@ vi.mock('../utils/validateResponse'); vi.mock('../utils/handleFetchError'); vi.mock('../utils/generateRequestHeaders'); -describe('_uploadSourceFiles', () => { +describe.sequential('_uploadSourceFiles', () => { const mockConfig: TranslationRequestConfig = { baseUrl: 'https://api.test.com', projectId: 'test-project', @@ -42,10 +42,6 @@ describe('_uploadSourceFiles', () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(fetchWithTimeout).mockReset(); - vi.mocked(validateResponse).mockReset(); - vi.mocked(handleFetchError).mockReset(); - vi.mocked(generateRequestHeaders).mockReset(); vi.mocked(generateRequestHeaders).mockReturnValue({ 'Content-Type': 'application/json', @@ -125,7 +121,9 @@ describe('_uploadSourceFiles', () => { ); expect(validateResponse).toHaveBeenCalledWith(mockFetchResponse); - expect(result).toEqual(mockResponse); + expect(result.data).toEqual(mockResponse.uploadedFiles); + expect(result.count).toBe(2); + expect(result.batchCount).toBe(1); }); it('should handle single source file upload', async () => { @@ -153,8 +151,8 @@ describe('_uploadSourceFiles', () => { const result = await _uploadSourceFiles(mockFiles, mockOptions, mockConfig); - expect(result.uploadedFiles).toHaveLength(1); - expect(result.uploadedFiles[0].fileName).toBe('test.json'); + expect(result.data).toHaveLength(1); + expect(result.data[0].fileName).toBe('test.json'); }); it('should handle files with data format', async () => { @@ -162,13 +160,11 @@ describe('_uploadSourceFiles', () => { { source: createMockFileUpload({ fileName: 'flat.json', - dataFormat: 'flat', }), }, { source: createMockFileUpload({ fileName: 'nested.json', - dataFormat: 'nested', }), }, ]; @@ -199,7 +195,6 @@ describe('_uploadSourceFiles', () => { fileName: 'flat.json', fileFormat: 'JSON', locale: 'en', - dataFormat: 'flat', }, }, { @@ -208,7 +203,6 @@ describe('_uploadSourceFiles', () => { fileName: 'nested.json', fileFormat: 'JSON', locale: 'en', - dataFormat: 'nested', }, }, ], @@ -352,7 +346,7 @@ describe('_uploadSourceFiles', () => { const mockFiles = [{ source: createMockFileUpload() }]; const mockOptions = createMockOptions(); - const mockResponse = { success: true }; + const mockResponse = { success: true, uploadedFiles: [] }; const mockFetchResponse = { json: vi.fn().mockResolvedValue(mockResponse), @@ -374,30 +368,13 @@ describe('_uploadSourceFiles', () => { const mockFiles: { source: FileUpload }[] = []; const mockOptions = createMockOptions(); - const mockResponse = { success: true, uploadedFiles: [] }; - const mockFetchResponse = { - json: vi.fn().mockResolvedValue(mockResponse), - } as unknown as Response; - - vi.mocked(fetchWithTimeout).mockResolvedValue(mockFetchResponse); - vi.mocked(validateResponse).mockResolvedValue(undefined); - const result = await _uploadSourceFiles(mockFiles, mockOptions, mockConfig); - expect(fetchWithTimeout).toHaveBeenCalledWith( - expect.any(String), - { - method: 'POST', - headers: expect.any(Object), - body: JSON.stringify({ - data: [], - sourceLocale: 'en', - }), - }, - expect.any(Number) - ); - - expect(result.uploadedFiles).toEqual([]); + // With batching, empty array returns early without making any API calls + expect(fetchWithTimeout).not.toHaveBeenCalled(); + expect(result.data).toEqual([]); + expect(result.count).toBe(0); + expect(result.batchCount).toBe(0); }); it('should handle different source locales', async () => { @@ -436,4 +413,64 @@ describe('_uploadSourceFiles', () => { expect.any(Number) ); }); + + it('should batch files when uploading more than 100 files', async () => { + // Create 150 mock files + const mockFiles = Array.from({ length: 150 }, (_, i) => ({ + source: createMockFileUpload({ fileName: `file-${i}.json` }), + })); + + const mockOptions = createMockOptions(); + + const mockResponse1 = { + success: true, + uploadedFiles: Array.from({ length: 100 }, (_, i) => ({ + fileId: `file-${i}`, + versionId: `version-${i}`, + fileName: `file-${i}.json`, + })), + }; + + const mockResponse2 = { + success: true, + uploadedFiles: Array.from({ length: 50 }, (_, i) => ({ + fileId: `file-${i + 100}`, + versionId: `version-${i + 100}`, + fileName: `file-${i + 100}.json`, + })), + }; + + const mockFetchResponse1 = { + json: vi.fn().mockResolvedValue(mockResponse1), + } as unknown as Response; + + const mockFetchResponse2 = { + json: vi.fn().mockResolvedValue(mockResponse2), + } as unknown as Response; + + vi.mocked(fetchWithTimeout) + .mockResolvedValueOnce(mockFetchResponse1) + .mockResolvedValueOnce(mockFetchResponse2); + vi.mocked(validateResponse).mockResolvedValue(undefined); + + const result = await _uploadSourceFiles(mockFiles, mockOptions, mockConfig); + + // Should make 2 batch calls + expect(fetchWithTimeout).toHaveBeenCalledTimes(2); + + // First call should have 100 files + const firstCall = vi.mocked(fetchWithTimeout).mock.calls[0]; + const firstBody = JSON.parse(firstCall[1]?.body as string); + expect(firstBody.data).toHaveLength(100); + + // Second call should have 50 files + const secondCall = vi.mocked(fetchWithTimeout).mock.calls[1]; + const secondBody = JSON.parse(secondCall[1]?.body as string); + expect(secondBody.data).toHaveLength(50); + + // Result should contain all 150 files + expect(result.data).toHaveLength(150); + expect(result.count).toBe(150); + expect(result.batchCount).toBe(2); + }); }); diff --git a/packages/core/src/translate/__tests__/uploadTranslations.test.ts b/packages/core/src/translate/__tests__/uploadTranslations.test.ts index c7a361194..383fdba44 100644 --- a/packages/core/src/translate/__tests__/uploadTranslations.test.ts +++ b/packages/core/src/translate/__tests__/uploadTranslations.test.ts @@ -4,7 +4,7 @@ import { TranslationRequestConfig } from '../../types'; import { FileUpload, RequiredUploadFilesOptions, -} from '../../types-dir/uploadFiles'; +} from '../../types-dir/api/uploadFiles'; import fetchWithTimeout from '../utils/fetchWithTimeout'; import validateResponse from '../utils/validateResponse'; import handleFetchError from '../utils/handleFetchError'; @@ -77,7 +77,7 @@ describe('_uploadTranslations', () => { const mockResponse = { success: true, - translations: [ + uploadedFiles: [ { translationId: 'trans-123', locale: 'es', @@ -145,7 +145,8 @@ describe('_uploadTranslations', () => { ); expect(validateResponse).toHaveBeenCalledWith(mockFetchResponse); - expect(result).toEqual(mockResponse); + expect(result.data).toEqual(mockResponse.uploadedFiles || []); + expect(result.batchCount).toBe(1); }); it('should handle single translation per source', async () => { diff --git a/packages/core/src/translate/api.ts b/packages/core/src/translate/api.ts new file mode 100644 index 000000000..4cb7081d4 --- /dev/null +++ b/packages/core/src/translate/api.ts @@ -0,0 +1 @@ +export const API_VERSION = '2025-11-03.v1'; diff --git a/packages/core/src/translate/checkFileTranslations.ts b/packages/core/src/translate/checkFileTranslations.ts deleted file mode 100644 index ff1af19ac..000000000 --- a/packages/core/src/translate/checkFileTranslations.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { defaultBaseUrl } from '../settings/settingsUrls'; -import fetchWithTimeout from './utils/fetchWithTimeout'; -import { maxTimeout } from '../settings/settings'; -import validateResponse from './utils/validateResponse'; -import handleFetchError from './utils/handleFetchError'; -import { TranslationRequestConfig } from '../types'; -import { - CheckFileTranslationsOptions, - CheckFileTranslationsResult, - FileTranslationQuery, -} from '../types-dir/api/checkFileTranslations'; -import generateRequestHeaders from './utils/generateRequestHeaders'; - -/** - * @internal - * Checks the translation status of files without downloading them. - * @param data - Object mapping source paths to file information - * @param options - The options for the API call - * @param config - The configuration for the API call - * @returns The file translation status information - */ -export default async function _checkFileTranslations( - data: FileTranslationQuery[], - options: CheckFileTranslationsOptions = {}, - config: TranslationRequestConfig -): Promise { - const timeout = Math.min(options.timeout || maxTimeout, maxTimeout); - const url = `${config.baseUrl || defaultBaseUrl}/v2/project/translations/files/retrieve`; - - // Validate data - data.forEach((item) => { - if (!item.fileName && !item.fileId) { - throw new Error('fileName or fileId is required'); - } - }); - // Request the file status - let response; - try { - response = await fetchWithTimeout( - url, - { - method: 'POST', - headers: generateRequestHeaders(config), - body: JSON.stringify({ files: data }), - }, - timeout - ); - } catch (error) { - handleFetchError(error, timeout); - } - - // Validate response - await validateResponse(response); - - // Parse response - const result = await response.json(); - return result as CheckFileTranslationsResult; -} diff --git a/packages/core/src/translate/checkSetupStatus.ts b/packages/core/src/translate/checkJobStatus.ts similarity index 62% rename from packages/core/src/translate/checkSetupStatus.ts rename to packages/core/src/translate/checkJobStatus.ts index b974e5aa1..05aae0e1a 100644 --- a/packages/core/src/translate/checkSetupStatus.ts +++ b/packages/core/src/translate/checkJobStatus.ts @@ -6,37 +6,43 @@ import validateResponse from './utils/validateResponse'; import handleFetchError from './utils/handleFetchError'; import generateRequestHeaders from './utils/generateRequestHeaders'; -export type SetupJobStatus = 'queued' | 'processing' | 'completed' | 'failed'; +export type JobStatus = + | 'queued' + | 'processing' + | 'completed' + | 'failed' + | 'unknown'; -export type CheckSetupStatusResult = { +export type CheckJobStatusResult = { jobId: string; - status: SetupJobStatus; + status: JobStatus; error?: { message: string }; -}; +}[]; /** * @internal - * Queries setup status for a project - * @param jobId - Setup job ID + * Queries job statuses for a project + * @param jobIds - Job IDs * @param config - The configuration for the API call * @param timeoutMS - The timeout in milliseconds * @returns The result of the API call */ -export async function _checkSetupStatus( - jobId: string, +export async function _checkJobStatus( + jobIds: string[], config: TranslationRequestConfig, timeoutMs?: number -): Promise { +): Promise { const timeout = Math.min(timeoutMs || maxTimeout, maxTimeout); - const url = `${config.baseUrl || defaultBaseUrl}/v2/project/setup/status/${encodeURIComponent(jobId)}`; + const url = `${config.baseUrl || defaultBaseUrl}/v2/project/jobs/info`; let response: Response; try { response = await fetchWithTimeout( url, { - method: 'GET', - headers: generateRequestHeaders(config, true), + method: 'POST', + headers: generateRequestHeaders(config), + body: JSON.stringify({ jobIds }), }, timeout ); @@ -45,5 +51,5 @@ export async function _checkSetupStatus( } await validateResponse(response); - return (await response.json()) as CheckSetupStatusResult; + return (await response.json()) as CheckJobStatusResult; } diff --git a/packages/core/src/translate/checkTranslationStatus.ts b/packages/core/src/translate/checkTranslationStatus.ts deleted file mode 100644 index edb2f481c..000000000 --- a/packages/core/src/translate/checkTranslationStatus.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { defaultBaseUrl } from '../settings/settingsUrls'; -import fetchWithTimeout from './utils/fetchWithTimeout'; -import { maxTimeout } from '../settings/settings'; -import validateResponse from './utils/validateResponse'; -import handleFetchError from './utils/handleFetchError'; -import { TranslationRequestConfig } from '../types'; -import { - CheckTranslationStatusOptions, - TranslationStatusResult, -} from '../types-dir/api/translationStatus'; -import generateRequestHeaders from './utils/generateRequestHeaders'; - -/** - * @internal - * Checks the translation status of a version. - * @param versionId - The ID of the version to check - * @param options - The options for the API call - * @param config - The configuration for the request - * @returns The translation status of the version - */ -export default async function _checkTranslationStatus( - versionId: string, - options: CheckTranslationStatusOptions, - config: TranslationRequestConfig -): Promise { - const { baseUrl } = config; - const timeout = Math.min(options.timeout || maxTimeout, maxTimeout); - const url = `${baseUrl || defaultBaseUrl}/v2/project/translations/status/${encodeURIComponent(versionId)}`; - - // Request the file download - let response; - try { - response = await fetchWithTimeout( - url, - { - method: 'GET', - headers: generateRequestHeaders(config, true), - }, - timeout - ); - } catch (error) { - handleFetchError(error, timeout); - } - - // Validate response - await validateResponse(response); - - const result = await response.json(); - return result as TranslationStatusResult; -} diff --git a/packages/core/src/translate/createBranch.ts b/packages/core/src/translate/createBranch.ts new file mode 100644 index 000000000..168b71f3e --- /dev/null +++ b/packages/core/src/translate/createBranch.ts @@ -0,0 +1,54 @@ +import { defaultBaseUrl } from '../settings/settingsUrls'; +import fetchWithTimeout from './utils/fetchWithTimeout'; +import { maxTimeout } from '../settings/settings'; +import validateResponse from './utils/validateResponse'; +import handleFetchError from './utils/handleFetchError'; +import { TranslationRequestConfig } from '../types'; +import generateRequestHeaders from './utils/generateRequestHeaders'; + +export type CreateBranchQuery = { + branchName: string; + defaultBranch: boolean; +}; + +export type CreateBranchResult = { + branch: { id: string; name: string }; +}; + +/** + * @internal + * Creates a new branch in the API. + * @param query - Object mapping the branch name and default branch flag + * @param config - The configuration for the API call + * @returns The created branch information + */ +export default async function _createBranch( + query: CreateBranchQuery, + config: TranslationRequestConfig +): Promise { + const timeout = Math.min(maxTimeout, maxTimeout); + const url = `${config.baseUrl || defaultBaseUrl}/v2/project/branches/create`; + + // Request the creation of the branch + let response; + try { + response = await fetchWithTimeout( + url, + { + method: 'POST', + headers: generateRequestHeaders(config), + body: JSON.stringify(query), + }, + timeout + ); + } catch (error) { + handleFetchError(error, timeout); + } + + // Validate response + await validateResponse(response); + + // Parse response + const result = await response.json(); + return result as CreateBranchResult; +} diff --git a/packages/core/src/translate/downloadFile.ts b/packages/core/src/translate/downloadFile.ts deleted file mode 100644 index 13dc9c752..000000000 --- a/packages/core/src/translate/downloadFile.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { defaultBaseUrl } from '../settings/settingsUrls'; -import fetchWithTimeout from './utils/fetchWithTimeout'; -import { maxTimeout } from '../settings/settings'; -import validateResponse from './utils/validateResponse'; -import handleFetchError from './utils/handleFetchError'; -import { TranslationRequestConfig } from '../types'; -import { DownloadFileOptions } from '../types-dir/api/downloadFile'; -import generateRequestHeaders from './utils/generateRequestHeaders'; -import { decode } from '../utils/base64'; - -/** - * @internal - * Downloads a single translation file content without writing to filesystem. - * @param translationId - The ID of the translation to download - * @param options - The options for the API call - * @param config - The configuration for the request - * @returns The downloaded file content as an ArrayBuffer - */ -export default async function _downloadFile( - translationId: string, - options: DownloadFileOptions, - config: TranslationRequestConfig -): Promise { - const { baseUrl } = config; - const timeout = Math.min(options.timeout || maxTimeout, maxTimeout); - const url = `${baseUrl || defaultBaseUrl}/v2/project/translations/files/${translationId}/download`; - - // Request the file download - let response; - try { - response = await fetchWithTimeout( - url, - { - method: 'GET', - headers: generateRequestHeaders(config, true), - }, - timeout - ); - } catch (error) { - handleFetchError(error, timeout); - } - - // Validate response - await validateResponse(response); - - const result = (await response.json()) as { data: string }; - return Buffer.from(result.data, 'base64').buffer; -} - -/** - * @internal - * Downloads a single translation file content without writing to filesystem. - * @param file - The file to download - * @param options - The options for the API call - * @param config - The configuration for the request - * @returns The downloaded file content as a UTF-8 string - */ -export async function _downloadFileV2( - file: { - fileId: string; - locale: string; - versionId?: string; - }, - options: DownloadFileOptions, - config: TranslationRequestConfig -): Promise { - const { baseUrl } = config; - const timeout = Math.min(options.timeout || maxTimeout, maxTimeout); - const searchParams = new URLSearchParams(); - if (file.versionId) { - searchParams.set('versionId', file.versionId); - } - searchParams.set('locale', file.locale); - const url = `${baseUrl || defaultBaseUrl}/v2/project/files/download/${file.fileId}?${searchParams.toString()}`; - - // Request the file download - let response; - try { - response = await fetchWithTimeout( - url, - { - method: 'GET', - headers: generateRequestHeaders(config, true), - }, - timeout - ); - } catch (error) { - handleFetchError(error, timeout); - } - - // Validate response - await validateResponse(response); - - const result = (await response.json()) as { data: string }; - return decode(result.data); -} diff --git a/packages/core/src/translate/downloadFileBatch.ts b/packages/core/src/translate/downloadFileBatch.ts index 7af5118d2..f471891e3 100644 --- a/packages/core/src/translate/downloadFileBatch.ts +++ b/packages/core/src/translate/downloadFileBatch.ts @@ -6,52 +6,62 @@ import handleFetchError from './utils/handleFetchError'; import { TranslationRequestConfig } from '../types'; import { DownloadFileBatchOptions, + DownloadFileBatchRequest, DownloadFileBatchResult, } from '../types-dir/api/downloadFileBatch'; import generateRequestHeaders from './utils/generateRequestHeaders'; import { decode } from '../utils/base64'; +import { processBatches } from './utils/batch'; /** * @internal - * Downloads multiple translation files in a single batch request. + * Downloads multiple translation files in batches. * @param files - Array of files to download * @param options - The options for the API call * @param config - The configuration for the request - * @returns The batch download results with success/failure tracking + * @returns Promise resolving to a BatchList with all downloaded files */ export default async function _downloadFileBatch( - fileIds: string[], + requests: DownloadFileBatchRequest, options: DownloadFileBatchOptions, config: TranslationRequestConfig -): Promise { +) { const timeout = Math.min(options.timeout || maxTimeout, maxTimeout); - const url = `${config.baseUrl || defaultBaseUrl}/v2/project/translations/files/batch-download`; + const url = `${config.baseUrl || defaultBaseUrl}/v2/project/files/download`; - // Request the batch download - let response; - try { - response = await fetchWithTimeout( - url, - { - method: 'POST', - headers: generateRequestHeaders(config), - body: JSON.stringify({ fileIds }), - }, - timeout - ); - } catch (error) { - handleFetchError(error, timeout); - } + return processBatches( + requests, + async (batch) => { + // Request the batch download + let response; + try { + response = await fetchWithTimeout( + url, + { + method: 'POST', + headers: generateRequestHeaders(config), + body: JSON.stringify(batch), + }, + timeout + ); + } catch (error) { + handleFetchError(error, timeout); + } - // Validate response - await validateResponse(response); + // Validate response + await validateResponse(response); - // Parse response - const result = (await response.json()) as DownloadFileBatchResult; - // convert from base64 to string - const files = result.files.map((file) => ({ - ...file, - data: decode(file.data), - })); - return { ...result, files } as DownloadFileBatchResult; + // Parse response + const result = (await response.json()) as DownloadFileBatchResult; + + // convert from base64 to string + const files = result.files.map((file) => ({ + ...file, + data: decode(file.data), + })); + + return files; + }, + { batchSize: 100 } + ); } diff --git a/packages/core/src/translate/enqueueEntries.ts b/packages/core/src/translate/enqueueEntries.ts deleted file mode 100644 index d18d6a41b..000000000 --- a/packages/core/src/translate/enqueueEntries.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { defaultBaseUrl } from '../settings/settingsUrls'; -import fetchWithTimeout from './utils/fetchWithTimeout'; -import { maxTimeout } from '../settings/settings'; -import validateResponse from './utils/validateResponse'; -import handleFetchError from './utils/handleFetchError'; -import { TranslationRequestConfig } from '../types'; -import { - Updates, - EnqueueEntriesOptions, - EnqueueEntriesResult, -} from '../types-dir/api/enqueueEntries'; -import generateRequestHeaders from './utils/generateRequestHeaders'; - -/** - * @internal - * Sends translation entries to the General Translation API for enqueueing. - * @param updates - The updates to send - * @param options - The options for the API call - * @param config - The configuration for the API call - * @returns The result of the API call - * @deprecated Use the {@link _enqueueFiles} method instead. Will be removed in v8.0.0. - */ -export default async function _enqueueEntries( - updates: Updates, - options: EnqueueEntriesOptions, - config: TranslationRequestConfig -): Promise { - const { projectId } = config; - const { - sourceLocale, - dataFormat, - targetLocales, - version, - description, - requireApproval, - } = options; - const timeout = Math.min(options.timeout || maxTimeout, maxTimeout); - const url = `${config.baseUrl || defaultBaseUrl}/v1/project/translations/update`; - - // Build request body - matches original sendUpdates structure - const body = { - updates, - ...(targetLocales && { locales: targetLocales }), - metadata: { - ...(projectId && { projectId }), - ...(sourceLocale && { sourceLocale }), - ...(options.modelProvider && { modelProvider: options.modelProvider }), - }, - ...(dataFormat && { dataFormat }), - ...(version && { versionId: version }), - ...(description && { description }), - ...(requireApproval !== undefined && { - requireApproval, - }), - }; - - // Request the updates - let response; - try { - response = await fetchWithTimeout( - url, - { - method: 'POST', - headers: generateRequestHeaders(config), - body: JSON.stringify(body), - }, - timeout - ); - } catch (error) { - handleFetchError(error, timeout); - } - - // Validate response - await validateResponse(response); - - // Parse response - const result = await response.json(); - return result as EnqueueEntriesResult; -} diff --git a/packages/core/src/translate/enqueueFiles.ts b/packages/core/src/translate/enqueueFiles.ts index 731fe31ea..704a7006e 100644 --- a/packages/core/src/translate/enqueueFiles.ts +++ b/packages/core/src/translate/enqueueFiles.ts @@ -5,7 +5,7 @@ import { maxTimeout } from '../settings/settings'; import validateResponse from './utils/validateResponse'; import handleFetchError from './utils/handleFetchError'; import generateRequestHeaders from './utils/generateRequestHeaders'; -import { FileUploadRef } from 'src/types-dir/api/uploadFiles'; +import type { FileReference } from '../types-dir/api/file'; export type EnqueueOptions = { sourceLocale: string; @@ -26,7 +26,7 @@ export type EnqueueOptions = { * @returns The result of the API call */ export default async function _enqueueFiles( - files: FileUploadRef[], + files: FileReference[], options: EnqueueOptions, config: TranslationRequestConfig ): Promise { @@ -35,6 +35,7 @@ export default async function _enqueueFiles( const body = { files: files.map((f) => ({ + branchId: f.branchId, fileId: f.fileId, versionId: f.versionId, fileName: f.fileName, @@ -54,7 +55,7 @@ export default async function _enqueueFiles( url, { method: 'POST', - headers: generateRequestHeaders(config, false), + headers: generateRequestHeaders(config), body: JSON.stringify(body), }, timeout diff --git a/packages/core/src/translate/fetchTranslations.ts b/packages/core/src/translate/fetchTranslations.ts deleted file mode 100644 index a2982a73a..000000000 --- a/packages/core/src/translate/fetchTranslations.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { defaultBaseUrl } from '../settings/settingsUrls'; -import fetchWithTimeout from './utils/fetchWithTimeout'; -import { maxTimeout } from '../settings/settings'; -import validateResponse from './utils/validateResponse'; -import handleFetchError from './utils/handleFetchError'; -import { TranslationRequestConfig } from '../types'; -import { - FetchTranslationsOptions, - FetchTranslationsResult, -} from '../types-dir/api/fetchTranslations'; -import generateRequestHeaders from './utils/generateRequestHeaders'; - -/** - * @internal - * Fetches translation metadata and information without downloading files. - * @param versionId - The version ID to fetch translations for - * @param options - The options for the API call - * @param config - The configuration for the request - * @returns The translation metadata and information - */ -export default async function _fetchTranslations( - versionId: string, - options: FetchTranslationsOptions, - config: TranslationRequestConfig -): Promise { - const timeout = Math.min(options.timeout || maxTimeout, maxTimeout); - const url = `${config.baseUrl || defaultBaseUrl}/v1/project/translations/info/${versionId}`; - - // Request the translation info - let response; - try { - response = await fetchWithTimeout( - url, - { - method: 'GET', - headers: generateRequestHeaders(config), - }, - timeout - ); - } catch (error) { - handleFetchError(error, timeout); - } - - // Validate response - await validateResponse(response); - - // Parse response - const result = await response.json(); - return result as FetchTranslationsResult; -} diff --git a/packages/core/src/translate/queryBranchData.ts b/packages/core/src/translate/queryBranchData.ts new file mode 100644 index 000000000..62cbb7b2c --- /dev/null +++ b/packages/core/src/translate/queryBranchData.ts @@ -0,0 +1,50 @@ +import { defaultBaseUrl } from '../settings/settingsUrls'; +import fetchWithTimeout from './utils/fetchWithTimeout'; +import { maxTimeout } from '../settings/settings'; +import validateResponse from './utils/validateResponse'; +import handleFetchError from './utils/handleFetchError'; +import { TranslationRequestConfig } from '../types'; +import generateRequestHeaders from './utils/generateRequestHeaders'; +import type { BranchDataResult } from '../types-dir/api/branch'; + +export type BranchQuery = { + branchNames: string[]; +}; + +/** + * @internal + * Queries branch information from the API. + * @param query - Object mapping the current branch and incoming branches + * @param config - The configuration for the API call + * @returns The branch information + */ +export default async function _queryBranchData( + query: BranchQuery, + config: TranslationRequestConfig +): Promise { + const timeout = Math.min(maxTimeout, maxTimeout); + const url = `${config.baseUrl || defaultBaseUrl}/v2/project/branches/info`; + + // Request the branch data + let response; + try { + response = await fetchWithTimeout( + url, + { + method: 'POST', + headers: generateRequestHeaders(config), + body: JSON.stringify(query), + }, + timeout + ); + } catch (error) { + handleFetchError(error, timeout); + } + + // Validate response + await validateResponse(response); + + // Parse response + const result = await response.json(); + return result as BranchDataResult; +} diff --git a/packages/core/src/translate/queryFileData.ts b/packages/core/src/translate/queryFileData.ts new file mode 100644 index 000000000..0033dc5e6 --- /dev/null +++ b/packages/core/src/translate/queryFileData.ts @@ -0,0 +1,105 @@ +import { defaultBaseUrl } from '../settings/settingsUrls'; +import fetchWithTimeout from './utils/fetchWithTimeout'; +import { maxTimeout } from '../settings/settings'; +import validateResponse from './utils/validateResponse'; +import handleFetchError from './utils/handleFetchError'; +import { TranslationRequestConfig } from '../types'; +import { CheckFileTranslationsOptions } from '../types-dir/api/checkFileTranslations'; +import generateRequestHeaders from './utils/generateRequestHeaders'; + +export type FileDataQuery = { + sourceFiles?: { + fileId: string; + versionId: string; + branchId: string; + }[]; + translatedFiles?: { + fileId: string; + versionId: string; + branchId: string; + locale: string; + }[]; +}; + +export type FileDataResult = { + sourceFiles?: { + branchId: string; + fileId: string; + versionId: string; + fileName: string; + fileFormat: string; + dataFormat: string | null; + createdAt: string; + updatedAt: string; + approvalRequiredAt: string | null; + publishedAt: string | null; + locales: string[]; + sourceLocale: string; + }[]; + translatedFiles?: { + branchId: string; + fileId: string; + versionId: string; + fileFormat: string; + dataFormat: string | null; + createdAt: string; + updatedAt: string; + approvedAt: string | null; + publishedAt: string | null; + completedAt: string | null; + locale: string; + }[]; +}; + +/** + * @internal + * Queries data about one or more source or translation files. + * @param data - Object mapping source or translation file information + * @param options - The options for the API call + * @param config - The configuration for the API call + * @returns The file data + */ +export default async function _queryFileData( + data: FileDataQuery, + options: CheckFileTranslationsOptions = {}, + config: TranslationRequestConfig +): Promise { + const timeout = Math.min(options.timeout || maxTimeout, maxTimeout); + const url = `${config.baseUrl || defaultBaseUrl}/v2/project/files/info`; + + const body = { + sourceFiles: data.sourceFiles?.map((item) => ({ + fileId: item.fileId, + versionId: item.versionId, + branchId: item.branchId, + })), + translatedFiles: data.translatedFiles?.map((item) => ({ + fileId: item.fileId, + versionId: item.versionId, + branchId: item.branchId, + locale: item.locale, + })), + }; + // Request the file data + let response; + try { + response = await fetchWithTimeout( + url, + { + method: 'POST', + headers: generateRequestHeaders(config), + body: JSON.stringify(body), + }, + timeout + ); + } catch (error) { + handleFetchError(error, timeout); + } + + // Validate response + await validateResponse(response); + + // Parse response + const result = await response.json(); + return result as FileDataResult; +} diff --git a/packages/core/src/translate/querySourceFile.ts b/packages/core/src/translate/querySourceFile.ts index fab84383f..31db19572 100644 --- a/packages/core/src/translate/querySourceFile.ts +++ b/packages/core/src/translate/querySourceFile.ts @@ -26,9 +26,18 @@ export default async function _querySourceFile( ): Promise { const { baseUrl } = config; const timeout = Math.min(options.timeout || maxTimeout, maxTimeout); + const branchId = query.branchId; const versionId = query.versionId; const fileId = query.fileId; - const url = `${baseUrl || defaultBaseUrl}/v2/project/translations/files/status/${encodeURIComponent(fileId)}${versionId ? `?versionId=${encodeURIComponent(versionId)}` : ''}`; + + const searchParams = new URLSearchParams(); + if (branchId) { + searchParams.set('branchId', branchId); + } + if (versionId) { + searchParams.set('versionId', versionId); + } + const url = `${baseUrl || defaultBaseUrl}/v2/project/translations/files/status/${encodeURIComponent(fileId)}?${searchParams.toString()}`; // Request the file download let response; @@ -37,7 +46,7 @@ export default async function _querySourceFile( url, { method: 'GET', - headers: generateRequestHeaders(config, true), + headers: generateRequestHeaders(config), }, timeout ); diff --git a/packages/core/src/translate/setupProject.ts b/packages/core/src/translate/setupProject.ts index 783ceeff2..2c1dc72da 100644 --- a/packages/core/src/translate/setupProject.ts +++ b/packages/core/src/translate/setupProject.ts @@ -5,7 +5,7 @@ import { maxTimeout } from '../settings/settings'; import validateResponse from './utils/validateResponse'; import handleFetchError from './utils/handleFetchError'; import generateRequestHeaders from './utils/generateRequestHeaders'; -import { FileUploadRef } from 'src/types-dir/api/uploadFiles'; +import type { FileReference } from '../types-dir/api/file'; export type SetupProjectResult = | { setupJobId: string; status: 'queued' } @@ -25,7 +25,7 @@ export type SetupProjectOptions = { * @returns The result of the API call */ export default async function _setupProject( - files: FileUploadRef[], + files: FileReference[], config: TranslationRequestConfig, options?: SetupProjectOptions ): Promise { @@ -34,11 +34,12 @@ export default async function _setupProject( const body = { files: files.map((f) => ({ + branchId: f.branchId, fileId: f.fileId, versionId: f.versionId, fileName: f.fileName, fileFormat: f.fileFormat, - ...(f.dataFormat && { dataFormat: f.dataFormat }), + dataFormat: f.dataFormat, })), locales: options?.locales, }; @@ -49,7 +50,7 @@ export default async function _setupProject( url, { method: 'POST', - headers: generateRequestHeaders(config, false), + headers: generateRequestHeaders(config), body: JSON.stringify(body), }, timeout diff --git a/packages/core/src/translate/shouldSetupProject.ts b/packages/core/src/translate/shouldSetupProject.ts deleted file mode 100644 index 4a938dbbd..000000000 --- a/packages/core/src/translate/shouldSetupProject.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { TranslationRequestConfig } from '../types'; -import { defaultBaseUrl } from '../settings/settingsUrls'; -import validateResponse from './utils/validateResponse'; -import generateRequestHeaders from './utils/generateRequestHeaders'; - -export type ShouldSetupProjectResult = { - shouldSetupProject: boolean; -}; - -/** - * @internal - * Checks if a project requires setup - * @param config - The configuration for the API call - * @returns The result of the API call - */ -export default async function _shouldSetupProject( - config: TranslationRequestConfig -): Promise { - const url = `${config.baseUrl || defaultBaseUrl}/v2/project/setup/should-generate`; - - const response = await fetch(url, { - method: 'GET', - headers: generateRequestHeaders(config, true), - }); - - await validateResponse(response); - return (await response.json()) as ShouldSetupProjectResult; -} diff --git a/packages/core/src/translate/submitUserEditDiffs.ts b/packages/core/src/translate/submitUserEditDiffs.ts index 1ff8fc408..664723a05 100644 --- a/packages/core/src/translate/submitUserEditDiffs.ts +++ b/packages/core/src/translate/submitUserEditDiffs.ts @@ -5,18 +5,19 @@ import { maxTimeout } from '../settings/settings'; import validateResponse from './utils/validateResponse'; import handleFetchError from './utils/handleFetchError'; import generateRequestHeaders from './utils/generateRequestHeaders'; +import { processBatches } from './utils/batch'; export type SubmitUserEditDiff = { fileName: string; locale: string; diff: string; - versionId?: string; - fileId?: string; + branchId: string; + versionId: string; + fileId: string; localContent?: string; }; export type SubmitUserEditDiffsPayload = { - projectId?: string; diffs: SubmitUserEditDiff[]; }; @@ -32,21 +33,31 @@ export default async function _submitUserEditDiffs( const timeout = Math.min(options.timeout || maxTimeout, maxTimeout); const url = `${config.baseUrl || defaultBaseUrl}/v2/project/files/diffs`; - let response; - try { - response = await fetchWithTimeout( - url, - { - method: 'POST', - headers: generateRequestHeaders(config, false), - body: JSON.stringify(payload), - }, - timeout - ); - } catch (error) { - handleFetchError(error, timeout); - } + await processBatches( + payload.diffs, + async (batch) => { + const body = { diffs: batch } satisfies SubmitUserEditDiffsPayload; + + let response: Response | undefined; + try { + response = await fetchWithTimeout( + url, + { + method: 'POST', + headers: generateRequestHeaders(config), + body: JSON.stringify(body), + }, + timeout + ); + } catch (error) { + handleFetchError(error, timeout); + } + + await validateResponse(response); + return [{ success: true }]; + }, + { batchSize: 100 } + ); - await validateResponse(response); return { success: true }; } diff --git a/packages/core/src/translate/uploadSourceFiles.ts b/packages/core/src/translate/uploadSourceFiles.ts index 9bf87e7f5..f2266fd90 100644 --- a/packages/core/src/translate/uploadSourceFiles.ts +++ b/packages/core/src/translate/uploadSourceFiles.ts @@ -6,6 +6,7 @@ import validateResponse from './utils/validateResponse'; import handleFetchError from './utils/handleFetchError'; import generateRequestHeaders from './utils/generateRequestHeaders'; import { encode } from '../utils/base64'; +import { processBatches } from './utils/batch'; import { FileUpload, @@ -15,66 +16,63 @@ import { /** * @internal - * Uploads source files to the General Translation API. + * Uploads source files to the General Translation API in batches. * @param files - The files to upload * @param options - The options for the API call * @param config - The configuration for the API call - * @returns The result of the API call + * @returns Promise resolving to a BatchList with all uploaded files */ export default async function _uploadSourceFiles( files: { source: FileUpload }[], options: RequiredUploadFilesOptions, config: TranslationRequestConfig -): Promise { +) { const timeout = Math.min(options?.timeout || maxTimeout, maxTimeout); const url = `${config.baseUrl || defaultBaseUrl}/v2/project/files/upload-files`; - const body = { - data: files.map(({ source }) => ({ - source: { - content: encode(source.content), - fileName: source.fileName, - fileFormat: source.fileFormat, - locale: source.locale, - ...(source.dataFormat && { dataFormat: source.dataFormat }), - ...(source.fileId && { fileId: source.fileId }), - ...(source.versionId && { versionId: source.versionId }), - }, - })), - sourceLocale: options.sourceLocale, - } satisfies { - data: Array<{ - source: { - content: string; - fileName: string; - fileFormat: FileUpload['fileFormat']; - locale: string; - dataFormat?: FileUpload['dataFormat']; - fileId?: FileUpload['fileId']; - versionId?: FileUpload['versionId']; + return processBatches( + files, + async (batch) => { + const body = { + data: batch.map(({ source }) => ({ + source: { + content: encode(source.content), + fileName: source.fileName, + fileFormat: source.fileFormat, + locale: source.locale, + dataFormat: source.dataFormat, + fileId: source.fileId, + versionId: source.versionId, + branchId: source.branchId, + incomingBranchId: source.incomingBranchId, + checkedOutBranchId: source.checkedOutBranchId, + }, + })), + sourceLocale: options.sourceLocale, }; - }>; - sourceLocale: string; - modelProvider?: string; - }; - let response: Response | undefined; - try { - // Request the file uploads - response = await fetchWithTimeout( - url, - { - method: 'POST', - headers: generateRequestHeaders(config, false), - body: JSON.stringify(body), - }, - timeout - ); - } catch (err) { - handleFetchError(err, timeout); - } + let response: Response | undefined; + try { + // Request the file uploads + response = await fetchWithTimeout( + url, + { + method: 'POST', + headers: generateRequestHeaders(config), + body: JSON.stringify(body), + }, + timeout + ); + } catch (err) { + handleFetchError(err, timeout); + } - // Validate response - await validateResponse(response); - return (await response!.json()) as UploadFilesResponse; + // Validate response + await validateResponse(response); + const batchResult = (await response!.json()) as UploadFilesResponse; + + return batchResult.uploadedFiles || []; + }, + { batchSize: 100 } + ); } diff --git a/packages/core/src/translate/uploadTranslations.ts b/packages/core/src/translate/uploadTranslations.ts index d2f37df81..ee76ffd6c 100644 --- a/packages/core/src/translate/uploadTranslations.ts +++ b/packages/core/src/translate/uploadTranslations.ts @@ -5,6 +5,7 @@ import { maxTimeout } from '../settings/settings'; import validateResponse from './utils/validateResponse'; import handleFetchError from './utils/handleFetchError'; import generateRequestHeaders from './utils/generateRequestHeaders'; +import { processBatches } from './utils/batch'; import { FileUpload, @@ -15,11 +16,11 @@ import { encode } from '../utils/base64'; /** * @internal - * Uploads multiple translations to the General Translation API. + * Uploads multiple translations to the General Translation API in batches. * @param files - Translations to upload with their source * @param options - The options for the API call * @param config - The configuration for the API call - * @returns The result of the API call + * @returns Promise resolving to a BatchList with all uploaded files */ export default async function _uploadTranslations( files: { @@ -28,51 +29,61 @@ export default async function _uploadTranslations( }[], options: RequiredUploadFilesOptions, config: TranslationRequestConfig -): Promise { +) { const timeout = Math.min(options?.timeout || maxTimeout, maxTimeout); const url = `${config.baseUrl || defaultBaseUrl}/v2/project/files/upload-translations`; - const body = { - data: files.map(({ source, translations }) => ({ - source: { - content: encode(source.content), - fileName: source.fileName, - fileFormat: source.fileFormat, - locale: source.locale, - ...(source.dataFormat && { dataFormat: source.dataFormat }), - ...(source.fileId && { fileId: source.fileId }), - ...(source.versionId && { versionId: source.versionId }), - }, - translations: translations.map((t) => ({ - content: encode(t.content), - fileName: t.fileName, - fileFormat: t.fileFormat, - locale: t.locale, - ...(t.dataFormat && { dataFormat: t.dataFormat }), - ...(t.fileId && { fileId: t.fileId }), - ...(t.versionId && { versionId: t.versionId }), - })), - })), - sourceLocale: options.sourceLocale, - }; + return processBatches( + files, + async (batch) => { + const body = { + data: batch.map(({ source, translations }) => ({ + source: { + content: encode(source.content), + fileName: source.fileName, + fileFormat: source.fileFormat, + locale: source.locale, + dataFormat: source.dataFormat, + fileId: source.fileId, + versionId: source.versionId, + branchId: source.branchId, + }, + translations: translations.map((t) => ({ + content: encode(t.content), + fileName: t.fileName, + fileFormat: t.fileFormat, + locale: t.locale, + dataFormat: t.dataFormat, + fileId: t.fileId, + versionId: t.versionId, + branchId: t.branchId, + })), + })), + sourceLocale: options.sourceLocale, + }; - let response: Response | undefined; - try { - // Request the file uploads - response = await fetchWithTimeout( - url, - { - method: 'POST', - headers: generateRequestHeaders(config, false), - body: JSON.stringify(body), - }, - timeout - ); - } catch (err) { - handleFetchError(err, timeout); - } + let response: Response | undefined; + try { + // Request the file uploads + response = await fetchWithTimeout( + url, + { + method: 'POST', + headers: generateRequestHeaders(config), + body: JSON.stringify(body), + }, + timeout + ); + } catch (err) { + handleFetchError(err, timeout); + } - // Validate response - await validateResponse(response); - return (await response!.json()) as UploadFilesResponse; + // Validate response + await validateResponse(response); + const batchResult = (await response!.json()) as UploadFilesResponse; + + return batchResult.uploadedFiles || []; + }, + { batchSize: 100 } + ); } diff --git a/packages/core/src/translate/utils/__tests__/generateRequestHeaders.test.ts b/packages/core/src/translate/utils/__tests__/generateRequestHeaders.test.ts index ec1e22325..24b7ac698 100644 --- a/packages/core/src/translate/utils/__tests__/generateRequestHeaders.test.ts +++ b/packages/core/src/translate/utils/__tests__/generateRequestHeaders.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import generateRequestHeaders from '../generateRequestHeaders'; import { TranslationRequestConfig } from '../../../types'; +import { API_VERSION } from '../../api'; describe('generateRequestHeaders', () => { it('should return headers with Content-Type and project ID', () => { @@ -14,6 +15,7 @@ describe('generateRequestHeaders', () => { expect(headers).toEqual({ 'Content-Type': 'application/json', 'x-gt-project-id': 'test-project', + 'gt-api-version': API_VERSION, }); }); @@ -30,6 +32,7 @@ describe('generateRequestHeaders', () => { 'Content-Type': 'application/json', 'x-gt-api-key': 'test-api-key', 'x-gt-project-id': 'test-project', + 'gt-api-version': API_VERSION, }); }); @@ -44,6 +47,7 @@ describe('generateRequestHeaders', () => { expect(headers).toEqual({ 'Content-Type': 'application/json', 'x-gt-project-id': 'test-project', + 'gt-api-version': API_VERSION, }); }); @@ -59,6 +63,7 @@ describe('generateRequestHeaders', () => { expect(headers).toEqual({ 'Content-Type': 'application/json', 'x-gt-project-id': 'test-project', + 'gt-api-version': API_VERSION, }); }); }); diff --git a/packages/core/src/translate/utils/__tests__/validateResponse.test.ts b/packages/core/src/translate/utils/__tests__/validateResponse.test.ts index a109ecc69..32bef6fd0 100644 --- a/packages/core/src/translate/utils/__tests__/validateResponse.test.ts +++ b/packages/core/src/translate/utils/__tests__/validateResponse.test.ts @@ -49,11 +49,6 @@ describe.sequential('validateResponse', () => { await expect(validateResponse(mockResponse)).rejects.toThrow(errorText); expect(mockResponse.text).toHaveBeenCalled(); expect(apiError).toHaveBeenCalledWith(401, 'Unauthorized', errorText); - expect(fetchLogger.error).toHaveBeenCalledWith(expectedErrorMessage, { - status: 401, - statusText: 'Unauthorized', - error: errorText, - }); }); it('should handle 404 not found errors', async () => { diff --git a/packages/core/src/translate/utils/batch.ts b/packages/core/src/translate/utils/batch.ts new file mode 100644 index 000000000..2055d4c80 --- /dev/null +++ b/packages/core/src/translate/utils/batch.ts @@ -0,0 +1,102 @@ +/** + * Splits an array into batches of a specified size. + * @param items - The array to split into batches + * @param batchSize - The maximum size of each batch + * @returns An array of batches + */ +export function createBatches(items: T[], batchSize: number): T[][] { + const batches: T[][] = []; + for (let i = 0; i < items.length; i += batchSize) { + batches.push(items.slice(i, i + batchSize)); + } + return batches; +} + +/** + * Result of processing batches + */ +export interface BatchList { + /** The items successfully processed across all batches */ + data: T[]; + /** The total number of items processed */ + count: number; + /** The number of batches processed */ + batchCount: number; +} + +/** + * Options for batch processing + */ +export interface BatchProcessOptions { + /** Maximum number of items per batch (default: 100) */ + batchSize?: number; + /** Whether to process batches in parallel (default: true) */ + parallel?: boolean; +} + +/** + * Processes items in batches using a provided processor function. + * + * @param items - The items to process + * @param processor - Async function that processes a single batch and returns items + * @param options - Optional configuration for batch processing + * @returns Promise that resolves to a BatchList containing all processed items + * + * @example + * ```typescript + * const result = await processBatches( + * files, + * async (batch) => { + * const response = await uploadFiles(batch); + * return response.uploadedFiles; + * }, + * { batchSize: 100 } + * ); + * + * console.log(result.data); // All items + * console.log(result.count); // Total count + * console.log(result.batchCount); // Number of batches processed + * ``` + */ +export async function processBatches( + items: TInput[], + processor: (batch: TInput[]) => Promise, + options: BatchProcessOptions = {} +): Promise> { + const { batchSize = 100, parallel = true } = options; + + if (items.length === 0) { + return { + data: [], + count: 0, + batchCount: 0, + }; + } + + const batches = createBatches(items, batchSize); + const allItems: TOutput[] = []; + + if (parallel) { + // Process all batches in parallel + const results = await Promise.all(batches.map((batch) => processor(batch))); + for (const result of results) { + if (result) { + allItems.push(...result); + } + } + } else { + // Process batches sequentially + for (const batch of batches) { + const result = await processor(batch); + if (result) { + allItems.push(...result); + } + } + } + + return { + data: allItems, + count: allItems.length, + batchCount: batches.length, + }; +} diff --git a/packages/core/src/translate/utils/generateRequestHeaders.ts b/packages/core/src/translate/utils/generateRequestHeaders.ts index c4e44e00a..527bf9f0b 100644 --- a/packages/core/src/translate/utils/generateRequestHeaders.ts +++ b/packages/core/src/translate/utils/generateRequestHeaders.ts @@ -1,4 +1,5 @@ import { TranslationRequestConfig } from '../../types'; +import { API_VERSION } from '../api'; export default function generateRequestHeaders( config: TranslationRequestConfig, @@ -17,5 +18,7 @@ export default function generateRequestHeaders( } } + authHeaders['gt-api-version'] = API_VERSION; + return authHeaders; } diff --git a/packages/core/src/translate/utils/validateResponse.ts b/packages/core/src/translate/utils/validateResponse.ts index 384d8c0a1..03ac9ca32 100644 --- a/packages/core/src/translate/utils/validateResponse.ts +++ b/packages/core/src/translate/utils/validateResponse.ts @@ -1,5 +1,5 @@ import { apiError } from '../../logging/errors'; -import { fetchLogger } from '../../logging/logger'; +import { ApiError } from '../../errors/ApiError'; export default async function validateResponse(response: Response) { if (!response.ok) { @@ -9,14 +9,7 @@ export default async function validateResponse(response: Response) { response.statusText, errorText ); - fetchLogger.error(errorMessage, { - status: response.status, - statusText: response.statusText, - error: errorText, - }); - const error = new Error(errorMessage); - (error as any).code = response.status; - (error as any).message = errorText; + const error = new ApiError(errorMessage, response.status, errorText); throw error; } } diff --git a/packages/core/src/types-dir/api/branch.ts b/packages/core/src/types-dir/api/branch.ts new file mode 100644 index 000000000..3b448c1da --- /dev/null +++ b/packages/core/src/types-dir/api/branch.ts @@ -0,0 +1,10 @@ +export type BranchDataResult = { + branches: { + id: string; + name: string; // branch name + }[]; + defaultBranch: { + id: string; + name: string; // branch name + } | null; +}; diff --git a/packages/core/src/types-dir/api/checkFileTranslations.ts b/packages/core/src/types-dir/api/checkFileTranslations.ts index 6baa55307..c808063cf 100644 --- a/packages/core/src/types-dir/api/checkFileTranslations.ts +++ b/packages/core/src/types-dir/api/checkFileTranslations.ts @@ -1,5 +1,3 @@ -import { CompletedFileTranslationData } from './file'; - // Types for the checkFileTranslations function export type FileTranslationQuery = { versionId: string; @@ -12,12 +10,9 @@ export type CheckFileTranslationsOptions = { timeout?: number; }; -export type CheckFileTranslationsResult = { - translations: CompletedFileTranslationData[]; -}; - export type FileQuery = { fileId: string; + branchId?: string; versionId?: string; }; diff --git a/packages/core/src/types-dir/api/downloadFileBatch.ts b/packages/core/src/types-dir/api/downloadFileBatch.ts index e815ce0f0..48783944a 100644 --- a/packages/core/src/types-dir/api/downloadFileBatch.ts +++ b/packages/core/src/types-dir/api/downloadFileBatch.ts @@ -1,28 +1,39 @@ import { FileFormat } from './file'; // Types for the downloadFileBatch function +export type DownloadFileBatchRequest = { + fileId: string; + branchId?: string; // if not provided, will use the default branch + versionId?: string; // if not provided, will use the latest version + locale?: string; // if not provided, will download the source file +}[]; + export type DownloadFileBatchOptions = { timeout?: number; }; export type BatchDownloadResult = { - translationId: string; - fileName?: string; + fileId: string; + fileName: string; success: boolean; content?: string; contentType?: string; error?: string; }; -type File = { +export type DownloadedFile = { id: string; - fileName: string; + branchId: string; + fileId: string; + versionId: string; + locale?: string; + fileName?: string; // Only present for source files (if locale is not present) data: string; - metadata: any; + metadata: Record; fileFormat: FileFormat; }; export type DownloadFileBatchResult = { - files: File[]; + files: DownloadedFile[]; count: number; }; diff --git a/packages/core/src/types-dir/api/enqueueFiles.ts b/packages/core/src/types-dir/api/enqueueFiles.ts index fe3efd184..2c6d9d513 100644 --- a/packages/core/src/types-dir/api/enqueueFiles.ts +++ b/packages/core/src/types-dir/api/enqueueFiles.ts @@ -1,5 +1,4 @@ -import { DataFormat, JsxChildren } from '../jsx/content'; -import { CompletedFileTranslationData, FileFormat, FileMetadata } from './file'; +import { JsxChildren } from '../jsx/content'; // Types for the enqueueTranslationEntries function export type Updates = ({ @@ -19,22 +18,6 @@ export type Updates = ({ } ))[]; -/** - * File object structure for enqueueing files - * @param content - Content of the file - * @param fileName - Unique identifier for the file (such as the file path + file name) - * @param fileFormat - The format of the file (JSON, MDX, MD, etc.) - * @param formatMetadata - Optional metadata for the file, specific to the format of the file - * @param dataFormat - Optional format of the data within the file - */ -export type FileToTranslate = { - content: string; - fileName: string; - fileFormat: FileFormat; - formatMetadata?: Record; - dataFormat?: DataFormat; -}; - /** * Options for enqueueing files * @param publish - Whether to publish the files @@ -63,8 +46,18 @@ export type RequiredEnqueueFilesOptions = EnqueueFilesOptions & Required>; export type EnqueueFilesResult = { - translations: CompletedFileTranslationData[]; - data: Record; + jobData: { + [jobId: string]: { + sourceFileId: string; + fileId: string; + versionId: string; + branchId: string; + targetLocale: string; + projectId: string; + force: boolean; + modelProvider?: string; + }; + }; locales: string[]; message: string; }; diff --git a/packages/core/src/types-dir/api/file.ts b/packages/core/src/types-dir/api/file.ts index 34cf22360..7e58a4f22 100644 --- a/packages/core/src/types-dir/api/file.ts +++ b/packages/core/src/types-dir/api/file.ts @@ -1,3 +1,4 @@ +import { DataFormat } from 'src/types'; import { Entry } from './entry'; export type FileFormat = @@ -23,13 +24,33 @@ export type File = { fileMetadata: FileMetadata; }; -export type CompletedFileTranslationData = { - locale: string; - metadata: any; +/** + * File object structure for uploading files + * @param content - Content of the file + * @param fileName - Unique identifier for the file (such as the file path + file name) + * @param fileFormat - The format of the file (JSON, MDX, MD, etc.) + * @param formatMetadata - Optional metadata for the file, specific to the format of the file + * @param dataFormat - Optional format of the data within the file + */ +export type FileToUpload = { + content: string; + formatMetadata?: Record; + incomingBranchId?: string; + checkedOutBranchId?: string; +} & Omit & { branchId?: string }; + +/** + * File object structure for referencing files + * @param fileId - The ID of the file + * @param versionId - The ID of the version of the file + * @param branchId - The ID of the branch of the file + * @param locale - The locale of the file () + */ +export type FileReference = { fileId: string; - fileName: string; versionId: string; - id: string; // Include ID for downloading - isReady: boolean; - downloadUrl: string; + branchId: string; + fileName: string; + fileFormat: FileFormat; + dataFormat?: DataFormat; }; diff --git a/packages/core/src/types-dir/api/uploadFiles.ts b/packages/core/src/types-dir/api/uploadFiles.ts index 5848e412c..407a5e1e0 100644 --- a/packages/core/src/types-dir/api/uploadFiles.ts +++ b/packages/core/src/types-dir/api/uploadFiles.ts @@ -1,7 +1,10 @@ import { DataFormat } from '../jsx/content'; -import { FileFormat } from './file'; +import { FileFormat, FileReference } from './file'; export type FileUpload = { + branchId?: string; // optional branch id. If not provided, will use the default branch. + incomingBranchId?: string; // optional branch id to use for incoming translations + checkedOutBranchId?: string; // optional branch id to use for checked out translations content: string; fileName: string; fileFormat: FileFormat; @@ -11,15 +14,6 @@ export type FileUpload = { fileId?: string; // Optional fileId. Only use this if you know what you are doing. }; -export type FileUploadRef = { - fileId: string; - versionId: string; - fileName: string; - fileFormat: FileFormat; - dataFormat?: DataFormat; - locale?: string; -}; - export type UploadData = { data: { source: FileUpload; translations: FileUpload[] }[]; sourceLocale: string; @@ -33,7 +27,7 @@ export type UploadFilesOptions = { }; export type UploadFilesResponse = { - uploadedFiles: FileUploadRef[]; + uploadedFiles: FileReference[]; count: number; message: string; }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3ada64cd7..da90eac65 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -41,7 +41,6 @@ export { export type { FileTranslationQuery, CheckFileTranslationsOptions, - CheckFileTranslationsResult, } from './types-dir/api/checkFileTranslations'; export type { DownloadFileBatchOptions, @@ -55,24 +54,34 @@ export type { export type { EnqueueFilesOptions, EnqueueFilesResult, - FileToTranslate, Updates, } from './types-dir/api/enqueueFiles'; +export type { FileToUpload } from './types-dir/api/file'; export type { EnqueueEntriesOptions, EnqueueEntriesResult, } from './types-dir/api/enqueueEntries'; +export type { FileReference } from './types-dir/api/file'; +export type { DownloadedFile } from './types-dir/api/downloadFileBatch'; export type { DownloadFileOptions } from './types-dir/api/downloadFile'; -export type { - FileFormat, - CompletedFileTranslationData, -} from './types-dir/api/file'; +export type { FileFormat } from './types-dir/api/file'; export type { TranslateManyResult } from './types-dir/api/translateMany'; export type { TranslationResult, TranslationError, TranslationResultReference, } from './types-dir/api/translate'; +export type { BranchDataResult } from './types-dir/api/branch'; +export type { BranchQuery } from './translate/queryBranchData'; +export type { FileDataQuery, FileDataResult } from './translate/queryFileData'; +export type { + JobStatus, + CheckJobStatusResult, +} from './translate/checkJobStatus'; +export type { + SubmitUserEditDiff, + SubmitUserEditDiffsPayload, +} from './translate/submitUserEditDiffs'; /** * @deprecated Use {@link Content} instead. diff --git a/packages/sanity/src/adapter/getTranslation.ts b/packages/sanity/src/adapter/getTranslation.ts index d4bc7f29e..25fca0170 100644 --- a/packages/sanity/src/adapter/getTranslation.ts +++ b/packages/sanity/src/adapter/getTranslation.ts @@ -11,7 +11,7 @@ export const getTranslation: Adapter['getTranslation'] = async ( return ''; } overrideConfig(secrets); - const text = await gt.downloadTranslatedFile({ + const text = await gt.downloadFile({ fileId: documentInfo.documentId, versionId: documentInfo.versionId || undefined, locale: localeId, diff --git a/packages/sanity/src/adapter/types.ts b/packages/sanity/src/adapter/types.ts index 43d99244f..bfa66bddf 100644 --- a/packages/sanity/src/adapter/types.ts +++ b/packages/sanity/src/adapter/types.ts @@ -7,3 +7,15 @@ export type TranslateDocumentFilter = { documentId?: string; type?: string; }; + +export type FileProperties = { + versionId: string; + fileId: string; + locale: string; + branchId: string; +}; +export type TranslationStatus = { + progress: number; + isReady: boolean; + fileData: FileProperties; +}; diff --git a/packages/sanity/src/components/TranslationsProvider.tsx b/packages/sanity/src/components/TranslationsProvider.tsx index 72cdfd4d8..27dc2cd0e 100644 --- a/packages/sanity/src/components/TranslationsProvider.tsx +++ b/packages/sanity/src/components/TranslationsProvider.tsx @@ -16,7 +16,7 @@ import { TranslationLocale, TranslationFunctionContext, } from '../types'; -import { pluginConfig } from '../adapter/core'; +import { gt, overrideConfig, pluginConfig } from '../adapter/core'; import { serializeDocument } from '../utils/serialize'; import { uploadFiles } from '../translation/uploadFiles'; import { initProject } from '../translation/initProject'; @@ -34,6 +34,7 @@ import { import { processBatch } from '../utils/batchProcessor'; import { publishTranslations } from '../sanity-api/publishDocuments'; import { getLocales } from '../adapter/getLocales'; +import type { FileProperties, TranslationStatus } from '../adapter/types'; interface ImportProgress { current: number; @@ -47,18 +48,15 @@ interface DownloadStatus { skipped: Set; } -interface TranslationStatus { - progress: number; - isReady: boolean; - translationId?: string; -} - interface TranslationsContextType { // State isBusy: boolean; documents: SanityDocument[]; locales: TranslationLocale[]; autoRefresh: boolean; + autoImport: boolean; + autoPatchReferences: boolean; + autoPublish: boolean; loadingDocuments: boolean; importProgress: ImportProgress; importedTranslations: Set; @@ -68,15 +66,23 @@ interface TranslationsContextType { isRefreshing: boolean; loadingSecrets: boolean; secrets: Secrets | null; + branchId: string | undefined; // Actions setLocales: (locales: TranslationLocale[]) => void; setAutoRefresh: (value: boolean) => void; + setAutoImport: (value: boolean) => void; + setAutoPatchReferences: (value: boolean) => void; + setAutoPublish: (value: boolean) => void; handleTranslateAll: () => Promise; handleImportAll: () => Promise; handleImportMissing: () => Promise; handleRefreshAll: () => Promise; - handleImportDocument: (documentId: string, localeId: string) => Promise; + handleImportDocument: ( + documentId: string, + versionId: string, + localeId: string + ) => Promise; handlePatchDocumentReferences: () => Promise; handlePublishAllTranslations: () => Promise; } @@ -104,6 +110,9 @@ export const TranslationsProvider: React.FC = ({ const [documents, setDocuments] = useState([]); const [locales, setLocales] = useState([]); const [autoRefresh, setAutoRefresh] = useState(false); + const [autoImport, setAutoImport] = useState(false); + const [autoPatchReferences, setAutoPatchReferences] = useState(false); + const [autoPublish, setAutoPublish] = useState(false); const [loadingDocuments, setLoadingDocuments] = useState(false); const [importProgress, setImportProgress] = useState({ current: 0, @@ -133,6 +142,7 @@ export const TranslationsProvider: React.FC = ({ const { loading: loadingSecrets, secrets } = useSecrets( pluginConfig.getSecretsNamespace() ); + const [branchId, setBranchId] = useState(undefined); const fetchDocuments = useCallback(async () => { setLoadingDocuments(true); @@ -294,15 +304,12 @@ export const TranslationsProvider: React.FC = ({ }, [secrets, documents, locales, schema]); const handleImportAll = useCallback(async () => { - if (!secrets || documents.length === 0) return; + if (!secrets || documents.length === 0 || !branchId) return; setIsBusy(true); try { - const readyFiles = await getReadyFilesForImport( - documents, - translationStatuses - ); + const readyFiles = await getReadyFilesForImport(translationStatuses); if (readyFiles.length === 0) { toast.push({ @@ -371,12 +378,14 @@ export const TranslationsProvider: React.FC = ({ translationStatuses, downloadStatus, translationContext, + branchId, ]); - const getMissingTranslations = useCallback( + const getExistingTranslations = useCallback( async ( documentIds: string[], - localeIds: string[] + localeIds: string[], + branchId: string ): Promise> => { const sourceLocale = pluginConfig.getSourceLocale(); @@ -384,6 +393,7 @@ export const TranslationsProvider: React.FC = ({ _type == 'translation.metadata' && translations[_key == $sourceLocale][0].value._ref in $documentIds ] { + _rev, 'sourceDocId': translations[_key == $sourceLocale][0].value._ref, 'existingTranslations': translations[_key in $localeIds]._key }`; @@ -398,30 +408,20 @@ export const TranslationsProvider: React.FC = ({ existingMetadata.forEach((metadata: any) => { metadata.existingTranslations?.forEach((localeId: string) => { if (localeId !== sourceLocale) { - existing.add(`${metadata.sourceDocId}:${localeId}`); - } - }); - }); - - const missing = new Set(); - documentIds.forEach((docId) => { - localeIds.forEach((localeId) => { - if (localeId !== sourceLocale) { - const key = `${docId}:${localeId}`; - if (!existing.has(key)) { - missing.add(key); - } + existing.add( + `${branchId}:${metadata.sourceDocId}:${metadata._rev}:${localeId}` + ); } }); }); - return missing; + return existing; }, [client] ); const handleImportMissing = useCallback(async () => { - if (!secrets || documents.length === 0) return; + if (!secrets || documents.length === 0 || !branchId) return; setIsBusy(true); @@ -434,19 +434,15 @@ export const TranslationsProvider: React.FC = ({ (doc) => doc._id?.replace('drafts.', '') || doc._id ); - const missingTranslations = await getMissingTranslations( + const existingTranslations = await getExistingTranslations( documentIds, - availableLocaleIds + availableLocaleIds, + branchId ); - console.log('missingTranslations', missingTranslations); - const readyFiles = await getReadyFilesForImport( - documents, - translationStatuses, - { - filterReadyFiles: (key) => missingTranslations.has(key), - } - ); + const readyFiles = await getReadyFilesForImport(translationStatuses, { + filterReadyFiles: (key) => !existingTranslations.has(key), + }); if (readyFiles.length === 0) { toast.push({ @@ -517,12 +513,24 @@ export const TranslationsProvider: React.FC = ({ translationStatuses, downloadStatus, translationContext, - getMissingTranslations, + getExistingTranslations, + branchId, ]); - const handleRefreshAll = useCallback(async () => { - if (!secrets || documents.length === 0) return; + const handleGetBranchId = useCallback( + async (secrets: Secrets) => { + overrideConfig(secrets); + const defaultBranch = await gt.createBranch({ + branchName: 'main', + defaultBranch: true, + }); + setBranchId(defaultBranch.branch.id); + }, + [secrets] + ); + const handleRefreshAll = useCallback(async () => { + if (!secrets || documents.length === 0 || !branchId) return; setIsRefreshing(true); try { @@ -530,13 +538,14 @@ export const TranslationsProvider: React.FC = ({ .filter((locale) => locale.enabled !== false) .map((locale) => locale.localeId); - const fileQueryData = []; + const fileQueryData: FileProperties[] = []; for (const doc of documents) { for (const localeId of availableLocaleIds) { const documentId = doc._id?.replace('drafts.', '') || doc._id; fileQueryData.push({ versionId: doc._rev, fileId: documentId, + branchId: branchId, locale: localeId, }); } @@ -554,18 +563,24 @@ export const TranslationsProvider: React.FC = ({ for (const doc of documents) { for (const localeId of availableLocaleIds) { const documentId = doc._id?.replace('drafts.', '') || doc._id; - const key = `${documentId}:${localeId}`; + const versionId = doc._rev; + const key = `${branchId}:${documentId}:${versionId}:${localeId}`; newStatuses.set(key, { progress: 0, isReady: false }); } } if (Array.isArray(readyTranslations)) { for (const translation of readyTranslations) { - const key = `${translation.fileId}:${translation.locale}`; + const key = `${branchId}:${translation.fileId}:${translation.versionId}:${translation.locale}`; newStatuses.set(key, { progress: 100, isReady: true, - translationId: translation.id, + fileData: { + versionId: translation.versionId, + fileId: translation.fileId, + branchId: translation.branchId, + locale: translation.locale, + }, }); } } @@ -588,16 +603,16 @@ export const TranslationsProvider: React.FC = ({ } finally { setIsRefreshing(false); } - }, [secrets, documents, locales]); + }, [secrets, documents, locales, branchId]); const handleImportDocument = useCallback( - async (documentId: string, localeId: string) => { + async (documentId: string, versionId: string, localeId: string) => { if (!secrets) return; - const key = `${documentId}:${localeId}`; + const key = `${branchId}:${documentId}:${versionId}:${localeId}`; const status = translationStatuses.get(key); - if (!status?.isReady || !status.translationId) { + if (!status?.isReady || !status.fileData) { toast.push({ title: `Translation not ready for ${documentId} (${localeId})`, status: 'warning', @@ -623,10 +638,10 @@ export const TranslationsProvider: React.FC = ({ const downloadedFiles = await downloadTranslations( [ { - documentId, - versionId: document._rev, - translationId: status.translationId, - locale: localeId, + fileId: status.fileData.fileId, + branchId: status.fileData.branchId, + versionId: status.fileData.versionId, + locale: status.fileData.locale, }, ], secrets @@ -682,7 +697,7 @@ export const TranslationsProvider: React.FC = ({ }); } }, - [secrets, documents, translationContext, translationStatuses] + [secrets, documents, translationContext, translationStatuses, branchId] ); const handlePatchDocumentReferences = useCallback(async () => { @@ -789,7 +804,7 @@ export const TranslationsProvider: React.FC = ({ setIsBusy(false); setImportProgress({ current: 0, total: 0, isImporting: false }); } - }, [secrets, documents, locales, client]); + }, [secrets, documents, locales, client, branchId]); const handlePublishAllTranslations = useCallback(async () => { if (!secrets || documents.length === 0) return 0; @@ -869,7 +884,7 @@ export const TranslationsProvider: React.FC = ({ } finally { setIsBusy(false); } - }, [secrets, documents, client]); + }, [secrets, documents, client, branchId]); useEffect(() => { fetchDocuments(); @@ -912,12 +927,18 @@ export const TranslationsProvider: React.FC = ({ setImportedTranslations(new Set(downloadStatus.downloaded)); }, [downloadStatus.downloaded]); + if (secrets) { + handleGetBranchId(secrets); + } const contextValue: TranslationsContextType = { // State isBusy, documents, locales, autoRefresh, + autoImport, + autoPatchReferences, + autoPublish, loadingDocuments, importProgress, importedTranslations, @@ -927,10 +948,14 @@ export const TranslationsProvider: React.FC = ({ isRefreshing, loadingSecrets, secrets, + branchId, // Actions setLocales, setAutoRefresh, + setAutoImport, + setAutoPatchReferences, + setAutoPublish, handleTranslateAll, handleImportAll, handleImportMissing, diff --git a/packages/sanity/src/components/page/TranslationsTable.tsx b/packages/sanity/src/components/page/TranslationsTable.tsx index 335831f39..a0957656d 100644 --- a/packages/sanity/src/components/page/TranslationsTable.tsx +++ b/packages/sanity/src/components/page/TranslationsTable.tsx @@ -12,6 +12,7 @@ export const TranslationsTable: React.FC = () => { downloadStatus, importedTranslations, handleImportDocument, + branchId, } = useTranslations(); if (loadingDocuments) { @@ -46,20 +47,21 @@ export const TranslationsTable: React.FC = () => { .map((locale) => { const documentId = document._id?.replace('drafts.', '') || document._id; - const key = `${documentId}:${locale.localeId}`; + const key = `${branchId}:${documentId}:${document._rev}:${locale.localeId}`; const status = translationStatuses.get(key); const isDownloaded = downloadStatus.downloaded.has(key); const isImported = importedTranslations.has(key); return ( { await handleImportDocument( documentId, + document._rev, locale.localeId ); }} diff --git a/packages/sanity/src/components/shared/SingleDocumentView.tsx b/packages/sanity/src/components/shared/SingleDocumentView.tsx index ba9e6b9aa..54d5324fa 100644 --- a/packages/sanity/src/components/shared/SingleDocumentView.tsx +++ b/packages/sanity/src/components/shared/SingleDocumentView.tsx @@ -13,6 +13,7 @@ export const SingleDocumentView: React.FC = () => { downloadStatus, importedTranslations, handleImportDocument, + branchId, } = useTranslations(); // Get the first (and only) document in single document mode @@ -74,20 +75,21 @@ export const SingleDocumentView: React.FC = () => { .map((locale) => { const documentId = document._id?.replace('drafts.', '') || document._id; - const key = `${documentId}:${locale.localeId}`; + const key = `${branchId}:${documentId}:${document._rev}:${locale.localeId}`; const status = translationStatuses.get(key); const isDownloaded = downloadStatus.downloaded.has(key); const isImported = importedTranslations.has(key); return ( { await handleImportDocument( documentId, + document._rev, locale.localeId ); }} diff --git a/packages/sanity/src/components/tab/TranslationView.tsx b/packages/sanity/src/components/tab/TranslationView.tsx index 38687c3f1..1f97e77bf 100644 --- a/packages/sanity/src/components/tab/TranslationView.tsx +++ b/packages/sanity/src/components/tab/TranslationView.tsx @@ -28,6 +28,7 @@ export const TranslationView = () => { documents, locales, translationStatuses, + branchId, isBusy, handleTranslateAll, handleImportDocument, @@ -37,13 +38,17 @@ export const TranslationView = () => { setLocales, handlePatchDocumentReferences, handlePublishAllTranslations, + autoRefresh, + setAutoRefresh, + autoImport, + setAutoImport, + autoPatchReferences, + setAutoPatchReferences, + autoPublish, + setAutoPublish, } = useTranslations(); - const [autoImport, setAutoImport] = useState(false); const [isImporting, setIsImporting] = useState(false); - const [autoRefresh, setAutoRefresh] = useState(true); - const [autoPatchReferences, setAutoPatchReferences] = useState(true); - const [autoPublish, setAutoPublish] = useState(true); const [isPublishing, setIsPublishing] = useState(false); const toast = useToast(); @@ -94,7 +99,7 @@ export const TranslationView = () => { // Find translations ready to import const readyTranslations = availableLocales.filter((locale) => { - const key = `${documentId}:${locale.localeId}`; + const key = `${branchId}:${documentId}:${document._rev}:${locale.localeId}`; const status = translationStatuses.get(key); return status?.isReady && !importedTranslations.has(key); }); @@ -106,7 +111,7 @@ export const TranslationView = () => { // Import all ready translations await Promise.all( readyTranslations.map((locale) => - handleImportDocument(documentId, locale.localeId) + handleImportDocument(documentId, document._rev, locale.localeId) ) ); @@ -144,31 +149,12 @@ export const TranslationView = () => { handleImportTranslations({ autoOnly: true }); }, [handleImportTranslations]); - // Auto refresh functionality - useEffect(() => { - if (!autoRefresh || !documentId || availableLocales.length === 0) return; - - const interval = setInterval(async () => { - await handleRefreshAll(); - await handleImportTranslations({ autoOnly: true }); - }, 10000); - - return () => clearInterval(interval); - }, [ - autoRefresh, - documentId, - availableLocales.length, - handleRefreshAll, - handleImportTranslations, - ]); - + // Enable auto features on mount useEffect(() => { - const initialRefresh = async () => { - await handleRefreshAll(); - await handleImportTranslations({ autoOnly: true }); - }; - initialRefresh(); - }, []); + setAutoRefresh(true); + setAutoPatchReferences(true); + setAutoPublish(true); + }, [setAutoRefresh, setAutoPatchReferences, setAutoPublish]); // Locale toggle functionality const toggleLocale = useCallback( @@ -285,14 +271,14 @@ export const TranslationView = () => { padding={2} text='Refresh Status' onClick={handleRefreshAll} - disabled={isRefreshing} + disabled={isRefreshing || isBusy} /> {availableLocales.map((locale) => { - const key = `${documentId}:${locale.localeId}`; + const key = `${branchId}:${documentId}:${document._rev}:${locale.localeId}`; const status = translationStatuses.get(key); const progress = status?.progress || 0; const isImported = importedTranslations.has(key); @@ -305,7 +291,11 @@ export const TranslationView = () => { isImported={isImported} importFile={async () => { if (!isImported && status?.isReady) { - await handleImportDocument(documentId, locale.localeId); + await handleImportDocument( + documentId, + document._rev, + locale.localeId + ); } }} /> @@ -326,7 +316,7 @@ export const TranslationView = () => { disabled={ isImporting || availableLocales.every((locale) => { - const key = `${documentId}:${locale.localeId}`; + const key = `${branchId}:${documentId}:${document._rev}:${locale.localeId}`; const status = translationStatuses.get(key); return !status?.isReady || importedTranslations.has(key); }) @@ -346,14 +336,14 @@ export const TranslationView = () => { Imported{' '} { availableLocales.filter((locale) => { - const key = `${documentId}:${locale.localeId}`; + const key = `${branchId}:${documentId}:${document._rev}:${locale.localeId}`; return importedTranslations.has(key); }).length } / { availableLocales.filter((locale) => { - const key = `${documentId}:${locale.localeId}`; + const key = `${branchId}:${documentId}:${document._rev}:${locale.localeId}`; const status = translationStatuses.get(key); return status?.isReady; }).length diff --git a/packages/sanity/src/translation/checkTranslationStatus.ts b/packages/sanity/src/translation/checkTranslationStatus.ts index c8a1605b2..9ec4f38c4 100644 --- a/packages/sanity/src/translation/checkTranslationStatus.ts +++ b/packages/sanity/src/translation/checkTranslationStatus.ts @@ -1,8 +1,9 @@ import type { Secrets } from '../types'; import { gt, overrideConfig } from '../adapter/core'; +import { FileProperties } from '../adapter/types'; export async function checkTranslationStatus( - fileQueryData: { versionId: string; fileId: string; locale: string }[], + fileQueryData: FileProperties[], downloadStatus: { downloaded: Set; failed: Set; @@ -25,13 +26,15 @@ export async function checkTranslationStatus( return true; } // Check for translations - const responseData = await gt.checkFileTranslations(currentQueryData); + const responseData = await gt.queryFileData({ + translatedFiles: currentQueryData, + }); - const translations = responseData.translations || []; + const translations = responseData.translatedFiles || []; // Filter for ready translations const readyTranslations = translations.filter( - (translation) => translation.isReady && translation.fileId + (translation) => translation.completedAt ); return readyTranslations; diff --git a/packages/sanity/src/translation/downloadTranslations.ts b/packages/sanity/src/translation/downloadTranslations.ts index 254f9a280..a9d964dbf 100644 --- a/packages/sanity/src/translation/downloadTranslations.ts +++ b/packages/sanity/src/translation/downloadTranslations.ts @@ -1,22 +1,8 @@ import { gt, overrideConfig } from '../adapter/core'; +import { FileProperties } from '../adapter/types'; +import { DownloadedFile } from 'generaltranslation/types'; import type { Secrets } from '../types'; -export type BatchedFiles = Array<{ - documentId: string; - versionId: string; - translationId: string; - locale: string; -}>; - -export type DownloadedFile = { - docData: { - documentId: string; - versionId: string; - translationId: string; - locale: string; - }; - data: string; -}; /** * Downloads multiple translation files in a single batch request * @param files - Array of files to download with their output paths @@ -25,38 +11,27 @@ export type DownloadedFile = { * @returns Object containing successful and failed file IDs */ export async function downloadTranslations( - files: BatchedFiles, + files: FileProperties[], secrets: Secrets, maxRetries = 3, retryDelay = 1000 ): Promise { overrideConfig(secrets); let retries = 0; - const fileIds = files.map((file) => file.translationId); - - const map = new Map(files.map((file) => [file.translationId, file])); - const result = [] as DownloadedFile[]; while (retries <= maxRetries) { try { // Download the files - const responseData = await gt.downloadFileBatch(fileIds); + const responseData = await gt.downloadFileBatch( + files.map((file) => ({ + fileId: file.fileId, + branchId: file.branchId, + versionId: file.versionId, + locale: file.locale, + })) + ); const downloadedFiles = responseData.files || []; - - // Process each file in the response - for (const file of downloadedFiles) { - const documentData = map.get(file.id); - if (!documentData) { - continue; - } - - result.push({ - docData: documentData, - data: file.data, - }); - } - - return result; + return downloadedFiles; } catch (error) { // Increment retry counter and wait before next attempt retries++; @@ -64,5 +39,5 @@ export async function downloadTranslations( } } - return result; + return []; } diff --git a/packages/sanity/src/translation/initProject.ts b/packages/sanity/src/translation/initProject.ts index 0c88f0462..3543fc72d 100644 --- a/packages/sanity/src/translation/initProject.ts +++ b/packages/sanity/src/translation/initProject.ts @@ -27,14 +27,17 @@ export async function initProject( let setupFailedMessage: string | null = null; while (true) { - const status = await gt.checkSetupStatus(setupJobId); - - if (status.status === 'completed') { + const status = await gt.checkJobStatus([setupJobId]); + if (!status[0]) { + setupFailedMessage = 'Unknown error'; + break; + } + if (status[0].status === 'completed') { setupCompleted = true; break; } - if (status.status === 'failed') { - setupFailedMessage = status.error?.message || 'Unknown error'; + if (status[0].status === 'failed') { + setupFailedMessage = status[0].error?.message || 'Unknown error'; break; } if (Date.now() - start > setupTimeoutMs) { diff --git a/packages/sanity/src/utils/importUtils.ts b/packages/sanity/src/utils/importUtils.ts index e971d0b87..ba580c839 100644 --- a/packages/sanity/src/utils/importUtils.ts +++ b/packages/sanity/src/utils/importUtils.ts @@ -1,16 +1,8 @@ import { SanityDocument } from 'sanity'; -import { GTFile, Secrets, TranslationFunctionContext } from '../types'; -import { - downloadTranslations, - BatchedFiles, -} from '../translation/downloadTranslations'; +import { Secrets, TranslationFunctionContext } from '../types'; +import { downloadTranslations } from '../translation/downloadTranslations'; import { processImportBatch, ImportBatchItem } from './batchProcessor'; - -export interface TranslationStatus { - progress: number; - isReady: boolean; - translationId?: string; -} +import type { FileProperties, TranslationStatus } from '../adapter/types'; export interface ImportResult { successCount: number; @@ -25,32 +17,20 @@ export interface ImportOptions { } export async function getReadyFilesForImport( - documents: SanityDocument[], translationStatuses: Map, options: ImportOptions = {} -): Promise { +): Promise { const { filterReadyFiles = () => true } = options; - const readyFiles: BatchedFiles = []; + const readyFiles: FileProperties[] = []; for (const [key, status] of translationStatuses.entries()) { - if ( - status.isReady && - status.translationId && - filterReadyFiles(key, status) - ) { - const [documentId, locale] = key.split(':'); - const document = documents.find( - (doc) => (doc._id?.replace('drafts.', '') || doc._id) === documentId - ); - - if (document) { - readyFiles.push({ - documentId, - versionId: document._rev, - translationId: status.translationId, - locale, - }); - } + if (status.isReady && filterReadyFiles(key, status)) { + readyFiles.push({ + fileId: status.fileData.fileId, + versionId: status.fileData.versionId, + branchId: status.fileData.branchId, + locale: status.fileData.locale, + }); } } @@ -58,7 +38,7 @@ export async function getReadyFilesForImport( } export async function importTranslations( - readyFiles: BatchedFiles, + readyFiles: FileProperties[], secrets: Secrets, translationContext: TranslationFunctionContext, options: ImportOptions = {} @@ -71,13 +51,13 @@ export async function importTranslations( const importItems: ImportBatchItem[] = downloadedFiles.map((file) => ({ docInfo: { - documentId: file.docData.documentId, - versionId: file.docData.versionId, + documentId: file.fileId, + versionId: file.versionId, }, - locale: file.docData.locale, + locale: file.locale!, data: file.data, translationContext, - key: `${file.docData.documentId}:${file.docData.locale}`, + key: `${file.branchId}:${file.fileId}:${file.versionId}:${file.locale}`, })); const result = await processImportBatch(importItems, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 341db437c..3376e7771 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,8 +79,8 @@ importers: specifier: ^7.25.7 version: 7.28.4 '@clack/prompts': - specifier: ^1.0.0-alpha.1 - version: 1.0.0-alpha.5 + specifier: ^1.0.0-alpha.6 + version: 1.0.0-alpha.6 '@formatjs/icu-messageformat-parser': specifier: ^2.11.4 version: 2.11.4 @@ -132,9 +132,6 @@ importers: open: specifier: ^10.1.1 version: 10.2.0 - ora: - specifier: ^8.2.0 - version: 8.2.0 remark-frontmatter: specifier: ^5.0.0 version: 5.0.0 @@ -1590,9 +1587,15 @@ packages: '@clack/core@1.0.0-alpha.5': resolution: {integrity: sha512-z02wRlW7F7L5N5r2otDMsrLNKAgjDIDD+m4do5/cBqiYCKKb7SNBJgpUISjuCmRI0/P5XyoInmMrr1rBoH8MKw==} + '@clack/core@1.0.0-alpha.6': + resolution: {integrity: sha512-eG5P45+oShFG17u9I1DJzLkXYB1hpUgTLi32EfsMjSHLEqJUR8BOBCVFkdbUX2g08eh/HCi6UxNGpPhaac1LAA==} + '@clack/prompts@1.0.0-alpha.5': resolution: {integrity: sha512-hY67bxfwwti2WkLOLOiuXtAdD43KNMV4yiJPPSEdZG8N5TfZ9lLxibmqwKe5UZ5364PIp4kzTEvge/4Crcd5bg==} + '@clack/prompts@1.0.0-alpha.6': + resolution: {integrity: sha512-75NCtYOgDHVBE2nLdKPTDYOaESxO0GLAKC7INREp5VbS988Xua1u+588VaGlcvXiLc/kSwc25Cd+4PeTSpY6QQ==} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -5594,9 +5597,6 @@ packages: resolution: {integrity: sha512-gYCwo7kh5S3IDyZPLZf6hSS0MnZT8QmJFqYvbqlDZSbwdZlY6QZWxJ4i/6UhITOJ4XzyI647Bm2MXKCLqnJ4nQ==} engines: {node: '>4.0'} - emoji-regex@10.5.0: - resolution: {integrity: sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==} - emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -6866,10 +6866,6 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} - is-unicode-supported@1.3.0: - resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} - engines: {node: '>=12'} - is-unicode-supported@2.1.0: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} @@ -7330,10 +7326,6 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} - log-symbols@6.0.0: - resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} - engines: {node: '>=18'} - log-symbols@7.0.1: resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} engines: {node: '>=18'} @@ -7905,10 +7897,6 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} - ora@8.2.0: - resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} - engines: {node: '>=18'} - ora@9.0.0: resolution: {integrity: sha512-m0pg2zscbYgWbqRR6ABga5c3sZdEon7bSgjnlXC64kxtxLOyjRcbbUkLj7HFyy/FTD+P2xdBWu8snGhYI0jc4A==} engines: {node: '>=20'} @@ -9328,10 +9316,6 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} - string-width@7.2.0: - resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} - engines: {node: '>=18'} - string-width@8.1.0: resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==} engines: {node: '>=20'} @@ -11521,12 +11505,23 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 + '@clack/core@1.0.0-alpha.6': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@clack/prompts@1.0.0-alpha.5': dependencies: '@clack/core': 1.0.0-alpha.5 picocolors: 1.1.1 sisteransi: 1.0.5 + '@clack/prompts@1.0.0-alpha.6': + dependencies: + '@clack/core': 1.0.0-alpha.6 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -15838,8 +15833,6 @@ snapshots: email-validator@2.0.4: {} - emoji-regex@10.5.0: {} - emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -17411,8 +17404,6 @@ snapshots: is-unicode-supported@0.1.0: {} - is-unicode-supported@1.3.0: {} - is-unicode-supported@2.1.0: {} is-url@1.2.4: {} @@ -17886,11 +17877,6 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 - log-symbols@6.0.0: - dependencies: - chalk: 5.6.2 - is-unicode-supported: 1.3.0 - log-symbols@7.0.1: dependencies: is-unicode-supported: 2.1.0 @@ -18690,18 +18676,6 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 - ora@8.2.0: - dependencies: - chalk: 5.6.2 - cli-cursor: 5.0.0 - cli-spinners: 2.9.2 - is-interactive: 2.0.0 - is-unicode-supported: 2.1.0 - log-symbols: 6.0.0 - stdin-discarder: 0.2.2 - string-width: 7.2.0 - strip-ansi: 7.1.2 - ora@9.0.0: dependencies: chalk: 5.6.2 @@ -20449,12 +20423,6 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.2 - string-width@7.2.0: - dependencies: - emoji-regex: 10.5.0 - get-east-asian-width: 1.4.0 - strip-ansi: 7.1.2 - string-width@8.1.0: dependencies: get-east-asian-width: 1.4.0 diff --git a/turbo.json b/turbo.json index 1350c9d70..d5f907e19 100644 --- a/turbo.json +++ b/turbo.json @@ -26,7 +26,7 @@ }, "build:clean": { "dependsOn": ["^build:clean"], - "cache": true, + "cache": false, "outputs": ["dist/**"] }, "build:turbopack": {