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,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retold Remote -- Region Service
|
|
3
|
+
*
|
|
4
|
+
* Stores and retrieves labeled regions for any file type: images,
|
|
5
|
+
* EPUB ebooks, PDF documents, CBZ/CBR comic pages. Each file can
|
|
6
|
+
* have multiple named regions that are persisted in Bibliograph.
|
|
7
|
+
*
|
|
8
|
+
* Region types:
|
|
9
|
+
* - visual-region: rectangular crop area (X, Y, Width, Height)
|
|
10
|
+
* - text-selection: captured text with location (CFI, PageNumber, SelectedText)
|
|
11
|
+
*
|
|
12
|
+
* Handles archive subfile paths (e.g. "comic.cbz/page001.jpg") by
|
|
13
|
+
* resolving to the archive file for existence checks and mtime keys.
|
|
14
|
+
*
|
|
15
|
+
* API:
|
|
16
|
+
* GET /api/media/subimage-regions?path= — List regions for a file
|
|
17
|
+
* POST /api/media/subimage-regions — Add a region
|
|
18
|
+
* PUT /api/media/subimage-regions/:id — Update a region
|
|
19
|
+
* DELETE /api/media/subimage-regions/:id — Remove a region
|
|
20
|
+
*
|
|
21
|
+
* @license MIT
|
|
22
|
+
*/
|
|
23
|
+
const libFableServiceProviderBase = require('fable-serviceproviderbase');
|
|
24
|
+
const libFs = require('fs');
|
|
25
|
+
const libPath = require('path');
|
|
26
|
+
const libCrypto = require('crypto');
|
|
27
|
+
const libUrl = require('url');
|
|
28
|
+
|
|
29
|
+
const libExplorerStateMixin = require('./RetoldRemote-ExplorerStateMixin');
|
|
30
|
+
|
|
31
|
+
const SUBIMAGE_SOURCE = 'retold-remote-subimage-regions';
|
|
32
|
+
|
|
33
|
+
const _DefaultServiceConfiguration =
|
|
34
|
+
{
|
|
35
|
+
"ContentPath": "."
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
class RetoldRemoteSubimageService extends libFableServiceProviderBase
|
|
39
|
+
{
|
|
40
|
+
constructor(pFable, pOptions, pServiceHash)
|
|
41
|
+
{
|
|
42
|
+
super(pFable, pOptions, pServiceHash);
|
|
43
|
+
|
|
44
|
+
this.serviceType = 'RetoldRemoteSubimageService';
|
|
45
|
+
|
|
46
|
+
// Merge with defaults
|
|
47
|
+
for (let tmpKey in _DefaultServiceConfiguration)
|
|
48
|
+
{
|
|
49
|
+
if (!(tmpKey in this.options))
|
|
50
|
+
{
|
|
51
|
+
this.options[tmpKey] = _DefaultServiceConfiguration[tmpKey];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.contentPath = libPath.resolve(this.options.ContentPath);
|
|
56
|
+
|
|
57
|
+
// Sharp module reference (set by Server-Setup via setSharpModule)
|
|
58
|
+
this._sharp = null;
|
|
59
|
+
|
|
60
|
+
// Apply explorer state persistence mixin for the Bibliograph source
|
|
61
|
+
libExplorerStateMixin.apply(this, SUBIMAGE_SOURCE, 'subimage');
|
|
62
|
+
|
|
63
|
+
this.fable.log.info('Subimage Region Service: regions stored in Bibliograph');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Set the sharp module reference for thumbnail generation.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} pSharp - The sharp module
|
|
70
|
+
*/
|
|
71
|
+
setSharpModule(pSharp)
|
|
72
|
+
{
|
|
73
|
+
this._sharp = pSharp;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Regex to detect archive extensions within a path
|
|
77
|
+
static get ARCHIVE_PATH_PATTERN()
|
|
78
|
+
{
|
|
79
|
+
return /^(.*?\.(zip|7z|rar|tar|tgz|cbz|cbr|tar\.gz|tar\.bz2|tar\.xz))\//i;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Validate and sanitize a relative path.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} pRelPath - Relative path
|
|
86
|
+
* @returns {string|null} Sanitized path or null if invalid
|
|
87
|
+
*/
|
|
88
|
+
_sanitizePath(pRelPath)
|
|
89
|
+
{
|
|
90
|
+
if (!pRelPath || typeof pRelPath !== 'string')
|
|
91
|
+
{
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
let tmpClean = pRelPath.replace(/^\/+/, '');
|
|
95
|
+
if (tmpClean.includes('..') || libPath.isAbsolute(tmpClean))
|
|
96
|
+
{
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return tmpClean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve a file path to an absolute path and stat, handling archive subfiles.
|
|
104
|
+
* For paths like "comics/batman.cbz/page001.jpg", the file doesn't exist on disk
|
|
105
|
+
* (it's extracted on the fly), so we resolve to the archive file itself.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} pRelPath - Relative file path
|
|
108
|
+
* @returns {object|null} { absPath, stat } or null if not found
|
|
109
|
+
*/
|
|
110
|
+
_resolveFileStat(pRelPath)
|
|
111
|
+
{
|
|
112
|
+
let tmpAbsPath = libPath.join(this.contentPath, pRelPath);
|
|
113
|
+
|
|
114
|
+
// Try direct file first
|
|
115
|
+
if (libFs.existsSync(tmpAbsPath))
|
|
116
|
+
{
|
|
117
|
+
return { absPath: tmpAbsPath, stat: libFs.statSync(tmpAbsPath) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check if this is an archive subfile path
|
|
121
|
+
let tmpArchiveMatch = pRelPath.match(RetoldRemoteSubimageService.ARCHIVE_PATH_PATTERN);
|
|
122
|
+
if (tmpArchiveMatch)
|
|
123
|
+
{
|
|
124
|
+
let tmpArchivePath = libPath.join(this.contentPath, tmpArchiveMatch[1]);
|
|
125
|
+
if (libFs.existsSync(tmpArchivePath))
|
|
126
|
+
{
|
|
127
|
+
return { absPath: tmpArchivePath, stat: libFs.statSync(tmpArchivePath) };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Load the regions record for a file, creating an empty one if none exists.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} pRelPath - Relative file path
|
|
138
|
+
* @param {number} pMtimeMs - File modification time in ms
|
|
139
|
+
* @param {Function} fCallback - Callback(pError, pRecord)
|
|
140
|
+
*/
|
|
141
|
+
_loadOrCreateRecord(pRelPath, pMtimeMs, fCallback)
|
|
142
|
+
{
|
|
143
|
+
this.loadExplorerState(pRelPath, pMtimeMs,
|
|
144
|
+
(pError, pRecord) =>
|
|
145
|
+
{
|
|
146
|
+
if (pError)
|
|
147
|
+
{
|
|
148
|
+
return fCallback(pError);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!pRecord)
|
|
152
|
+
{
|
|
153
|
+
pRecord =
|
|
154
|
+
{
|
|
155
|
+
Path: pRelPath,
|
|
156
|
+
Regions: []
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Ensure Regions array exists (guard against old records)
|
|
161
|
+
if (!Array.isArray(pRecord.Regions))
|
|
162
|
+
{
|
|
163
|
+
pRecord.Regions = [];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return fCallback(null, pRecord);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Connect REST routes to the Orator service server.
|
|
172
|
+
*
|
|
173
|
+
* @param {object} pServiceServer - The Orator service server instance
|
|
174
|
+
*/
|
|
175
|
+
connectRoutes(pServiceServer)
|
|
176
|
+
{
|
|
177
|
+
let tmpSelf = this;
|
|
178
|
+
let tmpContentPath = this.contentPath;
|
|
179
|
+
|
|
180
|
+
// -----------------------------------------------------------------
|
|
181
|
+
// GET /api/media/subimage-regions?path= — List all regions for an image
|
|
182
|
+
// -----------------------------------------------------------------
|
|
183
|
+
pServiceServer.get('/api/media/subimage-regions',
|
|
184
|
+
(pRequest, pResponse, fNext) =>
|
|
185
|
+
{
|
|
186
|
+
try
|
|
187
|
+
{
|
|
188
|
+
let tmpParsedUrl = libUrl.parse(pRequest.url, true);
|
|
189
|
+
let tmpRelPath = tmpSelf._sanitizePath(tmpParsedUrl.query.path);
|
|
190
|
+
|
|
191
|
+
if (!tmpRelPath)
|
|
192
|
+
{
|
|
193
|
+
pResponse.send(400, { Success: false, Error: 'Missing or invalid path parameter.' });
|
|
194
|
+
return fNext();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let tmpResolved = tmpSelf._resolveFileStat(tmpRelPath);
|
|
198
|
+
if (!tmpResolved)
|
|
199
|
+
{
|
|
200
|
+
pResponse.send(404, { Success: false, Error: 'File not found.' });
|
|
201
|
+
return fNext();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let tmpStat = tmpResolved.stat;
|
|
205
|
+
|
|
206
|
+
tmpSelf._loadOrCreateRecord(tmpRelPath, tmpStat.mtimeMs,
|
|
207
|
+
(pError, pRecord) =>
|
|
208
|
+
{
|
|
209
|
+
if (pError)
|
|
210
|
+
{
|
|
211
|
+
pResponse.send(500, { Success: false, Error: pError.message });
|
|
212
|
+
return fNext();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
pResponse.send(
|
|
216
|
+
{
|
|
217
|
+
Success: true,
|
|
218
|
+
Path: tmpRelPath,
|
|
219
|
+
Regions: pRecord.Regions
|
|
220
|
+
});
|
|
221
|
+
return fNext();
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
catch (pError)
|
|
225
|
+
{
|
|
226
|
+
pResponse.send(500, { Success: false, Error: pError.message });
|
|
227
|
+
return fNext();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// -----------------------------------------------------------------
|
|
232
|
+
// POST /api/media/subimage-regions — Add a new region
|
|
233
|
+
// Body: { Path, Region: { Label, X, Y, Width, Height } }
|
|
234
|
+
// -----------------------------------------------------------------
|
|
235
|
+
pServiceServer.post('/api/media/subimage-regions',
|
|
236
|
+
(pRequest, pResponse, fNext) =>
|
|
237
|
+
{
|
|
238
|
+
try
|
|
239
|
+
{
|
|
240
|
+
let tmpBody = pRequest.body || {};
|
|
241
|
+
let tmpRelPath = tmpSelf._sanitizePath(tmpBody.Path);
|
|
242
|
+
|
|
243
|
+
if (!tmpRelPath)
|
|
244
|
+
{
|
|
245
|
+
pResponse.send(400, { Success: false, Error: 'Missing or invalid Path in request body.' });
|
|
246
|
+
return fNext();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let tmpRegionInput = tmpBody.Region;
|
|
250
|
+
if (!tmpRegionInput || typeof tmpRegionInput !== 'object')
|
|
251
|
+
{
|
|
252
|
+
pResponse.send(400, { Success: false, Error: 'Missing Region object in request body.' });
|
|
253
|
+
return fNext();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Validate: visual regions need coordinates, text selections need SelectedText
|
|
257
|
+
let tmpIsTextSelection = (tmpRegionInput.Type === 'text-selection');
|
|
258
|
+
if (!tmpIsTextSelection)
|
|
259
|
+
{
|
|
260
|
+
if (typeof tmpRegionInput.X !== 'number' || typeof tmpRegionInput.Y !== 'number'
|
|
261
|
+
|| typeof tmpRegionInput.Width !== 'number' || typeof tmpRegionInput.Height !== 'number'
|
|
262
|
+
|| tmpRegionInput.Width <= 0 || tmpRegionInput.Height <= 0)
|
|
263
|
+
{
|
|
264
|
+
pResponse.send(400, { Success: false, Error: 'Visual region must have numeric X, Y, Width (>0), Height (>0).' });
|
|
265
|
+
return fNext();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let tmpResolved = tmpSelf._resolveFileStat(tmpRelPath);
|
|
270
|
+
if (!tmpResolved)
|
|
271
|
+
{
|
|
272
|
+
pResponse.send(404, { Success: false, Error: 'File not found.' });
|
|
273
|
+
return fNext();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let tmpStat = tmpResolved.stat;
|
|
277
|
+
|
|
278
|
+
tmpSelf._loadOrCreateRecord(tmpRelPath, tmpStat.mtimeMs,
|
|
279
|
+
(pLoadError, pRecord) =>
|
|
280
|
+
{
|
|
281
|
+
if (pLoadError)
|
|
282
|
+
{
|
|
283
|
+
pResponse.send(500, { Success: false, Error: pLoadError.message });
|
|
284
|
+
return fNext();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
let tmpRegion =
|
|
288
|
+
{
|
|
289
|
+
ID: tmpSelf.fable.getUUID(),
|
|
290
|
+
Type: tmpRegionInput.Type || 'visual-region',
|
|
291
|
+
Label: tmpRegionInput.Label || '',
|
|
292
|
+
X: (typeof tmpRegionInput.X === 'number') ? Math.round(tmpRegionInput.X) : null,
|
|
293
|
+
Y: (typeof tmpRegionInput.Y === 'number') ? Math.round(tmpRegionInput.Y) : null,
|
|
294
|
+
Width: (typeof tmpRegionInput.Width === 'number') ? Math.round(tmpRegionInput.Width) : null,
|
|
295
|
+
Height: (typeof tmpRegionInput.Height === 'number') ? Math.round(tmpRegionInput.Height) : null,
|
|
296
|
+
CreatedAt: new Date().toISOString(),
|
|
297
|
+
// Document-specific fields
|
|
298
|
+
PageNumber: (typeof tmpRegionInput.PageNumber === 'number') ? tmpRegionInput.PageNumber : null,
|
|
299
|
+
CFI: tmpRegionInput.CFI || null,
|
|
300
|
+
SpineIndex: (typeof tmpRegionInput.SpineIndex === 'number') ? tmpRegionInput.SpineIndex : null,
|
|
301
|
+
ChapterTitle: tmpRegionInput.ChapterTitle || null,
|
|
302
|
+
SelectedText: tmpRegionInput.SelectedText || null,
|
|
303
|
+
ViewportWidth: (typeof tmpRegionInput.ViewportWidth === 'number') ? tmpRegionInput.ViewportWidth : null,
|
|
304
|
+
ViewportHeight: (typeof tmpRegionInput.ViewportHeight === 'number') ? tmpRegionInput.ViewportHeight : null
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
pRecord.Regions.push(tmpRegion);
|
|
308
|
+
|
|
309
|
+
tmpSelf.saveExplorerState(tmpRelPath, tmpStat.mtimeMs, pRecord,
|
|
310
|
+
(pSaveError) =>
|
|
311
|
+
{
|
|
312
|
+
if (pSaveError)
|
|
313
|
+
{
|
|
314
|
+
pResponse.send(500, { Success: false, Error: pSaveError.message });
|
|
315
|
+
return fNext();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
pResponse.send(
|
|
319
|
+
{
|
|
320
|
+
Success: true,
|
|
321
|
+
Region: tmpRegion,
|
|
322
|
+
Regions: pRecord.Regions
|
|
323
|
+
});
|
|
324
|
+
return fNext();
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
catch (pError)
|
|
329
|
+
{
|
|
330
|
+
pResponse.send(500, { Success: false, Error: pError.message });
|
|
331
|
+
return fNext();
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// -----------------------------------------------------------------
|
|
336
|
+
// PUT /api/media/subimage-regions/:id — Update a region's label or bounds
|
|
337
|
+
// Body: { Path, Label?, X?, Y?, Width?, Height? }
|
|
338
|
+
// -----------------------------------------------------------------
|
|
339
|
+
pServiceServer.put('/api/media/subimage-regions/:id',
|
|
340
|
+
(pRequest, pResponse, fNext) =>
|
|
341
|
+
{
|
|
342
|
+
try
|
|
343
|
+
{
|
|
344
|
+
let tmpRegionId = pRequest.params.id;
|
|
345
|
+
let tmpBody = pRequest.body || {};
|
|
346
|
+
let tmpRelPath = tmpSelf._sanitizePath(tmpBody.Path);
|
|
347
|
+
|
|
348
|
+
if (!tmpRelPath || !tmpRegionId)
|
|
349
|
+
{
|
|
350
|
+
pResponse.send(400, { Success: false, Error: 'Missing Path or region ID.' });
|
|
351
|
+
return fNext();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
let tmpResolved = tmpSelf._resolveFileStat(tmpRelPath);
|
|
355
|
+
if (!tmpResolved)
|
|
356
|
+
{
|
|
357
|
+
pResponse.send(404, { Success: false, Error: 'File not found.' });
|
|
358
|
+
return fNext();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
let tmpStat = tmpResolved.stat;
|
|
362
|
+
|
|
363
|
+
tmpSelf._loadOrCreateRecord(tmpRelPath, tmpStat.mtimeMs,
|
|
364
|
+
(pLoadError, pRecord) =>
|
|
365
|
+
{
|
|
366
|
+
if (pLoadError)
|
|
367
|
+
{
|
|
368
|
+
pResponse.send(500, { Success: false, Error: pLoadError.message });
|
|
369
|
+
return fNext();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let tmpRegion = null;
|
|
373
|
+
for (let i = 0; i < pRecord.Regions.length; i++)
|
|
374
|
+
{
|
|
375
|
+
if (pRecord.Regions[i].ID === tmpRegionId)
|
|
376
|
+
{
|
|
377
|
+
tmpRegion = pRecord.Regions[i];
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (!tmpRegion)
|
|
383
|
+
{
|
|
384
|
+
pResponse.send(404, { Success: false, Error: 'Region not found.' });
|
|
385
|
+
return fNext();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Apply updates
|
|
389
|
+
if (typeof tmpBody.Label === 'string')
|
|
390
|
+
{
|
|
391
|
+
tmpRegion.Label = tmpBody.Label;
|
|
392
|
+
}
|
|
393
|
+
if (typeof tmpBody.X === 'number')
|
|
394
|
+
{
|
|
395
|
+
tmpRegion.X = Math.round(tmpBody.X);
|
|
396
|
+
}
|
|
397
|
+
if (typeof tmpBody.Y === 'number')
|
|
398
|
+
{
|
|
399
|
+
tmpRegion.Y = Math.round(tmpBody.Y);
|
|
400
|
+
}
|
|
401
|
+
if (typeof tmpBody.Width === 'number' && tmpBody.Width > 0)
|
|
402
|
+
{
|
|
403
|
+
tmpRegion.Width = Math.round(tmpBody.Width);
|
|
404
|
+
}
|
|
405
|
+
if (typeof tmpBody.Height === 'number' && tmpBody.Height > 0)
|
|
406
|
+
{
|
|
407
|
+
tmpRegion.Height = Math.round(tmpBody.Height);
|
|
408
|
+
}
|
|
409
|
+
// Document-specific field updates
|
|
410
|
+
if (typeof tmpBody.SelectedText === 'string')
|
|
411
|
+
{
|
|
412
|
+
tmpRegion.SelectedText = tmpBody.SelectedText;
|
|
413
|
+
}
|
|
414
|
+
if (typeof tmpBody.ChapterTitle === 'string')
|
|
415
|
+
{
|
|
416
|
+
tmpRegion.ChapterTitle = tmpBody.ChapterTitle;
|
|
417
|
+
}
|
|
418
|
+
if (typeof tmpBody.PageNumber === 'number')
|
|
419
|
+
{
|
|
420
|
+
tmpRegion.PageNumber = tmpBody.PageNumber;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
tmpSelf.saveExplorerState(tmpRelPath, tmpStat.mtimeMs, pRecord,
|
|
424
|
+
(pSaveError) =>
|
|
425
|
+
{
|
|
426
|
+
if (pSaveError)
|
|
427
|
+
{
|
|
428
|
+
pResponse.send(500, { Success: false, Error: pSaveError.message });
|
|
429
|
+
return fNext();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
pResponse.send(
|
|
433
|
+
{
|
|
434
|
+
Success: true,
|
|
435
|
+
Region: tmpRegion,
|
|
436
|
+
Regions: pRecord.Regions
|
|
437
|
+
});
|
|
438
|
+
return fNext();
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
catch (pError)
|
|
443
|
+
{
|
|
444
|
+
pResponse.send(500, { Success: false, Error: pError.message });
|
|
445
|
+
return fNext();
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// -----------------------------------------------------------------
|
|
450
|
+
// DELETE /api/media/subimage-regions/:id?path= — Remove a region
|
|
451
|
+
// -----------------------------------------------------------------
|
|
452
|
+
pServiceServer.del('/api/media/subimage-regions/:id',
|
|
453
|
+
(pRequest, pResponse, fNext) =>
|
|
454
|
+
{
|
|
455
|
+
try
|
|
456
|
+
{
|
|
457
|
+
let tmpRegionId = pRequest.params.id;
|
|
458
|
+
let tmpParsedUrl = libUrl.parse(pRequest.url, true);
|
|
459
|
+
let tmpRelPath = tmpSelf._sanitizePath(tmpParsedUrl.query.path);
|
|
460
|
+
|
|
461
|
+
if (!tmpRelPath || !tmpRegionId)
|
|
462
|
+
{
|
|
463
|
+
pResponse.send(400, { Success: false, Error: 'Missing path or region ID.' });
|
|
464
|
+
return fNext();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
let tmpResolved = tmpSelf._resolveFileStat(tmpRelPath);
|
|
468
|
+
if (!tmpResolved)
|
|
469
|
+
{
|
|
470
|
+
pResponse.send(404, { Success: false, Error: 'File not found.' });
|
|
471
|
+
return fNext();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
let tmpStat = tmpResolved.stat;
|
|
475
|
+
|
|
476
|
+
tmpSelf._loadOrCreateRecord(tmpRelPath, tmpStat.mtimeMs,
|
|
477
|
+
(pLoadError, pRecord) =>
|
|
478
|
+
{
|
|
479
|
+
if (pLoadError)
|
|
480
|
+
{
|
|
481
|
+
pResponse.send(500, { Success: false, Error: pLoadError.message });
|
|
482
|
+
return fNext();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
let tmpFound = false;
|
|
486
|
+
pRecord.Regions = pRecord.Regions.filter(
|
|
487
|
+
(pRegion) =>
|
|
488
|
+
{
|
|
489
|
+
if (pRegion.ID === tmpRegionId)
|
|
490
|
+
{
|
|
491
|
+
tmpFound = true;
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
return true;
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
if (!tmpFound)
|
|
498
|
+
{
|
|
499
|
+
pResponse.send(404, { Success: false, Error: 'Region not found.' });
|
|
500
|
+
return fNext();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
tmpSelf.saveExplorerState(tmpRelPath, tmpStat.mtimeMs, pRecord,
|
|
504
|
+
(pSaveError) =>
|
|
505
|
+
{
|
|
506
|
+
if (pSaveError)
|
|
507
|
+
{
|
|
508
|
+
pResponse.send(500, { Success: false, Error: pSaveError.message });
|
|
509
|
+
return fNext();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
pResponse.send(
|
|
513
|
+
{
|
|
514
|
+
Success: true,
|
|
515
|
+
Regions: pRecord.Regions
|
|
516
|
+
});
|
|
517
|
+
return fNext();
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
catch (pError)
|
|
522
|
+
{
|
|
523
|
+
pResponse.send(500, { Success: false, Error: pError.message });
|
|
524
|
+
return fNext();
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
module.exports = RetoldRemoteSubimageService;
|
|
@@ -40,6 +40,7 @@ class ToolDetector
|
|
|
40
40
|
p7zip: this._detectCommand('7z --help'),
|
|
41
41
|
audiowaveform: this._detectCommand('audiowaveform --version'),
|
|
42
42
|
ebook_convert: this._detectCommand('ebook-convert --version'),
|
|
43
|
+
libreoffice: this._detectLibreOffice(),
|
|
43
44
|
exiftool: this._detectCommand('exiftool -ver'),
|
|
44
45
|
dcraw: this._detectCommandExists('dcraw'),
|
|
45
46
|
dcrawJs: this._detectModule('dcraw'),
|
|
@@ -151,6 +152,55 @@ class ToolDetector
|
|
|
151
152
|
return this._detectCommand('vlc --version');
|
|
152
153
|
}
|
|
153
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Detect LibreOffice for headless document conversion.
|
|
157
|
+
* macOS: check for the .app bundle (soffice in the bundle).
|
|
158
|
+
* Linux: check the soffice command.
|
|
159
|
+
* Windows: check default install paths.
|
|
160
|
+
*
|
|
161
|
+
* @returns {boolean}
|
|
162
|
+
*/
|
|
163
|
+
_detectLibreOffice()
|
|
164
|
+
{
|
|
165
|
+
const libFS = require('fs');
|
|
166
|
+
|
|
167
|
+
// macOS: check for LibreOffice.app
|
|
168
|
+
try
|
|
169
|
+
{
|
|
170
|
+
if (libFS.existsSync('/Applications/LibreOffice.app/Contents/MacOS/soffice'))
|
|
171
|
+
{
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch (pError)
|
|
176
|
+
{
|
|
177
|
+
// ignore
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Windows: check default install paths
|
|
181
|
+
if (process.platform === 'win32')
|
|
182
|
+
{
|
|
183
|
+
try
|
|
184
|
+
{
|
|
185
|
+
if (libFS.existsSync('C:\\Program Files\\LibreOffice\\program\\soffice.exe'))
|
|
186
|
+
{
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
if (libFS.existsSync('C:\\Program Files (x86)\\LibreOffice\\program\\soffice.exe'))
|
|
190
|
+
{
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch (pError)
|
|
195
|
+
{
|
|
196
|
+
// ignore
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Linux / other: check soffice on PATH
|
|
201
|
+
return this._detectCommandExists('soffice');
|
|
202
|
+
}
|
|
203
|
+
|
|
154
204
|
/**
|
|
155
205
|
* Check if a command-line tool exists on the PATH using 'which'.
|
|
156
206
|
* Useful for tools that exit non-zero when invoked with no arguments (e.g. dcraw).
|
|
@@ -262,7 +262,7 @@ function getOperations()
|
|
|
262
262
|
Height: '{~D:Record.Operation.Height~}',
|
|
263
263
|
Format: '{~D:Record.Operation.Format~}',
|
|
264
264
|
Quality: '{~D:Record.Operation.Quality~}',
|
|
265
|
-
TimeoutMs:
|
|
265
|
+
TimeoutMs: 300000
|
|
266
266
|
},
|
|
267
267
|
ProcessSettings: ['InputFile', 'OutputFile', 'Width', 'Height', 'Format', 'Quality'],
|
|
268
268
|
ProcessOutputs: ['Result', 'StdOut'],
|
|
@@ -285,7 +285,7 @@ function getOperations()
|
|
|
285
285
|
OutputFile: 'thumbnail.jpg',
|
|
286
286
|
Timestamp: '{~D:Record.Operation.Timestamp~}',
|
|
287
287
|
Width: '{~D:Record.Operation.Width~}',
|
|
288
|
-
TimeoutMs:
|
|
288
|
+
TimeoutMs: 600000
|
|
289
289
|
},
|
|
290
290
|
ProcessSettings: ['InputFile', 'OutputFile', 'Timestamp', 'Width'],
|
|
291
291
|
ProcessOutputs: ['Result', 'StdOut'],
|
|
@@ -310,7 +310,7 @@ function getOperations()
|
|
|
310
310
|
_taskNode(tmpVfe + '-probe', 'beacon-mediaconversion-mediaprobe', 'Probe Video', 660, 180,
|
|
311
311
|
{
|
|
312
312
|
AffinityKey: '{~D:Record.Operation.VideoAddress~}',
|
|
313
|
-
TimeoutMs:
|
|
313
|
+
TimeoutMs: 600000
|
|
314
314
|
},
|
|
315
315
|
['InputFile'], ['Result', 'StdOut']),
|
|
316
316
|
_taskNode(tmpVfe + '-extract', 'beacon-mediaconversion-videoextractframe', 'Extract Frame', 880, 180,
|
|
@@ -319,7 +319,7 @@ function getOperations()
|
|
|
319
319
|
Timestamp: '{~D:Record.Operation.Timestamp~}',
|
|
320
320
|
Width: '{~D:Record.Operation.Width~}',
|
|
321
321
|
AffinityKey: '{~D:Record.Operation.VideoAddress~}',
|
|
322
|
-
TimeoutMs:
|
|
322
|
+
TimeoutMs: 600000
|
|
323
323
|
},
|
|
324
324
|
['InputFile', 'OutputFile', 'Timestamp', 'Width'], ['Result', 'StdOut']),
|
|
325
325
|
_taskNode(tmpVfe + '-result', 'send-result', 'Send Result', 1100, 180,
|
|
@@ -409,7 +409,7 @@ function getOperations()
|
|
|
409
409
|
OutputFile: 'page.png',
|
|
410
410
|
Page: '{~D:Record.Operation.Page~}',
|
|
411
411
|
LongSidePixels: '{~D:Record.Operation.LongSidePixels~}',
|
|
412
|
-
TimeoutMs:
|
|
412
|
+
TimeoutMs: 300000
|
|
413
413
|
},
|
|
414
414
|
ProcessSettings: ['InputFile', 'OutputFile', 'Page', 'LongSidePixels'],
|
|
415
415
|
ProcessOutputs: ['Result', 'StdOut'],
|
|
@@ -472,7 +472,7 @@ function getOperations()
|
|
|
472
472
|
ProcessTitle: 'Probe Metadata',
|
|
473
473
|
ProcessData:
|
|
474
474
|
{
|
|
475
|
-
TimeoutMs:
|
|
475
|
+
TimeoutMs: 300000
|
|
476
476
|
},
|
|
477
477
|
ProcessSettings: ['InputFile'],
|
|
478
478
|
ProcessOutputs: ['Result', 'StdOut'],
|