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
@@ -26,6 +26,8 @@
26
26
 
27
27
  const libFs = require('fs');
28
28
  const libPath = require('path');
29
+ const libOs = require('os');
30
+ const libCrypto = require('crypto');
29
31
  const libChildProcess = require('child_process');
30
32
 
31
33
  const libFable = require('fable');
@@ -48,8 +50,12 @@ const libRetoldRemoteMetadataCache = require('../server/RetoldRemote-MetadataCac
48
50
  const libRetoldRemoteFileOperationService = require('../server/RetoldRemote-FileOperationService.js');
49
51
  const libRetoldRemoteAISortService = require('../server/RetoldRemote-AISortService.js');
50
52
  const libRetoldRemoteImageService = require('../server/RetoldRemote-ImageService.js');
53
+ const libRetoldRemoteSubimageService = require('../server/RetoldRemote-SubimageService.js');
54
+ const libRetoldRemoteCollectionExportService = require('../server/RetoldRemote-CollectionExportService.js');
55
+ const libRetoldRemoteOperationBroadcaster = require('../server/RetoldRemote-OperationBroadcaster.js');
51
56
  const libRetoldRemoteUltravisorDispatcher = require('../server/RetoldRemote-UltravisorDispatcher.js');
52
57
  const libRetoldRemoteUltravisorBeacon = require('../server/RetoldRemote-UltravisorBeacon.js');
58
+ const libOratorConversion = require('orator-conversion');
53
59
  const libUrl = require('url');
54
60
 
55
61
  function setupRetoldRemoteServer(pOptions, fCallback)
@@ -104,6 +110,28 @@ function setupRetoldRemoteServer(pOptions, fCallback)
104
110
 
105
111
  let tmpFable = new libFable(tmpSettings);
106
112
 
113
+ // Apply LogNoisiness from RETOLD_LOG_NOISINESS env var (or pOptions override).
114
+ // Pict-style log noisiness is a 0-5 scale where 0 is silent (production
115
+ // default) and 5 shows everything. Diagnostic log statements throughout
116
+ // retold-remote and Ultravisor are gated with `if (this.fable.LogNoisiness
117
+ // >= N)` so they're free at level 0 and explosively detailed at level 4-5.
118
+ // Useful values:
119
+ // 1 — high-level decisions (auto-detected shared-fs peer X)
120
+ // 2 — entry points and decisions in shared-fs / dispatch paths
121
+ // 3 — per-candidate iteration in reachability
122
+ // 4 — per-mount comparison details
123
+ // 5 — everything
124
+ let tmpNoisy = parseInt(pOptions.LogNoisiness, 10);
125
+ if (isNaN(tmpNoisy))
126
+ {
127
+ tmpNoisy = parseInt(process.env.RETOLD_LOG_NOISINESS, 10);
128
+ }
129
+ if (!isNaN(tmpNoisy) && tmpNoisy > 0)
130
+ {
131
+ tmpFable.LogNoisiness = tmpNoisy;
132
+ tmpFable.log.info(`Retold-Remote: LogNoisiness=${tmpNoisy} (verbose diagnostics enabled).`);
133
+ }
134
+
107
135
  // Ensure the content directory exists
108
136
  if (!libFs.existsSync(tmpContentPath))
109
137
  {
@@ -189,6 +217,12 @@ function setupRetoldRemoteServer(pOptions, fCallback)
189
217
  ContentPath: tmpContentPath
190
218
  });
191
219
 
220
+ // Set up the subimage region service
221
+ let tmpSubimageService = new libRetoldRemoteSubimageService(tmpFable,
222
+ {
223
+ ContentPath: tmpContentPath
224
+ });
225
+
192
226
  // Set up the metadata cache service
