@aigne/doc-smith 0.9.9-beta → 0.9.9-beta.2

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 (38) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/agents/create/aggregate-document-structure.mjs +21 -0
  3. package/agents/create/analyze-diagram-type-llm.yaml +1 -2
  4. package/agents/create/analyze-diagram-type.mjs +160 -2
  5. package/agents/create/generate-diagram-image.yaml +31 -0
  6. package/agents/create/generate-structure.yaml +1 -12
  7. package/agents/create/replace-d2-with-image.mjs +12 -27
  8. package/agents/create/user-add-document/add-documents-to-structure.mjs +1 -1
  9. package/agents/create/user-review-document-structure.mjs +1 -1
  10. package/agents/create/utils/merge-document-structures.mjs +9 -3
  11. package/agents/localize/index.yaml +4 -0
  12. package/agents/localize/save-doc-translation-or-skip.mjs +18 -0
  13. package/agents/localize/set-review-content.mjs +58 -0
  14. package/agents/localize/translate-diagram.yaml +62 -0
  15. package/agents/localize/translate-document-wrapper.mjs +34 -0
  16. package/agents/localize/translate-multilingual.yaml +15 -9
  17. package/agents/localize/translate-or-skip-diagram.mjs +52 -0
  18. package/agents/publish/translate-meta.mjs +58 -6
  19. package/agents/update/generate-diagram.yaml +25 -8
  20. package/agents/update/index.yaml +1 -8
  21. package/agents/update/save-and-translate-document.mjs +5 -1
  22. package/agents/update/update-single/update-single-document-detail.mjs +52 -10
  23. package/agents/utils/analyze-feedback-intent.mjs +197 -80
  24. package/agents/utils/check-detail-result.mjs +14 -1
  25. package/agents/utils/choose-docs.mjs +3 -43
  26. package/agents/utils/save-doc-translation.mjs +2 -33
  27. package/agents/utils/save-doc.mjs +3 -37
  28. package/aigne.yaml +2 -2
  29. package/package.json +1 -1
  30. package/prompts/detail/diagram/generate-image-user.md +50 -1
  31. package/utils/d2-utils.mjs +10 -3
  32. package/utils/delete-diagram-images.mjs +3 -3
  33. package/utils/diagram-version-utils.mjs +14 -0
  34. package/utils/image-compress.mjs +1 -1
  35. package/utils/sync-diagram-to-translations.mjs +3 -3
  36. package/utils/translate-diagram-images.mjs +790 -0
  37. package/agents/update/check-sync-image-flag.mjs +0 -55
  38. package/agents/update/sync-images-and-exit.mjs +0 -148
