gt 2.19.0 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/api/collectUserEditDiffs.js +23 -23
  3. package/dist/api/collectUserEditDiffs.js.map +1 -1
  4. package/dist/api/downloadFileBatch.js +7 -4
  5. package/dist/api/downloadFileBatch.js.map +1 -1
  6. package/dist/api/saveLocalEdits.js +2 -2
  7. package/dist/api/saveLocalEdits.js.map +1 -1
  8. package/dist/cli/base.d.ts +2 -0
  9. package/dist/cli/base.js +45 -2
  10. package/dist/cli/base.js.map +1 -1
  11. package/dist/cli/commands/api.d.ts +17 -0
  12. package/dist/cli/commands/api.js +153 -0
  13. package/dist/cli/commands/api.js.map +1 -0
  14. package/dist/cli/commands/upload.js +15 -5
  15. package/dist/cli/commands/upload.js.map +1 -1
  16. package/dist/cli/commands/utils/validation.d.ts +4 -0
  17. package/dist/cli/commands/utils/validation.js +12 -5
  18. package/dist/cli/commands/utils/validation.js.map +1 -1
  19. package/dist/config/generateSettings.js +7 -0
  20. package/dist/config/generateSettings.js.map +1 -1
  21. package/dist/console/index.d.ts +1 -0
  22. package/dist/console/index.js +2 -1
  23. package/dist/console/index.js.map +1 -1
  24. package/dist/formats/files/aggregateFiles.js +14 -6
  25. package/dist/formats/files/aggregateFiles.js.map +1 -1
  26. package/dist/fs/fileContent.d.ts +65 -0
  27. package/dist/fs/fileContent.js +171 -0
  28. package/dist/fs/fileContent.js.map +1 -0
  29. package/dist/generated/version.d.ts +1 -1
  30. package/dist/generated/version.js +1 -1
  31. package/dist/generated/version.js.map +1 -1
  32. package/dist/state/recentDownloads.d.ts +3 -2
  33. package/dist/state/recentDownloads.js.map +1 -1
  34. package/dist/translation/reviewSetupWarning.js +3 -3
  35. package/dist/translation/reviewSetupWarning.js.map +1 -1
  36. package/dist/utils/api.d.ts +194 -0
  37. package/dist/utils/api.js +268 -0
  38. package/dist/utils/api.js.map +1 -0
  39. package/dist/utils/persistPostprocessHashes.d.ts +1 -7
  40. package/dist/utils/persistPostprocessHashes.js +15 -1
  41. package/dist/utils/persistPostprocessHashes.js.map +1 -1
  42. package/dist/workflows/download.js +4 -4
  43. package/dist/workflows/download.js.map +1 -1
  44. package/dist/workflows/enqueue.js +4 -4
  45. package/dist/workflows/enqueue.js.map +1 -1
  46. package/dist/workflows/publish.js +2 -2
  47. package/dist/workflows/publish.js.map +1 -1
  48. package/dist/workflows/setupProject.js +4 -4
  49. package/dist/workflows/setupProject.js.map +1 -1
  50. package/dist/workflows/stage.js +7 -7
  51. package/dist/workflows/stage.js.map +1 -1
  52. package/dist/workflows/steps/BranchStep.d.ts +2 -2
  53. package/dist/workflows/steps/BranchStep.js +9 -9
  54. package/dist/workflows/steps/BranchStep.js.map +1 -1
  55. package/dist/workflows/steps/DownloadStep.d.ts +3 -3
  56. package/dist/workflows/steps/DownloadStep.js +3 -3
  57. package/dist/workflows/steps/DownloadStep.js.map +1 -1
  58. package/dist/workflows/steps/EnqueueStep.d.ts +5 -3
  59. package/dist/workflows/steps/EnqueueStep.js.map +1 -1
  60. package/dist/workflows/steps/PollJobsStep.d.ts +3 -3
  61. package/dist/workflows/steps/PollJobsStep.js +6 -6
  62. package/dist/workflows/steps/PollJobsStep.js.map +1 -1
  63. package/dist/workflows/steps/PublishStep.d.ts +3 -3
  64. package/dist/workflows/steps/PublishStep.js +3 -3
  65. package/dist/workflows/steps/PublishStep.js.map +1 -1
  66. package/dist/workflows/steps/SetupStep.d.ts +6 -4
  67. package/dist/workflows/steps/SetupStep.js.map +1 -1
  68. package/dist/workflows/steps/TagStep.d.ts +3 -3
  69. package/dist/workflows/steps/TagStep.js +3 -3
  70. package/dist/workflows/steps/TagStep.js.map +1 -1
  71. package/dist/workflows/steps/UploadSourcesStep.d.ts +3 -3
  72. package/dist/workflows/steps/UploadSourcesStep.js +8 -6
  73. package/dist/workflows/steps/UploadSourcesStep.js.map +1 -1
  74. package/dist/workflows/steps/UploadTranslationsStep.d.ts +3 -3
  75. package/dist/workflows/steps/UploadTranslationsStep.js +24 -6
  76. package/dist/workflows/steps/UploadTranslationsStep.js.map +1 -1
  77. package/dist/workflows/upload.js +4 -4
  78. package/dist/workflows/upload.js.map +1 -1
  79. package/dist/workflows/utils/filterFilesForEnqueue.d.ts +2 -2
  80. package/dist/workflows/utils/filterFilesForEnqueue.js.map +1 -1
  81. package/dist/workflows/utils/queryCompletedTranslations.d.ts +3 -3
  82. package/dist/workflows/utils/queryCompletedTranslations.js +2 -2
  83. package/dist/workflows/utils/queryCompletedTranslations.js.map +1 -1
  84. package/package.json +5 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # gtx-cli
2
2
 
