retold-remote 0.0.4 → 0.0.6
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/docs/README.md +181 -0
- package/docs/_cover.md +14 -0
- package/docs/_sidebar.md +10 -0
- package/docs/_topbar.md +3 -0
- package/docs/audio-viewer.md +133 -0
- package/docs/ebook-reader.md +90 -0
- package/docs/image-viewer.md +90 -0
- package/docs/server-setup.md +262 -0
- package/docs/video-viewer.md +134 -0
- package/html/docs.html +59 -0
- package/package.json +21 -7
- package/source/Pict-Application-RetoldRemote.js +143 -2
- package/source/RetoldRemote-ExtensionMaps.js +33 -0
- package/source/cli/RetoldRemote-Server-Setup.js +82 -67
- package/source/cli/commands/RetoldRemote-Command-Serve.js +5 -26
- package/source/providers/Pict-Provider-CollectionManager.js +934 -0
- package/source/providers/Pict-Provider-FormattingUtilities.js +109 -0
- package/source/providers/Pict-Provider-GalleryFilterSort.js +2 -11
- package/source/providers/Pict-Provider-GalleryNavigation.js +270 -353
- package/source/providers/Pict-Provider-RetoldRemoteIcons.js +52 -0
- package/source/providers/Pict-Provider-ToastNotification.js +96 -0
- package/source/providers/keyboard-handlers/KeyHandler-AudioExplorer.js +88 -0
- package/source/providers/keyboard-handlers/KeyHandler-Gallery.js +190 -0
- package/source/providers/keyboard-handlers/KeyHandler-Sidebar.js +65 -0
- package/source/providers/keyboard-handlers/KeyHandler-VideoExplorer.js +57 -0
- package/source/providers/keyboard-handlers/KeyHandler-Viewer.js +197 -0
- package/source/server/RetoldRemote-ArchiveService.js +2 -12
- package/source/server/RetoldRemote-AudioWaveformService.js +7 -16
- package/source/server/RetoldRemote-CollectionService.js +684 -0
- package/source/server/RetoldRemote-EbookService.js +7 -16
- package/source/server/RetoldRemote-MediaService.js +3 -14
- package/source/server/RetoldRemote-ParimeCache.js +349 -0
- package/source/server/RetoldRemote-ThumbnailCache.js +52 -20
- package/source/server/RetoldRemote-VideoFrameService.js +7 -15
- package/source/views/PictView-Remote-AudioExplorer.js +10 -43
- package/source/views/PictView-Remote-CollectionsPanel.js +1087 -0
- package/source/views/PictView-Remote-Gallery.js +237 -44
- package/source/views/PictView-Remote-ImageViewer.js +1 -34
- package/source/views/PictView-Remote-Layout.js +410 -20
- package/source/views/PictView-Remote-MediaViewer.js +338 -51
- package/source/views/PictView-Remote-SettingsPanel.js +155 -138
- package/source/views/PictView-Remote-TopBar.js +615 -14
- package/source/views/PictView-Remote-VLCSetup.js +766 -0
- package/source/views/PictView-Remote-VideoExplorer.js +20 -54
- package/web-application/css/docuserve.css +73 -0
- package/web-application/docs/README.md +181 -0
- package/web-application/docs/_cover.md +14 -0
- package/web-application/docs/_sidebar.md +10 -0
- package/web-application/docs/_topbar.md +3 -0
- package/web-application/docs/audio-viewer.md +133 -0
- package/web-application/docs/ebook-reader.md +90 -0
- package/web-application/docs/image-viewer.md +90 -0
- package/web-application/docs/server-setup.md +262 -0
- package/web-application/docs/video-viewer.md +134 -0
- package/web-application/docs.html +59 -0
- package/web-application/js/pict-docuserve.min.js +58 -0
- 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 +2558 -439
- package/web-application/retold-remote.js.map +1 -1
- package/web-application/retold-remote.min.js +41 -11
- package/web-application/retold-remote.min.js.map +1 -1
- package/server.js +0 -43
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retold Remote -- Collection Service
|
|
3
|
+
*
|
|
4
|
+
* Provides REST API endpoints for managing user-defined collections
|
|
5
|
+
* of files, folders, and sub-file references. Collections are stored
|
|
6
|
+
* as Bibliograph JSON records under the source "retold-remote-collections".
|
|
7
|
+
*
|
|
8
|
+
* Endpoints:
|
|
9
|
+
* GET /api/collections -- List all collections (summaries)
|
|
10
|
+
* GET /api/collections/:guid -- Get full collection with items
|
|
11
|
+
* PUT /api/collections/:guid -- Create or update a collection
|
|
12
|
+
* DELETE /api/collections/:guid -- Delete a collection
|
|
13
|
+
* POST /api/collections/:guid/items -- Add item(s) to a collection
|
|
14
|
+
* DELETE /api/collections/:guid/items/:itemId -- Remove an item
|
|
15
|
+
* PUT /api/collections/:guid/reorder -- Reorder items (manual sort)
|
|
16
|
+
* POST /api/collections/copy-items -- Copy items between collections
|
|
17
|
+
*
|
|
18
|
+
* @license MIT
|
|
19
|
+
*/
|
|
20
|
+
const libFableServiceProviderBase = require('fable-serviceproviderbase');
|
|
21
|
+
|
|
22
|
+
const SOURCE_NAME = 'retold-remote-collections';
|
|
23
|
+
|
|
24
|
+
class RetoldRemoteCollectionService extends libFableServiceProviderBase
|
|
25
|
+
{
|
|
26
|
+
constructor(pFable, pOptions, pServiceHash)
|
|
27
|
+
{
|
|
28
|
+
super(pFable, pOptions, pServiceHash);
|
|
29
|
+
|
|
30
|
+
this.serviceType = 'RetoldRemoteCollectionService';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// -- Helpers ----------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build a lightweight summary object from a full collection record.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} pRecord - Full collection record
|
|
39
|
+
* @returns {object} Summary with GUID, Name, Description, CoverImage, Icon, ItemCount, ModifiedAt, Tags
|
|
40
|
+
*/
|
|
41
|
+
_buildCollectionSummary(pRecord)
|
|
42
|
+
{
|
|
43
|
+
return (
|
|
44
|
+
{
|
|
45
|
+
GUID: pRecord.GUID,
|
|
46
|
+
Name: pRecord.Name || '',
|
|
47
|
+
Description: pRecord.Description || '',
|
|
48
|
+
CoverImage: pRecord.CoverImage || '',
|
|
49
|
+
Icon: pRecord.Icon || 'bookmark',
|
|
50
|
+
ItemCount: (Array.isArray(pRecord.Items)) ? pRecord.Items.length : 0,
|
|
51
|
+
CreatedAt: pRecord.CreatedAt || '',
|
|
52
|
+
ModifiedAt: pRecord.ModifiedAt || '',
|
|
53
|
+
Tags: pRecord.Tags || []
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Sort a collection's Items array in place.
|
|
59
|
+
*
|
|
60
|
+
* @param {Array} pItems - Items array
|
|
61
|
+
* @param {string} pSortMode - "manual" | "name" | "modified" | "type" | "size"
|
|
62
|
+
* @param {string} pSortDirection - "asc" | "desc"
|
|
63
|
+
* @returns {Array} The same array, sorted
|
|
64
|
+
*/
|
|
65
|
+
_sortItems(pItems, pSortMode, pSortDirection)
|
|
66
|
+
{
|
|
67
|
+
if (!Array.isArray(pItems) || pItems.length < 2)
|
|
68
|
+
{
|
|
69
|
+
return pItems;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let tmpDirection = (pSortDirection === 'desc') ? -1 : 1;
|
|
73
|
+
|
|
74
|
+
switch (pSortMode)
|
|
75
|
+
{
|
|
76
|
+
case 'name':
|
|
77
|
+
pItems.sort((a, b) =>
|
|
78
|
+
{
|
|
79
|
+
let tmpA = (a.Label || a.Path || '').toLowerCase();
|
|
80
|
+
let tmpB = (b.Label || b.Path || '').toLowerCase();
|
|
81
|
+
// Sort by filename portion only (after last /)
|
|
82
|
+
let tmpSlashA = tmpA.lastIndexOf('/');
|
|
83
|
+
let tmpSlashB = tmpB.lastIndexOf('/');
|
|
84
|
+
if (tmpSlashA >= 0) tmpA = tmpA.substring(tmpSlashA + 1);
|
|
85
|
+
if (tmpSlashB >= 0) tmpB = tmpB.substring(tmpSlashB + 1);
|
|
86
|
+
return tmpDirection * tmpA.localeCompare(tmpB);
|
|
87
|
+
});
|
|
88
|
+
break;
|
|
89
|
+
|
|
90
|
+
case 'type':
|
|
91
|
+
pItems.sort((a, b) =>
|
|
92
|
+
{
|
|
93
|
+
let tmpA = (a.Type || '').toLowerCase();
|
|
94
|
+
let tmpB = (b.Type || '').toLowerCase();
|
|
95
|
+
return tmpDirection * tmpA.localeCompare(tmpB);
|
|
96
|
+
});
|
|
97
|
+
break;
|
|
98
|
+
|
|
99
|
+
case 'modified':
|
|
100
|
+
pItems.sort((a, b) =>
|
|
101
|
+
{
|
|
102
|
+
let tmpA = a.AddedAt || '';
|
|
103
|
+
let tmpB = b.AddedAt || '';
|
|
104
|
+
return tmpDirection * tmpA.localeCompare(tmpB);
|
|
105
|
+
});
|
|
106
|
+
break;
|
|
107
|
+
|
|
108
|
+
case 'manual':
|
|
109
|
+
default:
|
|
110
|
+
pItems.sort((a, b) =>
|
|
111
|
+
{
|
|
112
|
+
return tmpDirection * ((a.SortOrder || 0) - (b.SortOrder || 0));
|
|
113
|
+
});
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return pItems;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Create a blank collection record with defaults.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} pGUID - Collection GUID
|
|
124
|
+
* @param {string} pName - Collection name
|
|
125
|
+
* @returns {object} New collection record
|
|
126
|
+
*/
|
|
127
|
+
_createBlankCollection(pGUID, pName)
|
|
128
|
+
{
|
|
129
|
+
let tmpNow = new Date().toISOString();
|
|
130
|
+
return (
|
|
131
|
+
{
|
|
132
|
+
GUID: pGUID,
|
|
133
|
+
Name: pName || 'Untitled Collection',
|
|
134
|
+
Description: '',
|
|
135
|
+
CoverImage: '',
|
|
136
|
+
Icon: 'bookmark',
|
|
137
|
+
CreatedAt: tmpNow,
|
|
138
|
+
ModifiedAt: tmpNow,
|
|
139
|
+
SortMode: 'manual',
|
|
140
|
+
SortDirection: 'asc',
|
|
141
|
+
Tags: [],
|
|
142
|
+
Items: []
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// -- Route Wiring -----------------------------------------------------
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Wire all REST endpoints. Called from Server-Setup after Parime
|
|
150
|
+
* initialization is complete.
|
|
151
|
+
*
|
|
152
|
+
* @param {object} pServiceServer - Orator service server instance
|
|
153
|
+
*/
|
|
154
|
+
connectRoutes(pServiceServer)
|
|
155
|
+
{
|
|
156
|
+
let tmpSelf = this;
|
|
157
|
+
|
|
158
|
+
// Ensure the Bibliograph source directory exists (idempotent)
|
|
159
|
+
this.fable.Bibliograph.createSource(SOURCE_NAME,
|
|
160
|
+
(pError) =>
|
|
161
|
+
{
|
|
162
|
+
if (pError)
|
|
163
|
+
{
|
|
164
|
+
tmpSelf.fable.log.warn('Collection source creation notice: ' + pError.message);
|
|
165
|
+
}
|
|
166
|
+
tmpSelf._wireRoutes(pServiceServer);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Internal: register all endpoint handlers.
|
|
172
|
+
*
|
|
173
|
+
* @param {object} pServiceServer - Orator service server instance
|
|
174
|
+
*/
|
|
175
|
+
_wireRoutes(pServiceServer)
|
|
176
|
+
{
|
|
177
|
+
let tmpSelf = this;
|
|
178
|
+
let tmpServer = pServiceServer.server;
|
|
179
|
+
|
|
180
|
+
// -----------------------------------------------------------------
|
|
181
|
+
// GET /api/collections — List all collections (summaries)
|
|
182
|
+
// Optional: ?q=searchTerm to filter by name/description/tags
|
|
183
|
+
// -----------------------------------------------------------------
|
|
184
|
+
tmpServer.get('/api/collections',
|
|
185
|
+
(pRequest, pResponse, fNext) =>
|
|
186
|
+
{
|
|
187
|
+
tmpSelf.fable.Bibliograph.readRecordKeys(SOURCE_NAME,
|
|
188
|
+
(pError, pKeys) =>
|
|
189
|
+
{
|
|
190
|
+
if (pError)
|
|
191
|
+
{
|
|
192
|
+
pResponse.send(200, []);
|
|
193
|
+
return fNext();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!pKeys || pKeys.length === 0)
|
|
197
|
+
{
|
|
198
|
+
pResponse.send(200, []);
|
|
199
|
+
return fNext();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let tmpSummaries = [];
|
|
203
|
+
let tmpPending = pKeys.length;
|
|
204
|
+
let tmpSearchQuery = (pRequest.query && pRequest.query.q) ? pRequest.query.q.toLowerCase() : '';
|
|
205
|
+
|
|
206
|
+
for (let i = 0; i < pKeys.length; i++)
|
|
207
|
+
{
|
|
208
|
+
tmpSelf.fable.Bibliograph.read(SOURCE_NAME, pKeys[i],
|
|
209
|
+
(pReadError, pRecord) =>
|
|
210
|
+
{
|
|
211
|
+
if (!pReadError && pRecord)
|
|
212
|
+
{
|
|
213
|
+
// If there is a search query, filter
|
|
214
|
+
if (tmpSearchQuery)
|
|
215
|
+
{
|
|
216
|
+
let tmpName = (pRecord.Name || '').toLowerCase();
|
|
217
|
+
let tmpDesc = (pRecord.Description || '').toLowerCase();
|
|
218
|
+
let tmpTags = (pRecord.Tags || []).join(' ').toLowerCase();
|
|
219
|
+
if (tmpName.indexOf(tmpSearchQuery) >= 0 ||
|
|
220
|
+
tmpDesc.indexOf(tmpSearchQuery) >= 0 ||
|
|
221
|
+
tmpTags.indexOf(tmpSearchQuery) >= 0)
|
|
222
|
+
{
|
|
223
|
+
tmpSummaries.push(tmpSelf._buildCollectionSummary(pRecord));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
else
|
|
227
|
+
{
|
|
228
|
+
tmpSummaries.push(tmpSelf._buildCollectionSummary(pRecord));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
tmpPending--;
|
|
233
|
+
if (tmpPending <= 0)
|
|
234
|
+
{
|
|
235
|
+
// Sort by most recently modified first
|
|
236
|
+
tmpSummaries.sort((a, b) => (b.ModifiedAt || '').localeCompare(a.ModifiedAt || ''));
|
|
237
|
+
pResponse.send(200, tmpSummaries);
|
|
238
|
+
return fNext();
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// -----------------------------------------------------------------
|
|
246
|
+
// GET /api/collections/:guid — Get full collection
|
|
247
|
+
// -----------------------------------------------------------------
|
|
248
|
+
tmpServer.get('/api/collections/:guid',
|
|
249
|
+
(pRequest, pResponse, fNext) =>
|
|
250
|
+
{
|
|
251
|
+
let tmpGUID = pRequest.params.guid;
|
|
252
|
+
if (!tmpGUID)
|
|
253
|
+
{
|
|
254
|
+
pResponse.send(400, { Error: 'Missing collection GUID.' });
|
|
255
|
+
return fNext();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
tmpSelf.fable.Bibliograph.read(SOURCE_NAME, tmpGUID,
|
|
259
|
+
(pError, pRecord) =>
|
|
260
|
+
{
|
|
261
|
+
if (pError || !pRecord)
|
|
262
|
+
{
|
|
263
|
+
pResponse.send(404, { Error: 'Collection not found.' });
|
|
264
|
+
return fNext();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Sort items according to collection's sort preference
|
|
268
|
+
if (pRecord.Items)
|
|
269
|
+
{
|
|
270
|
+
tmpSelf._sortItems(pRecord.Items, pRecord.SortMode, pRecord.SortDirection);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
pResponse.send(200, pRecord);
|
|
274
|
+
return fNext();
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// -----------------------------------------------------------------
|
|
279
|
+
// PUT /api/collections/:guid — Create or update a collection
|
|
280
|
+
// -----------------------------------------------------------------
|
|
281
|
+
tmpServer.put('/api/collections/:guid',
|
|
282
|
+
(pRequest, pResponse, fNext) =>
|
|
283
|
+
{
|
|
284
|
+
let tmpGUID = pRequest.params.guid;
|
|
285
|
+
if (!tmpGUID)
|
|
286
|
+
{
|
|
287
|
+
pResponse.send(400, { Error: 'Missing collection GUID.' });
|
|
288
|
+
return fNext();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let tmpBody = pRequest.body || {};
|
|
292
|
+
|
|
293
|
+
// Check if this is a creation or update
|
|
294
|
+
tmpSelf.fable.Bibliograph.read(SOURCE_NAME, tmpGUID,
|
|
295
|
+
(pReadError, pExisting) =>
|
|
296
|
+
{
|
|
297
|
+
let tmpRecord;
|
|
298
|
+
|
|
299
|
+
if (pExisting)
|
|
300
|
+
{
|
|
301
|
+
// Update: merge body into existing record
|
|
302
|
+
tmpRecord = pExisting;
|
|
303
|
+
if (typeof tmpBody.Name === 'string') tmpRecord.Name = tmpBody.Name;
|
|
304
|
+
if (typeof tmpBody.Description === 'string') tmpRecord.Description = tmpBody.Description;
|
|
305
|
+
if (typeof tmpBody.CoverImage === 'string') tmpRecord.CoverImage = tmpBody.CoverImage;
|
|
306
|
+
if (typeof tmpBody.Icon === 'string') tmpRecord.Icon = tmpBody.Icon;
|
|
307
|
+
if (typeof tmpBody.SortMode === 'string') tmpRecord.SortMode = tmpBody.SortMode;
|
|
308
|
+
if (typeof tmpBody.SortDirection === 'string') tmpRecord.SortDirection = tmpBody.SortDirection;
|
|
309
|
+
if (Array.isArray(tmpBody.Tags)) tmpRecord.Tags = tmpBody.Tags;
|
|
310
|
+
if (Array.isArray(tmpBody.Items)) tmpRecord.Items = tmpBody.Items;
|
|
311
|
+
tmpRecord.ModifiedAt = new Date().toISOString();
|
|
312
|
+
}
|
|
313
|
+
else
|
|
314
|
+
{
|
|
315
|
+
// Create new
|
|
316
|
+
tmpRecord = tmpSelf._createBlankCollection(tmpGUID, tmpBody.Name);
|
|
317
|
+
if (typeof tmpBody.Description === 'string') tmpRecord.Description = tmpBody.Description;
|
|
318
|
+
if (typeof tmpBody.CoverImage === 'string') tmpRecord.CoverImage = tmpBody.CoverImage;
|
|
319
|
+
if (typeof tmpBody.Icon === 'string') tmpRecord.Icon = tmpBody.Icon;
|
|
320
|
+
if (typeof tmpBody.SortMode === 'string') tmpRecord.SortMode = tmpBody.SortMode;
|
|
321
|
+
if (typeof tmpBody.SortDirection === 'string') tmpRecord.SortDirection = tmpBody.SortDirection;
|
|
322
|
+
if (Array.isArray(tmpBody.Tags)) tmpRecord.Tags = tmpBody.Tags;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
tmpSelf.fable.Bibliograph.write(SOURCE_NAME, tmpGUID, tmpRecord,
|
|
326
|
+
(pWriteError) =>
|
|
327
|
+
{
|
|
328
|
+
if (pWriteError)
|
|
329
|
+
{
|
|
330
|
+
pResponse.send(500, { Error: 'Failed to save collection: ' + pWriteError.message });
|
|
331
|
+
return fNext();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
pResponse.send(200, tmpRecord);
|
|
335
|
+
return fNext();
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// -----------------------------------------------------------------
|
|
341
|
+
// DELETE /api/collections/:guid — Delete a collection
|
|
342
|
+
// -----------------------------------------------------------------
|
|
343
|
+
tmpServer.del('/api/collections/:guid',
|
|
344
|
+
(pRequest, pResponse, fNext) =>
|
|
345
|
+
{
|
|
346
|
+
let tmpGUID = pRequest.params.guid;
|
|
347
|
+
if (!tmpGUID)
|
|
348
|
+
{
|
|
349
|
+
pResponse.send(400, { Error: 'Missing collection GUID.' });
|
|
350
|
+
return fNext();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
tmpSelf.fable.Bibliograph.delete(SOURCE_NAME, tmpGUID,
|
|
354
|
+
(pError) =>
|
|
355
|
+
{
|
|
356
|
+
if (pError)
|
|
357
|
+
{
|
|
358
|
+
pResponse.send(500, { Error: 'Failed to delete collection: ' + pError.message });
|
|
359
|
+
return fNext();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
pResponse.send(200, { Success: true });
|
|
363
|
+
return fNext();
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// -----------------------------------------------------------------
|
|
368
|
+
// POST /api/collections/:guid/items — Add item(s)
|
|
369
|
+
// Body: { Items: [ { Type, Path, Hash, Label, ... }, ... ] }
|
|
370
|
+
// -----------------------------------------------------------------
|
|
371
|
+
tmpServer.post('/api/collections/:guid/items',
|
|
372
|
+
(pRequest, pResponse, fNext) =>
|
|
373
|
+
{
|
|
374
|
+
let tmpGUID = pRequest.params.guid;
|
|
375
|
+
if (!tmpGUID)
|
|
376
|
+
{
|
|
377
|
+
pResponse.send(400, { Error: 'Missing collection GUID.' });
|
|
378
|
+
return fNext();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
let tmpBody = pRequest.body || {};
|
|
382
|
+
let tmpNewItems = tmpBody.Items;
|
|
383
|
+
if (!Array.isArray(tmpNewItems) || tmpNewItems.length === 0)
|
|
384
|
+
{
|
|
385
|
+
pResponse.send(400, { Error: 'Body must contain an Items array.' });
|
|
386
|
+
return fNext();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
tmpSelf.fable.Bibliograph.read(SOURCE_NAME, tmpGUID,
|
|
390
|
+
(pReadError, pRecord) =>
|
|
391
|
+
{
|
|
392
|
+
if (pReadError || !pRecord)
|
|
393
|
+
{
|
|
394
|
+
pResponse.send(404, { Error: 'Collection not found.' });
|
|
395
|
+
return fNext();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (!Array.isArray(pRecord.Items))
|
|
399
|
+
{
|
|
400
|
+
pRecord.Items = [];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Determine the next SortOrder value
|
|
404
|
+
let tmpMaxSortOrder = 0;
|
|
405
|
+
for (let i = 0; i < pRecord.Items.length; i++)
|
|
406
|
+
{
|
|
407
|
+
if ((pRecord.Items[i].SortOrder || 0) > tmpMaxSortOrder)
|
|
408
|
+
{
|
|
409
|
+
tmpMaxSortOrder = pRecord.Items[i].SortOrder;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
let tmpNow = new Date().toISOString();
|
|
414
|
+
|
|
415
|
+
for (let i = 0; i < tmpNewItems.length; i++)
|
|
416
|
+
{
|
|
417
|
+
let tmpItem = tmpNewItems[i];
|
|
418
|
+
tmpMaxSortOrder++;
|
|
419
|
+
|
|
420
|
+
pRecord.Items.push(
|
|
421
|
+
{
|
|
422
|
+
ID: tmpItem.ID || tmpSelf.fable.getUUID(),
|
|
423
|
+
Type: tmpItem.Type || 'file',
|
|
424
|
+
Path: tmpItem.Path || '',
|
|
425
|
+
Hash: tmpItem.Hash || '',
|
|
426
|
+
Label: tmpItem.Label || '',
|
|
427
|
+
Note: tmpItem.Note || '',
|
|
428
|
+
SortOrder: tmpMaxSortOrder,
|
|
429
|
+
AddedAt: tmpNow,
|
|
430
|
+
ArchivePath: tmpItem.ArchivePath || null,
|
|
431
|
+
CropRegion: tmpItem.CropRegion || null,
|
|
432
|
+
VideoStart: (typeof tmpItem.VideoStart === 'number') ? tmpItem.VideoStart : null,
|
|
433
|
+
VideoEnd: (typeof tmpItem.VideoEnd === 'number') ? tmpItem.VideoEnd : null,
|
|
434
|
+
FrameTimestamp: (typeof tmpItem.FrameTimestamp === 'number') ? tmpItem.FrameTimestamp : null
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
pRecord.ModifiedAt = tmpNow;
|
|
439
|
+
|
|
440
|
+
tmpSelf.fable.Bibliograph.write(SOURCE_NAME, tmpGUID, pRecord,
|
|
441
|
+
(pWriteError) =>
|
|
442
|
+
{
|
|
443
|
+
if (pWriteError)
|
|
444
|
+
{
|
|
445
|
+
pResponse.send(500, { Error: 'Failed to save collection: ' + pWriteError.message });
|
|
446
|
+
return fNext();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
pResponse.send(200, pRecord);
|
|
450
|
+
return fNext();
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// -----------------------------------------------------------------
|
|
456
|
+
// DELETE /api/collections/:guid/items/:itemId — Remove an item
|
|
457
|
+
// -----------------------------------------------------------------
|
|
458
|
+
tmpServer.del('/api/collections/:guid/items/:itemId',
|
|
459
|
+
(pRequest, pResponse, fNext) =>
|
|
460
|
+
{
|
|
461
|
+
let tmpGUID = pRequest.params.guid;
|
|
462
|
+
let tmpItemId = pRequest.params.itemId;
|
|
463
|
+
if (!tmpGUID || !tmpItemId)
|
|
464
|
+
{
|
|
465
|
+
pResponse.send(400, { Error: 'Missing collection GUID or item ID.' });
|
|
466
|
+
return fNext();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
tmpSelf.fable.Bibliograph.read(SOURCE_NAME, tmpGUID,
|
|
470
|
+
(pReadError, pRecord) =>
|
|
471
|
+
{
|
|
472
|
+
if (pReadError || !pRecord)
|
|
473
|
+
{
|
|
474
|
+
pResponse.send(404, { Error: 'Collection not found.' });
|
|
475
|
+
return fNext();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (!Array.isArray(pRecord.Items))
|
|
479
|
+
{
|
|
480
|
+
pRecord.Items = [];
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
let tmpOriginalLength = pRecord.Items.length;
|
|
484
|
+
pRecord.Items = pRecord.Items.filter((pItem) => pItem.ID !== tmpItemId);
|
|
485
|
+
|
|
486
|
+
if (pRecord.Items.length === tmpOriginalLength)
|
|
487
|
+
{
|
|
488
|
+
pResponse.send(404, { Error: 'Item not found in collection.' });
|
|
489
|
+
return fNext();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
pRecord.ModifiedAt = new Date().toISOString();
|
|
493
|
+
|
|
494
|
+
tmpSelf.fable.Bibliograph.write(SOURCE_NAME, tmpGUID, pRecord,
|
|
495
|
+
(pWriteError) =>
|
|
496
|
+
{
|
|
497
|
+
if (pWriteError)
|
|
498
|
+
{
|
|
499
|
+
pResponse.send(500, { Error: 'Failed to save collection: ' + pWriteError.message });
|
|
500
|
+
return fNext();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
pResponse.send(200, pRecord);
|
|
504
|
+
return fNext();
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// -----------------------------------------------------------------
|
|
510
|
+
// PUT /api/collections/:guid/reorder — Reorder items
|
|
511
|
+
// Body: { ItemOrder: ["id1", "id2", ...] }
|
|
512
|
+
// -----------------------------------------------------------------
|
|
513
|
+
tmpServer.put('/api/collections/:guid/reorder',
|
|
514
|
+
(pRequest, pResponse, fNext) =>
|
|
515
|
+
{
|
|
516
|
+
let tmpGUID = pRequest.params.guid;
|
|
517
|
+
if (!tmpGUID)
|
|
518
|
+
{
|
|
519
|
+
pResponse.send(400, { Error: 'Missing collection GUID.' });
|
|
520
|
+
return fNext();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
let tmpBody = pRequest.body || {};
|
|
524
|
+
let tmpItemOrder = tmpBody.ItemOrder;
|
|
525
|
+
if (!Array.isArray(tmpItemOrder))
|
|
526
|
+
{
|
|
527
|
+
pResponse.send(400, { Error: 'Body must contain an ItemOrder array of item IDs.' });
|
|
528
|
+
return fNext();
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
tmpSelf.fable.Bibliograph.read(SOURCE_NAME, tmpGUID,
|
|
532
|
+
(pReadError, pRecord) =>
|
|
533
|
+
{
|
|
534
|
+
if (pReadError || !pRecord)
|
|
535
|
+
{
|
|
536
|
+
pResponse.send(404, { Error: 'Collection not found.' });
|
|
537
|
+
return fNext();
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (!Array.isArray(pRecord.Items))
|
|
541
|
+
{
|
|
542
|
+
pRecord.Items = [];
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Build a lookup map of item ID -> item
|
|
546
|
+
let tmpItemMap = {};
|
|
547
|
+
for (let i = 0; i < pRecord.Items.length; i++)
|
|
548
|
+
{
|
|
549
|
+
tmpItemMap[pRecord.Items[i].ID] = pRecord.Items[i];
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Renumber SortOrder based on the provided order
|
|
553
|
+
for (let i = 0; i < tmpItemOrder.length; i++)
|
|
554
|
+
{
|
|
555
|
+
if (tmpItemMap[tmpItemOrder[i]])
|
|
556
|
+
{
|
|
557
|
+
tmpItemMap[tmpItemOrder[i]].SortOrder = i;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
pRecord.SortMode = 'manual';
|
|
562
|
+
pRecord.ModifiedAt = new Date().toISOString();
|
|
563
|
+
|
|
564
|
+
tmpSelf.fable.Bibliograph.write(SOURCE_NAME, tmpGUID, pRecord,
|
|
565
|
+
(pWriteError) =>
|
|
566
|
+
{
|
|
567
|
+
if (pWriteError)
|
|
568
|
+
{
|
|
569
|
+
pResponse.send(500, { Error: 'Failed to save collection: ' + pWriteError.message });
|
|
570
|
+
return fNext();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Return sorted items
|
|
574
|
+
tmpSelf._sortItems(pRecord.Items, 'manual', 'asc');
|
|
575
|
+
pResponse.send(200, pRecord);
|
|
576
|
+
return fNext();
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// -----------------------------------------------------------------
|
|
582
|
+
// POST /api/collections/copy-items — Copy items between collections
|
|
583
|
+
// Body: { SourceGUID, TargetGUID, ItemIDs: [] }
|
|
584
|
+
// -----------------------------------------------------------------
|
|
585
|
+
tmpServer.post('/api/collections/copy-items',
|
|
586
|
+
(pRequest, pResponse, fNext) =>
|
|
587
|
+
{
|
|
588
|
+
let tmpBody = pRequest.body || {};
|
|
589
|
+
let tmpSourceGUID = tmpBody.SourceGUID;
|
|
590
|
+
let tmpTargetGUID = tmpBody.TargetGUID;
|
|
591
|
+
let tmpItemIDs = tmpBody.ItemIDs;
|
|
592
|
+
|
|
593
|
+
if (!tmpSourceGUID || !tmpTargetGUID)
|
|
594
|
+
{
|
|
595
|
+
pResponse.send(400, { Error: 'SourceGUID and TargetGUID are required.' });
|
|
596
|
+
return fNext();
|
|
597
|
+
}
|
|
598
|
+
if (!Array.isArray(tmpItemIDs) || tmpItemIDs.length === 0)
|
|
599
|
+
{
|
|
600
|
+
pResponse.send(400, { Error: 'ItemIDs must be a non-empty array.' });
|
|
601
|
+
return fNext();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Read the source collection
|
|
605
|
+
tmpSelf.fable.Bibliograph.read(SOURCE_NAME, tmpSourceGUID,
|
|
606
|
+
(pSourceError, pSourceRecord) =>
|
|
607
|
+
{
|
|
608
|
+
if (pSourceError || !pSourceRecord)
|
|
609
|
+
{
|
|
610
|
+
pResponse.send(404, { Error: 'Source collection not found.' });
|
|
611
|
+
return fNext();
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Read the target collection
|
|
615
|
+
tmpSelf.fable.Bibliograph.read(SOURCE_NAME, tmpTargetGUID,
|
|
616
|
+
(pTargetError, pTargetRecord) =>
|
|
617
|
+
{
|
|
618
|
+
if (pTargetError || !pTargetRecord)
|
|
619
|
+
{
|
|
620
|
+
pResponse.send(404, { Error: 'Target collection not found.' });
|
|
621
|
+
return fNext();
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (!Array.isArray(pTargetRecord.Items))
|
|
625
|
+
{
|
|
626
|
+
pTargetRecord.Items = [];
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Determine next sort order in target
|
|
630
|
+
let tmpMaxSortOrder = 0;
|
|
631
|
+
for (let i = 0; i < pTargetRecord.Items.length; i++)
|
|
632
|
+
{
|
|
633
|
+
if ((pTargetRecord.Items[i].SortOrder || 0) > tmpMaxSortOrder)
|
|
634
|
+
{
|
|
635
|
+
tmpMaxSortOrder = pTargetRecord.Items[i].SortOrder;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Build a set for quick lookup
|
|
640
|
+
let tmpItemIDSet = {};
|
|
641
|
+
for (let i = 0; i < tmpItemIDs.length; i++)
|
|
642
|
+
{
|
|
643
|
+
tmpItemIDSet[tmpItemIDs[i]] = true;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Copy matching items with new IDs
|
|
647
|
+
let tmpNow = new Date().toISOString();
|
|
648
|
+
let tmpSourceItems = pSourceRecord.Items || [];
|
|
649
|
+
for (let i = 0; i < tmpSourceItems.length; i++)
|
|
650
|
+
{
|
|
651
|
+
if (tmpItemIDSet[tmpSourceItems[i].ID])
|
|
652
|
+
{
|
|
653
|
+
tmpMaxSortOrder++;
|
|
654
|
+
let tmpCopy = JSON.parse(JSON.stringify(tmpSourceItems[i]));
|
|
655
|
+
tmpCopy.ID = tmpSelf.fable.getUUID();
|
|
656
|
+
tmpCopy.SortOrder = tmpMaxSortOrder;
|
|
657
|
+
tmpCopy.AddedAt = tmpNow;
|
|
658
|
+
pTargetRecord.Items.push(tmpCopy);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
pTargetRecord.ModifiedAt = tmpNow;
|
|
663
|
+
|
|
664
|
+
tmpSelf.fable.Bibliograph.write(SOURCE_NAME, tmpTargetGUID, pTargetRecord,
|
|
665
|
+
(pWriteError) =>
|
|
666
|
+
{
|
|
667
|
+
if (pWriteError)
|
|
668
|
+
{
|
|
669
|
+
pResponse.send(500, { Error: 'Failed to save target collection: ' + pWriteError.message });
|
|
670
|
+
return fNext();
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
pResponse.send(200, pTargetRecord);
|
|
674
|
+
return fNext();
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
this.fable.log.info('Collection Service: routes connected.');
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
module.exports = RetoldRemoteCollectionService;
|