@@ -0,0 +1,790 @@
1
+ import { readFileContent } from "./docs-finder-utils.mjs";
2
+ import { debug } from "./debug.mjs";
3
+ import path from "node:path";
4
+ import fs from "fs-extra";
5
+ import { copyFile } from "node:fs/promises";
6
+ import { diagramImageFullRegex } from "./d2-utils.mjs";
7
+ import { calculateImageTimestamp } from "./diagram-version-utils.mjs";
8
+ import { getFileName } from "./utils.mjs";
9
+ import { compressImage } from "./image-compress.mjs";
10
+
11
+ // Constants
12
+ const DEFAULT_DIAGRAM_TYPE = "architecture";
13
+ const DEFAULT_ASPECT_RATIO = "16:9";
14
+ const DEFAULT_ALT_TEXT = "Diagram";
15
+ const DEFAULT_IMAGE_QUALITY = 85;
16
+ const DEFAULT_IMAGE_SIZE = "1K";
17
+ const DEFAULT_MIME_TYPE = "image/jpeg";
18
+
19
+ /**
20
+ * Find translation files for a document, filtered by selected languages
21
+ * @param {string} docPath - Document path (e.g., "/guides/getting-started")
22
+ * @param {string} docsDir - Documentation directory
23
+ * @param {string} locale - Main language locale (e.g., "en")
24
+ * @param {Array<string>} selectedLanguages - Array of selected language codes to translate (e.g., ["zh", "ja"])
25
+ * @returns {Promise<Array<{language: string, fileName: string}>>} - Array of translation file info
26
+ */
27
+ async function findTranslationFiles(docPath, docsDir, locale, selectedLanguages = null) {
28
+ // Convert path to flat filename format
29
+ const flatName = docPath.replace(/^\//, "").replace(/\//g, "-");
30
+
31
+ try {
32
+ const files = await fs.readdir(docsDir);
33
+ const translationFiles = [];
34
+ const mainFileName = locale === "en" ? `${flatName}.md` : `${flatName}.${locale}.md`;
35
+
36
+ // Filter files to find translation files matching the pattern
37
+ for (const file of files) {
38
+ if (!file.endsWith(".md")) continue;
39
+ if (file === mainFileName) continue; // Skip main language file
40
+
41
+ // Case 1: File without language suffix (xxx.md) - this is English translation when main language is not English
42
+ if (file === `${flatName}.md` && locale !== "en") {
43
+ translationFiles.push({
44
+ language: "en",
45
+ fileName: file,
46
+ });
47
+ continue;
48
+ }
49
+
50
+ // Case 2: File with language suffix (xxx.{lang}.md) - all other translations
51
+ if (file.startsWith(`${flatName}.`) && file.match(/\.\w+(-\w+)?\.md$/)) {
52
+ const langMatch = file.match(/\.(\w+(-\w+)?)\.md$/);
53
+ if (langMatch && langMatch[1] !== locale) {
54
+ const fileLanguage = langMatch[1];
55
+ // If selectedLanguages is provided, only include files for selected languages
56
+ if (selectedLanguages === null || selectedLanguages.includes(fileLanguage)) {
57
+ translationFiles.push({
58
+ language: fileLanguage,
59
+ fileName: file,
60
+ });
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ return translationFiles;
67
+ } catch (error) {
68
+ debug(`⚠️ Could not read translation files from ${docsDir}: ${error.message}`);
69
+ return [];
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Extract diagram images with timestamp from content
75
+ * Supports both new format (with timestamp) and old format (without timestamp)
76
+ * Uses a single regex to extract all information in one pass
77
+ * @param {string} content - Document content
78
+ * @returns {Array<{type: string, aspectRatio: string, timestamp: string|null, path: string, altText: string, fullMatch: string, index: number}>} - Array of diagram image info
79
+ */
80
+ function extractDiagramImagesWithTimestamp(content) {
81
+ const images = [];
82
+
83
+ // Use unified regex to match both old and new formats in one pass
84
+ // Captures: type, aspectRatio, optional timestamp, altText, path, fullMatch
85
+ const matches = Array.from((content || "").matchAll(diagramImageFullRegex));
86
+ for (const match of matches) {
87
+ images.push({
88
+ type: match[1] || DEFAULT_DIAGRAM_TYPE, // Diagram type (e.g., "architecture", "guide")
89
+ aspectRatio: match[2] || DEFAULT_ASPECT_RATIO, // Aspect ratio (e.g., "16:9", "4:3")
90
+ timestamp: match[3] || null, // Timestamp (null for old format)
91
+ altText: match[4] || DEFAULT_ALT_TEXT, // Alt text from markdown
92
+ path: match[5] || "", // Image path
93
+ fullMatch: match[0] || "", // Full matched block
94
+ index: match.index || 0, // Position in document
95
+ });
96
+ }
97
+
98
+ // Sort by position in document
99
+ images.sort((a, b) => a.index - b.index);
100
+
101
+ return images;
102
+ }
103
+
104
+ /**
105
+ * Convert diagram info to mediaFile format for image generation
106
+ * @param {Object} diagramInfo - Diagram info with path
107
+ * @param {string} docPath - Document path
108
+ * @param {string} docsDir - Documentation directory
109
+ * @returns {Promise<Object|null>} - MediaFile object or null if file doesn't exist
110
+ */
111
+ async function convertDiagramInfoToMediaFile(diagramInfo, docPath, docsDir) {
112
+ if (!diagramInfo || !diagramInfo.path) {
113
+ return null;
114
+ }
115
+
116
+ try {
117
+ const imagePath = diagramInfo.path;
118
+
119
+ // Resolve absolute path
120
+ let absolutePath;
121
+ if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
122
+ // Remote URL, cannot convert to local file
123
+ return null;
124
+ } else if (path.isAbsolute(imagePath)) {
125
+ absolutePath = imagePath;
126
+ } else {
127
+ // Relative path resolution:
128
+ // - If path starts with "../", it's relative to the document directory
129
+ // - Otherwise, it's relative to docsDir (e.g., "assets/diagram/...")
130
+ if (imagePath.startsWith("../")) {
131
+ // Relative to document directory
132
+ const docDir = path.dirname(docPath);
133
+ const imageRelativePath = path.join(docDir, imagePath).replace(/\\/g, "/");
134
+ absolutePath = path.join(process.cwd(), docsDir, imageRelativePath);
135
+ } else {
136
+ // Relative to docsDir (most common case: "assets/diagram/...")
137
+ absolutePath = path.join(process.cwd(), docsDir, imagePath);
138
+ }
139
+ }
140
+
141
+ // Normalize path
142
+ const normalizedPath = path.normalize(absolutePath);
143
+
144
+ // Check if file exists
145
+ if (!(await fs.pathExists(normalizedPath))) {
146
+ return null;
147
+ }
148
+
149
+ // Get file extension for mimeType detection
150
+ const ext = path.extname(normalizedPath).toLowerCase();
151
+ let mimeType = DEFAULT_MIME_TYPE;
152
+ if (ext === ".png") mimeType = "image/png";
153
+ else if (ext === ".gif") mimeType = "image/gif";
154
+ else if (ext === ".webp") mimeType = "image/webp";
155
+
156
+ return {
157
+ type: "local",
158
+ path: normalizedPath,
159
+ filename: path.basename(normalizedPath),
160
+ mimeType,
161
+ };
162
+ } catch (error) {
163
+ debug(`Failed to convert diagram info to mediaFile: ${error.message}`);
164
+ return null;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Generate translated image filename with language suffix
170
+ * @param {string} originalPath - Original image path (e.g., "assets/diagram/overview-diagram-0.jpg")
171
+ * @param {string} language - Target language code (e.g., "zh")
172
+ * @returns {string} - Translated image path (e.g., "assets/diagram/overview-diagram-0.zh.jpg")
173
+ */
174
+ function generateTranslatedImagePath(originalPath, language) {
175
+ const pathParts = originalPath.split(".");
176
+ if (pathParts.length < 2) {
177
+ // No extension, just append language
178
+ return `${originalPath}.${language}`;
179
+ }
180
+ // Insert language before extension
181
+ const ext = pathParts.pop();
182
+ const base = pathParts.join(".");
183
+ return `${base}.${language}.${ext}`;
184
+ }
185
+
186
+ /**
187
+ * Compress and copy generated image to target location
188
+ * Tries compression first, falls back to copying if compression fails
189
+ * @param {Object} generatedImage - Generated image object with path
190
+ * @param {string} targetPath - Target absolute path for the image
191
+ * @param {string} fileName - Translation file name for error reporting
192
+ * @returns {Promise<void>}
193
+ */
194
+ async function compressAndCopyImage(generatedImage, targetPath, fileName = null) {
195
+ try {
196
+ const compressedPath = await compressImage(generatedImage.path, {
197
+ quality: DEFAULT_IMAGE_QUALITY,
198
+ outputPath: targetPath,
199
+ });
200
+
201
+ if (compressedPath === generatedImage.path) {
202
+ // Compression failed, copy original
203
+ await copyFile(generatedImage.path, targetPath);
204
+ }
205
+ } catch (error) {
206
+ console.error(`copy original image ${fileName} to ${targetPath}`);
207
+ debug(`copy original image ${fileName} to ${targetPath}`, error);
208
+ await copyFile(generatedImage.path, targetPath);
209
+ }
210
+ }
211
+
212
+ async function ensureImageTimestamp(imageInfo, docPath, docsDir) {
213
+ if (imageInfo.timestamp) {
214
+ return imageInfo.timestamp;
215
+ }
216
+
217
+ const existingImage = await convertDiagramInfoToMediaFile(
218
+ { path: imageInfo.path },
219
+ docPath,
220
+ docsDir,
221
+ );
222
+
223
+ return existingImage
224
+ ? await calculateImageTimestamp(existingImage.path)
225
+ : Math.floor(Date.now() / 1000).toString();
226
+ }
227
+
228
+ function createImageMarkdown(diagramType, aspectRatio, timestamp, altText, imagePath) {
229
+ const type = diagramType || DEFAULT_DIAGRAM_TYPE;
230
+ const ratio = aspectRatio || DEFAULT_ASPECT_RATIO;
231
+ const alt = altText || DEFAULT_ALT_TEXT;
232
+ return `<!-- DIAGRAM_IMAGE_START:${type}:${ratio}:${timestamp} -->\n![${alt}](${imagePath})\n<!-- DIAGRAM_IMAGE_END -->`;
233
+ }
234
+
235
+ const processedDocuments = new Set();
236
+
237
+ /**
238
+ * Translate diagram images for translation documents
239
+ * Only translates images when timestamp differs between main and translation documents
240
+ * If main document images don't have timestamp (old format), generates timestamps and updates both main and translation documents
241
+ * @param {string} mainContent - Main document content (with timestamp in markers)
242
+ * @param {string} docPath - Document path
243
+ * @param {string} docsDir - Documentation directory
244
+ * @param {string} locale - Main language locale
245
+ * @param {Object} options - Options object with context for invoking agents
246
+ * @param {Array<string>} selectedLanguages - Selected languages to translate
247
+ * @returns {Promise<{updated: number, skipped: number, errors: Array, mainContentUpdated: string|null}>} - Translation result with updated main content
248
+ */
249
+ export async function translateDiagramImages(
250
+ mainContent,
251
+ docPath,
252
+ docsDir,
253
+ locale = "en",
254
+ options = {},
255
+ selectedLanguages = null,
256
+ ) {
257
+ // Avoid duplicate processing when called multiple times in iterate_on context
258
+ const documentKey = `${docPath}:${docsDir}:${locale}`;
259
+ if (processedDocuments.has(documentKey)) {
260
+ debug(`⏭️ Diagram images for ${docPath} already processed, skipping`);
261
+ return { updated: 0, skipped: 0, errors: [] };
262
+ }
263
+ processedDocuments.add(documentKey);
264
+
265
+ const result = {
266
+ updated: 0,
267
+ skipped: 0,
268
+ errors: [],
269
+ mainContentUpdated: null, // Will contain updated main content if timestamps were added
270
+ };
271
+
272
+ try {
273
+ // Find translation files, filtered by selected languages
274
+ const translationFiles = await findTranslationFiles(
275
+ docPath,
276
+ docsDir,
277
+ locale,
278
+ selectedLanguages,
279
+ );
280
+
281
+ if (translationFiles.length === 0) {
282
+ debug("ℹ️ No translation files found, skipping image translation");
283
+ return result;
284
+ }
285
+
286
+ const mainImages = extractDiagramImagesWithTimestamp(mainContent);
287
+ let updatedMainContent = mainContent;
288
+ let mainContentNeedsUpdate = false;
289
+
290
+ if (mainImages.length === 0) {
291
+ debug("ℹ️ No diagram images in main content, skipping translation");
292
+ return result;
293
+ }
294
+
295
+ for (let i = 0; i < mainImages.length; i++) {
296
+ const mainImage = mainImages[i];
297
+ if (!mainImage.timestamp) {
298
+ const newTimestamp = await ensureImageTimestamp(mainImage, docPath, docsDir);
299
+ mainImages[i] = { ...mainImage, timestamp: newTimestamp };
300
+ updatedMainContent = updatedMainContent.replace(
301
+ mainImage.fullMatch,
302
+ createImageMarkdown(
303
+ mainImage.type,
304
+ mainImage.aspectRatio,
305
+ newTimestamp,
306
+ mainImage.altText,
307
+ mainImage.path,
308
+ ),
309
+ );
310
+ mainContentNeedsUpdate = true;
311
+ }
312
+ }
313
+
314
+ if (mainContentNeedsUpdate) {
315
+ const mainFileName = getFileName(docPath, locale);
316
+ await fs.writeFile(path.join(docsDir, mainFileName), updatedMainContent, "utf8");
317
+ result.mainContentUpdated = updatedMainContent;
318
+ debug(`✅ Updated main document with timestamps: ${mainFileName}`);
319
+ }
320
+ for (const { language, fileName } of translationFiles) {
321
+ try {
322
+ const translationFilePath = path.join(docsDir, fileName);
323
+ const translationContent = await readFileContent(docsDir, fileName);
324
+
325
+ if (translationContent === null || translationContent === undefined) {
326
+ debug(`⚠️ Could not read translation file: ${fileName}`);
327
+ result.skipped++;
328
+ continue;
329
+ }
330
+
331
+ const translationImages = extractDiagramImagesWithTimestamp(translationContent);
332
+ let hasChanges = false;
333
+ let updatedContent = translationContent;
334
+ for (let i = 0; i < mainImages.length; i++) {
335
+ const mainImage = mainImages[i];
336
+ const translationImage = translationImages[i];
337
+
338
+ const translationImagePath = translationImage?.path || "";
339
+ const hasLanguageSuffix =
340
+ translationImagePath.includes(`.${language}.`) ||
341
+ translationImagePath.endsWith(`.${language}`);
342
+
343
+ const needsTranslation =
344
+ mainImage &&
345
+ (!translationImage ||
346
+ !hasLanguageSuffix ||
347
+ !translationImage.timestamp ||
348
+ translationImage.timestamp !== mainImage.timestamp);
349
+
350
+ if (!needsTranslation) {
351
+ continue;
352
+ }
353
+
354
+ const existingImage = await convertDiagramInfoToMediaFile(
355
+ { path: mainImage.path },
356
+ docPath,
357
+ docsDir,
358
+ );
359
+
360
+ if (!existingImage) {
361
+ debug(
362
+ `⏭️ Main image not found: ${mainImage.path}, skipping translation for ${language}`,
363
+ );
364
+ continue;
365
+ }
366
+
367
+ try {
368
+ const translateDiagramAgent = options.context?.agents?.["translateDiagram"];
369
+ if (!translateDiagramAgent) {
370
+ debug(`⚠️ translateDiagram agent not found, skipping translation`);
371
+ result.errors.push({
372
+ file: fileName,
373
+ imageIndex: i,
374
+ error: "translateDiagram agent not found",
375
+ });
376
+ continue;
377
+ }
378
+
379
+ // Call translateDiagram agent with translation parameters
380
+ const imageResult = await options.context.invoke(translateDiagramAgent, {
381
+ existingImage: [existingImage], // Pass the main document image for translation
382
+ ratio: mainImage.aspectRatio || DEFAULT_ASPECT_RATIO, // Aspect ratio from main image
383
+ size: DEFAULT_IMAGE_SIZE, // Image clarity/size
384
+ locale: language, // Target language code for translation
385
+ });
386
+
387
+ let generatedImage = null;
388
+ if (
389
+ imageResult?.images &&
390
+ Array.isArray(imageResult.images) &&
391
+ imageResult.images.length > 0
392
+ ) {
393
+ generatedImage = imageResult.images[0];
394
+ } else if (imageResult?.image || imageResult?.imageUrl || imageResult?.path) {
395
+ generatedImage = {
396
+ path: imageResult.image || imageResult.imageUrl || imageResult.path,
397
+ filename: path.basename(
398
+ imageResult.image || imageResult.imageUrl || imageResult.path,
399
+ ),
400
+ mimeType: imageResult.mimeType || DEFAULT_MIME_TYPE,
401
+ type: "local",
402
+ };
403
+ }
404
+
405
+ if (!generatedImage) {
406
+ debug(`⚠️ No image generated for ${language} diagram ${i}`);
407
+ result.errors.push({
408
+ file: fileName,
409
+ imageIndex: i,
410
+ error: "No image generated from agent",
411
+ });
412
+ continue;
413
+ }
414
+
415
+ const translatedImagePath = generateTranslatedImagePath(mainImage.path, language);
416
+ const translatedImageAbsolutePath = path.join(
417
+ process.cwd(),
418
+ docsDir,
419
+ translatedImagePath,
420
+ );
421
+ await fs.ensureDir(path.dirname(translatedImageAbsolutePath));
422
+ await compressAndCopyImage(generatedImage, translatedImageAbsolutePath, fileName);
423
+
424
+ const altText = translationImage?.altText || mainImage.altText || DEFAULT_ALT_TEXT;
425
+ const newImageMarkdown = createImageMarkdown(
426
+ mainImage.type,
427
+ mainImage.aspectRatio,
428
+ mainImage.timestamp,
429
+ altText,
430
+ translatedImagePath,
431
+ );
432
+
433
+ if (translationImage) {
434
+ updatedContent = updatedContent.replace(translationImage.fullMatch, newImageMarkdown);
435
+ } else {
436
+ const lastImageIndex =
437
+ translationImages.length > 0
438
+ ? translationImages[translationImages.length - 1].index +
439
+ translationImages[translationImages.length - 1].fullMatch.length
440
+ : updatedContent.length;
441
+ updatedContent =
442
+ updatedContent.slice(0, lastImageIndex) +
443
+ "\n\n" +
444
+ newImageMarkdown +
445
+ "\n\n" +
446
+ updatedContent.slice(lastImageIndex);
447
+ }
448
+
449
+ hasChanges = true;
450
+ } catch (error) {
451
+ console.error(`❌ Error translating diagram image ${fileName} for ${language}`);
452
+ debug(`❌ Error translating diagram image ${fileName} for ${language}`, error);
453
+ result.errors.push({
454
+ file: fileName,
455
+ imageIndex: i,
456
+ error: error.message,
457
+ });
458
+ }
459
+ }
460
+
461
+ if (hasChanges) {
462
+ await fs.writeFile(translationFilePath, updatedContent, "utf8");
463
+ result.updated++;
464
+ } else {
465
+ result.skipped++;
466
+ }
467
+ } catch (error) {
468
+ debug(`❌ Error processing translation file ${fileName}: ${error.message}`);
469
+ result.errors.push({
470
+ file: fileName,
471
+ error: error.message,
472
+ });
473
+ }
474
+ }
475
+
476
+ return result;
477
+ } finally {
478
+ // Clear processed documents after completion (even on error) to allow re-processing if needed
479
+ processedDocuments.delete(documentKey);
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Cache diagram images for translation (before document translation)
485
+ * This function checks if images need translation and caches the translated image info
486
+ * @param {string} mainContent - Main document content
487
+ * @param {string} translationContent - Current translation document content (may be empty for new translations)
488
+ * @param {string} docPath - Document path
489
+ * @param {string} docsDir - Documentation directory
490
+ * @param {string} locale - Main language locale
491
+ * @param {string} language - Target language code
492
+ * @param {Object} options - Options object with context for invoking agents
493
+ * @param {boolean} shouldTranslateDiagramsOnly - Whether to translate diagrams only (from --diagram flag)
494
+ * @returns {Promise<Array<{originalMatch: string, translatedMarkdown: string, index: number}>|null>} - Cached image info or null
495
+ */
496
+ export async function cacheDiagramImagesForTranslation(
497
+ mainContent,
498
+ translationContent,
499
+ docPath,
500
+ docsDir,
501
+ locale = "en",
502
+ language,
503
+ options = {},
504
+ shouldTranslateDiagramsOnly = false,
505
+ ) {
506
+ try {
507
+ const mainImages = extractDiagramImagesWithTimestamp(mainContent);
508
+ let updatedMainContent = mainContent;
509
+ let mainContentNeedsUpdate = false;
510
+
511
+ if (mainImages.length === 0) {
512
+ debug("ℹ️ No diagram images in main content");
513
+ return null;
514
+ }
515
+
516
+ for (let i = 0; i < mainImages.length; i++) {
517
+ const mainImage = mainImages[i];
518
+ if (!mainImage.timestamp) {
519
+ const newTimestamp = await ensureImageTimestamp(mainImage, docPath, docsDir);
520
+ mainImages[i] = { ...mainImage, timestamp: newTimestamp };
521
+ updatedMainContent = updatedMainContent.replace(
522
+ mainImage.fullMatch,
523
+ createImageMarkdown(
524
+ mainImage.type,
525
+ mainImage.aspectRatio,
526
+ newTimestamp,
527
+ mainImage.altText,
528
+ mainImage.path,
529
+ ),
530
+ );
531
+ mainContentNeedsUpdate = true;
532
+ }
533
+ }
534
+
535
+ if (mainContentNeedsUpdate) {
536
+ const mainFileName = getFileName(docPath, locale);
537
+ await fs.writeFile(path.join(docsDir, mainFileName), updatedMainContent, "utf8");
538
+ debug(`✅ Updated main document with timestamps: ${mainFileName}`);
539
+ }
540
+
541
+ const translationImages = translationContent
542
+ ? extractDiagramImagesWithTimestamp(translationContent)
543
+ : [];
544
+
545
+ const cachedImages = [];
546
+
547
+ for (let i = 0; i < mainImages.length; i++) {
548
+ const mainImage = mainImages[i];
549
+ const translationImage = translationImages[i];
550
+
551
+ let needsTranslation = false;
552
+
553
+ if (shouldTranslateDiagramsOnly) {
554
+ // When --diagram flag is set, always regenerate translation images regardless of existing images
555
+ needsTranslation = true;
556
+ debug(
557
+ `🔄 --diagram flag set: forcing regeneration of translation for ${language} diagram ${i}`,
558
+ );
559
+ } else {
560
+ // Normal mode: check if translation is needed based on timestamp and language suffix
561
+ const translationImagePath = translationImage?.path || "";
562
+ const hasLanguageSuffix =
563
+ translationImagePath.includes(`.${language}.`) ||
564
+ translationImagePath.endsWith(`.${language}`);
565
+ needsTranslation =
566
+ !translationImage ||
567
+ !hasLanguageSuffix ||
568
+ !translationImage.timestamp ||
569
+ translationImage.timestamp !== mainImage.timestamp;
570
+ }
571
+
572
+ if (!needsTranslation) {
573
+ // No translation needed, but we should cache the existing translation image
574
+ // to ensure it's preserved in the final document (this applies to both normal and --diagram mode)
575
+ if (translationImage) {
576
+ cachedImages.push({
577
+ originalMatch: translationImage.fullMatch,
578
+ translatedMarkdown: translationImage.fullMatch, // Keep existing markdown
579
+ index: translationImage.index,
580
+ mainImageIndex: mainImage.index,
581
+ });
582
+ debug(
583
+ `💾 Cached existing diagram image for ${language} diagram ${i} (timestamp matches, no translation needed)`,
584
+ );
585
+ }
586
+ continue;
587
+ }
588
+
589
+ const existingImage = await convertDiagramInfoToMediaFile(
590
+ { path: mainImage.path },
591
+ docPath,
592
+ docsDir,
593
+ );
594
+
595
+ if (!existingImage) {
596
+ debug(`⏭️ Main image not found: ${mainImage.path}, skipping translation for ${language}`);
597
+ continue;
598
+ }
599
+
600
+ try {
601
+ const translateDiagramAgent = options.context?.agents?.["translateDiagram"];
602
+ if (!translateDiagramAgent) {
603
+ debug(`⚠️ translateDiagram agent not found, skipping translation`);
604
+ continue;
605
+ }
606
+
607
+ const imageResult = await options.context.invoke(translateDiagramAgent, {
608
+ existingImage: [existingImage],
609
+ ratio: mainImage.aspectRatio || DEFAULT_ASPECT_RATIO,
610
+ size: DEFAULT_IMAGE_SIZE,
611
+ locale: language,
612
+ });
613
+
614
+ let generatedImage = null;
615
+ if (
616
+ imageResult?.images &&
617
+ Array.isArray(imageResult.images) &&
618
+ imageResult.images.length > 0
619
+ ) {
620
+ generatedImage = imageResult.images[0];
621
+ } else if (imageResult?.image || imageResult?.imageUrl || imageResult?.path) {
622
+ generatedImage = {
623
+ path: imageResult.image || imageResult.imageUrl || imageResult.path,
624
+ filename: path.basename(imageResult.image || imageResult.imageUrl || imageResult.path),
625
+ mimeType: imageResult.mimeType || DEFAULT_MIME_TYPE,
626
+ type: "local",
627
+ };
628
+ }
629
+
630
+ if (!generatedImage) {
631
+ debug(`⚠️ No image generated for ${language} diagram ${i}`);
632
+ continue;
633
+ }
634
+
635
+ const translatedImagePath = generateTranslatedImagePath(mainImage.path, language);
636
+ const translatedImageAbsolutePath = path.join(process.cwd(), docsDir, translatedImagePath);
637
+ await fs.ensureDir(path.dirname(translatedImageAbsolutePath));
638
+ const translationFileName = getFileName(docPath, language);
639
+ await compressAndCopyImage(
640
+ generatedImage,
641
+ translatedImageAbsolutePath,
642
+ translationFileName,
643
+ );
644
+
645
+ const altText = translationImage?.altText || mainImage.altText || DEFAULT_ALT_TEXT;
646
+ const newImageMarkdown = createImageMarkdown(
647
+ mainImage.type,
648
+ mainImage.aspectRatio,
649
+ mainImage.timestamp,
650
+ altText,
651
+ translatedImagePath,
652
+ );
653
+
654
+ cachedImages.push({
655
+ originalMatch: translationImage?.fullMatch || null,
656
+ translatedMarkdown: newImageMarkdown,
657
+ index: translationImage?.index || mainImage.index,
658
+ mainImageIndex: mainImage.index,
659
+ });
660
+ } catch (error) {
661
+ const translationFileName = getFileName(docPath, language);
662
+ console.error(`❌ Error translating diagram image ${translationFileName} for ${language}`);
663
+ debug(`❌ Error translating diagram image ${translationFileName} for ${language}`, error);
664
+ // Continue processing other images even if one fails
665
+ }
666
+ }
667
+
668
+ return cachedImages.length > 0 ? cachedImages : null;
669
+ } catch (error) {
670
+ debug(`❌ Error caching diagram images: ${error.message}`);
671
+ return null;
672
+ }
673
+ }
674
+
675
+ /**
676
+ * Agent wrapper for caching diagram images during translation workflow
677
+ * This agent is called BEFORE translate-document-wrapper.mjs to cache image info
678
+ * @param {Object} input - Input parameters
679
+ * @param {string} input.path - Document path
680
+ * @param {string} input.docsDir - Documentation directory
681
+ * @param {string} input.locale - Main language locale
682
+ * @param {boolean} input.shouldTranslateDiagramsOnly - Whether to translate diagrams only (from --diagram flag)
683
+ * @param {string} input.language - Current language being translated
684
+ * @param {Object} options - Options with context for invoking agents
685
+ * @returns {Promise<Object>} - Result object with cached image info
686
+ */
687
+ export default async function translateDiagramImagesAgent(input, options) {
688
+ // Extract parameters from input
689
+ const docPath = input.path;
690
+ const docsDir = input.docsDir;
691
+ const locale = input.locale || "en";
692
+ const currentLanguage = input.language;
693
+ const shouldTranslateDiagramsOnly = input.shouldTranslateDiagramsOnly || false;
694
+
695
+ if (!docPath || !docsDir || !currentLanguage) {
696
+ debug(
697
+ "⚠️ Missing required parameters for diagram image translation (path, docsDir, or language)",
698
+ );
699
+ debug(` - path: ${docPath}`);
700
+ debug(` - docsDir: ${docsDir}`);
701
+ debug(` - language: ${currentLanguage}`);
702
+ return { ...input, cachedDiagramImages: null };
703
+ }
704
+
705
+ try {
706
+ // Read main document content
707
+ const mainFileName = getFileName(docPath, locale);
708
+ const mainContent = await readFileContent(docsDir, mainFileName);
709
+
710
+ if (!mainContent) {
711
+ debug(`⚠️ Could not read main document: ${mainFileName}`);
712
+ return { ...input, cachedDiagramImages: null };
713
+ }
714
+
715
+ // Read current translation file content (if exists) to check timestamps
716
+ const translationFileName = getFileName(docPath, currentLanguage);
717
+ const translationContent = await readFileContent(docsDir, translationFileName);
718
+
719
+ // Cache diagram images for translation
720
+ // This function will:
721
+ // 1. If --diagram flag is set, always translate images
722
+ // 2. Otherwise, check if main document image timestamps match translation document timestamps
723
+ // 3. Cache the image info that needs to be inserted into translated document
724
+ const cachedImages = await cacheDiagramImagesForTranslation(
725
+ mainContent,
726
+ translationContent || "",
727
+ docPath,
728
+ docsDir,
729
+ locale,
730
+ currentLanguage,
731
+ options,
732
+ shouldTranslateDiagramsOnly,
733
+ );
734
+
735
+ if (cachedImages && cachedImages.length > 0) {
736
+ // Check if any images were actually translated (not just cached existing ones)
737
+ const translatedCount = cachedImages.filter(
738
+ (img) => img.translatedMarkdown !== img.originalMatch,
739
+ ).length;
740
+ if (translatedCount > 0) {
741
+ debug(
742
+ `✅ Cached ${cachedImages.length} diagram image(s) for ${currentLanguage} (${translatedCount} translated)`,
743
+ );
744
+ } else {
745
+ debug(
746
+ `ℹ️ Cached ${cachedImages.length} existing diagram image(s) for ${currentLanguage} (no translation needed)`,
747
+ );
748
+ }
749
+ } else {
750
+ debug(`ℹ️ No diagram images found or cached for ${currentLanguage}`);
751
+ }
752
+
753
+ // In --diagram mode:
754
+ // - If translation document exists: use existing translation content (skip document translation, only replace images)
755
+ // - If translation document doesn't exist: allow document translation first, then replace images
756
+ let finalTranslation = input.translation;
757
+ let finalIsApproved = input.isApproved;
758
+
759
+ if (shouldTranslateDiagramsOnly) {
760
+ if (translationContent) {
761
+ // Translation document exists: use existing content, skip document translation
762
+ finalTranslation = translationContent;
763
+ finalIsApproved = true; // Skip document translation
764
+ debug(
765
+ `ℹ️ --diagram mode: using existing translation content for ${currentLanguage} (will only replace diagram images)`,
766
+ );
767
+ } else {
768
+ // Translation document doesn't exist: allow document translation first
769
+ finalTranslation = undefined; // Let translate-document-wrapper.mjs handle translation
770
+ finalIsApproved = false; // Allow document translation
771
+ debug(
772
+ `ℹ️ --diagram mode: translation document not found for ${currentLanguage}, will translate document first, then replace diagram images`,
773
+ );
774
+ }
775
+ }
776
+
777
+ return {
778
+ ...input,
779
+ translation: finalTranslation,
780
+ isApproved: finalIsApproved,
781
+ cachedDiagramImages: cachedImages || null,
782
+ };
783
+ } catch (error) {
784
+ // Don't fail the translation if image translation fails
785
+ debug(`⚠️ Failed to cache diagram images: ${error.message}`);
786
+ return { ...input, cachedDiagramImages: null };
787
+ }
788
+ }
789
+
790
+ translateDiagramImagesAgent.task_render_mode = "hide";