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.
- package/css/retold-remote.css +343 -20
- package/docs/.nojekyll +0 -0
- package/docs/README.md +64 -12
- package/docs/_cover.md +6 -6
- package/docs/_sidebar.md +2 -0
- package/docs/_topbar.md +1 -1
- package/docs/_version.json +7 -0
- package/docs/collections.md +30 -0
- package/docs/css/docuserve.css +327 -0
- package/docs/ebook-reader.md +75 -1
- package/docs/image-explorer.md +62 -2
- package/docs/index.html +39 -0
- package/docs/retold-catalog.json +254 -0
- package/docs/retold-keyword-index.json +31216 -0
- package/docs/server-setup.md +122 -91
- package/docs/stack-launcher.md +218 -0
- package/docs/synology.md +585 -0
- package/docs/ultravisor-configuration.md +5 -5
- package/docs/ultravisor-integration.md +4 -2
- package/package.json +20 -14
- package/source/Pict-Application-RetoldRemote.js +22 -0
- package/source/RetoldRemote-ExtensionMaps.js +1 -1
- package/source/cli/RetoldRemote-Server-Setup.js +460 -7
- package/source/cli/RetoldRemote-Stack-Launcher.js +563 -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 +55 -0
- package/source/providers/Pict-Provider-OperationStatus.js +597 -0
- package/source/providers/keyboard-handlers/KeyHandler-ImageExplorer.js +20 -1
- package/source/providers/keyboard-handlers/KeyHandler-Viewer.js +23 -0
- package/source/server/RetoldRemote-AudioWaveformService.js +49 -3
- package/source/server/RetoldRemote-CollectionExportService.js +763 -0
- package/source/server/RetoldRemote-CollectionService.js +5 -0
- package/source/server/RetoldRemote-EbookService.js +218 -3
- package/source/server/RetoldRemote-ImageService.js +221 -46
- package/source/server/RetoldRemote-MediaService.js +63 -4
- package/source/server/RetoldRemote-MetadataCache.js +25 -5
- package/source/server/RetoldRemote-OperationBroadcaster.js +363 -0
- package/source/server/RetoldRemote-SubimageService.js +680 -0
- package/source/server/RetoldRemote-ToolDetector.js +50 -0
- package/source/server/RetoldRemote-UltravisorBeacon.js +18 -3
- package/source/server/RetoldRemote-UltravisorDispatcher.js +65 -491
- package/source/server/RetoldRemote-UltravisorOperations.js +133 -20
- package/source/server/RetoldRemote-VideoFrameService.js +302 -9
- package/source/views/MediaViewer-EbookViewer.js +419 -1
- package/source/views/MediaViewer-PdfViewer.js +1050 -0
- package/source/views/PictView-Remote-AudioExplorer.js +77 -1
- package/source/views/PictView-Remote-CollectionsPanel.js +213 -0
- package/source/views/PictView-Remote-Gallery.js +365 -64
- package/source/views/PictView-Remote-ImageExplorer.js +1529 -44
- package/source/views/PictView-Remote-ImageViewer.js +2 -2
- package/source/views/PictView-Remote-Layout.js +58 -0
- package/source/views/PictView-Remote-MediaViewer.js +100 -25
- package/source/views/PictView-Remote-RegionsBrowser.js +554 -0
- package/source/views/PictView-Remote-SubimagesPanel.js +353 -0
- package/source/views/PictView-Remote-TopBar.js +1 -0
- package/source/views/PictView-Remote-VideoExplorer.js +77 -1
- package/web-application/css/docuserve.css +277 -23
- package/web-application/css/retold-remote.css +343 -20
- package/web-application/docs/README.md +64 -12
- package/web-application/docs/_cover.md +6 -6
- package/web-application/docs/_sidebar.md +2 -0
- package/web-application/docs/_topbar.md +1 -1
- package/web-application/docs/collections.md +30 -0
- package/web-application/docs/ebook-reader.md +75 -1
- package/web-application/docs/image-explorer.md +62 -2
- package/web-application/docs/server-setup.md +122 -91
- package/web-application/docs/stack-launcher.md +218 -0
- package/web-application/docs/synology.md +585 -0
- package/web-application/docs/ultravisor-configuration.md +5 -5
- package/web-application/docs/ultravisor-integration.md +4 -2
- package/web-application/js/pict-docuserve.min.js +12 -12
- package/web-application/js/pict.min.js +2 -2
- package/web-application/js/pict.min.js.map +1 -1
- package/web-application/retold-remote.js +6596 -1784
- package/web-application/retold-remote.js.map +1 -1
- package/web-application/retold-remote.min.js +75 -23
- 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;
|