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.
Files changed (63) hide show
  1. package/docs/README.md +181 -0
  2. package/docs/_cover.md +14 -0
  3. package/docs/_sidebar.md +10 -0
  4. package/docs/_topbar.md +3 -0
  5. package/docs/audio-viewer.md +133 -0
  6. package/docs/ebook-reader.md +90 -0
  7. package/docs/image-viewer.md +90 -0
  8. package/docs/server-setup.md +262 -0
  9. package/docs/video-viewer.md +134 -0
  10. package/html/docs.html +59 -0
  11. package/package.json +21 -7
  12. package/source/Pict-Application-RetoldRemote.js +143 -2
  13. package/source/RetoldRemote-ExtensionMaps.js +33 -0
  14. package/source/cli/RetoldRemote-Server-Setup.js +82 -67
  15. package/source/cli/commands/RetoldRemote-Command-Serve.js +5 -26
  16. package/source/providers/Pict-Provider-CollectionManager.js +934 -0
  17. package/source/providers/Pict-Provider-FormattingUtilities.js +109 -0
  18. package/source/providers/Pict-Provider-GalleryFilterSort.js +2 -11
  19. package/source/providers/Pict-Provider-GalleryNavigation.js +270 -353
  20. package/source/providers/Pict-Provider-RetoldRemoteIcons.js +52 -0
  21. package/source/providers/Pict-Provider-ToastNotification.js +96 -0
  22. package/source/providers/keyboard-handlers/KeyHandler-AudioExplorer.js +88 -0
  23. package/source/providers/keyboard-handlers/KeyHandler-Gallery.js +190 -0
  24. package/source/providers/keyboard-handlers/KeyHandler-Sidebar.js +65 -0
  25. package/source/providers/keyboard-handlers/KeyHandler-VideoExplorer.js +57 -0
  26. package/source/providers/keyboard-handlers/KeyHandler-Viewer.js +197 -0
  27. package/source/server/RetoldRemote-ArchiveService.js +2 -12
  28. package/source/server/RetoldRemote-AudioWaveformService.js +7 -16
  29. package/source/server/RetoldRemote-CollectionService.js +684 -0
  30. package/source/server/RetoldRemote-EbookService.js +7 -16
  31. package/source/server/RetoldRemote-MediaService.js +3 -14
  32. package/source/server/RetoldRemote-ParimeCache.js +349 -0
  33. package/source/server/RetoldRemote-ThumbnailCache.js +52 -20
  34. package/source/server/RetoldRemote-VideoFrameService.js +7 -15
  35. package/source/views/PictView-Remote-AudioExplorer.js +10 -43
  36. package/source/views/PictView-Remote-CollectionsPanel.js +1087 -0
  37. package/source/views/PictView-Remote-Gallery.js +237 -44
  38. package/source/views/PictView-Remote-ImageViewer.js +1 -34
  39. package/source/views/PictView-Remote-Layout.js +410 -20
  40. package/source/views/PictView-Remote-MediaViewer.js +338 -51
  41. package/source/views/PictView-Remote-SettingsPanel.js +155 -138
  42. package/source/views/PictView-Remote-TopBar.js +615 -14
  43. package/source/views/PictView-Remote-VLCSetup.js +766 -0
  44. package/source/views/PictView-Remote-VideoExplorer.js +20 -54
  45. package/web-application/css/docuserve.css +73 -0
  46. package/web-application/docs/README.md +181 -0
  47. package/web-application/docs/_cover.md +14 -0
  48. package/web-application/docs/_sidebar.md +10 -0
  49. package/web-application/docs/_topbar.md +3 -0
  50. package/web-application/docs/audio-viewer.md +133 -0
  51. package/web-application/docs/ebook-reader.md +90 -0
  52. package/web-application/docs/image-viewer.md +90 -0
  53. package/web-application/docs/server-setup.md +262 -0
  54. package/web-application/docs/video-viewer.md +134 -0
  55. package/web-application/docs.html +59 -0
  56. package/web-application/js/pict-docuserve.min.js +58 -0
  57. package/web-application/js/pict.min.js +2 -2
  58. package/web-application/js/pict.min.js.map +1 -1
  59. package/web-application/retold-remote.js +2558 -439
  60. package/web-application/retold-remote.js.map +1 -1
  61. package/web-application/retold-remote.min.js +41 -11
  62. package/web-application/retold-remote.min.js.map +1 -1
  63. 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;