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,680 @@
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
+ // In-memory cache for the folder-scoped region listing (Part D).
61
+ // null means "not populated"; an array means "populated with all
62
+ // file records". Mutations invalidate this via _invalidateFolderCache.
63
+ this._folderCache = null;
64
+
65
+ // Apply explorer state persistence mixin for the Bibliograph source
66
+ libExplorerStateMixin.apply(this, SUBIMAGE_SOURCE, 'subimage');
67
+
68
+ this.fable.log.info('Subimage Region Service: regions stored in Bibliograph');
69
+ }
70
+
71
+ /**
72
+ * Set the sharp module reference for thumbnail generation.
73
+ *
74
+ * @param {object} pSharp - The sharp module
75
+ */
76
+ setSharpModule(pSharp)
77
+ {
78
+ this._sharp = pSharp;
79
+ }
80
+
81
+ // Regex to detect archive extensions within a path
82
+ static get ARCHIVE_PATH_PATTERN()
83
+ {
84
+ return /^(.*?\.(zip|7z|rar|tar|tgz|cbz|cbr|tar\.gz|tar\.bz2|tar\.xz))\//i;
85
+ }
86
+
87
+ /**
88
+ * Validate and sanitize a relative path.
89
+ *
90
+ * @param {string} pRelPath - Relative path
91
+ * @returns {string|null} Sanitized path or null if invalid
92
+ */
93
+ _sanitizePath(pRelPath)
94
+ {
95
+ if (!pRelPath || typeof pRelPath !== 'string')
96
+ {
97
+ return null;
98
+ }
99
+ let tmpClean = pRelPath.replace(/^\/+/, '');
100
+ if (tmpClean.includes('..') || libPath.isAbsolute(tmpClean))
101
+ {
102
+ return null;
103
+ }
104
+ return tmpClean;
105
+ }
106
+
107
+ /**
108
+ * Resolve a file path to an absolute path and stat, handling archive subfiles.
109
+ * For paths like "comics/batman.cbz/page001.jpg", the file doesn't exist on disk
110
+ * (it's extracted on the fly), so we resolve to the archive file itself.
111
+ *
112
+ * @param {string} pRelPath - Relative file path
113
+ * @returns {object|null} { absPath, stat } or null if not found
114
+ */
115
+ _resolveFileStat(pRelPath)
116
+ {
117
+ let tmpAbsPath = libPath.join(this.contentPath, pRelPath);
118
+
119
+ // Try direct file first
120
+ if (libFs.existsSync(tmpAbsPath))
121
+ {
122
+ return { absPath: tmpAbsPath, stat: libFs.statSync(tmpAbsPath) };
123
+ }
124
+
125
+ // Check if this is an archive subfile path
126
+ let tmpArchiveMatch = pRelPath.match(RetoldRemoteSubimageService.ARCHIVE_PATH_PATTERN);
127
+ if (tmpArchiveMatch)
128
+ {
129
+ let tmpArchivePath = libPath.join(this.contentPath, tmpArchiveMatch[1]);
130
+ if (libFs.existsSync(tmpArchivePath))
131
+ {
132
+ return { absPath: tmpArchivePath, stat: libFs.statSync(tmpArchivePath) };
133
+ }
134
+ }
135
+
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * Load the regions record for a file, creating an empty one if none exists.
141
+ *
142
+ * @param {string} pRelPath - Relative file path
143
+ * @param {number} pMtimeMs - File modification time in ms
144
+ * @param {Function} fCallback - Callback(pError, pRecord)
145
+ */
146
+ _loadOrCreateRecord(pRelPath, pMtimeMs, fCallback)
147
+ {
148
+ this.loadExplorerState(pRelPath, pMtimeMs,
149
+ (pError, pRecord) =>
150
+ {
151
+ if (pError)
152
+ {
153
+ return fCallback(pError);
154
+ }
155
+
156
+ if (!pRecord)
157
+ {
158
+ pRecord =
159
+ {
160
+ Path: pRelPath,
161
+ Regions: []
162
+ };
163
+ }
164
+
165
+ // Ensure Regions array exists (guard against old records)
166
+ if (!Array.isArray(pRecord.Regions))
167
+ {
168
+ pRecord.Regions = [];
169
+ }
170
+
171
+ return fCallback(null, pRecord);
172
+ });
173
+ }
174
+
175
+ /**
176
+ * Invalidate the folder-regions cache. Called after any POST/PUT/DELETE
177
+ * so subsequent folder-listing requests see fresh data.
178
+ */
179
+ _invalidateFolderCache()
180
+ {
181
+ this._folderCache = null;
182
+ }
183
+
184
+ /**
185
+ * List all regions across all files under a folder prefix. Uses an
186
+ * in-memory cache (`this._folderCache`) that's populated on first call
187
+ * after a mutation and reused until the next mutation invalidates it.
188
+ *
189
+ * The cache stores the complete `[{ Path, Regions }, ...]` list. The
190
+ * folder filter is applied on every request (cheap — just a startsWith
191
+ * check per entry).
192
+ *
193
+ * Empty pFolderPrefix returns everything.
194
+ *
195
+ * NOTE: mtime-hash orphans are NOT filtered out here. If a file was
196
+ * modified after a region was created, the region stays in the cache
197
+ * under its original mtime-hash key. The navigateToRegion flow is
198
+ * expected to handle missing-file errors gracefully.
199
+ *
200
+ * @param {string} pFolderPrefix - Folder prefix (trailing / stripped); '' means all
201
+ * @param {Function} fCallback - (pError, pFiles) where pFiles is [{ Path, Regions }]
202
+ */
203
+ _listRegionsByFolder(pFolderPrefix, fCallback)
204
+ {
205
+ let tmpSelf = this;
206
+ let tmpFilter = function (pAllFiles)
207
+ {
208
+ let tmpOut = [];
209
+ for (let i = 0; i < pAllFiles.length; i++)
210
+ {
211
+ let tmpEntry = pAllFiles[i];
212
+ if (!tmpEntry || !tmpEntry.Path) continue;
213
+ if (!Array.isArray(tmpEntry.Regions) || tmpEntry.Regions.length === 0) continue;
214
+ if (pFolderPrefix === ''
215
+ || tmpEntry.Path === pFolderPrefix
216
+ || tmpEntry.Path.indexOf(pFolderPrefix + '/') === 0)
217
+ {
218
+ tmpOut.push({ Path: tmpEntry.Path, Regions: tmpEntry.Regions });
219
+ }
220
+ }
221
+ tmpOut.sort((a, b) => (a.Path || '').localeCompare(b.Path || ''));
222
+ return tmpOut;
223
+ };
224
+
225
+ // Cache hit — apply filter and return
226
+ if (Array.isArray(this._folderCache))
227
+ {
228
+ return fCallback(null, tmpFilter(this._folderCache));
229
+ }
230
+
231
+ // Cache miss — enumerate Bibliograph records
232
+ this.fable.Bibliograph.readRecordKeys(SUBIMAGE_SOURCE,
233
+ (pError, pKeys) =>
234
+ {
235
+ if (pError)
236
+ {
237
+ return fCallback(pError);
238
+ }
239
+ if (!Array.isArray(pKeys) || pKeys.length === 0)
240
+ {
241
+ tmpSelf._folderCache = [];
242
+ return fCallback(null, []);
243
+ }
244
+
245
+ let tmpAll = [];
246
+ let tmpPending = pKeys.length;
247
+ for (let i = 0; i < pKeys.length; i++)
248
+ {
249
+ tmpSelf.fable.Bibliograph.read(SUBIMAGE_SOURCE, pKeys[i],
250
+ (pReadError, pRecord) =>
251
+ {
252
+ if (!pReadError && pRecord && pRecord.Path)
253
+ {
254
+ tmpAll.push(
255
+ {
256
+ Path: pRecord.Path,
257
+ Regions: Array.isArray(pRecord.Regions) ? pRecord.Regions : []
258
+ });
259
+ }
260
+ tmpPending--;
261
+ if (tmpPending <= 0)
262
+ {
263
+ tmpSelf._folderCache = tmpAll;
264
+ return fCallback(null, tmpFilter(tmpAll));
265
+ }
266
+ });
267
+ }
268
+ });
269
+ }
270
+
271
+ /**
272
+ * Connect REST routes to the Orator service server.
273
+ *
274
+ * @param {object} pServiceServer - The Orator service server instance
275
+ */
276
+ connectRoutes(pServiceServer)
277
+ {
278
+ let tmpSelf = this;
279
+ let tmpContentPath = this.contentPath;
280
+
281
+ // -----------------------------------------------------------------
282
+ // GET /api/media/subimage-regions?path= — regions for one file
283
+ // GET /api/media/subimage-regions?folder=<pref> — regions for all files
284
+ // under a folder prefix
285
+ // GET /api/media/subimage-regions?folder= — regions for every file
286
+ // (use with caution)
287
+ //
288
+ // The folder form uses an in-memory cache (this._folderCache) that
289
+ // is invalidated by POST/PUT/DELETE mutations. First call after a
290
+ // mutation pays the O(n) cost; everything else is O(1) plus a filter.
291
+ // -----------------------------------------------------------------
292
+ pServiceServer.get('/api/media/subimage-regions',
293
+ (pRequest, pResponse, fNext) =>
294
+ {
295
+ try
296
+ {
297
+ let tmpParsedUrl = libUrl.parse(pRequest.url, true);
298
+
299
+ // Folder mode — new in Part D of the regions work.
300
+ if (typeof tmpParsedUrl.query.folder === 'string')
301
+ {
302
+ let tmpFolderPrefix = tmpParsedUrl.query.folder.replace(/\/+$/, '').replace(/^\/+/, '');
303
+ if (tmpFolderPrefix.includes('..'))
304
+ {
305
+ pResponse.send(400, { Success: false, Error: 'Invalid folder parameter.' });
306
+ return fNext();
307
+ }
308
+ tmpSelf._listRegionsByFolder(tmpFolderPrefix,
309
+ (pError, pFiles) =>
310
+ {
311
+ if (pError)
312
+ {
313
+ pResponse.send(500, { Success: false, Error: pError.message });
314
+ return fNext();
315
+ }
316
+ pResponse.send(
317
+ {
318
+ Success: true,
319
+ Folder: tmpFolderPrefix,
320
+ Files: pFiles
321
+ });
322
+ return fNext();
323
+ });
324
+ return;
325
+ }
326
+
327
+ // Per-file mode (existing behavior)
328
+ let tmpRelPath = tmpSelf._sanitizePath(tmpParsedUrl.query.path);
329
+ if (!tmpRelPath)
330
+ {
331
+ pResponse.send(400, { Success: false, Error: 'Missing or invalid path parameter.' });
332
+ return fNext();
333
+ }
334
+
335
+ let tmpResolved = tmpSelf._resolveFileStat(tmpRelPath);
336
+ if (!tmpResolved)
337
+ {
338
+ pResponse.send(404, { Success: false, Error: 'File not found.' });
339
+ return fNext();
340
+ }
341
+
342
+ let tmpStat = tmpResolved.stat;
343
+
344
+ tmpSelf._loadOrCreateRecord(tmpRelPath, tmpStat.mtimeMs,
345
+ (pError, pRecord) =>
346
+ {
347
+ if (pError)
348
+ {
349
+ pResponse.send(500, { Success: false, Error: pError.message });
350
+ return fNext();
351
+ }
352
+
353
+ pResponse.send(
354
+ {
355
+ Success: true,
356
+ Path: tmpRelPath,
357
+ Regions: pRecord.Regions
358
+ });
359
+ return fNext();
360
+ });
361
+ }
362
+ catch (pError)
363
+ {
364
+ pResponse.send(500, { Success: false, Error: pError.message });
365
+ return fNext();
366
+ }
367
+ });
368
+
369
+ // -----------------------------------------------------------------
370
+ // POST /api/media/subimage-regions — Add a new region
371
+ // Body: { Path, Region: { Label, X, Y, Width, Height } }
372
+ // -----------------------------------------------------------------
373
+ pServiceServer.post('/api/media/subimage-regions',
374
+ (pRequest, pResponse, fNext) =>
375
+ {
376
+ try
377
+ {
378
+ let tmpBody = pRequest.body || {};
379
+ let tmpRelPath = tmpSelf._sanitizePath(tmpBody.Path);
380
+
381
+ if (!tmpRelPath)
382
+ {
383
+ pResponse.send(400, { Success: false, Error: 'Missing or invalid Path in request body.' });
384
+ return fNext();
385
+ }
386
+
387
+ let tmpRegionInput = tmpBody.Region;
388
+ if (!tmpRegionInput || typeof tmpRegionInput !== 'object')
389
+ {
390
+ pResponse.send(400, { Success: false, Error: 'Missing Region object in request body.' });
391
+ return fNext();
392
+ }
393
+
394
+ // Validate: visual regions need coordinates, text selections need SelectedText
395
+ let tmpIsTextSelection = (tmpRegionInput.Type === 'text-selection');
396
+ if (!tmpIsTextSelection)
397
+ {
398
+ if (typeof tmpRegionInput.X !== 'number' || typeof tmpRegionInput.Y !== 'number'
399
+ || typeof tmpRegionInput.Width !== 'number' || typeof tmpRegionInput.Height !== 'number'
400
+ || tmpRegionInput.Width <= 0 || tmpRegionInput.Height <= 0)
401
+ {
402
+ pResponse.send(400, { Success: false, Error: 'Visual region must have numeric X, Y, Width (>0), Height (>0).' });
403
+ return fNext();
404
+ }
405
+ }
406
+
407
+ let tmpResolved = tmpSelf._resolveFileStat(tmpRelPath);
408
+ if (!tmpResolved)
409
+ {
410
+ pResponse.send(404, { Success: false, Error: 'File not found.' });
411
+ return fNext();
412
+ }
413
+
414
+ let tmpStat = tmpResolved.stat;
415
+
416
+ tmpSelf._loadOrCreateRecord(tmpRelPath, tmpStat.mtimeMs,
417
+ (pLoadError, pRecord) =>
418
+ {
419
+ if (pLoadError)
420
+ {
421
+ pResponse.send(500, { Success: false, Error: pLoadError.message });
422
+ return fNext();
423
+ }
424
+
425
+ let tmpRegion =
426
+ {
427
+ ID: tmpSelf.fable.getUUID(),
428
+ Type: tmpRegionInput.Type || 'visual-region',
429
+ Label: tmpRegionInput.Label || '',
430
+ X: (typeof tmpRegionInput.X === 'number') ? Math.round(tmpRegionInput.X) : null,
431
+ Y: (typeof tmpRegionInput.Y === 'number') ? Math.round(tmpRegionInput.Y) : null,
432
+ Width: (typeof tmpRegionInput.Width === 'number') ? Math.round(tmpRegionInput.Width) : null,
433
+ Height: (typeof tmpRegionInput.Height === 'number') ? Math.round(tmpRegionInput.Height) : null,
434
+ CreatedAt: new Date().toISOString(),
435
+ // Document-specific fields
436
+ PageNumber: (typeof tmpRegionInput.PageNumber === 'number') ? tmpRegionInput.PageNumber : null,
437
+ CFI: tmpRegionInput.CFI || null,
438
+ SpineIndex: (typeof tmpRegionInput.SpineIndex === 'number') ? tmpRegionInput.SpineIndex : null,
439
+ ChapterTitle: tmpRegionInput.ChapterTitle || null,
440
+ SelectedText: tmpRegionInput.SelectedText || null,
441
+ ViewportWidth: (typeof tmpRegionInput.ViewportWidth === 'number') ? tmpRegionInput.ViewportWidth : null,
442
+ ViewportHeight: (typeof tmpRegionInput.ViewportHeight === 'number') ? tmpRegionInput.ViewportHeight : null
443
+ };
444
+
445
+ pRecord.Regions.push(tmpRegion);
446
+
447
+ tmpSelf.saveExplorerState(tmpRelPath, tmpStat.mtimeMs, pRecord,
448
+ (pSaveError) =>
449
+ {
450
+ if (pSaveError)
451
+ {
452
+ pResponse.send(500, { Success: false, Error: pSaveError.message });
453
+ return fNext();
454
+ }
455
+
456
+ // Invalidate the folder cache so the next
457
+ // folder-listing request re-scans.
458
+ tmpSelf._invalidateFolderCache();
459
+
460
+ pResponse.send(
461
+ {
462
+ Success: true,
463
+ Region: tmpRegion,
464
+ Regions: pRecord.Regions
465
+ });
466
+ return fNext();
467
+ });
468
+ });
469
+ }
470
+ catch (pError)
471
+ {
472
+ pResponse.send(500, { Success: false, Error: pError.message });
473
+ return fNext();
474
+ }
475
+ });
476
+
477
+ // -----------------------------------------------------------------
478
+ // PUT /api/media/subimage-regions/:id — Update a region's label or bounds
479
+ // Body: { Path, Label?, X?, Y?, Width?, Height? }
480
+ // -----------------------------------------------------------------
481
+ pServiceServer.put('/api/media/subimage-regions/:id',
482
+ (pRequest, pResponse, fNext) =>
483
+ {
484
+ try
485
+ {
486
+ let tmpRegionId = pRequest.params.id;
487
+ let tmpBody = pRequest.body || {};
488
+ let tmpRelPath = tmpSelf._sanitizePath(tmpBody.Path);
489
+
490
+ if (!tmpRelPath || !tmpRegionId)
491
+ {
492
+ pResponse.send(400, { Success: false, Error: 'Missing Path or region ID.' });
493
+ return fNext();
494
+ }
495
+
496
+ let tmpResolved = tmpSelf._resolveFileStat(tmpRelPath);
497
+ if (!tmpResolved)
498
+ {
499
+ pResponse.send(404, { Success: false, Error: 'File not found.' });
500
+ return fNext();
501
+ }
502
+
503
+ let tmpStat = tmpResolved.stat;
504
+
505
+ tmpSelf._loadOrCreateRecord(tmpRelPath, tmpStat.mtimeMs,
506
+ (pLoadError, pRecord) =>
507
+ {
508
+ if (pLoadError)
509
+ {
510
+ pResponse.send(500, { Success: false, Error: pLoadError.message });
511
+ return fNext();
512
+ }
513
+
514
+ let tmpRegion = null;
515
+ for (let i = 0; i < pRecord.Regions.length; i++)
516
+ {
517
+ if (pRecord.Regions[i].ID === tmpRegionId)
518
+ {
519
+ tmpRegion = pRecord.Regions[i];
520
+ break;
521
+ }
522
+ }
523
+
524
+ if (!tmpRegion)
525
+ {
526
+ pResponse.send(404, { Success: false, Error: 'Region not found.' });
527
+ return fNext();
528
+ }
529
+
530
+ // Apply updates
531
+ if (typeof tmpBody.Label === 'string')
532
+ {
533
+ tmpRegion.Label = tmpBody.Label;
534
+ }
535
+ if (typeof tmpBody.X === 'number')
536
+ {
537
+ tmpRegion.X = Math.round(tmpBody.X);
538
+ }
539
+ if (typeof tmpBody.Y === 'number')
540
+ {
541
+ tmpRegion.Y = Math.round(tmpBody.Y);
542
+ }
543
+ if (typeof tmpBody.Width === 'number' && tmpBody.Width > 0)
544
+ {
545
+ tmpRegion.Width = Math.round(tmpBody.Width);
546
+ }
547
+ if (typeof tmpBody.Height === 'number' && tmpBody.Height > 0)
548
+ {
549
+ tmpRegion.Height = Math.round(tmpBody.Height);
550
+ }
551
+ // Document-specific field updates
552
+ if (typeof tmpBody.SelectedText === 'string')
553
+ {
554
+ tmpRegion.SelectedText = tmpBody.SelectedText;
555
+ }
556
+ if (typeof tmpBody.ChapterTitle === 'string')
557
+ {
558
+ tmpRegion.ChapterTitle = tmpBody.ChapterTitle;
559
+ }
560
+ if (typeof tmpBody.PageNumber === 'number')
561
+ {
562
+ tmpRegion.PageNumber = tmpBody.PageNumber;
563
+ }
564
+
565
+ tmpSelf.saveExplorerState(tmpRelPath, tmpStat.mtimeMs, pRecord,
566
+ (pSaveError) =>
567
+ {
568
+ if (pSaveError)
569
+ {
570
+ pResponse.send(500, { Success: false, Error: pSaveError.message });
571
+ return fNext();
572
+ }
573
+
574
+ // Invalidate the folder cache — changes
575
+ // to label/bounds may affect listing results.
576
+ tmpSelf._invalidateFolderCache();
577
+
578
+ pResponse.send(
579
+ {
580
+ Success: true,
581
+ Region: tmpRegion,
582
+ Regions: pRecord.Regions
583
+ });
584
+ return fNext();
585
+ });
586
+ });
587
+ }
588
+ catch (pError)
589
+ {
590
+ pResponse.send(500, { Success: false, Error: pError.message });
591
+ return fNext();
592
+ }
593
+ });
594
+
595
+ // -----------------------------------------------------------------
596
+ // DELETE /api/media/subimage-regions/:id?path= — Remove a region
597
+ // -----------------------------------------------------------------
598
+ pServiceServer.del('/api/media/subimage-regions/:id',
599
+ (pRequest, pResponse, fNext) =>
600
+ {
601
+ try
602
+ {
603
+ let tmpRegionId = pRequest.params.id;
604
+ let tmpParsedUrl = libUrl.parse(pRequest.url, true);
605
+ let tmpRelPath = tmpSelf._sanitizePath(tmpParsedUrl.query.path);
606
+
607
+ if (!tmpRelPath || !tmpRegionId)
608
+ {
609
+ pResponse.send(400, { Success: false, Error: 'Missing path or region ID.' });
610
+ return fNext();
611
+ }
612
+
613
+ let tmpResolved = tmpSelf._resolveFileStat(tmpRelPath);
614
+ if (!tmpResolved)
615
+ {
616
+ pResponse.send(404, { Success: false, Error: 'File not found.' });
617
+ return fNext();
618
+ }
619
+
620
+ let tmpStat = tmpResolved.stat;
621
+
622
+ tmpSelf._loadOrCreateRecord(tmpRelPath, tmpStat.mtimeMs,
623
+ (pLoadError, pRecord) =>
624
+ {
625
+ if (pLoadError)
626
+ {
627
+ pResponse.send(500, { Success: false, Error: pLoadError.message });
628
+ return fNext();
629
+ }
630
+
631
+ let tmpFound = false;
632
+ pRecord.Regions = pRecord.Regions.filter(
633
+ (pRegion) =>
634
+ {
635
+ if (pRegion.ID === tmpRegionId)
636
+ {
637
+ tmpFound = true;
638
+ return false;
639
+ }
640
+ return true;
641
+ });
642
+
643
+ if (!tmpFound)
644
+ {
645
+ pResponse.send(404, { Success: false, Error: 'Region not found.' });
646
+ return fNext();
647
+ }
648
+
649
+ tmpSelf.saveExplorerState(tmpRelPath, tmpStat.mtimeMs, pRecord,
650
+ (pSaveError) =>
651
+ {
652
+ if (pSaveError)
653
+ {
654
+ pResponse.send(500, { Success: false, Error: pSaveError.message });
655
+ return fNext();
656
+ }
657
+
658
+ // Invalidate the folder cache so the
659
+ // next folder-listing request re-scans.
660
+ tmpSelf._invalidateFolderCache();
661
+
662
+ pResponse.send(
663
+ {
664
+ Success: true,
665
+ Regions: pRecord.Regions
666
+ });
667
+ return fNext();
668
+ });
669
+ });
670
+ }
671
+ catch (pError)
672
+ {
673
+ pResponse.send(500, { Success: false, Error: pError.message });
674
+ return fNext();
675
+ }
676
+ });
677
+ }
678
+ }
679
+
680
+ module.exports = RetoldRemoteSubimageService;