retold-remote 0.0.22 → 0.0.25

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