retold-remote 0.0.23 → 0.0.26

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 (79) hide show
  1. package/css/retold-remote.css +343 -20
  2. package/docs/.nojekyll +0 -0
  3. package/docs/README.md +64 -12
  4. package/docs/_cover.md +6 -6
  5. package/docs/_sidebar.md +2 -0
  6. package/docs/_topbar.md +1 -1
  7. package/docs/_version.json +7 -0
  8. package/docs/collections.md +30 -0
  9. package/docs/css/docuserve.css +327 -0
  10. package/docs/ebook-reader.md +75 -1
  11. package/docs/image-explorer.md +62 -2
  12. package/docs/index.html +39 -0
  13. package/docs/retold-catalog.json +254 -0
  14. package/docs/retold-keyword-index.json +31216 -0
  15. package/docs/server-setup.md +122 -91
  16. package/docs/stack-launcher.md +218 -0
  17. package/docs/synology.md +585 -0
  18. package/docs/ultravisor-configuration.md +5 -5
  19. package/docs/ultravisor-integration.md +4 -2
  20. package/package.json +20 -14
  21. package/source/Pict-Application-RetoldRemote.js +22 -0
  22. package/source/RetoldRemote-ExtensionMaps.js +1 -1
  23. package/source/cli/RetoldRemote-Server-Setup.js +460 -7
  24. package/source/cli/RetoldRemote-Stack-Launcher.js +563 -0
  25. package/source/cli/RetoldRemote-Stack-Run.js +41 -0
  26. package/source/cli/commands/RetoldRemote-Command-Serve.js +129 -54
  27. package/source/providers/CollectionManager-AddItems.js +166 -0
  28. package/source/providers/Pict-Provider-GalleryNavigation.js +55 -0
  29. package/source/providers/Pict-Provider-OperationStatus.js +597 -0
  30. package/source/providers/keyboard-handlers/KeyHandler-ImageExplorer.js +20 -1
  31. package/source/providers/keyboard-handlers/KeyHandler-Viewer.js +23 -0
  32. package/source/server/RetoldRemote-AudioWaveformService.js +49 -3
  33. package/source/server/RetoldRemote-CollectionExportService.js +763 -0
  34. package/source/server/RetoldRemote-CollectionService.js +5 -0
  35. package/source/server/RetoldRemote-EbookService.js +218 -3
  36. package/source/server/RetoldRemote-ImageService.js +221 -46
  37. package/source/server/RetoldRemote-MediaService.js +63 -4
  38. package/source/server/RetoldRemote-MetadataCache.js +25 -5
  39. package/source/server/RetoldRemote-OperationBroadcaster.js +363 -0
  40. package/source/server/RetoldRemote-SubimageService.js +680 -0
  41. package/source/server/RetoldRemote-ToolDetector.js +50 -0
  42. package/source/server/RetoldRemote-UltravisorBeacon.js +18 -3
  43. package/source/server/RetoldRemote-UltravisorDispatcher.js +65 -491
  44. package/source/server/RetoldRemote-UltravisorOperations.js +133 -20
  45. package/source/server/RetoldRemote-VideoFrameService.js +302 -9
  46. package/source/views/MediaViewer-EbookViewer.js +419 -1
  47. package/source/views/MediaViewer-PdfViewer.js +1050 -0
  48. package/source/views/PictView-Remote-AudioExplorer.js +77 -1
  49. package/source/views/PictView-Remote-CollectionsPanel.js +213 -0
  50. package/source/views/PictView-Remote-Gallery.js +365 -64
  51. package/source/views/PictView-Remote-ImageExplorer.js +1529 -44
  52. package/source/views/PictView-Remote-ImageViewer.js +2 -2
  53. package/source/views/PictView-Remote-Layout.js +58 -0
  54. package/source/views/PictView-Remote-MediaViewer.js +100 -25
  55. package/source/views/PictView-Remote-RegionsBrowser.js +554 -0
  56. package/source/views/PictView-Remote-SubimagesPanel.js +353 -0
  57. package/source/views/PictView-Remote-TopBar.js +1 -0
  58. package/source/views/PictView-Remote-VideoExplorer.js +77 -1
  59. package/web-application/css/docuserve.css +277 -23
  60. package/web-application/css/retold-remote.css +343 -20
  61. package/web-application/docs/README.md +64 -12
  62. package/web-application/docs/_cover.md +6 -6
  63. package/web-application/docs/_sidebar.md +2 -0
  64. package/web-application/docs/_topbar.md +1 -1
  65. package/web-application/docs/collections.md +30 -0
  66. package/web-application/docs/ebook-reader.md +75 -1
  67. package/web-application/docs/image-explorer.md +62 -2
  68. package/web-application/docs/server-setup.md +122 -91
  69. package/web-application/docs/stack-launcher.md +218 -0
  70. package/web-application/docs/synology.md +585 -0
  71. package/web-application/docs/ultravisor-configuration.md +5 -5
  72. package/web-application/docs/ultravisor-integration.md +4 -2
  73. package/web-application/js/pict-docuserve.min.js +12 -12
  74. package/web-application/js/pict.min.js +2 -2
  75. package/web-application/js/pict.min.js.map +1 -1
  76. package/web-application/retold-remote.js +6596 -1784
  77. package/web-application/retold-remote.js.map +1 -1
  78. package/web-application/retold-remote.min.js +75 -23
  79. package/web-application/retold-remote.min.js.map +1 -1