3
+ ## 2.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#2202](https://github.com/generaltranslation/gt/pull/2202) [`6ac4ba3`](https://github.com/generaltranslation/gt/commit/6ac4ba33a0230fb8aecae7c7677cfd38c361bd9c) Thanks [@chenxin-yan](https://github.com/chenxin-yan)! - Add `gt api` for raw authenticated API requests and OpenAPI spec discovery.
8
+
9
+ - [#2179](https://github.com/generaltranslation/gt/pull/2179) [`33ea383`](https://github.com/generaltranslation/gt/commit/33ea383b5d1593793b0a704b35b8a750ea8c3274) Thanks [@chenxin-yan](https://github.com/chenxin-yan)! - Add `gt project create` and `gt project status` commands, and migrate CLI API requests to the generated SDK through `generaltranslation/api`.
10
+
11
+ ### Patch Changes
12
+
13
+ - [#2233](https://github.com/generaltranslation/gt/pull/2233) [`8725a5c`](https://github.com/generaltranslation/gt/commit/8725a5c9235926cf1653a9306cca44045874c0dc) Thanks [@eoinest](https://github.com/eoinest)! - Fix UTF-16 `.strings` and `.stringsdict` files being corrupted or rejected on upload.
14
+
15
+ Older Xcode wrote both formats as UTF-16, and legacy repositories still contain them. The CLI read every file as UTF-8 text, so a UTF-16 `.stringsdict` reached the API as mojibake and its XML parser rejected it outright — the file could not be uploaded at all. A UTF-16 `.strings` file did upload, but came back as byte-order-mark-less UTF-8, so every one of them showed as rewritten in `git diff`.
16
+
17
+ Encoding is now handled entirely by the CLI, which is the only part of the pipeline that touches the filesystem, and within the CLI by a single module at the disk boundary. Reads decode by byte order mark, so the API only ever receives UTF-8 text. Writes re-encode to the source file's own encoding, which the CLI already knows because it just read that file, so a UTF-16 repository stays UTF-16 and a UTF-8 one gains no byte order mark. This also fixes re-downloading a UTF-16 `.stringsdict` source, which previously produced a file `plutil` rejected with `Unexpected character ã at line 1`.
18
+
19
+ If the source file has been moved or deleted since it was uploaded, the translation already on disk is used as the record of the encoding instead, so those files keep their encoding rather than being rewritten as UTF-8.
20
+
21
+ The content hash recorded for a downloaded translation is now taken from its decoded text as well, so a file stored in UTF-16 stops reading as edited on every run because its hash had been computed from bytes read as UTF-8.
22
+
23
+ A file with no byte order mark is treated as UTF-8 even when its bytes are plainly UTF-16, matching Foundation: `plutil` rejects those files too, so guessing would translate content the platform itself cannot read.
24
+
25
+ `DOT_STRINGS` leaves `BINARY_FILE_FORMATS`, which now contains only `LOTTIE`. Every `.strings` file's version id changes once, because it is now hashed from the decoded text rather than the base64 payload, so each one re-translates a single time.
26
+
27
+ - Updated dependencies [[`8725a5c`](https://github.com/generaltranslation/gt/commit/8725a5c9235926cf1653a9306cca44045874c0dc), [`0e5d7e0`](https://github.com/generaltranslation/gt/commit/0e5d7e0efd48f98659a8229f7e48273070981f5b), [`3c33a75`](https://github.com/generaltranslation/gt/commit/3c33a75c738c2f433cfdf4e19d75b189c0100f38), [`4496e16`](https://github.com/generaltranslation/gt/commit/4496e161d7885f329bdfd7f4d04a7a58ed12d9d1), [`6ac4ba3`](https://github.com/generaltranslation/gt/commit/6ac4ba33a0230fb8aecae7c7677cfd38c361bd9c)]:
28
+ - generaltranslation@9.2.0
29
+ - @generaltranslation/python-extractor@0.2.47
30
+ - @generaltranslation/supported-locales@2.1.27
31
+ - @generaltranslation/vue-extractor@0.1.7
32
+
3
33
  ## 2.19.0
4
34
 
5
35
  ### Minor Changes
@@ -1,7 +1,8 @@
1
1
  import { logger } from "../console/logger.js";
2
2
  import { hashStringSync } from "../utils/hash.js";
3
- import { gt } from "../utils/gt.js";
4
3
  import { getRelative } from "../fs/findFilepath.js";
4
+ import { api } from "../utils/api.js";
5
+ import { readFileContent } from "../fs/fileContent.js";
5
6
  import { extractJson } from "../formats/json/extractJson.js";
6
7
  import { recordWarning } from "../state/translateWarnings.js";
7
8
  import { readLockfile } from "../fs/config/downloadedVersions.js";
@@ -14,16 +15,12 @@ import * as fs$1 from "node:fs";
14
15
  import { isBinaryFileFormat } from "generaltranslation/types";
15
16
  import os from "node:os";
16
17
  //#region src/api/collectUserEditDiffs.ts
17
- const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
18
- /** Whether these bytes round trip as UTF-8 text. */
19
- const isUtf8Text = (bytes) => {
20
- try {
21
- utf8Decoder.decode(bytes);
22
- return true;
23
- } catch {
24
- return false;
25
- }
26
- };
18
+ /**
19
+ * The bytes a file's pipeline content stands for: binary formats carry base64,
20
+ * everything else carries UTF-8 text. Applied to both sides of the comparison
21
+ * so they are measured the same way.
22
+ */
23
+ const contentBytes = (content, fileFormat) => isBinaryFileFormat(fileFormat) ? Buffer.from(content, "base64") : Buffer.from(content, "utf8");
27
24
  const findLatestDownloadedVersion = (entryMap, fileId, locale) => {
28
25
  const entry = entryMap.get(fileId);
29
26
  if (!entry) return null;
@@ -56,7 +53,7 @@ async function collectAndSendUserEditDiffs(files, settings) {
56
53
  if (!latestDownloaded) continue;
57
54
  const downloadedVersion = latestDownloaded.entry;
58
55
  if (downloadedVersion.postProcessHash) try {
59
- if (hashStringSync(await fs$1.promises.readFile(outputPath, "utf8")) === downloadedVersion.postProcessHash) continue;
56
+ if (hashStringSync(readFileContent(outputPath, uploadedFile.fileFormat)) === downloadedVersion.postProcessHash) continue;
60
57
  } catch {}
61
58
  candidates.push({
62
59
  branchId: uploadedFile.branchId,
@@ -64,7 +61,8 @@ async function collectAndSendUserEditDiffs(files, settings) {
64
61
  fileId: uploadedFile.fileId,
65
62
  versionId: latestDownloaded.versionId,
66
63
  locale,
67
- outputPath
64
+ outputPath,
65
+ fileFormat: uploadedFile.fileFormat
68
66
  });
69
67
  }
70
68
  const collectedDiffs = [];
@@ -75,27 +73,27 @@ async function collectAndSendUserEditDiffs(files, settings) {
75
73
  fileId: c.fileId,
76
74
  branchId: c.branchId
77
75
  }));
78
- const translatedFiles = (await gt.queryFileData({ translatedFiles: fileQueryData })).translatedFiles?.filter((t) => t.completedAt) ?? [];
76
+ const translatedFiles = (await api.queryFileData({ translatedFiles: fileQueryData })).translatedFiles?.filter((t) => t.completedAt) ?? [];
79
77
  const serverContentByKey = /* @__PURE__ */ new Map();
80
78
  try {
81
- const files = (await gt.downloadFileBatch(translatedFiles.map((file) => ({
79
+ const files = (await api.downloadFileBatch(translatedFiles.map((file) => ({
82
80
  branchId: file.branchId,
83
81
  fileId: file.fileId,
84
82
  locale: file.locale,
85
83
  versionId: file.versionId
86
84
  }))))?.files || [];
87
- for (const f of files) serverContentByKey.set(`${f.branchId}:${f.fileId}:${f.versionId}:${f.locale}`, isBinaryFileFormat(f.fileFormat) ? Buffer.from(f.data, "base64") : Buffer.from(f.data, "utf8"));
85
+ for (const f of files) serverContentByKey.set(`${f.branchId}:${f.fileId}:${f.versionId}:${f.locale}`, contentBytes(f.data, f.fileFormat));
88
86
  } catch {}
89
87
  for (const c of candidates) {
90
88
  const key = `${c.branchId}:${c.fileId}:${c.versionId}:${c.locale}`;
91
89
  const serverBytes = serverContentByKey.get(key);
92
90
  if (!serverBytes) continue;
93
91
  try {
94
- const localBytes = await fs$1.promises.readFile(c.outputPath);
92
+ const localBytes = contentBytes(readFileContent(c.outputPath, c.fileFormat), c.fileFormat);
95
93
  if (localBytes.equals(serverBytes)) continue;
96
- if (!isUtf8Text(serverBytes) || !isUtf8Text(localBytes)) {
94
+ if (isBinaryFileFormat(c.fileFormat)) {
97
95
  const relativePath = getRelative(c.outputPath);
98
- const reason = "Edited file is not valid UTF-8, so its changes cannot be submitted";
96
+ const reason = "Edited file is a binary format, so its changes cannot be submitted";
99
97
  logger.warn(`Skipping local edits to ${relativePath}: ${reason}`);
100
98
  recordWarning("skipped_file", relativePath, reason);
101
99
  continue;
@@ -103,9 +101,11 @@ async function collectAndSendUserEditDiffs(files, settings) {
103
101
  const safeName = Buffer.from(`${c.branchId}:${c.fileId}:${c.versionId}:${c.locale}`).toString("base64").replace(/=+$/g, "");
104
102
  const tempServerFile = path$1.join(tempDir, `${safeName}.server`);
105
103
  await fs$1.promises.writeFile(tempServerFile, serverBytes);
106
- const diff = await getGitUnifiedDiff(tempServerFile, c.outputPath);
107
- try {
108
- await fs$1.promises.unlink(tempServerFile);
104
+ const tempLocalFile = path$1.join(tempDir, `${safeName}.local`);
105
+ await fs$1.promises.writeFile(tempLocalFile, localBytes);
106
+ const diff = await getGitUnifiedDiff(tempServerFile, tempLocalFile);
107
+ for (const tempFile of [tempServerFile, tempLocalFile]) try {
108
+ await fs$1.promises.unlink(tempFile);
109
109
  } catch {}
110
110
  if (diff && diff.trim().length > 0) {
111
111
  const rawLocalContent = localBytes.toString("utf8");
@@ -130,7 +130,7 @@ async function collectAndSendUserEditDiffs(files, settings) {
130
130
  } catch {}
131
131
  }
132
132
  }
133
- if (collectedDiffs.length > 0) await gt.submitUserEditDiffs({ diffs: collectedDiffs });
133
+ if (collectedDiffs.length > 0) await api.submitUserEditDiffs({ diffs: collectedDiffs });
134
134
  return collectedDiffs.length > 0;
135
135
  }
136
136
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"collectUserEditDiffs.js","names":["path","fs"],"sources":["../../src/api/collectUserEditDiffs.ts"],"sourcesContent":["import * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport {\n readLockfile,\n EntryMap,\n DownloadedTranslation,\n} from '../fs/config/downloadedVersions.js';\nimport { Settings } from '../types/index.js';\nimport { createFileMapping } from '../formats/files/fileMapping.js';\nimport { getGitUnifiedDiff } from '../utils/gitDiff.js';\nimport { gt } from '../utils/gt.js';\nimport {\n FileReference,\n isBinaryFileFormat,\n SubmitUserEditDiff,\n} from 'generaltranslation/types';\nimport os from 'node:os';\nimport { randomUUID } from 'node:crypto';\nimport { hashStringSync } from '../utils/hash.js';\nimport { extractJson } from '../formats/json/extractJson.js';\nimport { extractYaml } from '../formats/yaml/extractYaml.js';\nimport { logger } from '../console/logger.js';\nimport { recordWarning } from '../state/translateWarnings.js';\nimport { getRelative } from '../fs/findFilepath.js';\n\ntype LatestDownloadedVersion = {\n versionId: string;\n entry: DownloadedTranslation;\n};\n\nconst utf8Decoder = new TextDecoder('utf-8', { fatal: true });\n\n/** Whether these bytes round trip as UTF-8 text. */\nconst isUtf8Text = (bytes: Buffer): boolean => {\n try {\n utf8Decoder.decode(bytes);\n return true;\n } catch {\n return false;\n }\n};\n\nconst findLatestDownloadedVersion = (\n entryMap: EntryMap,\n fileId: string,\n locale: string\n): LatestDownloadedVersion | null => {\n const entry = entryMap.get(fileId);\n if (!entry) return null;\n\n const translation = entry.translations[locale];\n if (!translation) return null;\n\n return { versionId: entry.versionId, entry: translation };\n};\n\n/**\n * Collects local user edits by diffing the latest downloaded server translation version\n * against the current local translation file, and submits the diffs upstream.\n *\n * Must run before enqueueing new translations so rules are available to the generator.\n */\nexport async function collectAndSendUserEditDiffs(\n files: FileReference[],\n settings: Settings\n): Promise<boolean> {\n if (!settings.files) return false;\n\n const { resolvedPaths, placeholderPaths, transformPaths, transformFormats } =\n settings.files;\n const fileMapping = createFileMapping(\n resolvedPaths,\n placeholderPaths,\n transformPaths,\n transformFormats,\n settings.locales,\n settings.defaultLocale\n );\n\n const { entryMap } = readLockfile(settings);\n\n const tempDir = path.join(os.tmpdir(), randomUUID());\n if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });\n\n // Build candidates for diff and batch-fetch server contents\n type DiffCandidate = {\n branchId: string;\n fileName: string;\n fileId: string;\n versionId: string;\n locale: string; // resolved\n outputPath: string;\n };\n const candidates: DiffCandidate[] = [];\n\n for (const uploadedFile of files) {\n for (const locale of settings.locales) {\n const outputPath = fileMapping[locale]?.[uploadedFile.fileName] ?? null;\n if (!outputPath) continue;\n if (!fs.existsSync(outputPath)) continue;\n\n const latestDownloaded = findLatestDownloadedVersion(\n entryMap,\n uploadedFile.fileId,\n locale\n );\n\n if (!latestDownloaded) continue;\n const downloadedVersion = latestDownloaded.entry;\n\n // Skip if local file matches the last postprocessed content hash\n if (downloadedVersion.postProcessHash) {\n try {\n const localContent = await fs.promises.readFile(outputPath, 'utf8');\n const localHash = hashStringSync(localContent);\n if (localHash === downloadedVersion.postProcessHash) {\n continue;\n }\n } catch {\n // If hash check fails, fall through to diff\n }\n }\n\n candidates.push({\n branchId: uploadedFile.branchId,\n fileName: uploadedFile.fileName,\n fileId: uploadedFile.fileId,\n versionId: latestDownloaded.versionId,\n locale: locale,\n outputPath,\n });\n }\n }\n\n const collectedDiffs: SubmitUserEditDiff[] = [];\n\n if (candidates.length > 0) {\n const fileQueryData = candidates.map((c) => ({\n versionId: c.versionId,\n locale: c.locale,\n fileId: c.fileId,\n branchId: c.branchId,\n }));\n\n // Single batched check to obtain translation IDs\n const checkResponse = await gt.queryFileData({\n translatedFiles: fileQueryData,\n });\n const translatedFiles =\n checkResponse.translatedFiles?.filter((t) => t.completedAt) ?? [];\n\n const serverContentByKey = new Map<string, Buffer>();\n try {\n const resp = await gt.downloadFileBatch(\n translatedFiles.map((file) => ({\n branchId: file.branchId,\n fileId: file.fileId,\n locale: file.locale,\n versionId: file.versionId,\n }))\n );\n const files = resp?.files || [];\n for (const f of files) {\n serverContentByKey.set(\n `${f.branchId}:${f.fileId}:${f.versionId}:${f.locale}`,\n // Formats in BINARY_FILE_FORMATS are still base64 at this point;\n // everything else has already been decoded to a UTF-8 string.\n isBinaryFileFormat(f.fileFormat)\n ? Buffer.from(f.data, 'base64')\n : Buffer.from(f.data, 'utf8')\n );\n }\n } catch {\n // Ignore chunk failures; proceed with what we have\n }\n\n // Compute diffs using fetched server contents\n for (const c of candidates) {\n const key = `${c.branchId}:${c.fileId}:${c.versionId}:${c.locale}`;\n const serverBytes = serverContentByKey.get(key);\n // Absent means the batch did not return this file, so there is no\n // baseline. An empty payload is a baseline of nothing, which the user\n // may well have written against.\n if (!serverBytes) continue;\n\n try {\n const localBytes = await fs.promises.readFile(c.outputPath);\n\n // Nothing was edited, so there is no diff to compute or report.\n if (localBytes.equals(serverBytes)) continue;\n\n // A unified diff and localContent are both UTF-8 text. Content that is\n // not valid UTF-8 — a Lottie zip, a UTF-16 .strings file — has no\n // faithful text form here, and sending mojibake upstream is worse than\n // sending nothing. Say so: the edit is real and will be lost on the\n // next download.\n if (!isUtf8Text(serverBytes) || !isUtf8Text(localBytes)) {\n const relativePath = getRelative(c.outputPath);\n const reason =\n 'Edited file is not valid UTF-8, so its changes cannot be submitted';\n logger.warn(`Skipping local edits to ${relativePath}: ${reason}`);\n recordWarning('skipped_file', relativePath, reason);\n continue;\n }\n\n const safeName = Buffer.from(\n `${c.branchId}:${c.fileId}:${c.versionId}:${c.locale}`\n )\n .toString('base64')\n .replace(/=+$/g, '');\n const tempServerFile = path.join(tempDir, `${safeName}.server`);\n await fs.promises.writeFile(tempServerFile, serverBytes);\n\n const diff = await getGitUnifiedDiff(tempServerFile, c.outputPath);\n try {\n await fs.promises.unlink(tempServerFile);\n } catch {\n // Ignore cleanup errors for temporary comparison files.\n }\n\n if (diff && diff.trim().length > 0) {\n const rawLocalContent = localBytes.toString('utf8');\n\n // For JSON files with jsonSchema config, extract to composite format\n let localContent = rawLocalContent;\n if (\n c.fileName.endsWith('.json') &&\n settings.options?.jsonSchema &&\n c.locale !== settings.defaultLocale\n ) {\n const extractedContent = extractJson(\n rawLocalContent,\n c.fileName,\n settings.options,\n c.locale,\n settings.defaultLocale\n );\n if (extractedContent) {\n localContent = extractedContent;\n }\n } else if (\n (c.fileName.endsWith('.yaml') || c.fileName.endsWith('.yml')) &&\n settings.options?.yamlSchema &&\n c.locale !== settings.defaultLocale\n ) {\n const extractedContent = extractYaml(\n rawLocalContent,\n c.fileName,\n settings.options\n );\n if (extractedContent) {\n localContent = extractedContent;\n }\n }\n\n collectedDiffs.push({\n fileName: c.fileName,\n locale: c.locale,\n diff,\n branchId: c.branchId,\n versionId: c.versionId,\n fileId: c.fileId,\n localContent,\n } satisfies SubmitUserEditDiff);\n }\n } catch {\n // Ignore failures for this file\n }\n }\n }\n\n if (collectedDiffs.length > 0) {\n await gt.submitUserEditDiffs({ diffs: collectedDiffs });\n }\n\n return collectedDiffs.length > 0;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"collectUserEditDiffs.js","names":["path","fs"],"sources":["../../src/api/collectUserEditDiffs.ts"],"sourcesContent":["import * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport {\n readLockfile,\n EntryMap,\n DownloadedTranslation,\n} from '../fs/config/downloadedVersions.js';\nimport { Settings } from '../types/index.js';\nimport { createFileMapping } from '../formats/files/fileMapping.js';\nimport { getGitUnifiedDiff } from '../utils/gitDiff.js';\nimport { api } from '../utils/api.js';\nimport {\n FileReference,\n isBinaryFileFormat,\n SubmitUserEditDiff,\n type FileFormat,\n} from 'generaltranslation/types';\nimport { readFileContent } from '../fs/fileContent.js';\nimport os from 'node:os';\nimport { randomUUID } from 'node:crypto';\nimport { hashStringSync } from '../utils/hash.js';\nimport { extractJson } from '../formats/json/extractJson.js';\nimport { extractYaml } from '../formats/yaml/extractYaml.js';\nimport { logger } from '../console/logger.js';\nimport { recordWarning } from '../state/translateWarnings.js';\nimport { getRelative } from '../fs/findFilepath.js';\n\ntype LatestDownloadedVersion = {\n versionId: string;\n entry: DownloadedTranslation;\n};\n\n/**\n * The bytes a file's pipeline content stands for: binary formats carry base64,\n * everything else carries UTF-8 text. Applied to both sides of the comparison\n * so they are measured the same way.\n */\nconst contentBytes = (content: string, fileFormat: FileFormat): Buffer =>\n isBinaryFileFormat(fileFormat)\n ? Buffer.from(content, 'base64')\n : Buffer.from(content, 'utf8');\n\nconst findLatestDownloadedVersion = (\n entryMap: EntryMap,\n fileId: string,\n locale: string\n): LatestDownloadedVersion | null => {\n const entry = entryMap.get(fileId);\n if (!entry) return null;\n\n const translation = entry.translations[locale];\n if (!translation) return null;\n\n return { versionId: entry.versionId, entry: translation };\n};\n\n/**\n * Collects local user edits by diffing the latest downloaded server translation version\n * against the current local translation file, and submits the diffs upstream.\n *\n * Must run before enqueueing new translations so rules are available to the generator.\n */\nexport async function collectAndSendUserEditDiffs(\n files: FileReference[],\n settings: Settings\n): Promise<boolean> {\n if (!settings.files) return false;\n\n const { resolvedPaths, placeholderPaths, transformPaths, transformFormats } =\n settings.files;\n const fileMapping = createFileMapping(\n resolvedPaths,\n placeholderPaths,\n transformPaths,\n transformFormats,\n settings.locales,\n settings.defaultLocale\n );\n\n const { entryMap } = readLockfile(settings);\n\n const tempDir = path.join(os.tmpdir(), randomUUID());\n if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });\n\n // Build candidates for diff and batch-fetch server contents\n type DiffCandidate = {\n branchId: string;\n fileName: string;\n fileId: string;\n versionId: string;\n locale: string; // resolved\n outputPath: string;\n fileFormat: FileFormat;\n };\n const candidates: DiffCandidate[] = [];\n\n for (const uploadedFile of files) {\n for (const locale of settings.locales) {\n const outputPath = fileMapping[locale]?.[uploadedFile.fileName] ?? null;\n if (!outputPath) continue;\n if (!fs.existsSync(outputPath)) continue;\n\n const latestDownloaded = findLatestDownloadedVersion(\n entryMap,\n uploadedFile.fileId,\n locale\n );\n\n if (!latestDownloaded) continue;\n const downloadedVersion = latestDownloaded.entry;\n\n // Skip if local file matches the last postprocessed content hash\n if (downloadedVersion.postProcessHash) {\n try {\n // Hashed from the same pipeline content the hash was recorded from,\n // so a file stored in UTF-16 still matches when it is untouched.\n const localContent = readFileContent(\n outputPath,\n uploadedFile.fileFormat\n );\n const localHash = hashStringSync(localContent);\n if (localHash === downloadedVersion.postProcessHash) {\n continue;\n }\n } catch {\n // If hash check fails, fall through to diff\n }\n }\n\n candidates.push({\n branchId: uploadedFile.branchId,\n fileName: uploadedFile.fileName,\n fileId: uploadedFile.fileId,\n versionId: latestDownloaded.versionId,\n locale: locale,\n outputPath,\n fileFormat: uploadedFile.fileFormat,\n });\n }\n }\n\n const collectedDiffs: SubmitUserEditDiff[] = [];\n\n if (candidates.length > 0) {\n const fileQueryData = candidates.map((c) => ({\n versionId: c.versionId,\n locale: c.locale,\n fileId: c.fileId,\n branchId: c.branchId,\n }));\n\n // Single batched check to obtain translation IDs\n const checkResponse = await api.queryFileData({\n translatedFiles: fileQueryData,\n });\n const translatedFiles =\n checkResponse.translatedFiles?.filter((t) => t.completedAt) ?? [];\n\n const serverContentByKey = new Map<string, Buffer>();\n try {\n const resp = await api.downloadFileBatch(\n translatedFiles.map((file) => ({\n branchId: file.branchId,\n fileId: file.fileId,\n locale: file.locale,\n versionId: file.versionId,\n }))\n );\n const files = resp?.files || [];\n for (const f of files) {\n serverContentByKey.set(\n `${f.branchId}:${f.fileId}:${f.versionId}:${f.locale}`,\n contentBytes(f.data, f.fileFormat)\n );\n }\n } catch {\n // Ignore chunk failures; proceed with what we have\n }\n\n // Compute diffs using fetched server contents\n for (const c of candidates) {\n const key = `${c.branchId}:${c.fileId}:${c.versionId}:${c.locale}`;\n const serverBytes = serverContentByKey.get(key);\n // Absent means the batch did not return this file, so there is no\n // baseline. An empty payload is a baseline of nothing, which the user\n // may well have written against.\n if (!serverBytes) continue;\n\n try {\n // Read the local file the same way the pipeline read it originally, so\n // a file stored differently on disk than the server's copy is compared\n // as content rather than as bytes. Otherwise every such file would read\n // as edited on every run.\n const localBytes = contentBytes(\n readFileContent(c.outputPath, c.fileFormat),\n c.fileFormat\n );\n\n // Nothing was edited, so there is no diff to compute or report.\n if (localBytes.equals(serverBytes)) continue;\n\n // A unified diff and localContent are both UTF-8 text, and every text\n // format's content is UTF-8 by the time it reaches here. Only a binary\n // format's bytes — a Lottie zip — have no text form at all. Say so:\n // the edit is real and will be lost on the next download.\n if (isBinaryFileFormat(c.fileFormat)) {\n const relativePath = getRelative(c.outputPath);\n const reason =\n 'Edited file is a binary format, so its changes cannot be submitted';\n logger.warn(`Skipping local edits to ${relativePath}: ${reason}`);\n recordWarning('skipped_file', relativePath, reason);\n continue;\n }\n\n const safeName = Buffer.from(\n `${c.branchId}:${c.fileId}:${c.versionId}:${c.locale}`\n )\n .toString('base64')\n .replace(/=+$/g, '');\n const tempServerFile = path.join(tempDir, `${safeName}.server`);\n await fs.promises.writeFile(tempServerFile, serverBytes);\n\n // git diff reads bytes, so both sides are written from the content\n // they represent rather than diffing the file as it sits on disk.\n const tempLocalFile = path.join(tempDir, `${safeName}.local`);\n await fs.promises.writeFile(tempLocalFile, localBytes);\n\n const diff = await getGitUnifiedDiff(tempServerFile, tempLocalFile);\n for (const tempFile of [tempServerFile, tempLocalFile]) {\n try {\n await fs.promises.unlink(tempFile);\n } catch {\n // Ignore cleanup errors for temporary comparison files.\n }\n }\n\n if (diff && diff.trim().length > 0) {\n const rawLocalContent = localBytes.toString('utf8');\n\n // For JSON files with jsonSchema config, extract to composite format\n let localContent = rawLocalContent;\n if (\n c.fileName.endsWith('.json') &&\n settings.options?.jsonSchema &&\n c.locale !== settings.defaultLocale\n ) {\n const extractedContent = extractJson(\n rawLocalContent,\n c.fileName,\n settings.options,\n c.locale,\n settings.defaultLocale\n );\n if (extractedContent) {\n localContent = extractedContent;\n }\n } else if (\n (c.fileName.endsWith('.yaml') || c.fileName.endsWith('.yml')) &&\n settings.options?.yamlSchema &&\n c.locale !== settings.defaultLocale\n ) {\n const extractedContent = extractYaml(\n rawLocalContent,\n c.fileName,\n settings.options\n );\n if (extractedContent) {\n localContent = extractedContent;\n }\n }\n\n collectedDiffs.push({\n fileName: c.fileName,\n locale: c.locale,\n diff,\n branchId: c.branchId,\n versionId: c.versionId,\n fileId: c.fileId,\n localContent,\n } satisfies SubmitUserEditDiff);\n }\n } catch {\n // Ignore failures for this file\n }\n }\n }\n\n if (collectedDiffs.length > 0) {\n await api.submitUserEditDiffs({ diffs: collectedDiffs });\n }\n\n return collectedDiffs.length > 0;\n}\n"],"mappings":"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"}
@@ -1,9 +1,10 @@
1
1
  import { logger } from "../console/logger.js";
2
- import { gt } from "../utils/gt.js";
3
2
  import { getInlineElementsLabel } from "../types/libraries.js";
4
3
  import { getRelative } from "../fs/findFilepath.js";
5
4
  import { SUPPORTED_FILE_EXTENSIONS } from "../formats/files/supportedFiles.js";
6
5
  import { hasNonIdentityFileFormatTransformForType } from "../formats/files/transformFormat.js";
6
+ import { api } from "../utils/api.js";
7
+ import { encodeFileContent } from "../fs/fileContent.js";
7
8
  import { validateJsonSchema } from "../formats/json/utils.js";
8
9
  import { extractJson } from "../formats/json/extractJson.js";
9
10
  import { recordWarning } from "../state/translateWarnings.js";
@@ -40,7 +41,7 @@ function countGtJsonEntries(content) {
40
41
  try {
41
42
  const parsed = JSON.parse(content);
42
43
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
43
- const entries = parsed.type === "GTJSON" && parsed.data && typeof parsed.data === "object" && !Array.isArray(parsed.data) ? parsed.data : parsed;
44
+ const entries = "type" in parsed && parsed.type === "GTJSON" && "data" in parsed && parsed.data && typeof parsed.data === "object" && !Array.isArray(parsed.data) ? parsed.data : parsed;
44
45
  return Object.keys(entries).length;
45
46
  } catch {
46
47
  return;
@@ -109,7 +110,7 @@ async function downloadFileBatch(fileTracker, files, options, forceDownload = fa
109
110
  };
110
111
  const outputPathMap = new Map(files.map((file) => [`${file.branchId}:${file.fileId}:${file.versionId}:${file.locale}`, file.outputPath]));
111
112
  try {
112
- const downloadedFiles = (await gt.downloadFileBatch(files.map((file) => ({
113
+ const downloadedFiles = (await api.downloadFileBatch(files.map((file) => ({
113
114
  fileId: file.fileId,
114
115
  branchId: file.branchId,
115
116
  versionId: file.versionId,
@@ -145,6 +146,7 @@ async function downloadFileBatch(fileTracker, files, options, forceDownload = fa
145
146
  fileId,
146
147
  versionId,
147
148
  locale,
149
+ fileFormat: file.fileFormat,
148
150
  inputPath
149
151
  });
150
152
  result.successful.push(requestedFile);
@@ -190,12 +192,13 @@ async function downloadFileBatch(fileTracker, files, options, forceDownload = fa
190
192
  } catch (error) {
191
193
  logger.warn(`Failed to sort JSON file: ${file.id}: ` + error);
192
194
  }
193
- await fs$1.promises.writeFile(outputPath, data);
195
+ await fs$1.promises.writeFile(outputPath, encodeFileContent(data, file.fileFormat, inputPath, outputPath));
194
196
  recordDownloaded(outputPath, {
195
197
  branchId,
196
198
  fileId,
197
199
  versionId,
198
200
  locale,
201
+ fileFormat: file.fileFormat,
199
202
  inputPath
200
203
  });
201
204
  reportWithheldGtJsonComponents(file.fileFormat, data, fileProperties.componentCount, locale, inlineLibrary);
@@ -1 +1 @@
1
- {"version":3,"file":"downloadFileBatch.js","names":["fs","path"],"sources":["../../src/api/downloadFileBatch.ts"],"sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport { isBinaryFileFormat } from 'generaltranslation/types';\nimport { logger } from '../console/logger.js';\nimport { gt } from '../utils/gt.js';\nimport { Settings } from '../types/index.js';\nimport { validateJsonSchema } from '../formats/json/utils.js';\nimport { validateYamlSchema } from '../formats/yaml/utils.js';\nimport { mergeJson } from '../formats/json/mergeJson.js';\nimport { extractJson } from '../formats/json/extractJson.js';\nimport mergeYaml from '../formats/yaml/mergeYaml.js';\nimport { extractYaml } from '../formats/yaml/extractYaml.js';\nimport {\n resolveMintlifyRefs,\n shouldResolveRefs,\n} from '../utils/resolveMintlifyRefs.js';\nimport {\n readLockfile,\n writeLockfile,\n findOrCreateEntry,\n} from '../fs/config/downloadedVersions.js';\nimport { recordDownloaded, recordRemerged } from '../state/recentDownloads.js';\nimport { recordWarning } from '../state/translateWarnings.js';\nimport stringify from 'fast-json-stable-stringify';\nimport type { FileStatusTracker } from '../workflows/steps/PollJobsStep.js';\nimport { SUPPORTED_FILE_EXTENSIONS } from '../formats/files/supportedFiles.js';\nimport { hasNonIdentityFileFormatTransformForType } from '../formats/files/transformFormat.js';\nimport { getRelative } from '../fs/findFilepath.js';\nimport {\n getInlineElementsLabel,\n type InlineLibrary,\n} from '../types/libraries.js';\n\n/**\n * The platform withholds unapproved review-gated GTJSON components from\n * served output. Reports the delta between the source component count and\n * the served content so partially-served files don't read as fully\n * translated — for both fresh downloads and already-downloaded skips.\n */\nfunction reportWithheldGtJsonComponents(\n fileFormat: string | undefined,\n servedContent: string,\n componentCount: number | undefined,\n locale: string,\n inlineLibrary?: InlineLibrary\n): void {\n if (fileFormat !== 'GTJSON' || componentCount == null) return;\n const received = countGtJsonEntries(servedContent);\n if (received == null) return;\n const withheld = componentCount - received;\n if (withheld > 0) {\n recordWarning(\n 'pending_review',\n `<${getInlineElementsLabel(inlineLibrary)}>`,\n `${withheld} component translation(s) for locale ${locale} require review and are not approved yet`\n );\n }\n}\n\n/**\n * Counts entries in downloaded GTJSON output, tolerating both the flat\n * public shape and a wrapped { type, data } shape.\n */\nfunction countGtJsonEntries(content: string): number | undefined {\n try {\n const parsed = JSON.parse(content);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n return undefined;\n }\n const entries =\n parsed.type === 'GTJSON' &&\n parsed.data &&\n typeof parsed.data === 'object' &&\n !Array.isArray(parsed.data)\n ? parsed.data\n : parsed;\n return Object.keys(entries).length;\n } catch {\n return undefined;\n }\n}\n\nfunction sortJsonString(data: string): string {\n const sortedData = stringify(JSON.parse(data));\n return JSON.stringify(JSON.parse(sortedData), null, 2);\n}\n\n/**\n * Merges translated content with the current source file for schema-based formats.\n */\nfunction mergeWithSource(\n translatedContent: string,\n locale: string,\n inputPath: string,\n options: Settings\n): string {\n if (shouldSkipSourceFormatMerge(inputPath, options)) {\n return translatedContent;\n }\n if (!options.options) return translatedContent;\n\n const jsonSchema = options.options.jsonSchema\n ? validateJsonSchema(options.options, inputPath)\n : null;\n const yamlSchema =\n !jsonSchema && options.options.yamlSchema\n ? validateYamlSchema(options.options, inputPath)\n : null;\n\n if (!jsonSchema && !yamlSchema) return translatedContent;\n\n const sourceContent = fs.readFileSync(inputPath, 'utf8');\n if (!sourceContent) return translatedContent;\n\n if (jsonSchema) {\n // Resolve $ref before merging if configured\n let resolvedSourceContent = sourceContent;\n if (shouldResolveRefs(inputPath, options.options)) {\n try {\n const json = JSON.parse(sourceContent);\n const { resolved } = resolveMintlifyRefs(json, inputPath);\n resolvedSourceContent = JSON.stringify(resolved, null, 2);\n } catch {\n // Fall through with original content\n }\n }\n return mergeJson(\n resolvedSourceContent,\n inputPath,\n options.options,\n [{ translatedContent, targetLocale: locale }],\n options.defaultLocale,\n options.locales\n )[0];\n } else {\n return mergeYaml(\n sourceContent,\n inputPath,\n options.options,\n [{ translatedContent, targetLocale: locale }],\n options.defaultLocale\n )[0];\n }\n}\n\n/**\n * Determines whether a source file should be skipped for schema re-merging.\n * @param inputPath - The path of the source file\n * @param options - The settings for the project\n * @returns True if the source file should be skipped for schema re-merging, false otherwise\n */\nfunction shouldSkipSourceFormatMerge(\n inputPath: string,\n options: Settings\n): boolean {\n for (const fileType of SUPPORTED_FILE_EXTENSIONS) {\n if (!hasNonIdentityFileFormatTransformForType(options, fileType)) continue;\n\n const transformedSourcePaths = options.files.resolvedPaths[fileType] || [];\n if (\n transformedSourcePaths.some(\n (sourcePath) => getRelative(sourcePath) === inputPath\n )\n ) {\n return true;\n }\n }\n\n return false;\n}\n\nexport type BatchedFiles = {\n branchId: string;\n fileId: string;\n versionId: string;\n locale: string;\n outputPath: string;\n inputPath: string;\n}[];\n\nexport type DownloadFileBatchResult = {\n successful: BatchedFiles;\n failed: BatchedFiles;\n skipped: BatchedFiles;\n};\n/**\n * Downloads multiple translation files in a single batch request\n * @param fileTracker - Current translation status keyed by file identity\n * @param files - Array of files to download with their output paths\n * @param options - Resolved CLI settings used for merge and lockfile behavior\n * @param forceDownload - Whether to bypass an up-to-date local translation\n * @param inlineLibrary - Inline runtime used to label the shared GTJSON file\n * @returns Object containing successful and failed file IDs\n */\nexport async function downloadFileBatch(\n fileTracker: FileStatusTracker,\n files: BatchedFiles,\n options: Settings,\n forceDownload: boolean = false,\n inlineLibrary?: InlineLibrary\n): Promise<DownloadFileBatchResult> {\n // Local record of what version was last downloaded for each fileName:locale\n const {\n data: downloadedVersions,\n entryMap,\n originalV1,\n } = readLockfile(options);\n let didUpdateDownloadedLock = false;\n\n // Create a map of requested file keys to the file object\n const requestedFileMap = new Map(\n files.map((file) => [\n `${file.branchId}:${file.fileId}:${file.versionId}:${file.locale}`,\n file,\n ])\n );\n const result: DownloadFileBatchResult = {\n successful: [],\n failed: [],\n skipped: [],\n };\n\n // Create a map of translationId to outputPath for easier lookup\n const outputPathMap = new Map(\n files.map((file) => [\n `${file.branchId}:${file.fileId}:${file.versionId}:${file.locale}`,\n file.outputPath,\n ])\n );\n\n try {\n // Download the files\n const responseData = await gt.downloadFileBatch(\n files.map((file) => ({\n fileId: file.fileId,\n branchId: file.branchId,\n versionId: file.versionId,\n locale: file.locale,\n }))\n );\n const downloadedFiles = responseData.files || [];\n\n // Process each file in the response\n for (const file of downloadedFiles) {\n const fileKey = `${file.branchId}:${file.fileId}:${file.versionId}:${file.locale}`;\n const requestedFile = requestedFileMap.get(fileKey);\n if (!requestedFile) {\n continue;\n }\n try {\n const outputPath = outputPathMap.get(fileKey);\n const fileProperties = fileTracker.completed.get(fileKey);\n\n if (!outputPath || !fileProperties) {\n logger.warn(`No input/output path found for file: ${fileKey}`);\n recordWarning(\n 'failed_download',\n fileKey,\n 'No input/output path found'\n );\n result.failed.push(requestedFile);\n continue;\n }\n\n const {\n fileId,\n versionId,\n locale,\n branchId,\n fileName: inputPath,\n } = fileProperties;\n\n // Ensure the directory exists\n const dir = path.dirname(outputPath);\n if (!fs.existsSync(dir)) {\n fs.mkdirSync(dir, { recursive: true });\n }\n // If a local translation already exists for the same source version, skip overwrite\n const existingEntry = entryMap.get(fileId);\n const downloadedTranslation =\n existingEntry?.versionId === versionId\n ? existingEntry.translations[locale]\n : undefined;\n const fileExists = fs.existsSync(outputPath);\n\n // Binary formats (e.g. LOTTIE zip bundles) carry base64 content that must\n // not go through the text merge/sort path — decode straight to bytes and\n // write. Skip only when an unchanged local translation already exists.\n if (isBinaryFileFormat(file.fileFormat)) {\n if (!forceDownload && fileExists && downloadedTranslation) {\n result.skipped.push(requestedFile);\n continue;\n }\n await fs.promises.writeFile(\n outputPath,\n Buffer.from(file.data, 'base64')\n );\n recordDownloaded(outputPath, {\n branchId,\n fileId,\n versionId,\n locale,\n inputPath,\n });\n result.successful.push(requestedFile);\n if (fileId && versionId && locale) {\n const entry = findOrCreateEntry(\n entryMap,\n downloadedVersions.entries,\n fileId,\n versionId\n );\n entry.fileName = inputPath;\n entry.translations[locale] = {\n updatedAt: new Date().toISOString(),\n // Store a relative path (matches the text path) so gt-lock.json\n // stays portable and the skip-on-re-download check matches.\n fileName: getRelative(outputPath),\n };\n // A staged entry tracks the whole file, not one locale. Keep it\n // staged until every configured locale has downloaded, so a partial\n // batch (locales still translating) doesn't abandon the rest.\n if (entry.staged) {\n entry.staged = !options.locales.every(\n (l) => entry.translations[l]\n );\n }\n didUpdateDownloadedLock = true;\n }\n continue;\n }\n\n // Composite schema files merge translations into the source file itself,\n // so outputPath always exists and the lock can't tell whether derived\n // split outputs (e.g. {locale}/docs.json) are still on disk. Always\n // merge fresh API data so derived files are regenerated every run;\n // local edits to translated output are preserved via `gt save-local`.\n const isInPlaceComposite = options.options?.jsonSchema\n ? !!validateJsonSchema(options.options, inputPath)?.composite\n : false;\n\n if (\n !forceDownload &&\n fileExists &&\n downloadedTranslation &&\n !isInPlaceComposite\n ) {\n // For schema-based files, re-merge with current source in case\n // non-translatable fields changed (skip the API download, not the merge)\n try {\n let existingContent = fs.readFileSync(outputPath, 'utf8');\n // Resolve $ref before extraction if configured\n if (shouldResolveRefs(outputPath, options.options)) {\n try {\n const json = JSON.parse(existingContent);\n const { resolved } = resolveMintlifyRefs(json, outputPath);\n existingContent = JSON.stringify(resolved, null, 2);\n } catch {\n // Fall through with original content\n }\n }\n const jsonExtracted = options.options?.jsonSchema\n ? extractJson(\n existingContent,\n inputPath,\n options.options,\n locale,\n options.defaultLocale\n )\n : null;\n const extracted =\n jsonExtracted ??\n (options.options?.yamlSchema\n ? extractYaml(existingContent, inputPath, options.options)\n : null);\n if (extracted) {\n const remerged = mergeWithSource(\n extracted,\n locale,\n inputPath,\n options\n );\n let remergedData = remerged;\n if (outputPath.endsWith('.json')) {\n try {\n remergedData = sortJsonString(remergedData);\n } catch {\n // Fall through with unsorted content\n }\n }\n if (remergedData !== existingContent) {\n await fs.promises.writeFile(outputPath, remergedData);\n }\n // Track for postprocessing (e.g. openapi path localization)\n // even when the API download was skipped\n recordRemerged(outputPath);\n }\n } catch (error) {\n // If re-merge fails, still count as skipped — not worth failing\n // the download, but surface it so missing output is diagnosable\n logger.warn(\n `Failed to re-merge existing translation for ${outputPath} (${locale}): ${error}`\n );\n }\n // Even when the local copy is current, the served content may\n // still be missing review-withheld components — keep reporting\n reportWithheldGtJsonComponents(\n file.fileFormat,\n file.data,\n fileProperties.componentCount,\n locale,\n inlineLibrary\n );\n result.skipped.push(requestedFile);\n continue;\n }\n let data = mergeWithSource(file.data, locale, inputPath, options);\n\n // Stable sort JSON keys for deterministic output\n if (file.fileFormat === 'GTJSON' || outputPath.endsWith('.json')) {\n try {\n data = sortJsonString(data);\n } catch (error) {\n logger.warn(`Failed to sort JSON file: ${file.id}: ` + error);\n }\n }\n\n // Write the file to disk\n await fs.promises.writeFile(outputPath, data);\n // Track as downloaded with metadata for downstream postprocessing\n recordDownloaded(outputPath, {\n branchId,\n fileId,\n versionId,\n locale,\n inputPath,\n });\n\n reportWithheldGtJsonComponents(\n file.fileFormat,\n data,\n fileProperties.componentCount,\n locale,\n inlineLibrary\n );\n\n result.successful.push(requestedFile);\n if (fileId && versionId && locale) {\n const entry = findOrCreateEntry(\n entryMap,\n downloadedVersions.entries,\n fileId,\n versionId\n );\n entry.fileName = inputPath;\n entry.translations[locale] = {\n updatedAt: new Date().toISOString(),\n fileName: getRelative(outputPath),\n };\n // A staged entry tracks the whole file, not one locale. Keep it\n // staged until every configured locale has downloaded, so a partial\n // batch (locales still translating) doesn't abandon the rest.\n if (entry.staged) {\n entry.staged = !options.locales.every((l) => entry.translations[l]);\n }\n didUpdateDownloadedLock = true;\n }\n } catch (error) {\n logger.error(`Error saving file ${fileKey}: ` + error);\n recordWarning(\n 'failed_download',\n fileKey,\n `Error saving file: ${error}`\n );\n result.failed.push(requestedFile);\n }\n }\n\n // Add any files that weren't in the response to the failed list\n const downloadedFileKeys = new Set(\n downloadedFiles.map(\n (file) =>\n `${file.branchId}:${file.fileId}:${file.versionId}:${file.locale}`\n )\n );\n for (const [fileKey, requestedFile] of requestedFileMap.entries()) {\n if (!downloadedFileKeys.has(fileKey)) {\n result.failed.push(requestedFile);\n }\n }\n\n // Persist any updates to the downloaded map at the end of a successful cycle\n if (didUpdateDownloadedLock) {\n writeLockfile(downloadedVersions, originalV1);\n didUpdateDownloadedLock = false;\n }\n return result;\n } catch (error) {\n logger.error(\n `An unexpected error occurred while downloading files: ` + error\n );\n }\n\n // Mark all files as failed if we get here\n result.failed = [...requestedFileMap.values()];\n if (didUpdateDownloadedLock) {\n writeLockfile(downloadedVersions, originalV1);\n }\n return result;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"downloadFileBatch.js","names":["fs","path"],"sources":["../../src/api/downloadFileBatch.ts"],"sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport { isBinaryFileFormat } from 'generaltranslation/types';\nimport { logger } from '../console/logger.js';\nimport { api } from '../utils/api.js';\nimport { Settings } from '../types/index.js';\nimport { validateJsonSchema } from '../formats/json/utils.js';\nimport { validateYamlSchema } from '../formats/yaml/utils.js';\nimport { mergeJson } from '../formats/json/mergeJson.js';\nimport { extractJson } from '../formats/json/extractJson.js';\nimport mergeYaml from '../formats/yaml/mergeYaml.js';\nimport { extractYaml } from '../formats/yaml/extractYaml.js';\nimport {\n resolveMintlifyRefs,\n shouldResolveRefs,\n} from '../utils/resolveMintlifyRefs.js';\nimport {\n readLockfile,\n writeLockfile,\n findOrCreateEntry,\n} from '../fs/config/downloadedVersions.js';\nimport { recordDownloaded, recordRemerged } from '../state/recentDownloads.js';\nimport { recordWarning } from '../state/translateWarnings.js';\nimport stringify from 'fast-json-stable-stringify';\nimport type { FileStatusTracker } from '../workflows/steps/PollJobsStep.js';\nimport { SUPPORTED_FILE_EXTENSIONS } from '../formats/files/supportedFiles.js';\nimport { hasNonIdentityFileFormatTransformForType } from '../formats/files/transformFormat.js';\nimport { getRelative } from '../fs/findFilepath.js';\nimport { encodeFileContent } from '../fs/fileContent.js';\nimport {\n getInlineElementsLabel,\n type InlineLibrary,\n} from '../types/libraries.js';\n\n/**\n * The platform withholds unapproved review-gated GTJSON components from\n * served output. Reports the delta between the source component count and\n * the served content so partially-served files don't read as fully\n * translated — for both fresh downloads and already-downloaded skips.\n */\nfunction reportWithheldGtJsonComponents(\n fileFormat: string | undefined,\n servedContent: string,\n componentCount: number | undefined,\n locale: string,\n inlineLibrary?: InlineLibrary\n): void {\n if (fileFormat !== 'GTJSON' || componentCount == null) return;\n const received = countGtJsonEntries(servedContent);\n if (received == null) return;\n const withheld = componentCount - received;\n if (withheld > 0) {\n recordWarning(\n 'pending_review',\n `<${getInlineElementsLabel(inlineLibrary)}>`,\n `${withheld} component translation(s) for locale ${locale} require review and are not approved yet`\n );\n }\n}\n\n/**\n * Counts entries in downloaded GTJSON output, tolerating both the flat\n * public shape and a wrapped { type, data } shape.\n */\nfunction countGtJsonEntries(content: string): number | undefined {\n try {\n const parsed: unknown = JSON.parse(content);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n return undefined;\n }\n const entries =\n 'type' in parsed &&\n parsed.type === 'GTJSON' &&\n 'data' in parsed &&\n parsed.data &&\n typeof parsed.data === 'object' &&\n !Array.isArray(parsed.data)\n ? parsed.data\n : parsed;\n return Object.keys(entries).length;\n } catch {\n return undefined;\n }\n}\n\nfunction sortJsonString(data: string): string {\n const sortedData = stringify(JSON.parse(data));\n return JSON.stringify(JSON.parse(sortedData), null, 2);\n}\n\n/**\n * Merges translated content with the current source file for schema-based formats.\n */\nfunction mergeWithSource(\n translatedContent: string,\n locale: string,\n inputPath: string,\n options: Settings\n): string {\n if (shouldSkipSourceFormatMerge(inputPath, options)) {\n return translatedContent;\n }\n if (!options.options) return translatedContent;\n\n const jsonSchema = options.options.jsonSchema\n ? validateJsonSchema(options.options, inputPath)\n : null;\n const yamlSchema =\n !jsonSchema && options.options.yamlSchema\n ? validateYamlSchema(options.options, inputPath)\n : null;\n\n if (!jsonSchema && !yamlSchema) return translatedContent;\n\n const sourceContent = fs.readFileSync(inputPath, 'utf8');\n if (!sourceContent) return translatedContent;\n\n if (jsonSchema) {\n // Resolve $ref before merging if configured\n let resolvedSourceContent = sourceContent;\n if (shouldResolveRefs(inputPath, options.options)) {\n try {\n const json = JSON.parse(sourceContent);\n const { resolved } = resolveMintlifyRefs(json, inputPath);\n resolvedSourceContent = JSON.stringify(resolved, null, 2);\n } catch {\n // Fall through with original content\n }\n }\n return mergeJson(\n resolvedSourceContent,\n inputPath,\n options.options,\n [{ translatedContent, targetLocale: locale }],\n options.defaultLocale,\n options.locales\n )[0];\n } else {\n return mergeYaml(\n sourceContent,\n inputPath,\n options.options,\n [{ translatedContent, targetLocale: locale }],\n options.defaultLocale\n )[0];\n }\n}\n\n/**\n * Determines whether a source file should be skipped for schema re-merging.\n * @param inputPath - The path of the source file\n * @param options - The settings for the project\n * @returns True if the source file should be skipped for schema re-merging, false otherwise\n */\nfunction shouldSkipSourceFormatMerge(\n inputPath: string,\n options: Settings\n): boolean {\n for (const fileType of SUPPORTED_FILE_EXTENSIONS) {\n if (!hasNonIdentityFileFormatTransformForType(options, fileType)) continue;\n\n const transformedSourcePaths = options.files.resolvedPaths[fileType] || [];\n if (\n transformedSourcePaths.some(\n (sourcePath) => getRelative(sourcePath) === inputPath\n )\n ) {\n return true;\n }\n }\n\n return false;\n}\n\nexport type BatchedFiles = {\n branchId: string;\n fileId: string;\n versionId: string;\n locale: string;\n outputPath: string;\n inputPath: string;\n}[];\n\nexport type DownloadFileBatchResult = {\n successful: BatchedFiles;\n failed: BatchedFiles;\n skipped: BatchedFiles;\n};\n/**\n * Downloads multiple translation files in a single batch request\n * @param fileTracker - Current translation status keyed by file identity\n * @param files - Array of files to download with their output paths\n * @param options - Resolved CLI settings used for merge and lockfile behavior\n * @param forceDownload - Whether to bypass an up-to-date local translation\n * @param inlineLibrary - Inline runtime used to label the shared GTJSON file\n * @returns Object containing successful and failed file IDs\n */\nexport async function downloadFileBatch(\n fileTracker: FileStatusTracker,\n files: BatchedFiles,\n options: Settings,\n forceDownload: boolean = false,\n inlineLibrary?: InlineLibrary\n): Promise<DownloadFileBatchResult> {\n // Local record of what version was last downloaded for each fileName:locale\n const {\n data: downloadedVersions,\n entryMap,\n originalV1,\n } = readLockfile(options);\n let didUpdateDownloadedLock = false;\n\n // Create a map of requested file keys to the file object\n const requestedFileMap = new Map(\n files.map((file) => [\n `${file.branchId}:${file.fileId}:${file.versionId}:${file.locale}`,\n file,\n ])\n );\n const result: DownloadFileBatchResult = {\n successful: [],\n failed: [],\n skipped: [],\n };\n\n // Create a map of translationId to outputPath for easier lookup\n const outputPathMap = new Map(\n files.map((file) => [\n `${file.branchId}:${file.fileId}:${file.versionId}:${file.locale}`,\n file.outputPath,\n ])\n );\n\n try {\n // Download the files\n const responseData = await api.downloadFileBatch(\n files.map((file) => ({\n fileId: file.fileId,\n branchId: file.branchId,\n versionId: file.versionId,\n locale: file.locale,\n }))\n );\n const downloadedFiles = responseData.files || [];\n\n // Process each file in the response\n for (const file of downloadedFiles) {\n const fileKey = `${file.branchId}:${file.fileId}:${file.versionId}:${file.locale}`;\n const requestedFile = requestedFileMap.get(fileKey);\n if (!requestedFile) {\n continue;\n }\n try {\n const outputPath = outputPathMap.get(fileKey);\n const fileProperties = fileTracker.completed.get(fileKey);\n\n if (!outputPath || !fileProperties) {\n logger.warn(`No input/output path found for file: ${fileKey}`);\n recordWarning(\n 'failed_download',\n fileKey,\n 'No input/output path found'\n );\n result.failed.push(requestedFile);\n continue;\n }\n\n const {\n fileId,\n versionId,\n locale,\n branchId,\n fileName: inputPath,\n } = fileProperties;\n\n // Ensure the directory exists\n const dir = path.dirname(outputPath);\n if (!fs.existsSync(dir)) {\n fs.mkdirSync(dir, { recursive: true });\n }\n // If a local translation already exists for the same source version, skip overwrite\n const existingEntry = entryMap.get(fileId);\n const downloadedTranslation =\n existingEntry?.versionId === versionId\n ? existingEntry.translations[locale]\n : undefined;\n const fileExists = fs.existsSync(outputPath);\n\n // Binary formats (e.g. LOTTIE zip bundles) carry base64 content that must\n // not go through the text merge/sort path — decode straight to bytes and\n // write. Skip only when an unchanged local translation already exists.\n if (isBinaryFileFormat(file.fileFormat)) {\n if (!forceDownload && fileExists && downloadedTranslation) {\n result.skipped.push(requestedFile);\n continue;\n }\n await fs.promises.writeFile(\n outputPath,\n Buffer.from(file.data, 'base64')\n );\n recordDownloaded(outputPath, {\n branchId,\n fileId,\n versionId,\n locale,\n fileFormat: file.fileFormat,\n inputPath,\n });\n result.successful.push(requestedFile);\n if (fileId && versionId && locale) {\n const entry = findOrCreateEntry(\n entryMap,\n downloadedVersions.entries,\n fileId,\n versionId\n );\n entry.fileName = inputPath;\n entry.translations[locale] = {\n updatedAt: new Date().toISOString(),\n // Store a relative path (matches the text path) so gt-lock.json\n // stays portable and the skip-on-re-download check matches.\n fileName: getRelative(outputPath),\n };\n // A staged entry tracks the whole file, not one locale. Keep it\n // staged until every configured locale has downloaded, so a partial\n // batch (locales still translating) doesn't abandon the rest.\n if (entry.staged) {\n entry.staged = !options.locales.every(\n (l) => entry.translations[l]\n );\n }\n didUpdateDownloadedLock = true;\n }\n continue;\n }\n\n // Composite schema files merge translations into the source file itself,\n // so outputPath always exists and the lock can't tell whether derived\n // split outputs (e.g. {locale}/docs.json) are still on disk. Always\n // merge fresh API data so derived files are regenerated every run;\n // local edits to translated output are preserved via `gt save-local`.\n const isInPlaceComposite = options.options?.jsonSchema\n ? !!validateJsonSchema(options.options, inputPath)?.composite\n : false;\n\n if (\n !forceDownload &&\n fileExists &&\n downloadedTranslation &&\n !isInPlaceComposite\n ) {\n // For schema-based files, re-merge with current source in case\n // non-translatable fields changed (skip the API download, not the merge)\n try {\n let existingContent = fs.readFileSync(outputPath, 'utf8');\n // Resolve $ref before extraction if configured\n if (shouldResolveRefs(outputPath, options.options)) {\n try {\n const json = JSON.parse(existingContent);\n const { resolved } = resolveMintlifyRefs(json, outputPath);\n existingContent = JSON.stringify(resolved, null, 2);\n } catch {\n // Fall through with original content\n }\n }\n const jsonExtracted = options.options?.jsonSchema\n ? extractJson(\n existingContent,\n inputPath,\n options.options,\n locale,\n options.defaultLocale\n )\n : null;\n const extracted =\n jsonExtracted ??\n (options.options?.yamlSchema\n ? extractYaml(existingContent, inputPath, options.options)\n : null);\n if (extracted) {\n const remerged = mergeWithSource(\n extracted,\n locale,\n inputPath,\n options\n );\n let remergedData = remerged;\n if (outputPath.endsWith('.json')) {\n try {\n remergedData = sortJsonString(remergedData);\n } catch {\n // Fall through with unsorted content\n }\n }\n if (remergedData !== existingContent) {\n await fs.promises.writeFile(outputPath, remergedData);\n }\n // Track for postprocessing (e.g. openapi path localization)\n // even when the API download was skipped\n recordRemerged(outputPath);\n }\n } catch (error) {\n // If re-merge fails, still count as skipped — not worth failing\n // the download, but surface it so missing output is diagnosable\n logger.warn(\n `Failed to re-merge existing translation for ${outputPath} (${locale}): ${error}`\n );\n }\n // Even when the local copy is current, the served content may\n // still be missing review-withheld components — keep reporting\n reportWithheldGtJsonComponents(\n file.fileFormat,\n file.data,\n fileProperties.componentCount,\n locale,\n inlineLibrary\n );\n result.skipped.push(requestedFile);\n continue;\n }\n let data = mergeWithSource(file.data, locale, inputPath, options);\n\n // Stable sort JSON keys for deterministic output\n if (file.fileFormat === 'GTJSON' || outputPath.endsWith('.json')) {\n try {\n data = sortJsonString(data);\n } catch (error) {\n logger.warn(`Failed to sort JSON file: ${file.id}: ` + error);\n }\n }\n\n // Write the file to disk\n await fs.promises.writeFile(\n outputPath,\n encodeFileContent(data, file.fileFormat, inputPath, outputPath)\n );\n // Track as downloaded with metadata for downstream postprocessing\n recordDownloaded(outputPath, {\n branchId,\n fileId,\n versionId,\n locale,\n fileFormat: file.fileFormat,\n inputPath,\n });\n\n reportWithheldGtJsonComponents(\n file.fileFormat,\n data,\n fileProperties.componentCount,\n locale,\n inlineLibrary\n );\n\n result.successful.push(requestedFile);\n if (fileId && versionId && locale) {\n const entry = findOrCreateEntry(\n entryMap,\n downloadedVersions.entries,\n fileId,\n versionId\n );\n entry.fileName = inputPath;\n entry.translations[locale] = {\n updatedAt: new Date().toISOString(),\n fileName: getRelative(outputPath),\n };\n // A staged entry tracks the whole file, not one locale. Keep it\n // staged until every configured locale has downloaded, so a partial\n // batch (locales still translating) doesn't abandon the rest.\n if (entry.staged) {\n entry.staged = !options.locales.every((l) => entry.translations[l]);\n }\n didUpdateDownloadedLock = true;\n }\n } catch (error) {\n logger.error(`Error saving file ${fileKey}: ` + error);\n recordWarning(\n 'failed_download',\n fileKey,\n `Error saving file: ${error}`\n );\n result.failed.push(requestedFile);\n }\n }\n\n // Add any files that weren't in the response to the failed list\n const downloadedFileKeys = new Set(\n downloadedFiles.map(\n (file) =>\n `${file.branchId}:${file.fileId}:${file.versionId}:${file.locale}`\n )\n );\n for (const [fileKey, requestedFile] of requestedFileMap.entries()) {\n if (!downloadedFileKeys.has(fileKey)) {\n result.failed.push(requestedFile);\n }\n }\n\n // Persist any updates to the downloaded map at the end of a successful cycle\n if (didUpdateDownloadedLock) {\n writeLockfile(downloadedVersions, originalV1);\n didUpdateDownloadedLock = false;\n }\n return result;\n } catch (error) {\n logger.error(\n `An unexpected error occurred while downloading files: ` + error\n );\n }\n\n // Mark all files as failed if we get here\n result.failed = [...requestedFileMap.values()];\n if (didUpdateDownloadedLock) {\n writeLockfile(downloadedVersions, originalV1);\n }\n return result;\n}\n"],"mappings":"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"}
@@ -1,7 +1,7 @@
1
1
  import { logger } from "../console/logger.js";
2
- import { gt } from "../utils/gt.js";
3
2
  import { logErrorAndExit } from "../console/logging.js";
4
3
  import { branchResolutionError } from "../console/index.js";
4
+ import { api } from "../utils/api.js";
5
5
  import { BranchStep } from "../workflows/steps/BranchStep.js";
6
6
  import { runPublishWorkflow } from "../workflows/publish.js";
7
7
  import { aggregateFiles } from "../formats/files/aggregateFiles.js";
@@ -16,7 +16,7 @@ async function saveLocalEdits(settings) {
16
16
  if (!settings.files) return;
17
17
  const { files, publishMap } = await aggregateFiles(settings);
18
18
  if (!files.length) return;
19
- const branchResult = await new BranchStep(gt, settings).run();
19
+ const branchResult = await new BranchStep(api, settings).run();
20
20
  if (!branchResult) return logErrorAndExit(branchResolutionError);
21
21
  const uploads = files.map((file) => ({
22
22
  fileName: file.fileName,
@@ -1 +1 @@
1
- {"version":3,"file":"saveLocalEdits.js","names":[],"sources":["../../src/api/saveLocalEdits.ts"],"sourcesContent":["import { Settings } from '../types/index.js';\nimport { aggregateFiles } from '../formats/files/aggregateFiles.js';\nimport { collectAndSendUserEditDiffs } from './collectUserEditDiffs.js';\nimport { gt } from '../utils/gt.js';\nimport { BranchStep } from '../workflows/steps/BranchStep.js';\nimport { logErrorAndExit } from '../console/logging.js';\nimport { logger } from '../console/logger.js';\nimport { branchResolutionError } from '../console/index.js';\nimport type { FileReference } from 'generaltranslation/types';\nimport chalk from 'chalk';\nimport { runPublishWorkflow } from '../workflows/publish.js';\n\n/**\n * Uploads current source files to obtain file references, then collects and sends\n * diffs for all locales based on last downloaded versions. Does not enqueue translations.\n */\nexport async function saveLocalEdits(settings: Settings): Promise<void> {\n if (!settings.files) return;\n\n // Collect current files from config\n const { files, publishMap } = await aggregateFiles(settings);\n if (!files.length) return;\n\n // run branch query to get branch id\n // Run the branch step\n const branchStep = new BranchStep(gt, settings);\n const branchResult = await branchStep.run();\n if (!branchResult) {\n return logErrorAndExit(branchResolutionError);\n }\n\n const uploads = files.map((file) => ({\n fileName: file.fileName,\n fileFormat: file.fileFormat,\n branchId: branchResult.currentBranch.id,\n fileId: file.fileId,\n versionId: file.versionId,\n })) satisfies FileReference[];\n\n const spinner = logger.createSpinner('dots');\n spinner.start('Saving local edits...');\n\n const hadDiffs = await collectAndSendUserEditDiffs(uploads, settings);\n spinner.stop(chalk.green('Local edits saved successfully'));\n\n // Publish files to CDN if diffs were detected and publish config exists\n if (hadDiffs) {\n await runPublishWorkflow(\n files,\n publishMap,\n branchResult.currentBranch.id,\n settings\n );\n }\n}\n"],"mappings":";;;;;;;;;;;;;;AAgBA,eAAsB,eAAe,UAAmC;AACtE,KAAI,CAAC,SAAS,MAAO;CAGrB,MAAM,EAAE,OAAO,eAAe,MAAM,eAAe,SAAS;AAC5D,KAAI,CAAC,MAAM,OAAQ;CAKnB,MAAM,eAAe,MAAM,IADJ,WAAW,IAAI,SACD,CAAC,KAAK;AAC3C,KAAI,CAAC,aACH,QAAO,gBAAgB,sBAAsB;CAG/C,MAAM,UAAU,MAAM,KAAK,UAAU;EACnC,UAAU,KAAK;EACf,YAAY,KAAK;EACjB,UAAU,aAAa,cAAc;EACrC,QAAQ,KAAK;EACb,WAAW,KAAK;EACjB,EAAE;CAEH,MAAM,UAAU,OAAO,cAAc,OAAO;AAC5C,SAAQ,MAAM,wBAAwB;CAEtC,MAAM,WAAW,MAAM,4BAA4B,SAAS,SAAS;AACrE,SAAQ,KAAK,MAAM,MAAM,iCAAiC,CAAC;AAG3D,KAAI,SACF,OAAM,mBACJ,OACA,YACA,aAAa,cAAc,IAC3B,SACD"}
1
+ {"version":3,"file":"saveLocalEdits.js","names":[],"sources":["../../src/api/saveLocalEdits.ts"],"sourcesContent":["import { Settings } from '../types/index.js';\nimport { aggregateFiles } from '../formats/files/aggregateFiles.js';\nimport { collectAndSendUserEditDiffs } from './collectUserEditDiffs.js';\nimport { api } from '../utils/api.js';\nimport { BranchStep } from '../workflows/steps/BranchStep.js';\nimport { logErrorAndExit } from '../console/logging.js';\nimport { logger } from '../console/logger.js';\nimport { branchResolutionError } from '../console/index.js';\nimport type { FileReference } from 'generaltranslation/types';\nimport chalk from 'chalk';\nimport { runPublishWorkflow } from '../workflows/publish.js';\n\n/**\n * Uploads current source files to obtain file references, then collects and sends\n * diffs for all locales based on last downloaded versions. Does not enqueue translations.\n */\nexport async function saveLocalEdits(settings: Settings): Promise<void> {\n if (!settings.files) return;\n\n // Collect current files from config\n const { files, publishMap } = await aggregateFiles(settings);\n if (!files.length) return;\n\n // run branch query to get branch id\n // Run the branch step\n const branchStep = new BranchStep(api, settings);\n const branchResult = await branchStep.run();\n if (!branchResult) {\n return logErrorAndExit(branchResolutionError);\n }\n\n const uploads = files.map((file) => ({\n fileName: file.fileName,\n fileFormat: file.fileFormat,\n branchId: branchResult.currentBranch.id,\n fileId: file.fileId,\n versionId: file.versionId,\n })) satisfies FileReference[];\n\n const spinner = logger.createSpinner('dots');\n spinner.start('Saving local edits...');\n\n const hadDiffs = await collectAndSendUserEditDiffs(uploads, settings);\n spinner.stop(chalk.green('Local edits saved successfully'));\n\n // Publish files to CDN if diffs were detected and publish config exists\n if (hadDiffs) {\n await runPublishWorkflow(\n files,\n publishMap,\n branchResult.currentBranch.id,\n settings\n );\n }\n}\n"],"mappings":";;;;;;;;;;;;;;AAgBA,eAAsB,eAAe,UAAmC;AACtE,KAAI,CAAC,SAAS,MAAO;CAGrB,MAAM,EAAE,OAAO,eAAe,MAAM,eAAe,SAAS;AAC5D,KAAI,CAAC,MAAM,OAAQ;CAKnB,MAAM,eAAe,MAAM,IADJ,WAAW,KAAK,SACF,CAAC,KAAK;AAC3C,KAAI,CAAC,aACH,QAAO,gBAAgB,sBAAsB;CAG/C,MAAM,UAAU,MAAM,KAAK,UAAU;EACnC,UAAU,KAAK;EACf,YAAY,KAAK;EACjB,UAAU,aAAa,cAAc;EACrC,QAAQ,KAAK;EACb,WAAW,KAAK;EACjB,EAAE;CAEH,MAAM,UAAU,OAAO,cAAc,OAAO;AAC5C,SAAQ,MAAM,wBAAwB;CAEtC,MAAM,WAAW,MAAM,4BAA4B,SAAS,SAAS;AACrE,SAAQ,KAAK,MAAM,MAAM,iCAAiC,CAAC;AAG3D,KAAI,SACF,OAAM,mBACJ,OACA,YACA,aAAa,cAAc,IAC3B,SACD"}
@@ -49,6 +49,8 @@ export declare class BaseCLI {
49
49
  protected setupDownloadCommand(): void;
50
50
  protected setupTranslateCommand(): void;
51
51
  protected setupSendDiffsCommand(): void;
52
+ protected setupApiCommand(): void;
53
+ protected setupProjectCommands(): void;
52
54
  protected setupGitCommand(): void;
53
55
  protected resolveGitSetupOmitConfigIds(options: GitSetupOptions, settings: Settings): Promise<boolean>;
54
56
  protected handleSetupProject(initOptions: TranslateFlags): Promise<void>;
package/dist/cli/base.js CHANGED
@@ -8,6 +8,7 @@ import findFilepath from "../fs/findFilepath.js";
8
8
  import { lottieTranslateError } from "../console/index.js";
9
9
  import { loadConfig } from "../fs/config/loadConfig.js";
10
10
  import { FILE_EXT_TO_EXT_LABEL } from "../formats/files/supportedFiles.js";
11
+ import { api } from "../utils/api.js";
11
12
  import { generateSettings } from "../config/generateSettings.js";
12
13
  import { createOrUpdateConfig } from "../fs/config/setupConfig.js";
13
14
  import { getPackageManager } from "../utils/packageManager.js";
@@ -17,6 +18,7 @@ import { handleSetupReactCommand } from "../setup/wizard.js";
17
18
  import { getDesiredLocales } from "../setup/userInput.js";
18
19
  import { areCredentialsSet, retrieveCredentials, setCredentials } from "../utils/credentials.js";
19
20
  import { clearWarnings } from "../state/translateWarnings.js";
21
+ import { hasValidApiKey, hasValidCredentials } from "./commands/utils/validation.js";
20
22
  import { upload } from "./commands/upload.js";
21
23
  import { attachSharedFlags, attachTranslateFlags } from "./flags.js";
22
24
  import updateConfig from "../fs/config/updateConfig.js";
@@ -37,10 +39,11 @@ import { runMergeDriver } from "../git/mergeDrivers.js";
37
39
  import { setupGitMergeDrivers } from "../git/setupMergeDrivers.js";
38
40
  import { warnReactPackageCompatibility } from "../utils/reactPackageCompatibility.js";
39
41
  import { setupViteSPA } from "../setup/setupViteSPA.js";
42
+ import { handleApiCommand } from "./commands/api.js";
40
43
  import chalk from "chalk";
41
44
  import path from "node:path";
42
45
  import fs from "node:fs";
43
- import { createDiagnosticMessage } from "generaltranslation/internal";
46
+ import { createDiagnosticMessage, formatDiagnosticErrorDetails } from "generaltranslation/internal";
44
47
  import { manifestDirectlyDeclaresGTVue } from "@generaltranslation/vue-extractor/integration";
45
48
  //#region src/cli/base.ts
46
49
  const ID_COMPATIBILITY_WARNING_COMMANDS = new Set([
@@ -59,6 +62,14 @@ const workspaceRootSetupError = createDiagnosticMessage({
59
62
  why: "GT must be configured in the specific app you want to localize",
60
63
  fix: "Change to that app's directory and rerun `npx gt@latest`"
61
64
  });
65
+ function createProjectCommandError(whatHappened, error) {
66
+ return createDiagnosticMessage({
67
+ source: "gt",
68
+ severity: "Error",
69
+ whatHappened,
70
+ details: formatDiagnosticErrorDetails(error)
71
+ });
72
+ }
62
73
  const electronSetupError = createDiagnosticMessage({
63
74
  source: "gt",
64
75
  severity: "Error",
@@ -94,6 +105,8 @@ var BaseCLI = class {
94
105
  this.setupUploadCommand();
95
106
  this.setupLoginCommand();
96
107
  this.setupSendDiffsCommand();
108
+ this.setupApiCommand();
109
+ this.setupProjectCommands();
97
110
  this.setupGitCommand();
98
111
  }
99
112
  init() {
@@ -154,10 +167,40 @@ var BaseCLI = class {
154
167
  setupSendDiffsCommand() {
155
168
  attachSharedFlags(this.program.command("save-local").description("Save local edits for all configured files by sending diffs (no translation enqueued)")).option("--publish", "Publish translations to the CDN", false).action(async (initOptions) => {
156
169
  displayHeader("Saving local edits...");
157
- await saveLocalEdits(await generateSettings(initOptions, void 0, { requireConfig: true }));
170
+ const settings = await generateSettings(initOptions, void 0, { requireConfig: true });
171
+ if (!hasValidCredentials(settings)) return exitSync(1);
172
+ await saveLocalEdits(settings);
158
173
  logger.endCommand("Saved local edits");
159
174
  });
160
175
  }
176
+ setupApiCommand() {
177
+ attachSharedFlags(this.program.command("api [endpoint]").description("Make an authenticated request to the GT API").option("-X, --method <method>", "HTTP method", "GET").option("--input <file>", "Request body file, or - for standard input").option("-H, --header <header>", "Request header in \"Key: Value\" format", (header, headers) => [...headers ?? [], header]).option("-i, --include", "Include response status and headers").option("--spec", "Print the bundled OpenAPI specification")).action((endpoint, options) => handleApiCommand(endpoint, options));
178
+ }
179
+ setupProjectCommands() {
180
+ const projectCommand = this.program.command("project").description("Manage General Translation projects");
181
+ attachSharedFlags(projectCommand.command("create").description("Create a General Translation project using an organization API key").requiredOption("--name <name>", "Project name").requiredOption("--default-locale <locale>", "Project default locale").option("--cdn-enabled", "Enable CDN delivery", false)).action(async (options) => {
182
+ try {
183
+ if (!hasValidApiKey(await generateSettings(options))) return exitSync(1);
184
+ const { project } = await api.createProject({
185
+ name: options.name,
186
+ defaultLocale: options.defaultLocale,
187
+ cdnEnabled: options.cdnEnabled
188
+ });
189
+ logger.info(`Created ${project.name} (${project.id})`);
190
+ } catch (error) {
191
+ return logErrorAndExit(createProjectCommandError("Failed to create the project", error));
192
+ }
193
+ });
194
+ attachSharedFlags(projectCommand.command("status").description("Check the status of a project setup job").argument("<job-id>", "Setup job ID")).action(async (jobId, options) => {
195
+ try {
196
+ if (!hasValidCredentials(await generateSettings(options))) return exitSync(1);
197
+ const [status] = await api.checkJobStatus([jobId]);
198
+ logger.info(status?.status ?? "unknown");
199
+ } catch (error) {
200
+ return logErrorAndExit(createProjectCommandError("Failed to check the project setup status", error));
201
+ }
202
+ });
203
+ }
161
204
  setupGitCommand() {
162
205
  const gitCommand = this.program.command("git").description("Configure Git integrations for General Translation");
163
206
  gitCommand.command("setup").description("Set up GT merge drivers for generated translation files").option("-c, --config <path>", "Filepath to config file, by default gt.config.json", findFilepath(["gt.config.json"])).option("--dry-run", "Print changes without writing files", false).option("--omit-config-ids", "Persist omitConfigIds and remove generated config IDs").option("--driver-command <command>", "Command Git should use to invoke gt, e.g. \"pnpm exec gt\"").action(async (options) => {