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.
- package/css/retold-remote.css +87 -20
- package/docs/README.md +59 -11
- package/docs/_sidebar.md +1 -0
- package/docs/collections.md +30 -0
- package/docs/ebook-reader.md +75 -1
- package/docs/image-explorer.md +27 -1
- package/docs/server-setup.md +28 -18
- package/docs/stack-launcher.md +218 -0
- package/docs/ultravisor-integration.md +2 -0
- package/package.json +10 -7
- package/source/Pict-Application-RetoldRemote.js +2 -0
- package/source/RetoldRemote-ExtensionMaps.js +1 -1
- package/source/cli/RetoldRemote-Server-Setup.js +240 -2
- package/source/cli/RetoldRemote-Stack-Launcher.js +387 -0
- package/source/cli/RetoldRemote-Stack-Run.js +41 -0
- package/source/cli/commands/RetoldRemote-Command-Serve.js +129 -54
- package/source/providers/CollectionManager-AddItems.js +166 -0
- package/source/providers/Pict-Provider-GalleryNavigation.js +46 -0
- package/source/providers/keyboard-handlers/KeyHandler-ImageExplorer.js +5 -0
- package/source/providers/keyboard-handlers/KeyHandler-Viewer.js +23 -0
- package/source/server/RetoldRemote-CollectionExportService.js +696 -0
- package/source/server/RetoldRemote-CollectionService.js +5 -0
- package/source/server/RetoldRemote-EbookService.js +194 -3
- package/source/server/RetoldRemote-SubimageService.js +530 -0
- package/source/server/RetoldRemote-ToolDetector.js +50 -0
- package/source/server/RetoldRemote-UltravisorOperations.js +6 -6
- package/source/views/MediaViewer-EbookViewer.js +419 -1
- package/source/views/MediaViewer-PdfViewer.js +963 -0
- package/source/views/PictView-Remote-CollectionsPanel.js +166 -0
- package/source/views/PictView-Remote-ImageExplorer.js +606 -1
- package/source/views/PictView-Remote-ImageViewer.js +2 -2
- package/source/views/PictView-Remote-Layout.js +12 -0
- package/source/views/PictView-Remote-MediaViewer.js +83 -25
- package/source/views/PictView-Remote-SubimagesPanel.js +353 -0
- package/web-application/css/retold-remote.css +87 -20
- package/web-application/docs/README.md +59 -11
- package/web-application/docs/_sidebar.md +1 -0
- package/web-application/docs/collections.md +30 -0
- package/web-application/docs/ebook-reader.md +75 -1
- package/web-application/docs/image-explorer.md +27 -1
- package/web-application/docs/server-setup.md +28 -18
- package/web-application/docs/stack-launcher.md +218 -0
- package/web-application/docs/ultravisor-integration.md +2 -0
- package/web-application/retold-remote.js +399 -45
- package/web-application/retold-remote.js.map +1 -1
- package/web-application/retold-remote.min.js +13 -12
- 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,
|