@@ -462,6 +462,11 @@ class RetoldRemoteCollectionService extends libFableServiceProviderBase
462
462
  FrameFilename: tmpItem.FrameFilename || null,
463
463
  AudioStart: (typeof tmpItem.AudioStart === 'number') ? tmpItem.AudioStart : null,
464
464
  AudioEnd: (typeof tmpItem.AudioEnd === 'number') ? tmpItem.AudioEnd : null,
465
+ // Document region fields
466
+ PageNumber: (typeof tmpItem.PageNumber === 'number') ? tmpItem.PageNumber : null,
467
+ CFI: tmpItem.CFI || null,
468
+ SelectedText: tmpItem.SelectedText || null,
469
+ DocumentRegionType: tmpItem.DocumentRegionType || null,
465
470
  // Operation fields (for operation-plan collections)
466
471
  Operation: tmpItem.Operation || null,
467
472
  DestinationPath: tmpItem.DestinationPath || null,
@@ -1,12 +1,19 @@
1
1
  /**
2
- * Retold Remote -- Ebook Conversion Service
2
+ * Retold Remote -- Document Conversion Service
3
3
  *
4
- * Converts MOBI/AZW/KF8 ebooks to EPUB using Calibre's ebook-convert tool.
5
- * Conversions are cached so repeated requests are instant.
4
+ * Converts ebooks (MOBI/AZW/KF8) to EPUB and document formats
5
+ * (DOC/DOCX/RTF/ODT/WPD) to PDF for in-browser viewing.
6
+ *
7
+ * Uses:
8
+ * - Calibre's ebook-convert for EPUB conversions
9
+ * - LibreOffice headless for PDF conversions (preferred for layout docs)
10
+ * - ebook-convert as fallback for PDF if LibreOffice unavailable
6
11
  *
7
12
  * API:
8
13
  * convertToEpub(pAbsPath, pRelPath, fCallback)
9
14
  * -> { Success, CacheKey, OutputFilename, SourcePath, FileSize }
15
+ * convertToPdf(pAbsPath, pRelPath, fCallback)
16
+ * -> { Success, CacheKey, OutputFilename, SourcePath, FileSize }
10
17
  *
11
18
  * @license MIT
12
19
  */
@@ -40,6 +47,26 @@ const _ConvertibleExtensions =
40
47
  'cbr': true
41
48
  };
42
49
 
50
+ // Extensions that should be converted to PDF for viewing
51
+ // (layout-heavy documents that benefit from fixed-page rendering)
52
+ const _PdfConvertibleExtensions =
53
+ {
54
+ 'doc': true,
55
+ 'docx': true,
56
+ 'rtf': true,
57
+ 'odt': true,
58
+ 'wpd': true, // WordPerfect
59
+ 'wps': true, // Microsoft Works
60
+ 'pages': true, // Apple Pages (LibreOffice can sometimes handle)
61
+ 'odp': true, // OpenDocument Presentation
62
+ 'ppt': true, // PowerPoint
63
+ 'pptx': true, // PowerPoint (XML)
64
+ 'ods': true, // OpenDocument Spreadsheet
65
+ 'xls': true, // Excel
66
+ 'xlsx': true, // Excel (XML)
67
+ 'csv': true // CSV (renders as table in PDF)
68
+ };
69
+
43
70
  class RetoldRemoteEbookService extends libFableServiceProviderBase
44
71
  {
45
72
  constructor(pFable, pOptions, pServiceHash)
@@ -62,9 +89,26 @@ class RetoldRemoteEbookService extends libFableServiceProviderBase
62
89
  // Ultravisor dispatcher — set via setDispatcher()
63
90
  this._dispatcher = null;
64
91
 
92
+ // Orator-Conversion service reference — set via setConversionService()
93
+ this._conversionService = null;
94
+
95
+ // Operation broadcaster — set via setBroadcaster()
96
+ this._broadcaster = null;
97
+
65
98
  this.fable.log.info('Ebook Service: using ParimeBinaryStorage (category: ebook-cache)');
66
99
  }
