retold-remote 0.0.5 → 0.0.7

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