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
@@ -0,0 +1,763 @@
1
+ /**
2
+ * Retold Remote -- Collection Export Service
3
+ *
4
+ * Exports a collection's items to a subfolder within the content root.
5
+ * Handles all item types:
6
+ * - file: copy directly
7
+ * - subfile: extract from archive
8
+ * - image-crop: extract region via sharp
9
+ * - video-clip: extract clip via ffmpeg
10
+ * - video-frame: extract single frame via ffmpeg
11
+ * - audio-clip: extract segment via ffmpeg
12
+ * - folder/folder-contents: copy folder or contents
13
+ *
14
+ * API:
15
+ * POST /api/collections/:guid/export
16
+ * Body: { DestinationPath: 'relative/path/within/content-root' }
17
+ *
18
+ * @license MIT
19
+ */
20
+ const libFableServiceProviderBase = require('fable-serviceproviderbase');
21
+ const libFs = require('fs');
22
+ const libPath = require('path');
23
+ const libChildProcess = require('child_process');
24
+
25
+ const _DefaultServiceConfiguration =
26
+ {
27
+ "ContentPath": "."
28
+ };
29
+
30
+ class RetoldRemoteCollectionExportService extends libFableServiceProviderBase
31
+ {
32
+ constructor(pFable, pOptions, pServiceHash)
33
+ {
34
+ super(pFable, pOptions, pServiceHash);
35
+
36
+ this.serviceType = 'RetoldRemoteCollectionExportService';
37
+
38
+ for (let tmpKey in _DefaultServiceConfiguration)
39
+ {
40
+ if (!(tmpKey in this.options))
41
+ {
42
+ this.options[tmpKey] = _DefaultServiceConfiguration[tmpKey];
43
+ }
44
+ }
45
+
46
+ this.contentPath = libPath.resolve(this.options.ContentPath);
47
+
48
+ // External dependencies (set via setter methods)
49
+ this._sharp = null;
50
+ this._collectionService = null;
51
+ this._broadcaster = null;
52
+ this._hasFfmpeg = this._detectCommand('ffmpeg -version');
53
+
54
+ this.fable.log.info('Collection Export Service initialized');
55
+ this.fable.log.info(` ffmpeg: ${this._hasFfmpeg ? 'available' : 'not found'}`);
56
+ }
57
+
58
+ /**
59
+ * Set the sharp module reference.
60
+ *
61
+ * @param {object} pSharp - The sharp module
62
+ */
63
+ setSharpModule(pSharp)
64
+ {
65
+ this._sharp = pSharp;
66
+ }
67
+
68
+ /**
69
+ * Set the collection service reference (for reading collections).
70
+ *
71
+ * @param {object} pService - RetoldRemoteCollectionService instance
72
+ */
73
+ setCollectionService(pService)
74
+ {
75
+ this._collectionService = pService;
76
+ }
77
+
78
+ /**
79
+ * Set the operation broadcaster for progress events and cancellation.
80
+ */
81
+ setBroadcaster(pBroadcaster)
82
+ {
83
+ this._broadcaster = pBroadcaster;
84
+ }
85
+
86
+ _emitProgress(pOperationId, pPayload)
87
+ {
88
+ if (this._broadcaster && pOperationId)
89
+ {
90
+ this._broadcaster.broadcastProgress(pOperationId, pPayload);
91
+ }
92
+ }
93
+
94
+ _isCancelled(pOperationId)
95
+ {
96
+ return !!(this._broadcaster && pOperationId && this._broadcaster.isCancelled(pOperationId));
97
+ }
98
+
99
+ /**
100
+ * Check if a command-line tool is available.
101
+ *
102
+ * @param {string} pCommand - The command to test
103
+ * @returns {boolean}
104
+ */
105
+ _detectCommand(pCommand)
106
+ {
107
+ try
108
+ {
109
+ libChildProcess.execSync(pCommand, { stdio: 'ignore', timeout: 5000 });
110
+ return true;
111
+ }
112
+ catch (pError)
113
+ {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Sanitize a string for use as a filename.
120
+ *
121
+ * @param {string} pStr - Input string
122
+ * @returns {string} Sanitized filename
123
+ */
124
+ _sanitizeFilename(pStr)
125
+ {
126
+ return (pStr || 'unnamed')
127
+ .replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
128
+ .replace(/\s+/g, '_')
129
+ .replace(/_+/g, '_')
130
+ .substring(0, 200);
131
+ }
132
+
133
+ /**
134
+ * Format a timestamp in seconds to a compact string for filenames.
135
+ *
136
+ * @param {number} pSeconds - Timestamp in seconds
137
+ * @returns {string} e.g. "1m30s" or "1h02m15s"
138
+ */
139
+ _formatTimestampCompact(pSeconds)
140
+ {
141
+ if (typeof pSeconds !== 'number' || isNaN(pSeconds))
142
+ {
143
+ return '0s';
144
+ }
145
+
146
+ let tmpTotal = Math.floor(pSeconds);
147
+ let tmpHours = Math.floor(tmpTotal / 3600);
148
+ let tmpMinutes = Math.floor((tmpTotal % 3600) / 60);
149
+ let tmpSecs = tmpTotal % 60;
150
+
151
+ if (tmpHours > 0)
152
+ {
153
+ return tmpHours + 'h' + (tmpMinutes < 10 ? '0' : '') + tmpMinutes + 'm' + (tmpSecs < 10 ? '0' : '') + tmpSecs + 's';
154
+ }
155
+ if (tmpMinutes > 0)
156
+ {
157
+ return tmpMinutes + 'm' + (tmpSecs < 10 ? '0' : '') + tmpSecs + 's';
158
+ }
159
+ return tmpSecs + 's';
160
+ }
161
+
162
+ /**
163
+ * Format a ffmpeg-compatible timestamp.
164
+ *
165
+ * @param {number} pSeconds - Timestamp in seconds
166
+ * @returns {string} e.g. "00:01:30.500"
167
+ */
168
+ _formatFfmpegTimestamp(pSeconds)
169
+ {
170
+ let tmpTotal = Math.floor(pSeconds);
171
+ let tmpMs = Math.round((pSeconds - tmpTotal) * 1000);
172
+ let tmpH = Math.floor(tmpTotal / 3600);
173
+ let tmpM = Math.floor((tmpTotal % 3600) / 60);
174
+ let tmpS = tmpTotal % 60;
175
+ return (tmpH < 10 ? '0' : '') + tmpH + ':' +
176
+ (tmpM < 10 ? '0' : '') + tmpM + ':' +
177
+ (tmpS < 10 ? '0' : '') + tmpS + '.' +
178
+ (tmpMs < 100 ? '0' : '') + (tmpMs < 10 ? '0' : '') + tmpMs;
179
+ }
180
+
181
+ /**
182
+ * Get the file extension from a path.
183
+ *
184
+ * @param {string} pPath - File path
185
+ * @returns {string} Extension without dot, lowercase
186
+ */
187
+ _getExtension(pPath)
188
+ {
189
+ return (pPath || '').replace(/^.*\./, '').toLowerCase();
190
+ }
191
+
192
+ /**
193
+ * Export a single item to the destination folder.
194
+ *
195
+ * @param {object} pItem - Collection item
196
+ * @param {number} pIndex - Item index (for filename prefix)
197
+ * @param {string} pDestDir - Absolute destination directory path
198
+ * @param {Function} fCallback - Callback(pError, pResult)
199
+ */
200
+ _exportItem(pItem, pIndex, pDestDir, fCallback)
201
+ {
202
+ let tmpPrefix = String(pIndex + 1).padStart(3, '0');
203
+ let tmpType = pItem.Type || 'file';
204
+
205
+ try
206
+ {
207
+ switch (tmpType)
208
+ {
209
+ case 'file':
210
+ case 'subfile':
211
+ return this._exportFile(pItem, tmpPrefix, pDestDir, fCallback);
212
+
213
+ case 'image-crop':
214
+ return this._exportImageCrop(pItem, tmpPrefix, pDestDir, fCallback);
215
+
216
+ case 'video-clip':
217
+ return this._exportVideoClip(pItem, tmpPrefix, pDestDir, fCallback);
218
+
219
+ case 'video-frame':
220
+ return this._exportVideoFrame(pItem, tmpPrefix, pDestDir, fCallback);
221
+
222
+ case 'audio-clip':
223
+ return this._exportAudioClip(pItem, tmpPrefix, pDestDir, fCallback);
224
+
225
+ case 'document-region':
226
+ return this._exportDocumentRegion(pItem, tmpPrefix, pDestDir, fCallback);
227
+
228
+ case 'folder':
229
+ case 'folder-contents':
230
+ return this._exportFolder(pItem, tmpPrefix, pDestDir, fCallback);
231
+
232
+ default:
233
+ return fCallback(null, { Skipped: true, Reason: 'Unsupported type: ' + tmpType });
234
+ }
235
+ }
236
+ catch (pError)
237
+ {
238
+ return fCallback(null, { Error: pError.message });
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Export a regular file by copying it.
244
+ */
245
+ _exportFile(pItem, pPrefix, pDestDir, fCallback)
246
+ {
247
+ let tmpSrcPath = libPath.join(this.contentPath, pItem.Path);
248
+ if (!libFs.existsSync(tmpSrcPath))
249
+ {
250
+ return fCallback(null, { Error: 'Source file not found: ' + pItem.Path });
251
+ }
252
+
253
+ let tmpOrigName = libPath.basename(pItem.Path);
254
+ let tmpLabel = pItem.Label ? this._sanitizeFilename(pItem.Label) : null;
255
+ let tmpExt = libPath.extname(tmpOrigName);
256
+ let tmpDestName = tmpLabel
257
+ ? pPrefix + '_' + tmpLabel + tmpExt
258
+ : pPrefix + '_' + tmpOrigName;
259
+
260
+ let tmpDestPath = libPath.join(pDestDir, tmpDestName);
261
+
262
+ try
263
+ {
264
+ libFs.copyFileSync(tmpSrcPath, tmpDestPath);
265
+ return fCallback(null, { Exported: tmpDestName });
266
+ }
267
+ catch (pError)
268
+ {
269
+ return fCallback(null, { Error: 'Copy failed: ' + pError.message });
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Export an image crop by extracting the region with sharp.
275
+ */
276
+ _exportImageCrop(pItem, pPrefix, pDestDir, fCallback)
277
+ {
278
+ if (!this._sharp)
279
+ {
280
+ return fCallback(null, { Error: 'sharp not available — cannot export image crop' });
281
+ }
282
+
283
+ let tmpCrop = pItem.CropRegion;
284
+ if (!tmpCrop || !tmpCrop.Width || !tmpCrop.Height)
285
+ {
286
+ return fCallback(null, { Error: 'Invalid crop region' });
287
+ }
288
+
289
+ let tmpSrcPath = libPath.join(this.contentPath, pItem.Path);
290
+ if (!libFs.existsSync(tmpSrcPath))
291
+ {
292
+ return fCallback(null, { Error: 'Source file not found: ' + pItem.Path });
293
+ }
294
+
295
+ let tmpLabel = pItem.Label ? this._sanitizeFilename(pItem.Label) : 'crop';
296
+ let tmpDestName = pPrefix + '_' + tmpLabel + '.jpg';
297
+ let tmpDestPath = libPath.join(pDestDir, tmpDestName);
298
+
299
+ this._sharp(tmpSrcPath, { limitInputPixels: false })
300
+ .extract(
301
+ {
302
+ left: Math.max(0, Math.round(tmpCrop.X)),
303
+ top: Math.max(0, Math.round(tmpCrop.Y)),
304
+ width: Math.round(tmpCrop.Width),
305
+ height: Math.round(tmpCrop.Height)
306
+ })
307
+ .jpeg({ quality: 95 })
308
+ .toFile(tmpDestPath)
309
+ .then(() =>
310
+ {
311
+ return fCallback(null, { Exported: tmpDestName });
312
+ })
313
+ .catch((pError) =>
314
+ {
315
+ return fCallback(null, { Error: 'Image crop failed: ' + pError.message });
316
+ });
317
+ }
318
+
319
+ /**
320
+ * Export a video clip by extracting the time range with ffmpeg.
321
+ */
322
+ _exportVideoClip(pItem, pPrefix, pDestDir, fCallback)
323
+ {
324
+ if (!this._hasFfmpeg)
325
+ {
326
+ return fCallback(null, { Error: 'ffmpeg not available — cannot export video clip' });
327
+ }
328
+
329
+ let tmpSrcPath = libPath.join(this.contentPath, pItem.Path);
330
+ if (!libFs.existsSync(tmpSrcPath))
331
+ {
332
+ return fCallback(null, { Error: 'Source file not found: ' + pItem.Path });
333
+ }
334
+
335
+ let tmpStart = pItem.VideoStart || 0;
336
+ let tmpEnd = pItem.VideoEnd || 0;
337
+ let tmpDuration = tmpEnd - tmpStart;
338
+ if (tmpDuration <= 0)
339
+ {
340
+ return fCallback(null, { Error: 'Invalid video clip range' });
341
+ }
342
+
343
+ let tmpExt = libPath.extname(pItem.Path) || '.mp4';
344
+ let tmpLabel = pItem.Label
345
+ ? this._sanitizeFilename(pItem.Label)
346
+ : 'clip_' + this._formatTimestampCompact(tmpStart) + '-' + this._formatTimestampCompact(tmpEnd);
347
+ let tmpDestName = pPrefix + '_' + tmpLabel + tmpExt;
348
+ let tmpDestPath = libPath.join(pDestDir, tmpDestName);
349
+
350
+ let tmpStartStr = this._formatFfmpegTimestamp(tmpStart);
351
+ let tmpDurationStr = this._formatFfmpegTimestamp(tmpDuration);
352
+
353
+ try
354
+ {
355
+ let tmpCmd = `ffmpeg -ss ${tmpStartStr} -t ${tmpDurationStr} -i "${tmpSrcPath}" -c copy -y "${tmpDestPath}"`;
356
+ libChildProcess.execSync(tmpCmd, { stdio: 'ignore', timeout: 120000 });
357
+
358
+ if (libFs.existsSync(tmpDestPath))
359
+ {
360
+ return fCallback(null, { Exported: tmpDestName });
361
+ }
362
+ return fCallback(null, { Error: 'ffmpeg did not produce output file' });
363
+ }
364
+ catch (pError)
365
+ {
366
+ return fCallback(null, { Error: 'Video clip extraction failed: ' + pError.message });
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Export a single video frame as a JPEG image.
372
+ */
373
+ _exportVideoFrame(pItem, pPrefix, pDestDir, fCallback)
374
+ {
375
+ if (!this._hasFfmpeg)
376
+ {
377
+ return fCallback(null, { Error: 'ffmpeg not available — cannot export video frame' });
378
+ }
379
+
380
+ let tmpSrcPath = libPath.join(this.contentPath, pItem.Path);
381
+ if (!libFs.existsSync(tmpSrcPath))
382
+ {
383
+ return fCallback(null, { Error: 'Source file not found: ' + pItem.Path });
384
+ }
385
+
386
+ let tmpTimestamp = pItem.FrameTimestamp || 0;
387
+ let tmpLabel = pItem.Label
388
+ ? this._sanitizeFilename(pItem.Label)
389
+ : 'frame_' + this._formatTimestampCompact(tmpTimestamp);
390
+ let tmpDestName = pPrefix + '_' + tmpLabel + '.jpg';
391
+ let tmpDestPath = libPath.join(pDestDir, tmpDestName);
392
+
393
+ let tmpTimeStr = this._formatFfmpegTimestamp(tmpTimestamp);
394
+
395
+ try
396
+ {
397
+ let tmpCmd = `ffmpeg -ss ${tmpTimeStr} -i "${tmpSrcPath}" -vframes 1 -f mjpeg -y "${tmpDestPath}"`;
398
+ libChildProcess.execSync(tmpCmd, { stdio: 'ignore', timeout: 30000 });
399
+
400
+ if (libFs.existsSync(tmpDestPath))
401
+ {
402
+ return fCallback(null, { Exported: tmpDestName });
403
+ }
404
+ return fCallback(null, { Error: 'ffmpeg did not produce output file' });
405
+ }
406
+ catch (pError)
407
+ {
408
+ return fCallback(null, { Error: 'Frame extraction failed: ' + pError.message });
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Export an audio clip by extracting the time range with ffmpeg.
414
+ */
415
+ _exportAudioClip(pItem, pPrefix, pDestDir, fCallback)
416
+ {
417
+ if (!this._hasFfmpeg)
418
+ {
419
+ return fCallback(null, { Error: 'ffmpeg not available — cannot export audio clip' });
420
+ }
421
+
422
+ let tmpSrcPath = libPath.join(this.contentPath, pItem.Path);
423
+ if (!libFs.existsSync(tmpSrcPath))
424
+ {
425
+ return fCallback(null, { Error: 'Source file not found: ' + pItem.Path });
426
+ }
427
+
428
+ let tmpStart = pItem.AudioStart || 0;
429
+ let tmpEnd = pItem.AudioEnd || 0;
430
+ let tmpDuration = tmpEnd - tmpStart;
431
+ if (tmpDuration <= 0)
432
+ {
433
+ return fCallback(null, { Error: 'Invalid audio clip range' });
434
+ }
435
+
436
+ let tmpExt = libPath.extname(pItem.Path) || '.mp3';
437
+ let tmpLabel = pItem.Label
438
+ ? this._sanitizeFilename(pItem.Label)
439
+ : 'clip_' + this._formatTimestampCompact(tmpStart) + '-' + this._formatTimestampCompact(tmpEnd);
440
+ let tmpDestName = pPrefix + '_' + tmpLabel + tmpExt;
441
+ let tmpDestPath = libPath.join(pDestDir, tmpDestName);
442
+
443
+ let tmpStartStr = this._formatFfmpegTimestamp(tmpStart);
444
+ let tmpDurationStr = this._formatFfmpegTimestamp(tmpDuration);
445
+
446
+ try
447
+ {
448
+ let tmpCmd = `ffmpeg -ss ${tmpStartStr} -t ${tmpDurationStr} -i "${tmpSrcPath}" -vn -y "${tmpDestPath}"`;
449
+ libChildProcess.execSync(tmpCmd, { stdio: 'ignore', timeout: 60000 });
450
+
451
+ if (libFs.existsSync(tmpDestPath))
452
+ {
453
+ return fCallback(null, { Exported: tmpDestName });
454
+ }
455
+ return fCallback(null, { Error: 'ffmpeg did not produce output file' });
456
+ }
457
+ catch (pError)
458
+ {
459
+ return fCallback(null, { Error: 'Audio clip extraction failed: ' + pError.message });
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Export a document region — text selection as .txt, visual region as image crop if possible.
465
+ */
466
+ _exportDocumentRegion(pItem, pPrefix, pDestDir, fCallback)
467
+ {
468
+ let tmpLabel = pItem.Label ? this._sanitizeFilename(pItem.Label) : 'region';
469
+ let tmpRegionType = pItem.DocumentRegionType || 'text-selection';
470
+
471
+ // If there's selected text, export as a text file
472
+ if (pItem.SelectedText)
473
+ {
474
+ let tmpDestName = pPrefix + '_' + tmpLabel + '.txt';
475
+ let tmpDestPath = libPath.join(pDestDir, tmpDestName);
476
+
477
+ let tmpContent = '';
478
+ if (pItem.Label)
479
+ {
480
+ tmpContent += pItem.Label + '\n';
481
+ tmpContent += '='.repeat(pItem.Label.length) + '\n\n';
482
+ }
483
+ if (pItem.PageNumber)
484
+ {
485
+ tmpContent += 'Page ' + pItem.PageNumber + '\n';
486
+ }
487
+ if (pItem.Path)
488
+ {
489
+ tmpContent += 'Source: ' + pItem.Path + '\n';
490
+ }
491
+ tmpContent += '\n' + pItem.SelectedText;
492
+
493
+ try
494
+ {
495
+ libFs.writeFileSync(tmpDestPath, tmpContent, 'utf8');
496
+ return fCallback(null, { Exported: tmpDestName });
497
+ }
498
+ catch (pError)
499
+ {
500
+ return fCallback(null, { Error: 'Text export failed: ' + pError.message });
501
+ }
502
+ }
503
+
504
+ // Visual region on PDF with crop coordinates — try to export as image
505
+ if (tmpRegionType === 'visual-region' && pItem.CropRegion && pItem.PageNumber && this._sharp)
506
+ {
507
+ // For PDFs with visual regions, we'd need to render the page first
508
+ // This is complex without pdfjs on the server — skip for now and export metadata
509
+ let tmpDestName = pPrefix + '_' + tmpLabel + '.txt';
510
+ let tmpDestPath = libPath.join(pDestDir, tmpDestName);
511
+ let tmpContent = 'Visual region on ' + (pItem.Path || 'unknown') + '\n';
512
+ tmpContent += 'Page ' + pItem.PageNumber + ', Region: '
513
+ + pItem.CropRegion.X + ',' + pItem.CropRegion.Y
514
+ + ' ' + pItem.CropRegion.Width + 'x' + pItem.CropRegion.Height + '\n';
515
+ if (pItem.Label) tmpContent += 'Label: ' + pItem.Label + '\n';
516
+
517
+ try
518
+ {
519
+ libFs.writeFileSync(tmpDestPath, tmpContent, 'utf8');
520
+ return fCallback(null, { Exported: tmpDestName });
521
+ }
522
+ catch (pError)
523
+ {
524
+ return fCallback(null, { Error: 'Export failed: ' + pError.message });
525
+ }
526
+ }
527
+
528
+ return fCallback(null, { Skipped: true, Reason: 'Document region has no exportable content' });
529
+ }
530
+
531
+ /**
532
+ * Export a folder or folder contents by copying files.
533
+ */
534
+ _exportFolder(pItem, pPrefix, pDestDir, fCallback)
535
+ {
536
+ let tmpSrcDir = libPath.join(this.contentPath, pItem.Path);
537
+ if (!libFs.existsSync(tmpSrcDir) || !libFs.statSync(tmpSrcDir).isDirectory())
538
+ {
539
+ return fCallback(null, { Error: 'Source folder not found: ' + pItem.Path });
540
+ }
541
+
542
+ let tmpFolderName = libPath.basename(pItem.Path);
543
+ let tmpLabel = pItem.Label ? this._sanitizeFilename(pItem.Label) : tmpFolderName;
544
+
545
+ if (pItem.Type === 'folder')
546
+ {
547
+ // Copy entire folder
548
+ let tmpDestSubDir = libPath.join(pDestDir, pPrefix + '_' + tmpLabel);
549
+ this._copyDirSync(tmpSrcDir, tmpDestSubDir);
550
+ return fCallback(null, { Exported: pPrefix + '_' + tmpLabel + '/' });
551
+ }
552
+
553
+ // folder-contents: copy all files flat into the dest dir
554
+ let tmpEntries = libFs.readdirSync(tmpSrcDir);
555
+ let tmpCopied = 0;
556
+ for (let i = 0; i < tmpEntries.length; i++)
557
+ {
558
+ let tmpEntry = libPath.join(tmpSrcDir, tmpEntries[i]);
559
+ let tmpStat = libFs.statSync(tmpEntry);
560
+ if (tmpStat.isFile())
561
+ {
562
+ libFs.copyFileSync(tmpEntry, libPath.join(pDestDir, tmpEntries[i]));
563
+ tmpCopied++;
564
+ }
565
+ }
566
+ return fCallback(null, { Exported: tmpCopied + ' files from ' + tmpFolderName });
567
+ }
568
+
569
+ /**
570
+ * Recursively copy a directory.
571
+ */
572
+ _copyDirSync(pSrc, pDest)
573
+ {
574
+ libFs.mkdirSync(pDest, { recursive: true });
575
+ let tmpEntries = libFs.readdirSync(pSrc);
576
+ for (let i = 0; i < tmpEntries.length; i++)
577
+ {
578
+ let tmpSrcPath = libPath.join(pSrc, tmpEntries[i]);
579
+ let tmpDestPath = libPath.join(pDest, tmpEntries[i]);
580
+ let tmpStat = libFs.statSync(tmpSrcPath);
581
+ if (tmpStat.isDirectory())
582
+ {
583
+ this._copyDirSync(tmpSrcPath, tmpDestPath);
584
+ }
585
+ else
586
+ {
587
+ libFs.copyFileSync(tmpSrcPath, tmpDestPath);
588
+ }
589
+ }
590
+ }
591
+
592
+ /**
593
+ * Connect REST routes for collection export.
594
+ *
595
+ * @param {object} pServiceServer - The Orator service server instance
596
+ */
597
+ connectRoutes(pServiceServer)
598
+ {
599
+ let tmpSelf = this;
600
+ let tmpContentPath = this.contentPath;
601
+
602
+ // POST /api/collections/:guid/export
603
+ pServiceServer.post('/api/collections/:guid/export',
604
+ (pRequest, pResponse, fNext) =>
605
+ {
606
+ try
607
+ {
608
+ let tmpGUID = pRequest.params.guid;
609
+ let tmpBody = pRequest.body || {};
610
+ let tmpDestRelPath = tmpBody.DestinationPath;
611
+ let tmpOpId = pRequest.OperationId || null;
612
+
613
+ if (!tmpGUID)
614
+ {
615
+ pResponse.send(400, { Success: false, Error: 'Missing collection GUID.' });
616
+ return fNext();
617
+ }
618
+
619
+ if (!tmpDestRelPath || typeof tmpDestRelPath !== 'string')
620
+ {
621
+ pResponse.send(400, { Success: false, Error: 'Missing DestinationPath in request body.' });
622
+ return fNext();
623
+ }
624
+
625
+ // Sanitize destination path — must be within content root
626
+ tmpDestRelPath = tmpDestRelPath.replace(/^\/+/, '');
627
+ if (tmpDestRelPath.includes('..') || libPath.isAbsolute(tmpDestRelPath))
628
+ {
629
+ pResponse.send(400, { Success: false, Error: 'Destination path must be within the content root.' });
630
+ return fNext();
631
+ }
632
+
633
+ let tmpDestDir = libPath.join(tmpContentPath, tmpDestRelPath);
634
+
635
+ // Read the collection
636
+ tmpSelf.fable.Bibliograph.read('retold-remote-collections', tmpGUID,
637
+ (pReadError, pRecord) =>
638
+ {
639
+ if (pReadError || !pRecord)
640
+ {
641
+ pResponse.send(404, { Success: false, Error: 'Collection not found.' });
642
+ return fNext();
643
+ }
644
+
645
+ let tmpItems = pRecord.Items || [];
646
+ if (tmpItems.length === 0)
647
+ {
648
+ pResponse.send(400, { Success: false, Error: 'Collection is empty.' });
649
+ return fNext();
650
+ }
651
+
652
+ // Create the destination directory
653
+ try
654
+ {
655
+ libFs.mkdirSync(tmpDestDir, { recursive: true });
656
+ }
657
+ catch (pMkdirError)
658
+ {
659
+ pResponse.send(500, { Success: false, Error: 'Failed to create destination directory: ' + pMkdirError.message });
660
+ return fNext();
661
+ }
662
+
663
+ // Export items sequentially (some use async sharp/ffmpeg)
664
+ let tmpResults = [];
665
+ let tmpExportedCount = 0;
666
+ let tmpErrorCount = 0;
667
+ let tmpCancelled = false;
668
+
669
+ tmpSelf._emitProgress(tmpOpId,
670
+ {
671
+ Phase: 'exporting',
672
+ Current: 0,
673
+ Total: tmpItems.length,
674
+ Message: 'Exporting 0 of ' + tmpItems.length + ' items',
675
+ Cancelable: true
676
+ });
677
+
678
+ let tmpExportNext = function (pIdx)
679
+ {
680
+ // Cooperative cancel check before each item
681
+ if (tmpSelf._isCancelled(tmpOpId))
682
+ {
683
+ tmpCancelled = true;
684
+ pResponse.send(
685
+ {
686
+ Success: true,
687
+ Cancelled: true,
688
+ ExportedCount: tmpExportedCount,
689
+ ErrorCount: tmpErrorCount,
690
+ TotalItems: tmpItems.length,
691
+ DestinationPath: tmpDestRelPath,
692
+ Results: tmpResults
693
+ });
694
+ return fNext();
695
+ }
696
+
697
+ if (pIdx >= tmpItems.length)
698
+ {
699
+ // All done
700
+ tmpSelf._emitProgress(tmpOpId,
701
+ {
702
+ Phase: 'done',
703
+ Current: tmpExportedCount,
704
+ Total: tmpItems.length,
705
+ Message: 'Exported ' + tmpExportedCount + ' of ' + tmpItems.length + ' items'
706
+ });
707
+ pResponse.send(
708
+ {
709
+ Success: true,
710
+ ExportedCount: tmpExportedCount,
711
+ ErrorCount: tmpErrorCount,
712
+ TotalItems: tmpItems.length,
713
+ DestinationPath: tmpDestRelPath,
714
+ Results: tmpResults
715
+ });
716
+ return fNext();
717
+ }
718
+
719
+ tmpSelf._exportItem(tmpItems[pIdx], pIdx, tmpDestDir,
720
+ (pItemError, pItemResult) =>
721
+ {
722
+ let tmpResult = pItemResult || {};
723
+ tmpResult.Index = pIdx;
724
+ tmpResult.Type = tmpItems[pIdx].Type;
725
+ tmpResult.Label = tmpItems[pIdx].Label || '';
726
+
727
+ if (tmpResult.Exported)
728
+ {
729
+ tmpExportedCount++;
730
+ }
731
+ else if (tmpResult.Error)
732
+ {
733
+ tmpErrorCount++;
734
+ }
735
+
736
+ tmpResults.push(tmpResult);
737
+
738
+ tmpSelf._emitProgress(tmpOpId,
739
+ {
740
+ Phase: 'exporting',
741
+ Current: pIdx + 1,
742
+ Total: tmpItems.length,
743
+ Message: 'Exported ' + (pIdx + 1) + ' of ' + tmpItems.length + ' items',
744
+ Cancelable: true
745
+ });
746
+
747
+ tmpExportNext(pIdx + 1);
748
+ });
749
+ };
750
+
751
+ tmpExportNext(0);
752
+ });
753
+ }
754
+ catch (pError)
755
+ {
756
+ pResponse.send(500, { Success: false, Error: pError.message });
757
+ return fNext();
758
+ }
759
+ });
760
+ }
761
+ }
762
+
763
+ module.exports = RetoldRemoteCollectionExportService;