67
100
 
101
+ /**
102
+ * Set the orator-conversion service reference for document conversion.
103
+ * Called from Server-Setup after the conversion service is instantiated.
104
+ *
105
+ * @param {object} pService - OratorFileTranslation instance
106
+ */
107
+ setConversionService(pService)
108
+ {
109
+ this._conversionService = pService;
110
+ }
111
+
68
112
  /**
69
113
  * Set the Ultravisor dispatcher for offloading heavy processing.
70
114
  *
@@ -75,6 +119,27 @@ class RetoldRemoteEbookService extends libFableServiceProviderBase
75
119
  this._dispatcher = pDispatcher;
76
120
  }
77
121
 
122
+ /**
123
+ * Set the operation broadcaster for progress events and cancellation.
124
+ */
125
+ setBroadcaster(pBroadcaster)
126
+ {
127
+ this._broadcaster = pBroadcaster;
128
+ }
129
+
130
+ _emitProgress(pOperationId, pPayload)
131
+ {
132
+ if (this._broadcaster && pOperationId)
133
+ {
134
+ this._broadcaster.broadcastProgress(pOperationId, pPayload);
135
+ }
136
+ }
137
+
138
+ _isCancelled(pOperationId)
139
+ {
140
+ return !!(this._broadcaster && pOperationId && this._broadcaster.isCancelled(pOperationId));
141
+ }
142
+
78
143
  /**
79
144
  * Check if a file extension is convertible to EPUB.
80
145
  *
@@ -86,6 +151,17 @@ class RetoldRemoteEbookService extends libFableServiceProviderBase
86
151
  return !!_ConvertibleExtensions[pExtension];
87
152
  }
88
153
 
154
+ /**
155
+ * Check if a file extension can be converted to PDF for viewing.
156
+ *
157
+ * @param {string} pExtension - Lowercase file extension (no dot)
158
+ * @returns {boolean}
159
+ */
160
+ isPdfConvertible(pExtension)
161
+ {
162
+ return !!_PdfConvertibleExtensions[pExtension];
163
+ }
164
+
89
165
  /**
90
166
  * Get the cache directory for a specific ebook file.
91
167
  * The key is based on the absolute path and modification time,
@@ -295,6 +371,145 @@ class RetoldRemoteEbookService extends libFableServiceProviderBase
295
371
  }
296
372
  }
297
373
 
374
+ /**
375
+ * Convert a document to PDF via the orator-conversion doc-to-pdf converter.
376
+ * Falls back to ebook-convert if the conversion service is not available.
377
+ * Results are cached for fast repeated access.
378
+ *
379
+ * @param {string} pAbsPath - Absolute path to the source document
380
+ * @param {string} pRelPath - Relative path (for the response)
381
+ * @param {Function} fCallback - Callback(pError, pResult)
382
+ */
383
+ convertToPdf(pAbsPath, pRelPath, fCallback)
384
+ {
385
+ let tmpSelf = this;
386
+
387
+ // Get file stats for cache key
388
+ let tmpStat;
389
+ try
390
+ {
391
+ tmpStat = libFs.statSync(pAbsPath);
392
+ }
393
+ catch (pError)
394
+ {
395
+ return fCallback(new Error('File not found.'));
396
+ }
397
+
398
+ let tmpCacheDir = this._getCacheDir(pAbsPath, tmpStat.mtimeMs);
399
+
400
+ // Check for cached manifest
401
+ let tmpManifestPath = libPath.join(tmpCacheDir, 'manifest-pdf.json');
402
+ if (libFs.existsSync(tmpManifestPath))
403
+ {
404
+ try
405
+ {
406
+ let tmpManifest = JSON.parse(libFs.readFileSync(tmpManifestPath, 'utf8'));
407
+ let tmpOutputPath = libPath.join(tmpCacheDir, tmpManifest.OutputFilename);
408
+ if (libFs.existsSync(tmpOutputPath))
409
+ {
410
+ this.fable.log.info(`PDF conversion cache hit for ${pRelPath}`);
411
+ return fCallback(null, tmpManifest);
412
+ }
413
+ }
414
+ catch (pError)
415
+ {
416
+ // Corrupted manifest, regenerate
417
+ }
418
+ }
419
+
420
+ // Ensure cache directory exists
421
+ if (!libFs.existsSync(tmpCacheDir))
422
+ {
423
+ libFs.mkdirSync(tmpCacheDir, { recursive: true });
424
+ }
425
+
426
+ let tmpOutputFilename = 'converted.pdf';
427
+ let tmpOutputPath = libPath.join(tmpCacheDir, tmpOutputFilename);
428
+ let tmpExt = libPath.extname(pAbsPath).replace(/^\./, '').toLowerCase();
429
+
430
+ this.fable.log.info(`Converting document to PDF: ${pRelPath}`);
431
+
432
+ // Try orator-conversion doc-to-pdf converter first
433
+ if (this._conversionService && this._conversionService.converters['doc-to-pdf'])
434
+ {
435
+ let tmpInputBuffer = libFs.readFileSync(pAbsPath);
436
+ let tmpMockRequest = { query: { ext: tmpExt }, params: {} };
437
+
438
+ this._conversionService.converters['doc-to-pdf'](tmpInputBuffer, tmpMockRequest,
439
+ (pConvertError, pPdfBuffer) =>
440
+ {
441
+ if (!pConvertError && pPdfBuffer && pPdfBuffer.length > 0)
442
+ {
443
+ libFs.writeFileSync(tmpOutputPath, pPdfBuffer);
444
+ return tmpSelf._finishPdfConversion(tmpOutputPath, tmpOutputFilename, tmpCacheDir, tmpManifestPath, pRelPath, 'orator-conversion', fCallback);
445
+ }
446
+
447
+ // Fall back to ebook-convert
448
+ tmpSelf.fable.log.info(`Orator-conversion failed, trying ebook-convert: ${pConvertError ? pConvertError.message : 'empty output'}`);
449
+ tmpSelf._convertToPdfLocal(pAbsPath, tmpOutputPath, tmpOutputFilename, tmpCacheDir, tmpManifestPath, pRelPath, fCallback);
450
+ });
451
+ }
452
+ else
453
+ {
454
+ // No orator-conversion doc-to-pdf — fall back to ebook-convert
455
+ this._convertToPdfLocal(pAbsPath, tmpOutputPath, tmpOutputFilename, tmpCacheDir, tmpManifestPath, pRelPath, fCallback);
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Convert to PDF locally using Calibre's ebook-convert.
461
+ */
462
+ _convertToPdfLocal(pAbsPath, pOutputPath, pOutputFilename, pCacheDir, pManifestPath, pRelPath, fCallback)
463
+ {
464
+ try
465
+ {
466
+ let tmpCmd = `ebook-convert "${pAbsPath}" "${pOutputPath}"`;
467
+ libChildProcess.execSync(tmpCmd, { stdio: 'ignore', timeout: 120000 });
468
+
469
+ if (!libFs.existsSync(pOutputPath))
470
+ {
471
+ return fCallback(new Error('ebook-convert produced no output file.'));
472
+ }
473
+
474
+ return this._finishPdfConversion(pOutputPath, pOutputFilename, pCacheDir, pManifestPath, pRelPath, 'ebook-convert', fCallback);
475
+ }
476
+ catch (pError)
477
+ {
478
+ return fCallback(new Error('Document conversion failed: ' + pError.message));
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Finalize a PDF conversion: write manifest and return result.
484
+ */
485
+ _finishPdfConversion(pOutputPath, pOutputFilename, pCacheDir, pManifestPath, pRelPath, pTool, fCallback)
486
+ {
487
+ let tmpOutputStat = libFs.statSync(pOutputPath);
488
+
489
+ let tmpResult =
490
+ {
491
+ Success: true,
492
+ SourcePath: pRelPath,
493
+ CacheKey: libPath.basename(pCacheDir),
494
+ OutputFilename: pOutputFilename,
495
+ FileSize: tmpOutputStat.size,
496
+ ConvertedAt: new Date().toISOString(),
497
+ ConvertedWith: pTool
498
+ };
499
+
500
+ try
501
+ {
502
+ libFs.writeFileSync(pManifestPath, JSON.stringify(tmpResult, null, '\t'));
503
+ }
504
+ catch (pWriteError)
505
+ {
506
+ this.fable.log.warn(`Could not write PDF manifest: ${pWriteError.message}`);
507
+ }
508
+
509
+ this.fable.log.info(`Converted to PDF: ${pRelPath} (${tmpOutputStat.size} bytes, via ${pTool})`);
510
+ return fCallback(null, tmpResult);
511
+ }
512
+
298
513
  /**
299
514
  * Get the absolute path to a cached converted ebook file.
300
515
  *
@@ -81,6 +81,9 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
81
81
 
82
82
  // Ultravisor dispatcher — set via setDispatcher()
83
83
  this._dispatcher = null;
84
+
85
+ // Operation broadcaster — set via setBroadcaster()
86
+ this._broadcaster = null;
84
87
  }
85
88
 
86
89
  /**
@@ -93,6 +96,35 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
93
96
  this._dispatcher = pDispatcher;
94
97
  }
95
98
 
99
+ /**
100
+ * Set the operation broadcaster for progress events and cancellation.
101
+ *
102
+ * @param {object} pBroadcaster - RetoldRemoteOperationBroadcaster instance
103
+ */
104
+ setBroadcaster(pBroadcaster)
105
+ {
106
+ this._broadcaster = pBroadcaster;
107
+ }
108
+
109
+ /**
110
+ * Emit a progress event if a broadcaster is attached and an opId was supplied.
111
+ */
112
+ _emitProgress(pOperationId, pPayload)
113
+ {
114
+ if (this._broadcaster && pOperationId)
115
+ {
116
+ this._broadcaster.broadcastProgress(pOperationId, pPayload);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Check whether a given operation has been cancelled.
122
+ */
123
+ _isCancelled(pOperationId)
124
+ {
125
+ return !!(this._broadcaster && pOperationId && this._broadcaster.isCancelled(pOperationId));
126
+ }
127
+
96
128
  /**
97
129
  * Check if sharp is available.
98
130
  *
@@ -776,6 +808,49 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
776
808
  let tmpOutputPath = libPath.join(tmpCacheDir, tmpManifest.OutputFilename);
777
809
  if (libFs.existsSync(tmpOutputPath))
778
810
  {
811
+ // Repair stale manifests written by an older build that
812
+ // hardcoded OrigWidth/OrigHeight to 0 when using the
813
+ // dispatcher path. The front-end relies on these values to
814
+ // (a) display dimensions in the info bar and (b) decide
815
+ // whether to auto-trigger DZI tile generation. If we read
816
+ // them as 0 we can patch the manifest in place using sharp
817
+ // metadata, no full regeneration needed.
818
+ if ((!tmpManifest.OrigWidth || !tmpManifest.OrigHeight) && this._sharp)
819
+ {
820
+ try
821
+ {
822
+ this._sharp(pAbsPath, { limitInputPixels: false }).metadata()
823
+ .then((pMeta) =>
824
+ {
825
+ if (pMeta && pMeta.width && pMeta.height)
826
+ {
827
+ tmpManifest.OrigWidth = pMeta.width;
828
+ tmpManifest.OrigHeight = pMeta.height;
829
+ try
830
+ {
831
+ libFs.writeFileSync(tmpManifestPath, JSON.stringify(tmpManifest, null, '\t'));
832
+ this.fable.log.info(`Image preview manifest repaired with dimensions ${pMeta.width}×${pMeta.height} for ${pRelPath}`);
833
+ }
834
+ catch (pWriteError)
835
+ {
836
+ // Non-fatal — manifest still serves the cached image
837
+ }
838
+ }
839
+ this.fable.log.info(`Image preview cache hit for ${pRelPath}`);
840
+ return fCallback(null, tmpManifest);
841
+ })
842
+ .catch(() =>
843
+ {
844
+ this.fable.log.info(`Image preview cache hit for ${pRelPath}`);
845
+ return fCallback(null, tmpManifest);
846
+ });
847
+ return;
848
+ }
849
+ catch (pSharpError)
850
+ {
851
+ // Fall through to the original cache-hit return
852
+ }
853
+ }
779
854
  this.fable.log.info(`Image preview cache hit for ${pRelPath}`);
780
855
  return fCallback(null, tmpManifest);
781
856
  }
@@ -1146,57 +1221,95 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
1146
1221
  return fCallback(new Error('File is outside content root.'));
1147
1222
  }
1148
1223
 
1149
- this._dispatcher.triggerOperation('rr-image-thumbnail',
1150
- {
1151
- ImageAddress: '>retold-remote/File/' + tmpRelPath,
1152
- Width: pMaxDim,
1153
- Height: pMaxDim,
1154
- Format: 'jpeg',
1155
- Quality: this.options.PreviewQuality || 85
1156
- },
1157
- (pTriggerError, pResult) =>
1224
+ // Read the source image's true dimensions BEFORE dispatching the resize.
1225
+ // We need OrigWidth/OrigHeight in the response so the front-end can:
1226
+ // 1. Display the correct image dimensions in the info bar
1227
+ // 2. Decide whether to auto-trigger DZI tile generation (>4096px on
1228
+ // either side)
1229
+ // Sharp's metadata read is just a header parse — extremely fast — and
1230
+ // is available locally in stack mode. If sharp is not available, we
1231
+ // fall back to 0,0 (which mirrors the older behavior but at least keeps
1232
+ // the dispatch working).
1233
+ let _readSourceMetadata = function (pCallback)
1158
1234
  {
1159
- if (pTriggerError || !pResult || !pResult.OutputBuffer)
1235
+ if (!tmpSelf._sharp)
1160
1236
  {
1161
- return fCallback(new Error('Operation trigger preview generation failed: ' + (pTriggerError ? pTriggerError.message : 'no output')));
1237
+ return pCallback(null, { width: 0, height: 0 });
1162
1238
  }
1163
-
1164
1239
  try
1165
1240
  {
1166
- libFs.writeFileSync(pOutputPath, pResult.OutputBuffer);
1241
+ tmpSelf._sharp(pInputPath, { limitInputPixels: false }).metadata()
1242
+ .then(function (pMeta)
1243
+ {
1244
+ pCallback(null, { width: pMeta.width || 0, height: pMeta.height || 0 });
1245
+ })
1246
+ .catch(function (pError)
1247
+ {
1248
+ tmpSelf.fable.log.warn(`Could not read source metadata for ${pRelPath}: ${pError.message}`);
1249
+ pCallback(null, { width: 0, height: 0 });
1250
+ });
1167
1251
  }
1168
- catch (pWriteError)
1252
+ catch (pSyncError)
1169
1253
  {
1170
- return fCallback(new Error('Failed to write preview output: ' + pWriteError.message));
1254
+ tmpSelf.fable.log.warn(`Sharp metadata threw for ${pRelPath}: ${pSyncError.message}`);
1255
+ return pCallback(null, { width: 0, height: 0 });
1171
1256
  }
1257
+ };
1172
1258
 
1173
- let tmpResult =
1259
+ _readSourceMetadata(function (pMetaErr, pSourceMeta)
1260
+ {
1261
+ tmpSelf._dispatcher.triggerOperation('rr-image-thumbnail',
1174
1262
  {
1175
- Success: true,
1176
- SourcePath: pRelPath,
1177
- CacheKey: pCacheKey,
1178
- OutputFilename: pOutputFilename,
1263
+ ImageAddress: '>retold-remote/File/' + tmpRelPath,
1179
1264
  Width: pMaxDim,
1180
1265
  Height: pMaxDim,
1181
- OrigWidth: 0,
1182
- OrigHeight: 0,
1183
- FileSize: pResult.OutputBuffer.length,
1184
- NeedsPreview: true,
1185
- IsRawFormat: pIsRaw,
1186
- GeneratedAt: new Date().toISOString()
1187
- };
1188
-
1189
- try
1266
+ Format: 'jpeg',
1267
+ Quality: tmpSelf.options.PreviewQuality || 85
1268
+ },
1269
+ (pTriggerError, pResult) =>
1190
1270
  {
1191
- libFs.writeFileSync(pManifestPath, JSON.stringify(tmpResult, null, '\t'));
1192
- }
1193
- catch (pWriteError)
1194
- {
1195
- tmpSelf.fable.log.warn(`Could not write preview manifest: ${pWriteError.message}`);
1196
- }
1271
+ if (pTriggerError || !pResult || !pResult.OutputBuffer)
1272
+ {
1273
+ return fCallback(new Error('Operation trigger preview generation failed: ' + (pTriggerError ? pTriggerError.message : 'no output')));
1274
+ }
1197
1275
 
1198
- tmpSelf.fable.log.info(`Generated image preview (operation trigger): ${pRelPath}`);
1199
- return fCallback(null, tmpResult);
1276
+ try
1277
+ {
1278
+ libFs.writeFileSync(pOutputPath, pResult.OutputBuffer);
1279
+ }
1280
+ catch (pWriteError)
1281
+ {
1282
+ return fCallback(new Error('Failed to write preview output: ' + pWriteError.message));
1283
+ }
1284
+
1285
+ let tmpResult =
1286
+ {
1287
+ Success: true,
1288
+ SourcePath: pRelPath,
1289
+ CacheKey: pCacheKey,
1290
+ OutputFilename: pOutputFilename,
1291
+ Width: pMaxDim,
1292
+ Height: pMaxDim,
1293
+ OrigWidth: pSourceMeta.width || 0,
1294
+ OrigHeight: pSourceMeta.height || 0,
1295
+ FileSize: pResult.OutputBuffer.length,
1296
+ NeedsPreview: true,
1297
+ IsRawFormat: pIsRaw,
1298
+ GeneratedAt: new Date().toISOString()
1299
+ };
1300
+
1301
+ try
1302
+ {
1303
+ libFs.writeFileSync(pManifestPath, JSON.stringify(tmpResult, null, '\t'));
1304
+ }
1305
+ catch (pWriteError)
1306
+ {
1307
+ tmpSelf.fable.log.warn(`Could not write preview manifest: ${pWriteError.message}`);
1308
+ }
1309
+
1310
+ tmpSelf.fable.log.info(`Generated image preview (operation trigger): ${pRelPath} (${tmpResult.OrigWidth}×${tmpResult.OrigHeight})`);
1311
+ return fCallback(null, tmpResult);
1312
+ });
1200
1313
  });
1201
1314
  }
1202
1315
 
@@ -1215,13 +1328,29 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
1215
1328
  * @param {string} pRelPath - Relative path (for the response)
1216
1329
  * @param {Function} fCallback - Callback(pError, pResult)
1217
1330
  */
1218
- generateDziTiles(pAbsPath, pRelPath, fCallback)
1331
+ generateDziTiles(pAbsPath, pRelPath, pOptionsOrCallback, fCallback)
1219
1332
  {
1220
1333
  let tmpSelf = this;
1221
1334
 
1335
+ // Backward-compatible: if third arg is a function, it's the callback
1336
+ // (no options). Otherwise it's an options bag.
1337
+ let tmpOptions;
1338
+ let tmpCallback;
1339
+ if (typeof pOptionsOrCallback === 'function')
1340
+ {
1341
+ tmpOptions = {};
1342
+ tmpCallback = pOptionsOrCallback;
1343
+ }
1344
+ else
1345
+ {
1346
+ tmpOptions = pOptionsOrCallback || {};
1347
+ tmpCallback = fCallback;
1348
+ }
1349
+ let tmpOpId = tmpOptions.OperationId || null;
1350
+
1222
1351
  if (!this._sharp)
1223
1352
  {
1224
- return fCallback(new Error('sharp is not available.'));
1353
+ return tmpCallback(new Error('sharp is not available.'));
1225
1354
  }
1226
1355
 
1227
1356
  // Get file stats for cache key
@@ -1232,7 +1361,7 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
1232
1361
  }
1233
1362
  catch (pError)
1234
1363
  {
1235
- return fCallback(new Error('File not found.'));
1364
+ return tmpCallback(new Error('File not found.'));
1236
1365
  }
1237
1366
 
1238
1367
  let tmpCacheKey = this._buildCacheKey(pAbsPath, tmpStat.mtimeMs, 'dzi');
@@ -1250,7 +1379,8 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
1250
1379
  if (libFs.existsSync(tmpDziPath))
1251
1380
  {
1252
1381
  this.fable.log.info(`DZI tile cache hit for ${pRelPath}`);
1253
- return fCallback(null, tmpManifest);
1382
+ this._emitProgress(tmpOpId, { Phase: 'cached', Message: 'Cached tiles available' });
1383
+ return tmpCallback(null, tmpManifest);
1254
1384
  }
1255
1385
  }
1256
1386
  catch (pError)
@@ -1259,12 +1389,19 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
1259
1389
  }
1260
1390
  }
1261
1391
 
1392
+ // Cancellation gate before the long work starts
1393
+ if (this._isCancelled(tmpOpId))
1394
+ {
1395
+ return tmpCallback(new Error('Cancelled'));
1396
+ }
1397
+
1262
1398
  // If another request is already generating tiles for this file,
1263
1399
  // queue our callback to receive the same result.
1264
1400
  if (this._dziInFlight.has(tmpCacheKey))
1265
1401
  {
1266
1402
  this.fable.log.info(`DZI tiles already generating, queuing: ${pRelPath}`);
1267
- this._dziInFlight.get(tmpCacheKey).push(fCallback);
1403
+ this._emitProgress(tmpOpId, { Phase: 'queued', Message: 'Tile generation already in progress, waiting' });
1404
+ this._dziInFlight.get(tmpCacheKey).push(tmpCallback);
1268
1405
  return;
1269
1406
  }
1270
1407
  this._dziInFlight.set(tmpCacheKey, []);
@@ -1276,10 +1413,17 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
1276
1413
  }
1277
1414
 
1278
1415
  this.fable.log.info(`Generating DZI tiles: ${pRelPath}`);
1416
+ this._emitProgress(tmpOpId,
1417
+ {
1418
+ Phase: 'reading',
1419
+ Message: 'Reading image',
1420
+ Cancelable: true
1421
+ });
1279
1422
 
1280
1423
  // For raw formats, convert first then tile the converted file
1281
1424
  if (this._isRawFormat(pAbsPath))
1282
1425
  {
1426
+ this._emitProgress(tmpOpId, { Phase: 'raw-convert', Message: 'Converting raw camera file' });
1283
1427
  this._ensureConvertedRaw(pAbsPath, tmpStat.mtimeMs, true, (pError, pConvertedPath) =>
1284
1428
  {
1285
1429
  if (pError)
@@ -1291,14 +1435,19 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
1291
1435
  {
1292
1436
  tmpWaiters[i](tmpErr);
1293
1437
  }
1294
- return fCallback(tmpErr);
1438
+ return tmpCallback(tmpErr);
1439
+ }
1440
+ if (tmpSelf._isCancelled(tmpOpId))
1441
+ {
1442
+ tmpSelf._dziInFlight.delete(tmpCacheKey);
1443
+ return tmpCallback(new Error('Cancelled'));
1295
1444
  }
1296
- tmpSelf._doGenerateDziTiles(pConvertedPath, pRelPath, tmpCacheKey, tmpCacheDir, tmpManifestPath, fCallback);
1445
+ tmpSelf._doGenerateDziTiles(pConvertedPath, pRelPath, tmpCacheKey, tmpCacheDir, tmpManifestPath, tmpOpId, tmpCallback);
1297
1446
  });
1298
1447
  }
1299
1448
  else
1300
1449
  {
1301
- this._doGenerateDziTiles(pAbsPath, pRelPath, tmpCacheKey, tmpCacheDir, tmpManifestPath, fCallback);
1450
+ this._doGenerateDziTiles(pAbsPath, pRelPath, tmpCacheKey, tmpCacheDir, tmpManifestPath, tmpOpId, tmpCallback);
1302
1451
  }
1303
1452
  }