193
227
  let tmpMetadataCache = new libRetoldRemoteMetadataCache(tmpFable,
194
228
  {
@@ -212,6 +246,12 @@ function setupRetoldRemoteServer(pOptions, fCallback)
212
246
  let tmpCollectionService = new libRetoldRemoteCollectionService(tmpFable, {});
213
247
  tmpCollectionService.setFileOperationService(tmpFileOperationService);
214
248
 
249
+ // Set up the collection export service
250
+ let tmpCollectionExportService = new libRetoldRemoteCollectionExportService(tmpFable,
251
+ {
252
+ ContentPath: tmpContentPath
253
+ });
254
+
215
255
  // Set up the media service
216
256
  let tmpMediaService = new libRetoldRemoteMediaService(tmpFable,
217
257
  {
@@ -226,11 +266,38 @@ function setupRetoldRemoteServer(pOptions, fCallback)
226
266
  // Set up the Ultravisor beacon for mesh registration
227
267
  let tmpBeacon = new libRetoldRemoteUltravisorBeacon(tmpFable, {});
228
268
 
269
+ // Set up the Operation Broadcaster — WebSocket pub/sub for
270
+ // long-running operation progress + cooperative cancellation.
271
+ // Attached to the HTTP server AFTER startService(), below.
272
+ let tmpOperationBroadcaster = new libRetoldRemoteOperationBroadcaster(tmpFable, {});
273
+
274
+ // Set up orator-conversion for document format conversion
275
+ tmpFable.serviceManager.addServiceType('OratorFileTranslation', libOratorConversion);
276
+ let tmpConversionService = tmpFable.serviceManager.instantiateServiceProvider('OratorFileTranslation',
277
+ {
278
+ RoutePrefix: '/api/conversion',
279
+ MaxFileSize: 100 * 1024 * 1024, // 100MB for large documents
280
+ LogLevel: 1
281
+ });
282
+
229
283
  // Wire the dispatcher to services that can offload processing
230
284
  tmpMediaService.setDispatcher(tmpDispatcher);
231
285
  tmpVideoFrameService.setDispatcher(tmpDispatcher);
232
286
  tmpAudioWaveformService.setDispatcher(tmpDispatcher);
233
287
  tmpEbookService.setDispatcher(tmpDispatcher);
288
+
289
+ // Wire the operation broadcaster to services that emit progress.
290
+ // Services must gracefully handle a null broadcaster — this keeps
291
+ // them runnable outside the stack.
292
+ tmpMediaService.setBroadcaster(tmpOperationBroadcaster);
293
+ tmpVideoFrameService.setBroadcaster(tmpOperationBroadcaster);
294
+ tmpAudioWaveformService.setBroadcaster(tmpOperationBroadcaster);
295
+ tmpEbookService.setBroadcaster(tmpOperationBroadcaster);
296
+ tmpImageService.setBroadcaster(tmpOperationBroadcaster);
297
+ tmpCollectionExportService.setBroadcaster(tmpOperationBroadcaster);
298
+
299
+ // Share the orator-conversion service with the ebook service for PDF conversion
300
+ tmpEbookService.setConversionService(tmpConversionService);
234
301
  tmpImageService.setDispatcher(tmpDispatcher);
235
302
 
236
303
  // Share tool capabilities with the image service so it can
@@ -238,10 +305,12 @@ function setupRetoldRemoteServer(pOptions, fCallback)
238
305
  // Also provides the centrally-verified sharp module reference.
239
306
  tmpImageService.setCapabilities(tmpMediaService.capabilities);
240
307
 
241
- // Share the verified sharp module with the metadata cache
308
+ // Share the verified sharp module with the metadata cache, subimage, and export services
242
309
  if (tmpMediaService.capabilities.sharpModule)
243
310
  {
244
311
  tmpMetadataCache.setSharpModule(tmpMediaService.capabilities.sharpModule);
312
+ tmpSubimageService.setSharpModule(tmpMediaService.capabilities.sharpModule);
313
+ tmpCollectionExportService.setSharpModule(tmpMediaService.capabilities.sharpModule);
245
314
  }
246
315
 
247
316
  tmpOrator.initialize(
@@ -252,6 +321,20 @@ function setupRetoldRemoteServer(pOptions, fCallback)
252
321
  // Enable body parsing
253
322
  tmpServiceServer.server.use(tmpServiceServer.bodyParser());
254
323
 
324
+ // X-Op-Id extraction middleware: stash the client-provided
325
+ // operation id on the request so route handlers can pass it
326
+ // into services (for progress emission + cancellation).
327
+ tmpServiceServer.server.use(
328
+ (pRequest, pResponse, fNext) =>
329
+ {
330
+ let tmpOpId = pRequest.headers && pRequest.headers['x-op-id'];
331
+ if (tmpOpId && typeof tmpOpId === 'string' && tmpOpId.length <= 128)
332
+ {
333
+ pRequest.OperationId = tmpOpId;
334
+ }
335
+ return fNext();
336
+ });
337
+
255
338
  // Hash resolution middleware: if hashed filenames is enabled,
256
339
  // intercept ?path= query params and resolve hashes to paths.
257
340
  // Uses three-tier lookup (memory → Bibliograph → directory walk).
@@ -470,6 +553,74 @@ function setupRetoldRemoteServer(pOptions, fCallback)
470
553
  // Connect collection service API routes
471
554
  tmpCollectionService.connectRoutes(tmpServiceServer);
472
555
 
556
+ // Connect subimage region service API routes
557
+ tmpSubimageService.connectRoutes(tmpServiceServer);
558
+
559
+ // Connect collection export service API routes
560
+ tmpCollectionExportService.connectRoutes(tmpServiceServer);
561
+
562
+ // Connect orator-conversion routes and register custom doc-to-pdf converter
563
+ if (tmpMediaService.capabilities.libreoffice)
564
+ {
565
+ let tmpSofficePath = 'soffice';
566
+ if (libFs.existsSync('/Applications/LibreOffice.app/Contents/MacOS/soffice'))
567
+ {
568
+ tmpSofficePath = '/Applications/LibreOffice.app/Contents/MacOS/soffice';
569
+ }
570
+
571
+ tmpConversionService.addConverter('doc-to-pdf',
572
+ (pInputBuffer, pRequest, fCallback) =>
573
+ {
574
+ // Write input to temp file, convert with LibreOffice, return PDF buffer
575
+ let tmpTempDir = require('os').tmpdir();
576
+ let tmpUniqueId = Date.now() + '_' + Math.random().toString(36).slice(2);
577
+ let tmpInputExt = (pRequest.query && pRequest.query.ext) || 'docx';
578
+ let tmpInputPath = libPath.join(tmpTempDir, 'retold_doc_' + tmpUniqueId + '.' + tmpInputExt);
579
+ let tmpOutputDir = libPath.join(tmpTempDir, 'retold_docout_' + tmpUniqueId);
580
+
581
+ libFs.mkdirSync(tmpOutputDir, { recursive: true });
582
+
583
+ try
584
+ {
585
+ libFs.writeFileSync(tmpInputPath, pInputBuffer);
586
+ libChildProcess.execSync(
587
+ '"' + tmpSofficePath + '" --headless --convert-to pdf --outdir "' + tmpOutputDir + '" "' + tmpInputPath + '"',
588
+ { stdio: 'ignore', timeout: 120000 });
589
+
590
+ // Find the output PDF (LibreOffice names it after the input)
591
+ let tmpBaseName = libPath.basename(tmpInputPath, '.' + tmpInputExt);
592
+ let tmpOutputPath = libPath.join(tmpOutputDir, tmpBaseName + '.pdf');
593
+
594
+ if (!libFs.existsSync(tmpOutputPath))
595
+ {
596
+ // Clean up
597
+ try { libFs.unlinkSync(tmpInputPath); } catch (e) { /* */ }
598
+ try { libFs.rmSync(tmpOutputDir, { recursive: true }); } catch (e) { /* */ }
599
+ return fCallback(new Error('LibreOffice conversion produced no output.'));
600
+ }
601
+
602
+ let tmpPdfBuffer = libFs.readFileSync(tmpOutputPath);
603
+
604
+ // Clean up temp files
605
+ try { libFs.unlinkSync(tmpInputPath); } catch (e) { /* */ }
606
+ try { libFs.rmSync(tmpOutputDir, { recursive: true }); } catch (e) { /* */ }
607
+
608
+ return fCallback(null, tmpPdfBuffer, 'application/pdf');
609
+ }
610
+ catch (pError)
611
+ {
612
+ try { libFs.unlinkSync(tmpInputPath); } catch (e) { /* */ }
613
+ try { libFs.rmSync(tmpOutputDir, { recursive: true }); } catch (e) { /* */ }
614
+ return fCallback(new Error('Document conversion failed: ' + pError.message));
615
+ }
616
+ });
617
+
618
+ tmpFable.log.info('Orator-Conversion: doc-to-pdf converter registered (LibreOffice)');
619
+ }
620
+
621
+ tmpConversionService.connectRoutes();
622
+ tmpFable.log.info('Orator-Conversion: routes connected at /api/conversion/1.0/');
623
+
473
624
  // Connect AI sort service API routes
474
625
  tmpAISortService.connectRoutes(tmpServiceServer);
475
626
 
@@ -486,6 +637,10 @@ function setupRetoldRemoteServer(pOptions, fCallback)
486
637
  {
487
638
  tmpFable.log.info('Audio explorer state Bibliograph source initialized.');
488
639
  });
640
+ tmpSubimageService.initializeState(() =>
641
+ {
642
+ tmpFable.log.info('Subimage region state Bibliograph source initialized.');
643
+ });
489
644
  tmpEpubMetadataService.initialize(() =>
490
645
  {
491
646
  tmpFable.log.info('EPUB metadata service initialized.');
@@ -495,6 +650,112 @@ function setupRetoldRemoteServer(pOptions, fCallback)
495
650
  // Non-fatal — if Ultravisor is down, processing stays local
496
651
  });
497
652
 
653
+ // --- GET /api/media/pdf-text ---
654
+ // Extract text from a specific PDF page (or all pages if no page specified).
655
+ tmpServiceServer.get('/api/media/pdf-text',
656
+ (pRequest, pResponse, fNext) =>
657
+ {
658
+ try
659
+ {
660
+ let tmpParsedUrl = libUrl.parse(pRequest.url, true);
661
+ let tmpQuery = tmpParsedUrl.query;
662
+ let tmpRelPath = tmpQuery.path;
663
+ let tmpPageNum = parseInt(tmpQuery.page, 10) || 0;
664
+
665
+ if (!tmpRelPath || typeof (tmpRelPath) !== 'string')
666
+ {
667
+ pResponse.send(400, { Success: false, Error: 'Missing path parameter.' });
668
+ return fNext();
669
+ }
670
+
671
+ tmpRelPath = decodeURIComponent(tmpRelPath).replace(/^\/+/, '');
672
+ if (tmpRelPath.includes('..') || libPath.isAbsolute(tmpRelPath))
673
+ {
674
+ pResponse.send(400, { Success: false, Error: 'Invalid path.' });
675
+ return fNext();
676
+ }
677
+
678
+ let tmpAbsPath = libPath.join(tmpContentPath, tmpRelPath);
679
+ if (!libFs.existsSync(tmpAbsPath))
680
+ {
681
+ pResponse.send(404, { Success: false, Error: 'File not found.' });
682
+ return fNext();
683
+ }
684
+
685
+ let tmpExt = tmpRelPath.replace(/^.*\./, '').toLowerCase();
686
+ if (tmpExt !== 'pdf')
687
+ {
688
+ pResponse.send(400, { Success: false, Error: 'Not a PDF file.' });
689
+ return fNext();
690
+ }
691
+
692
+ // pdf-parse v2.x exports a PDFParse class with async methods.
693
+ // (v1.x used to export a default async function — that API is gone.)
694
+ let tmpPdfParseModule = require('pdf-parse');
695
+ let tmpPDFParseClass = tmpPdfParseModule.PDFParse;
696
+ if (typeof tmpPDFParseClass !== 'function')
697
+ {
698
+ pResponse.send(500, { Success: false, Error: 'pdf-parse module does not export PDFParse class. Update pdf-parse.' });
699
+ return fNext();
700
+ }
701
+
702
+ let tmpBuffer = libFs.readFileSync(tmpAbsPath);
703
+ let tmpParser = new tmpPDFParseClass({ data: tmpBuffer });
704
+
705
+ let tmpExtractText;
706
+ if (tmpPageNum > 0)
707
+ {
708
+ // Single page text — first/last bound the range
709
+ tmpExtractText = tmpParser.getText({ first: tmpPageNum, last: tmpPageNum });
710
+ }
711
+ else
712
+ {
713
+ // All pages
714
+ tmpExtractText = tmpParser.getText();
715
+ }
716
+
717
+ tmpExtractText
718
+ .then((pData) =>
719
+ {
720
+ // pdf-parse v2 result shape: { text, total, pages: [...] } or similar
721
+ let tmpText = (pData && typeof pData.text === 'string') ? pData.text : '';
722
+ let tmpPageCount = (pData && typeof pData.total === 'number') ? pData.total
723
+ : (pData && Array.isArray(pData.pages)) ? pData.pages.length : 0;
724
+
725
+ pResponse.send(
726
+ {
727
+ Success: true,
728
+ Path: tmpRelPath,
729
+ PageCount: tmpPageCount,
730
+ Text: tmpText,
731
+ RequestedPage: tmpPageNum || null
732
+ });
733
+
734
+ // Release native resources
735
+ if (typeof tmpParser.destroy === 'function')
736
+ {
737
+ tmpParser.destroy().catch(() => { /* ignore */ });
738
+ }
739
+
740
+ return fNext();
741
+ })
742
+ .catch((pPdfError) =>
743
+ {
744
+ if (typeof tmpParser.destroy === 'function')
745
+ {
746
+ tmpParser.destroy().catch(() => { /* ignore */ });
747
+ }
748
+ pResponse.send(500, { Success: false, Error: 'PDF parse failed: ' + pPdfError.message });
749
+ return fNext();
750
+ });
751
+ }
752
+ catch (pError)
753
+ {
754
+ pResponse.send(500, { Success: false, Error: pError.message });
755
+ return fNext();
756
+ }
757
+ });
758
+
498
759
  // --- GET /api/media/metadata ---
499
760
  // Get cached metadata (with ID3/format tags) for a single file.
500
761
  tmpServiceServer.get('/api/media/metadata',
@@ -696,7 +957,8 @@ function setupRetoldRemoteServer(pOptions, fCallback)
696
957
  count: tmpQuery.count,
697
958
  width: tmpQuery.width,
698
959
  height: tmpQuery.height,
699
- format: tmpQuery.format
960
+ format: tmpQuery.format,
961
+ OperationId: pRequest.OperationId || null
700
962
  },
701
963
  (pError, pResult) =>
702
964
  {
@@ -1307,9 +1569,17 @@ function setupRetoldRemoteServer(pOptions, fCallback)
1307
1569
 
1308
1570
  let tmpStat = libFs.statSync(tmpEbookPath);
1309
1571
 
1572
+ // Determine content type based on file extension
1573
+ let tmpEbookExt = tmpFilename.replace(/^.*\./, '').toLowerCase();
1574
+ let tmpContentType = 'application/epub+zip';
1575
+ if (tmpEbookExt === 'pdf')
1576
+ {
1577
+ tmpContentType = 'application/pdf';
1578
+ }
1579
+
1310
1580
  pResponse.writeHead(200,
1311
1581
  {
1312
- 'Content-Type': 'application/epub+zip',
1582
+ 'Content-Type': tmpContentType,
1313
1583
  'Content-Length': tmpStat.size,
1314
1584
  'Cache-Control': 'public, max-age=86400'
1315
1585
  });
@@ -1330,6 +1600,57 @@ function setupRetoldRemoteServer(pOptions, fCallback)
1330
1600
  }
1331
1601
  });
1332
1602
 
1603
+ // --- GET /api/media/doc-convert ---
1604
+ // Convert a document (DOC, DOCX, RTF, ODT, WPD, etc.) to PDF.
1605
+ tmpServiceServer.get('/api/media/doc-convert',
1606
+ (pRequest, pResponse, fNext) =>
1607
+ {
1608
+ try
1609
+ {
1610
+ let tmpParsedUrl = libUrl.parse(pRequest.url, true);
1611
+ let tmpQuery = tmpParsedUrl.query;
1612
+ let tmpRelPath = tmpQuery.path;
1613
+
1614
+ if (!tmpRelPath || typeof (tmpRelPath) !== 'string')
1615
+ {
1616
+ pResponse.send(400, { Success: false, Error: 'Missing path parameter.' });
1617
+ return fNext();
1618
+ }
1619
+
1620
+ tmpRelPath = decodeURIComponent(tmpRelPath).replace(/^\/+/, '');
1621
+ if (tmpRelPath.includes('..') || libPath.isAbsolute(tmpRelPath))
1622
+ {
1623
+ pResponse.send(400, { Success: false, Error: 'Invalid path.' });
1624
+ return fNext();
1625
+ }
1626
+
1627
+ let tmpAbsPath = libPath.join(tmpContentPath, tmpRelPath);
1628
+ if (!libFs.existsSync(tmpAbsPath))
1629
+ {
1630
+ pResponse.send(404, { Success: false, Error: 'File not found.' });
1631
+ return fNext();
1632
+ }
1633
+
1634
+ tmpEbookService.convertToPdf(tmpAbsPath, tmpRelPath,
1635
+ (pError, pResult) =>
1636
+ {
1637
+ if (pError)
1638
+ {
1639
+ pResponse.send(400, { Success: false, Error: pError.message });
1640
+ return fNext();
1641
+ }
1642
+
1643
+ pResponse.send(pResult);
1644
+ return fNext();
1645
+ });
1646
+ }
1647
+ catch (pError)
1648
+ {
1649
+ pResponse.send(500, { Success: false, Error: pError.message });
1650
+ return fNext();
1651
+ }
1652
+ });
1653
+
1333
1654
  // --- GET /api/media/ebook-metadata ---
1334
1655
  // Extract and return cached EPUB metadata (TOC, spine, word counts, etc.)
1335
1656
  tmpServiceServer.get('/api/media/ebook-metadata',
@@ -1720,6 +2041,7 @@ function setupRetoldRemoteServer(pOptions, fCallback)
1720
2041
  }
1721
2042
 
1722
2043
  tmpImageService.generateDziTiles(tmpAbsPath, tmpRelPath,
2044
+ { OperationId: pRequest.OperationId || null },
1723
2045
  (pError, pResult) =>
1724
2046
  {
1725
2047
  if (pError)
@@ -1775,6 +2097,18 @@ function setupRetoldRemoteServer(pOptions, fCallback)
1775
2097
 
1776
2098
  // --- GET /api/media/dzi-tile/:cacheKey/:level/:tile ---
1777
2099
  // Serve an individual DZI tile image.
2100
+ //
2101
+ // OpenSeadragon's DziTileSource.configure() rewrites whatever Url
2102
+ // we hand it and appends a "_files" suffix to the last path segment
2103
+ // (that's how the standard DZI directory layout works — a foo.dzi
2104
+ // descriptor expects the tiles in a sibling foo_files/ directory).
2105
+ // So when we hand OSD `Url: '/api/media/dzi-tile/{cacheKey}/'` it
2106
+ // rewrites the tilesUrl to `/api/media/dzi-tile/{cacheKey}_files/`
2107
+ // and asks for tiles at `/api/media/dzi-tile/{cacheKey}_files/{level}/{x}_{y}.jpg`.
2108
+ //
2109
+ // Strip the trailing "_files" from the cacheKey before resolving so
2110
+ // both the canonical URL (used by anything that bypasses OSD) and
2111
+ // the OSD-generated URL hit the same on-disk cache directory.
1778
2112
  tmpServiceServer.get('/api/media/dzi-tile/:cacheKey/:level/:tile',
1779
2113
  (pRequest, pResponse, fNext) =>
1780
2114
  {
@@ -1783,6 +2117,10 @@ function setupRetoldRemoteServer(pOptions, fCallback)
1783
2117
  let tmpCacheKey = pRequest.params.cacheKey;
1784
2118
  let tmpLevel = pRequest.params.level;
1785
2119
  let tmpTile = pRequest.params.tile;
2120
+ if (typeof tmpCacheKey === 'string' && tmpCacheKey.endsWith('_files'))
2121
+ {
2122
+ tmpCacheKey = tmpCacheKey.slice(0, -6);
2123
+ }
1786
2124
  let tmpPath = tmpImageService.getDziTilePath(tmpCacheKey, tmpLevel, tmpTile);
1787
2125
 
1788
2126
  if (!tmpPath)
@@ -2138,6 +2476,29 @@ function setupRetoldRemoteServer(pOptions, fCallback)
2138
2476
  tmpOrator.startService(
2139
2477
  function ()
2140
2478
  {
2479
+ // Attach the operation broadcaster to the underlying Node
2480
+ // http.Server so WebSocket upgrades on /ws/operations reach
2481
+ // our pub/sub hub. The Restify server exposes the raw
2482
+ // http.Server as `.server`.
2483
+ try
2484
+ {
2485
+ let tmpHttpServer = tmpOrator.serviceServer && tmpOrator.serviceServer.server
2486
+ ? tmpOrator.serviceServer.server.server
2487
+ : null;
2488
+ if (tmpHttpServer)
2489
+ {
2490
+ tmpOperationBroadcaster.attachTo(tmpHttpServer);
2491
+ }
2492
+ else
2493
+ {
2494
+ tmpFable.log.warn('OperationBroadcaster: could not find underlying http.Server; WebSocket status unavailable');
2495
+ }
2496
+ }
2497
+ catch (pAttachError)
2498
+ {
2499
+ tmpFable.log.warn('OperationBroadcaster attach failed: ' + pAttachError.message);
2500
+ }
2501
+
2141
2502
  let tmpServerInfo =
2142
2503
  {
2143
2504
  Fable: tmpFable,
@@ -2146,6 +2507,10 @@ function setupRetoldRemoteServer(pOptions, fCallback)
2146
2507
  ArchiveService: tmpArchiveService,
2147
2508
  VideoFrameService: tmpVideoFrameService,
2148
2509
  AudioWaveformService: tmpAudioWaveformService,
2510
+ SubimageService: tmpSubimageService,
2511
+ CollectionExportService: tmpCollectionExportService,
2512
+ ConversionService: tmpConversionService,
2513
+ OperationBroadcaster: tmpOperationBroadcaster,
2149
2514
  PathRegistry: tmpPathRegistry,
2150
2515
  ParimeCache: tmpParimeCache,
2151
2516
  MetadataCache: tmpMetadataCache,
@@ -2160,7 +2525,7 @@ function setupRetoldRemoteServer(pOptions, fCallback)
2160
2525
  {
2161
2526
  // Discover bind addresses from network interfaces
2162
2527
  let tmpBindAddresses = [];
2163
- let tmpNetworkInterfaces = require('os').networkInterfaces();
2528
+ let tmpNetworkInterfaces = libOs.networkInterfaces();
2164
2529
  for (let tmpIfName of Object.keys(tmpNetworkInterfaces))
2165
2530
  {
2166
2531
  for (let tmpIf of tmpNetworkInterfaces[tmpIfName])
@@ -2174,6 +2539,48 @@ function setupRetoldRemoteServer(pOptions, fCallback)
2174
2539
  // Always include loopback as a fallback
2175
2540
  tmpBindAddresses.push({ IP: '127.0.0.1', Port: tmpPort, Protocol: 'http' });
2176
2541
 
2542
+ // Shared-fs identity: when retold-remote and the in-process
2543
+ // orator-conversion beacon both live in the same container/host,
2544
+ // they share the same content mount. By computing one HostID and
2545
+ // one MountID here and passing the SAME values to both beacons,
2546
+ // the Ultravisor reachability matrix can detect the overlap and
2547
+ // pick the 'shared-fs' strategy instead of forcing a 374 MB
2548
+ // HTTP file-transfer between two beacons in the same process.
2549
+ //
2550
+ // The RETOLD_SHARED_FS_ENABLED env var provides a test hook to
2551
+ // disable the shared-fs advertisement. When set to 'false' or '0'
2552
+ // both beacons skip SharedMounts, which forces the reachability
2553
+ // matrix to fall through to 'direct' (HTTP file-transfer) or
2554
+ // 'proxy'. This lets the docker smoke test exercise the legacy
2555
+ // path to confirm nothing has regressed there.
2556
+ let tmpHostID = libOs.hostname();
2557
+ let tmpSharedMounts = [];
2558
+ let tmpSharedFsEnvValue = (typeof process.env.RETOLD_SHARED_FS_ENABLED === 'string')
2559
+ ? process.env.RETOLD_SHARED_FS_ENABLED.trim().toLowerCase()
2560
+ : '';
2561
+ let tmpSharedFsEnabled = !(tmpSharedFsEnvValue === 'false' || tmpSharedFsEnvValue === '0' || tmpSharedFsEnvValue === 'no' || tmpSharedFsEnvValue === 'off');
2562
+ if (!tmpSharedFsEnabled)
2563
+ {
2564
+ tmpFable.log.warn(`Ultravisor Beacons: RETOLD_SHARED_FS_ENABLED=${process.env.RETOLD_SHARED_FS_ENABLED} -- SharedMounts advertisement DISABLED (forcing direct/proxy transfer strategy).`);
2565
+ }
2566
+ else
2567
+ {
2568
+ try
2569
+ {
2570
+ let tmpMountRoot = libPath.resolve(tmpContentPath);
2571
+ let tmpStat = libFs.statSync(tmpMountRoot);
2572
+ let tmpMountID = libCrypto.createHash('sha256')
2573
+ .update(tmpStat.dev + ':' + tmpMountRoot)
2574
+ .digest('hex').substring(0, 16);
2575
+ tmpSharedMounts.push({ MountID: tmpMountID, Root: tmpMountRoot });
2576
+ tmpFable.log.info(`Ultravisor Beacons: advertising shared mount [${tmpMountID}] = ${tmpMountRoot} (HostID: ${tmpHostID})`);
2577
+ }
2578
+ catch (pMountError)
2579
+ {
2580
+ tmpFable.log.warn(`Ultravisor Beacons: could not stat content path for shared-fs detection: ${pMountError.message}`);
2581
+ }
2582
+ }
2583
+
2177
2584
  tmpBeacon.connectBeacon(
2178
2585
  {
2179
2586
  ServerURL: pOptions.UltravisorURL,
@@ -2182,7 +2589,9 @@ function setupRetoldRemoteServer(pOptions, fCallback)
2182
2589
  ContentBaseURL: '/content/',
2183
2590
  CacheRoot: tmpCacheRoot,
2184
2591
  StagingPath: tmpCacheRoot || process.cwd(),
2185
- BindAddresses: tmpBindAddresses
2592
+ BindAddresses: tmpBindAddresses,
2593
+ HostID: tmpHostID,
2594
+ SharedMounts: tmpSharedMounts
2186
2595
  },
2187
2596
  (pBeaconError) =>
2188
2597
  {
@@ -2191,8 +2600,52 @@ function setupRetoldRemoteServer(pOptions, fCallback)
2191
2600
  tmpFable.log.warn(`Ultravisor Beacon: registration failed (server may not be running): ${pBeaconError.message}`);
2192
2601
  tmpFable.log.warn('Ultravisor Beacon: server is still running. Beacon will not be active.');
2193
2602
  }
2194
- // Non-fatal — return server info regardless
2195
- return fCallback(null, tmpServerInfo);
2603
+
2604
+ // Also register orator-conversion as a separate beacon
2605
+ // so its MediaConversion capability (ImageResize, VideoExtractFrame,
2606
+ // etc.) becomes available as auto-generated task types in the
2607
+ // coordinator's catalog. Without this, operations like
2608
+ // rr-image-thumbnail can't find their `beacon-mediaconversion-imageresize`
2609
+ // process node and silently fall back to local processing.
2610
+ //
2611
+ // We pass the SAME HostID and SharedMounts as the retold-remote
2612
+ // beacon above so the reachability matrix knows these two beacons
2613
+ // can read each other's files directly through the shared mount.
2614
+ try
2615
+ {
2616
+ let tmpConvStagingPath = libPath.join(tmpCacheRoot || process.cwd(), 'orator-conversion-staging');
2617
+ tmpConversionService.connectBeacon(
2618
+ {
2619
+ ServerURL: pOptions.UltravisorURL,
2620
+ Name: 'orator-conversion',
2621
+ MaxConcurrent: 2,
2622
+ StagingPath: tmpConvStagingPath,
2623
+ BindAddresses: tmpBindAddresses,
2624
+ FfmpegPath: 'ffmpeg',
2625
+ FfprobePath: 'ffprobe',
2626
+ HostID: tmpHostID,
2627
+ SharedMounts: tmpSharedMounts
2628
+ },
2629
+ (pConvBeaconError) =>
2630
+ {
2631
+ if (pConvBeaconError)
2632
+ {
2633
+ tmpFable.log.warn('Orator-Conversion beacon registration failed: ' + pConvBeaconError.message);
2634
+ tmpFable.log.warn('Orator-Conversion: media conversion task types will not be available; operations will fall back to local processing.');
2635
+ }
2636
+ else
2637
+ {
2638
+ tmpFable.log.info('Orator-Conversion beacon connected — MediaConversion capability registered with coordinator.');
2639
+ }
2640
+ // Non-fatal — return server info regardless
2641
+ return fCallback(null, tmpServerInfo);
2642
+ });
2643
+ }
2644
+ catch (pConvSetupError)
2645
+ {
2646
+ tmpFable.log.warn('Orator-Conversion beacon setup failed: ' + pConvSetupError.message);
2647
+ return fCallback(null, tmpServerInfo);
2648
+ }
2196
2649
  });
2197
2650
  }
2198
2651
  else