gtx-cli 2.3.15 → 2.4.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # gtx-cli
2
2
 
3
+ ## 2.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#751](https://github.com/generaltranslation/gt/pull/751) [`7114780`](https://github.com/generaltranslation/gt/commit/71147803bf3e4cf21556ffb9b5f77756e283a32a) Thanks [@SamEggert](https://github.com/SamEggert)! - transform for yaml files -- retrieve file format in downloadFileBatch
8
+
9
+ - Updated dependencies [[`7114780`](https://github.com/generaltranslation/gt/commit/71147803bf3e4cf21556ffb9b5f77756e283a32a)]:
10
+ - generaltranslation@7.7.1
11
+
12
+ ## 2.4.0
13
+
14
+ ### Minor Changes
15
+
16
+ - [#745](https://github.com/generaltranslation/gt/pull/745) [`5208937`](https://github.com/generaltranslation/gt/commit/520893719480b40774ccd749fe73727cf490f46c) Thanks [@fernando-aviles](https://github.com/fernando-aviles)! - Adding local translation editing. Local user edits to translation will now be saved and used to inform future translations of the same file.
17
+
18
+ ### Patch Changes
19
+
20
+ - Updated dependencies [[`5208937`](https://github.com/generaltranslation/gt/commit/520893719480b40774ccd749fe73727cf490f46c)]:
21
+ - generaltranslation@7.7.0
22
+
3
23
  ## 2.3.15
4
24
 
5
25
  ### Patch Changes
@@ -225,6 +225,7 @@ async function checkTranslationDeployment(fileQueryData, downloadStatus, spinner
225
225
  outputPath,
226
226
  locale,
227
227
  fileLocale: `${fileName}:${locale}`,
228
+ fileId: translation.fileId,
228
229
  versionId: versionMap.get(`${fileName}:${locale}`),
229
230
  };
230
231
  })
@@ -0,0 +1,14 @@
1
+ import { Settings } from '../types/index.js';
2
+ type UploadedFileRef = {
3
+ fileId: string;
4
+ versionId: string;
5
+ fileName: string;
6
+ };
7
+ /**
8
+ * Collects local user edits by diffing the latest downloaded server translation version
9
+ * against the current local translation file, and submits the diffs upstream.
10
+ *
11
+ * Must run before enqueueing new translations so rules are available to the generator.
12
+ */
13
+ export declare function collectAndSendUserEditDiffs(uploadedFiles: UploadedFileRef[], settings: Settings): Promise<void>;
14
+ export {};
@@ -0,0 +1,157 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { getDownloadedVersions } from '../fs/config/downloadedVersions.js';
4
+ import { createFileMapping } from '../formats/files/fileMapping.js';
5
+ import { getGitUnifiedDiff } from '../utils/gitDiff.js';
6
+ import { sendUserEditDiffs } from './sendUserEdits.js';
7
+ import { gt } from '../utils/gt.js';
8
+ const MAX_DIFF_BATCH_BYTES = 1_500_000;
9
+ const MAX_DOWNLOAD_BATCH = 100;
10
+ /**
11
+ * Collects local user edits by diffing the latest downloaded server translation version
12
+ * against the current local translation file, and submits the diffs upstream.
13
+ *
14
+ * Must run before enqueueing new translations so rules are available to the generator.
15
+ */
16
+ export async function collectAndSendUserEditDiffs(uploadedFiles, settings) {
17
+ if (!settings.files)
18
+ return;
19
+ const { resolvedPaths, placeholderPaths, transformPaths } = settings.files;
20
+ const fileMapping = createFileMapping(resolvedPaths, placeholderPaths, transformPaths, settings.locales, settings.defaultLocale);
21
+ const downloadedVersions = getDownloadedVersions(settings.configDirectory);
22
+ const tempDir = path.join(settings.configDirectory, 'tmp');
23
+ if (!fs.existsSync(tempDir))
24
+ fs.mkdirSync(tempDir, { recursive: true });
25
+ const candidates = [];
26
+ for (const uploadedFile of uploadedFiles) {
27
+ for (const locale of settings.locales) {
28
+ const resolvedLocale = gt.resolveAliasLocale(locale);
29
+ const outputPath = fileMapping[locale]?.[uploadedFile.fileName] ?? null;
30
+ if (!outputPath)
31
+ continue;
32
+ if (!fs.existsSync(outputPath))
33
+ continue;
34
+ const lockKeyById = uploadedFile.fileId
35
+ ? `${uploadedFile.fileId}:${resolvedLocale}`
36
+ : null;
37
+ const lockKeyByName = `${uploadedFile.fileName}:${resolvedLocale}`;
38
+ const lockEntry = (lockKeyById && downloadedVersions.entries[lockKeyById]) ||
39
+ downloadedVersions.entries[lockKeyByName];
40
+ const versionId = lockEntry?.versionId;
41
+ if (!versionId)
42
+ continue;
43
+ candidates.push({
44
+ fileName: uploadedFile.fileName,
45
+ fileId: uploadedFile.fileId,
46
+ versionId,
47
+ locale: resolvedLocale,
48
+ outputPath,
49
+ });
50
+ }
51
+ }
52
+ const collectedDiffs = [];
53
+ if (candidates.length > 0) {
54
+ const fileQueryData = candidates.map((c) => ({
55
+ versionId: c.versionId,
56
+ fileName: c.fileName,
57
+ locale: c.locale,
58
+ }));
59
+ // Single batched check to obtain translation IDs
60
+ const checkResponse = await gt.checkFileTranslations(fileQueryData);
61
+ const translations = (checkResponse?.translations || []).filter((t) => t && t.isReady && t.id && t.fileName && t.locale);
62
+ // Map fileName:resolvedLocale -> translationId
63
+ const idByKey = new Map();
64
+ for (const t of translations) {
65
+ const resolved = gt.resolveAliasLocale(t.locale);
66
+ idByKey.set(`${t.fileName}:${resolved}`, t.id);
67
+ }
68
+ // Collect translation IDs in batches and download contents
69
+ const ids = [];
70
+ for (const c of candidates) {
71
+ const id = idByKey.get(`${c.fileName}:${c.locale}`);
72
+ if (id)
73
+ ids.push(id);
74
+ }
75
+ // Helper to chunk array
76
+ function chunk(arr, size) {
77
+ const res = [];
78
+ for (let i = 0; i < arr.length; i += size)
79
+ res.push(arr.slice(i, i + size));
80
+ return res;
81
+ }
82
+ const serverContentByKey = new Map();
83
+ for (const idChunk of chunk(ids, MAX_DOWNLOAD_BATCH)) {
84
+ try {
85
+ const resp = await gt.downloadFileBatch(idChunk);
86
+ const files = resp?.files || [];
87
+ for (const f of files) {
88
+ // Find corresponding candidate key via idByKey reverse lookup
89
+ for (const [key, id] of idByKey.entries()) {
90
+ if (id === f.id) {
91
+ serverContentByKey.set(key, f.data);
92
+ break;
93
+ }
94
+ }
95
+ }
96
+ }
97
+ catch {
98
+ // Ignore chunk failures; proceed with what we have
99
+ }
100
+ }
101
+ // Compute diffs using fetched server contents
102
+ for (const c of candidates) {
103
+ const key = `${c.fileName}:${c.locale}`;
104
+ const serverContent = serverContentByKey.get(key);
105
+ if (!serverContent)
106
+ continue;
107
+ try {
108
+ const safeName = Buffer.from(`${c.fileName}:${c.locale}`)
109
+ .toString('base64')
110
+ .replace(/=+$/g, '');
111
+ const tempServerFile = path.join(tempDir, `${safeName}.server`);
112
+ await fs.promises.writeFile(tempServerFile, serverContent, 'utf8');
113
+ const diff = await getGitUnifiedDiff(tempServerFile, c.outputPath);
114
+ try {
115
+ await fs.promises.unlink(tempServerFile);
116
+ }
117
+ catch { }
118
+ if (diff && diff.trim().length > 0) {
119
+ const localContent = await fs.promises.readFile(c.outputPath, 'utf8');
120
+ collectedDiffs.push({
121
+ fileName: c.fileName,
122
+ locale: c.locale,
123
+ diff,
124
+ versionId: c.versionId,
125
+ fileId: c.fileId,
126
+ localContent,
127
+ });
128
+ }
129
+ }
130
+ catch {
131
+ // Ignore failures for this file
132
+ }
133
+ }
134
+ }
135
+ if (collectedDiffs.length > 0) {
136
+ // Batch by payload size
137
+ const maxBatchBytes = MAX_DIFF_BATCH_BYTES;
138
+ const batches = [];
139
+ let currentBatch = [];
140
+ for (const d of collectedDiffs) {
141
+ const tentative = [...currentBatch, d];
142
+ const bytes = Buffer.byteLength(JSON.stringify({ projectId: settings.projectId, diffs: tentative }), 'utf8');
143
+ if (bytes > maxBatchBytes && currentBatch.length > 0) {
144
+ batches.push(currentBatch);
145
+ currentBatch = [d];
146
+ }
147
+ else {
148
+ currentBatch = tentative;
149
+ }
150
+ }
151
+ if (currentBatch.length > 0)
152
+ batches.push(currentBatch);
153
+ for (const batch of batches) {
154
+ await sendUserEditDiffs(batch, settings);
155
+ }
156
+ }
157
+ }
@@ -8,6 +8,7 @@ import { mergeJson } from '../formats/json/mergeJson.js';
8
8
  import mergeYaml from '../formats/yaml/mergeYaml.js';
9
9
  import { getDownloadedVersions, saveDownloadedVersions, } from '../fs/config/downloadedVersions.js';
10
10
  import { recordDownloaded } from '../state/recentDownloads.js';
11
+ import stringify from 'fast-json-stable-stringify';
11
12
  /**
12
13
  * Downloads multiple translation files in a single batch request
13
14
  * @param files - Array of files to download with their output paths
@@ -92,10 +93,22 @@ export async function downloadFileBatch(files, options, maxRetries = 3, retryDel
92
93
  translatedContent: file.data,
93
94
  targetLocale: locale,
94
95
  },
95
- ])[0];
96
+ ], options.defaultLocale)[0];
96
97
  }
97
98
  }
98
99
  }
100
+ // If the file is a GTJSON file, stable sort the order and format the data
101
+ if (file.fileFormat === 'GTJSON') {
102
+ try {
103
+ const jsonData = JSON.parse(data);
104
+ const sortedData = stringify(jsonData); // stably sort with fast-json-stable-stringify
105
+ const sortedJsonData = JSON.parse(sortedData);
106
+ data = JSON.stringify(sortedJsonData, null, 2); // format the data
107
+ }
108
+ catch (error) {
109
+ logWarning(`Failed to sort GTJson file: ${file.id}: ` + error);
110
+ }
111
+ }
99
112
  // Write the file to disk
100
113
  await fs.promises.writeFile(outputPath, data);
101
114
  // Track as downloaded
@@ -0,0 +1,6 @@
1
+ import { Settings } from '../types/index.js';
2
+ /**
3
+ * Uploads current source files to obtain file references, then collects and sends
4
+ * diffs for all locales based on last downloaded versions. Does not enqueue translations.
5
+ */
6
+ export declare function saveLocalEdits(settings: Settings): Promise<void>;
@@ -0,0 +1,30 @@
1
+ import { gt } from '../utils/gt.js';
2
+ import { aggregateFiles } from '../formats/files/translate.js';
3
+ import { collectAndSendUserEditDiffs } from './collectUserEditDiffs.js';
4
+ /**
5
+ * Uploads current source files to obtain file references, then collects and sends
6
+ * diffs for all locales based on last downloaded versions. Does not enqueue translations.
7
+ */
8
+ export async function saveLocalEdits(settings) {
9
+ if (!settings.files)
10
+ return;
11
+ // Collect current files from config
12
+ const files = await aggregateFiles(settings);
13
+ if (!files.length)
14
+ return;
15
+ const uploads = files.map(({ content, fileName, fileFormat, dataFormat }) => ({
16
+ source: {
17
+ content,
18
+ fileName,
19
+ fileFormat,
20
+ dataFormat,
21
+ locale: settings.defaultLocale,
22
+ },
23
+ }));
24
+ // Upload sources only to get file references, then compute diffs
25
+ const upload = await gt.uploadSourceFiles(uploads, {
26
+ sourceLocale: settings.defaultLocale,
27
+ modelProvider: settings.modelProvider,
28
+ });
29
+ await collectAndSendUserEditDiffs(upload.uploadedFiles, settings);
30
+ }
@@ -2,6 +2,7 @@ import chalk from 'chalk';
2
2
  import { createSpinner, logErrorAndExit, logMessage, logSuccess, } from '../console/logging.js';
3
3
  import { gt } from '../utils/gt.js';
4
4
  import { TEMPLATE_FILE_NAME } from '../cli/commands/stage.js';
5
+ import { collectAndSendUserEditDiffs } from './collectUserEditDiffs.js';
5
6
  /**
6
7
  * Sends multiple files for translation to the API
7
8
  * @param files - Array of file objects to translate
@@ -88,7 +89,20 @@ export async function sendFiles(files, options, settings) {
88
89
  setupSpinner.stop(chalk.yellow(`Setup ${setupFailedMessage ? 'failed' : 'timed out'} — proceeding without setup${setupFailedMessage ? ` (${setupFailedMessage})` : ''}`));
89
90
  }
90
91
  }
91
- // Step 3: Enqueue translations by reference
92
+ // Step 3: Prior to enqueue, detect and submit user edit diffs (minimal UX)
93
+ const prepSpinner = createSpinner('dots');
94
+ currentSpinner = prepSpinner;
95
+ prepSpinner.start('Updating translations...');
96
+ try {
97
+ await collectAndSendUserEditDiffs(upload.uploadedFiles, settings);
98
+ }
99
+ catch {
100
+ // Non-fatal; keep going to enqueue
101
+ }
102
+ finally {
103
+ prepSpinner.stop('Updated translations');
104
+ }
105
+ // Step 4: Enqueue translations by reference
92
106
  const enqueueSpinner = createSpinner('dots');
93
107
  currentSpinner = enqueueSpinner;
94
108
  enqueueSpinner.start('Enqueuing translations...');
@@ -0,0 +1,19 @@
1
+ import { Settings } from '../types/index.js';
2
+ export type UserEditDiff = {
3
+ fileName: string;
4
+ locale: string;
5
+ diff: string;
6
+ versionId?: string;
7
+ fileId?: string;
8
+ localContent?: string;
9
+ };
10
+ export type SendUserEditsPayload = {
11
+ projectId?: string;
12
+ diffs: UserEditDiff[];
13
+ };
14
+ /**
15
+ * Sends user edit diffs to the API for persistence/rule extraction.
16
+ * This function is intentionally decoupled from the translate pipeline
17
+ * so it can be called as an independent action.
18
+ */
19
+ export declare function sendUserEditDiffs(diffs: UserEditDiff[], settings: Settings): Promise<void>;
@@ -0,0 +1,15 @@
1
+ import { gt } from '../utils/gt.js';
2
+ /**
3
+ * Sends user edit diffs to the API for persistence/rule extraction.
4
+ * This function is intentionally decoupled from the translate pipeline
5
+ * so it can be called as an independent action.
6
+ */
7
+ export async function sendUserEditDiffs(diffs, settings) {
8
+ if (!diffs.length)
9
+ return;
10
+ const payload = {
11
+ projectId: settings.projectId,
12
+ diffs,
13
+ };
14
+ await gt.submitUserEditDiffs(payload);
15
+ }
@@ -18,6 +18,7 @@ export declare class BaseCLI {
18
18
  execute(): void;
19
19
  protected setupStageCommand(): void;
20
20
  protected setupTranslateCommand(): void;
21
+ protected setupSendDiffsCommand(): void;
21
22
  protected handleStage(initOptions: TranslateFlags): Promise<void>;
22
23
  protected handleTranslate(initOptions: TranslateFlags): Promise<void>;
23
24
  protected setupUploadCommand(): void;
package/dist/cli/base.js CHANGED
@@ -20,6 +20,7 @@ import { handleDownload, handleTranslate, postProcessTranslations, } from './com
20
20
  import { getDownloaded, clearDownloaded } from '../state/recentDownloads.js';
21
21
  import updateConfig from '../fs/config/updateConfig.js';
22
22
  import { createLoadTranslationsFile } from '../fs/createLoadTranslationsFile.js';
23
+ import { saveLocalEdits } from '../api/saveLocalEdits.js';
23
24
  export class BaseCLI {
24
25
  library;
25
26
  additionalModules;
@@ -34,6 +35,7 @@ export class BaseCLI {
34
35
  this.setupSetupCommand();
35
36
  this.setupUploadCommand();
36
37
  this.setupLoginCommand();
38
+ this.setupSendDiffsCommand();
37
39
  }
38
40
  // Init is never called in a child class
39
41
  init() {
@@ -64,6 +66,17 @@ export class BaseCLI {
64
66
  endCommand('Done!');
65
67
  });
66
68
  }
69
+ setupSendDiffsCommand() {
70
+ this.program
71
+ .command('save-local')
72
+ .description('Save local edits for all configured files by sending diffs (no translation enqueued)')
73
+ .action(async () => {
74
+ const config = findFilepath(['gt.config.json']);
75
+ const settings = await generateSettings({ config });
76
+ await saveLocalEdits(settings);
77
+ endCommand('Saved local edits');
78
+ });
79
+ }
67
80
  async handleStage(initOptions) {
68
81
  const settings = await generateSettings(initOptions);
69
82
  if (!settings.stageTranslations) {
@@ -0,0 +1,8 @@
1
+ import { Settings } from '../../types/index.js';
2
+ export type SendDiffsFlags = {
3
+ fileName: string;
4
+ locale: string;
5
+ old: string;
6
+ next: string;
7
+ };
8
+ export declare function handleSendDiffs(flags: SendDiffsFlags, settings: Settings): Promise<void>;
@@ -0,0 +1,32 @@
1
+ import fs from 'node:fs';
2
+ import { getGitUnifiedDiff } from '../../utils/gitDiff.js';
3
+ import { sendUserEditDiffs } from '../../api/sendUserEdits.js';
4
+ import { logErrorAndExit, logMessage } from '../../console/logging.js';
5
+ export async function handleSendDiffs(flags, settings) {
6
+ const { fileName, locale, old, next } = flags;
7
+ if (!fs.existsSync(old)) {
8
+ logErrorAndExit(`Old/original file not found: ${old}`);
9
+ }
10
+ if (!fs.existsSync(next)) {
11
+ logErrorAndExit(`New/local file not found: ${next}`);
12
+ }
13
+ let diff;
14
+ try {
15
+ diff = await getGitUnifiedDiff(old, next);
16
+ }
17
+ catch (e) {
18
+ logErrorAndExit('Git is required to compute diffs. Please install Git and ensure it is available on your PATH.');
19
+ return; // unreachable
20
+ }
21
+ if (!diff || diff.trim().length === 0) {
22
+ logMessage('No differences detected — nothing to send.');
23
+ return;
24
+ }
25
+ await sendUserEditDiffs([
26
+ {
27
+ fileName,
28
+ locale,
29
+ diff,
30
+ },
31
+ ], settings);
32
+ }
@@ -2,4 +2,4 @@ import { AdditionalOptions } from '../../types/index.js';
2
2
  export default function mergeYaml(originalContent: string, inputPath: string, options: AdditionalOptions, targets: {
3
3
  translatedContent: string;
4
4
  targetLocale: string;
5
- }[]): string[];
5
+ }[], defaultLocale: string): string[];
@@ -2,7 +2,8 @@ import JSONPointer from 'jsonpointer';
2
2
  import { exit, logError } from '../../console/logging.js';
3
3
  import { validateYamlSchema } from './utils.js';
4
4
  import YAML from 'yaml';
5
- export default function mergeYaml(originalContent, inputPath, options, targets) {
5
+ import { applyTransformations } from '../json/mergeJson.js';
6
+ export default function mergeYaml(originalContent, inputPath, options, targets, defaultLocale) {
6
7
  const yamlSchema = validateYamlSchema(options, inputPath);
7
8
  if (!yamlSchema) {
8
9
  return targets.map((target) => target.translatedContent);
@@ -46,6 +47,10 @@ export default function mergeYaml(originalContent, inputPath, options, targets)
46
47
  // Silently ignore invalid or non-existent JSON pointers
47
48
  }
48
49
  }
50
+ // Apply transformations if they exist
51
+ if (yamlSchema.transform) {
52
+ applyTransformations(mergedYaml, yamlSchema.transform, target.targetLocale, defaultLocale);
53
+ }
49
54
  output.push(YAML.stringify(mergedYaml));
50
55
  }
51
56
  if (!output.length) {
@@ -163,6 +163,7 @@ export type JsonSchema = {
163
163
  export type YamlSchema = {
164
164
  preset?: 'mintlify';
165
165
  include?: string[];
166
+ transform?: TransformOptions;
166
167
  };
167
168
  export type SourceObjectOptions = {
168
169
  type: 'array' | 'object';
@@ -36,7 +36,7 @@ function extractHeadingText(heading) {
36
36
  function hasExplicitId(heading, ast) {
37
37
  const lastChild = heading.children[heading.children.length - 1];
38
38
  if (lastChild?.type === 'text') {
39
- return /(\{#[^}]+\}|\[[^\]]+\])$/.test(lastChild.value);
39
+ return /(\{#[^}]+\}|\\\{#[^}]+\\\}|\[[^\]]+\])\s*$/.test(lastChild.value);
40
40
  }
41
41
  return false;
42
42
  }
@@ -124,7 +124,7 @@ export function addExplicitAnchorIds(translatedContent, sourceHeadingMap, settin
124
124
  }
125
125
  return {
126
126
  content,
127
- hasChanges: addedIds.length > 0,
127
+ hasChanges: content !== translatedContent,
128
128
  addedIds,
129
129
  };
130
130
  }
@@ -148,6 +148,7 @@ function applyInlineIds(translatedContent, idMappings) {
148
148
  }
149
149
  // Apply IDs to headings based on position
150
150
  let headingIndex = 0;
151
+ let actuallyModifiedContent = false;
151
152
  visit(processedAst, 'heading', (heading) => {
152
153
  const id = idMappings.get(headingIndex);
153
154
  if (id) {
@@ -168,9 +169,14 @@ function applyInlineIds(translatedContent, idMappings) {
168
169
  value: ` \\{#${id}\\}`,
169
170
  });
170
171
  }
172
+ actuallyModifiedContent = true;
171
173
  }
172
174
  headingIndex++;
173
175
  });
176
+ // If we didn't modify any headings, return original content
177
+ if (!actuallyModifiedContent) {
178
+ return translatedContent;
179
+ }
174
180
  // Convert the modified AST back to MDX string
175
181
  try {
176
182
  const stringifyProcessor = unified()
@@ -248,6 +254,10 @@ function applyDivWrappedIds(translatedContent, translatedHeadings, idMappings) {
248
254
  // Process headings from longest to shortest original line to avoid partial matches
249
255
  const sortedHeadings = headingsToWrap.sort((a, b) => b.originalLine.length - a.originalLine.length);
250
256
  for (const heading of sortedHeadings) {
257
+ // If already wrapped with this id, skip (idempotent)
258
+ if (content.includes(`<div id="${heading.id}">`)) {
259
+ continue;
260
+ }
251
261
  // Escape the original line for use in regex
252
262
  const escapedLine = heading.originalLine.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
253
263
  const headingPattern = new RegExp(`^${escapedLine}\\s*$`, 'gm');
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Returns a unified diff using the system's git, comparing two paths even if not in a repo.
3
+ * Uses `git diff --no-index` so neither path needs to be tracked by git.
4
+ *
5
+ * Exit codes: 0 (no changes), 1 (changes), >1 (error). We treat 0/1 as success.
6
+ * Throws if git is unavailable or another error occurs.
7
+ */
8
+ export declare function getGitUnifiedDiff(oldPath: string, newPath: string): Promise<string>;
@@ -0,0 +1,32 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ /**
5
+ * Returns a unified diff using the system's git, comparing two paths even if not in a repo.
6
+ * Uses `git diff --no-index` so neither path needs to be tracked by git.
7
+ *
8
+ * Exit codes: 0 (no changes), 1 (changes), >1 (error). We treat 0/1 as success.
9
+ * Throws if git is unavailable or another error occurs.
10
+ */
11
+ export async function getGitUnifiedDiff(oldPath, newPath) {
12
+ const res = await execFileAsync('git', [
13
+ 'diff',
14
+ '--no-index',
15
+ '--text',
16
+ '--unified=3',
17
+ '--no-color',
18
+ '--',
19
+ oldPath,
20
+ newPath,
21
+ ], {
22
+ windowsHide: true,
23
+ }).catch((error) => {
24
+ // Exit code 1 means differences found; stdout contains the diff
25
+ if (error && error.code === 1 && typeof error.stdout === 'string') {
26
+ return { stdout: error.stdout };
27
+ }
28
+ throw error;
29
+ });
30
+ // When there are no changes, stdout is empty string and exit code 0
31
+ return res.stdout || '';
32
+ }
@@ -163,6 +163,12 @@ function transformNonDefaultLocaleImportPathWithHidden(fullPath, patternHead, ta
163
163
  * Transforms import path for non-default locale processing with hideDefaultLocale=false
164
164
  */
165
165
  function transformNonDefaultLocaleImportPath(fullPath, patternHead, targetLocale, defaultLocale) {
166
+ // If already localized to target, skip
167
+ const expectedPathWithTarget = `${patternHead}${targetLocale}`;
168
+ if (fullPath.startsWith(`${expectedPathWithTarget}/`) ||
169
+ fullPath === expectedPathWithTarget) {
170
+ return null;
171
+ }
166
172
  const expectedPathWithLocale = `${patternHead}${defaultLocale}`;
167
173
  if (fullPath.startsWith(`${expectedPathWithLocale}/`) ||
168
174
  fullPath === expectedPathWithLocale) {
@@ -175,6 +175,10 @@ export function transformUrlPath(originalUrl, patternHead, targetLocale, default
175
175
  }
176
176
  }
177
177
  else if (hideDefaultLocale) {
178
+ // Avoid duplicating target locale if already present
179
+ if (originalPathArray?.[patternHeadArray.length] === targetLocale) {
180
+ return null;
181
+ }
178
182
  const newPathArray = [
179
183
  ...originalPathArray.slice(0, patternHeadArray.length),
180
184
  targetLocale,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtx-cli",
3
- "version": "2.3.15",
3
+ "version": "2.4.1",
4
4
  "main": "dist/index.js",
5
5
  "bin": "dist/main.js",
6
6
  "files": [
@@ -73,6 +73,7 @@
73
73
  "dotenv": "^16.4.5",
74
74
  "esbuild": "^0.25.4",
75
75
  "fast-glob": "^3.3.3",
76
+ "fast-json-stable-stringify": "^2.1.0",
76
77
  "form-data": "^4.0.4",
77
78
  "gt-remark": "^1.0.1",
78
79
  "json-pointer": "^0.6.2",
@@ -91,7 +92,7 @@
91
92
  "unified": "^11.0.5",
92
93
  "unist-util-visit": "^5.0.0",
93
94
  "yaml": "^2.8.0",
94
- "generaltranslation": "7.6.5"
95
+ "generaltranslation": "7.7.1"
95
96
  },
96
97
  "devDependencies": {
97
98
  "@babel/types": "^7.28.4",