1304
1453
 
@@ -1310,21 +1459,46 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
1310
1459
  * @param {string} pCacheKey - Cache key
1311
1460
  * @param {string} pCacheDir - Cache directory
1312
1461
  * @param {string} pManifestPath - Manifest file path
1462
+ * @param {string} pOpId - Optional operation id for progress events
1313
1463
  * @param {Function} fCallback - Callback(pError, pResult)
1314
1464
  */
1315
- _doGenerateDziTiles(pInputPath, pRelPath, pCacheKey, pCacheDir, pManifestPath, fCallback)
1465
+ _doGenerateDziTiles(pInputPath, pRelPath, pCacheKey, pCacheDir, pManifestPath, pOpId, fCallback)
1316
1466
  {
1317
1467
  let tmpSelf = this;
1318
1468
 
1469
+ // Cancellation gate before metadata read
1470
+ if (this._isCancelled(pOpId))
1471
+ {
1472
+ this._dziInFlight.delete(pCacheKey);
1473
+ return fCallback(new Error('Cancelled'));
1474
+ }
1475
+
1476
+ this._emitProgress(pOpId, { Phase: 'metadata', Message: 'Reading image dimensions', Cancelable: true });
1477
+
1319
1478
  // Get metadata first
1320
1479
  this._sharp(pInputPath, { limitInputPixels: false }).metadata()
1321
1480
  .then((pMetadata) =>
1322
1481
  {
1482
+ // Cancellation gate after metadata, before the expensive
1483
+ // tile-generation sharp call
1484
+ if (tmpSelf._isCancelled(pOpId))
1485
+ {
1486
+ tmpSelf._dziInFlight.delete(pCacheKey);
1487
+ return fCallback(new Error('Cancelled'));
1488
+ }
1489
+
1323
1490
  let tmpTileSize = tmpSelf.options.DziTileSize;
1324
1491
  let tmpOverlap = tmpSelf.options.DziOverlap;
1325
1492
  let tmpFormat = tmpSelf.options.DziFormat;
1326
1493
  let tmpQuality = tmpSelf.options.DziQuality;
1327
1494
 
1495
+ tmpSelf._emitProgress(pOpId,
1496
+ {
1497
+ Phase: 'tiling',
1498
+ Message: 'Generating tiles for ' + pMetadata.width + '\u00d7' + pMetadata.height + ' image',
1499
+ Cancelable: true
1500
+ });
1501
+
1328
1502
  // The output filename for sharp's tile() is based on the
1329
1503
  // input to toFile() — sharp generates:
1330
1504
  // {basename}.dzi (the XML descriptor)
@@ -1383,6 +1557,7 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
1383
1557
  }
1384
1558
 
1385
1559
  tmpSelf.fable.log.info(`Generated DZI tiles: ${pRelPath} (${pMetadata.width}×${pMetadata.height})`);
1560
+ tmpSelf._emitProgress(pOpId, { Phase: 'done', Message: 'Tile generation complete' });
1386
1561
 
1387
1562
  // Notify queued waiters
1388
1563
  let tmpWaiters = tmpSelf._dziInFlight.get(pCacheKey) || [];