@protontech/drive-sdk 0.4.0 → 0.5.0

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 (176) hide show
  1. package/dist/diagnostic/sdkDiagnostic.js +1 -1
  2. package/dist/diagnostic/sdkDiagnostic.js.map +1 -1
  3. package/dist/interface/download.d.ts +4 -4
  4. package/dist/interface/nodes.d.ts +4 -0
  5. package/dist/interface/nodes.js.map +1 -1
  6. package/dist/interface/upload.d.ts +6 -3
  7. package/dist/internal/apiService/apiService.d.ts +3 -0
  8. package/dist/internal/apiService/apiService.js +25 -2
  9. package/dist/internal/apiService/apiService.js.map +1 -1
  10. package/dist/internal/apiService/apiService.test.js +38 -0
  11. package/dist/internal/apiService/apiService.test.js.map +1 -1
  12. package/dist/internal/apiService/driveTypes.d.ts +31 -48
  13. package/dist/internal/apiService/errors.js +3 -0
  14. package/dist/internal/apiService/errors.js.map +1 -1
  15. package/dist/internal/apiService/errors.test.js +15 -7
  16. package/dist/internal/apiService/errors.test.js.map +1 -1
  17. package/dist/internal/asyncIteratorMap.d.ts +1 -1
  18. package/dist/internal/asyncIteratorMap.js +6 -1
  19. package/dist/internal/asyncIteratorMap.js.map +1 -1
  20. package/dist/internal/asyncIteratorMap.test.js +9 -0
  21. package/dist/internal/asyncIteratorMap.test.js.map +1 -1
  22. package/dist/internal/download/fileDownloader.d.ts +3 -3
  23. package/dist/internal/download/fileDownloader.js +5 -5
  24. package/dist/internal/download/fileDownloader.js.map +1 -1
  25. package/dist/internal/download/fileDownloader.test.js +8 -8
  26. package/dist/internal/download/fileDownloader.test.js.map +1 -1
  27. package/dist/internal/nodes/apiService.d.ts +6 -1
  28. package/dist/internal/nodes/apiService.js +45 -32
  29. package/dist/internal/nodes/apiService.js.map +1 -1
  30. package/dist/internal/nodes/apiService.test.js +164 -17
  31. package/dist/internal/nodes/apiService.test.js.map +1 -1
  32. package/dist/internal/nodes/cache.test.js +1 -0
  33. package/dist/internal/nodes/cache.test.js.map +1 -1
  34. package/dist/internal/nodes/debouncer.d.ts +23 -0
  35. package/dist/internal/nodes/debouncer.js +80 -0
  36. package/dist/internal/nodes/debouncer.js.map +1 -0
  37. package/dist/internal/nodes/debouncer.test.d.ts +1 -0
  38. package/dist/internal/nodes/debouncer.test.js +100 -0
  39. package/dist/internal/nodes/debouncer.test.js.map +1 -0
  40. package/dist/internal/nodes/extendedAttributes.d.ts +2 -2
  41. package/dist/internal/nodes/extendedAttributes.js +15 -11
  42. package/dist/internal/nodes/extendedAttributes.js.map +1 -1
  43. package/dist/internal/nodes/extendedAttributes.test.js +19 -1
  44. package/dist/internal/nodes/extendedAttributes.test.js.map +1 -1
  45. package/dist/internal/nodes/index.test.js +1 -0
  46. package/dist/internal/nodes/index.test.js.map +1 -1
  47. package/dist/internal/nodes/interface.d.ts +1 -0
  48. package/dist/internal/nodes/nodesAccess.d.ts +2 -1
  49. package/dist/internal/nodes/nodesAccess.js +24 -5
  50. package/dist/internal/nodes/nodesAccess.js.map +1 -1
  51. package/dist/internal/nodes/nodesAccess.test.js +2 -2
  52. package/dist/internal/nodes/nodesAccess.test.js.map +1 -1
  53. package/dist/internal/nodes/nodesManagement.js +1 -0
  54. package/dist/internal/nodes/nodesManagement.js.map +1 -1
  55. package/dist/internal/photos/index.d.ts +11 -0
  56. package/dist/internal/photos/index.js +27 -0
  57. package/dist/internal/photos/index.js.map +1 -1
  58. package/dist/internal/photos/upload.d.ts +60 -0
  59. package/dist/internal/photos/upload.js +104 -0
  60. package/dist/internal/photos/upload.js.map +1 -0
  61. package/dist/internal/sharingPublic/apiService.d.ts +2 -2
  62. package/dist/internal/sharingPublic/apiService.js +2 -62
  63. package/dist/internal/sharingPublic/apiService.js.map +1 -1
  64. package/dist/internal/sharingPublic/cryptoCache.d.ts +0 -4
  65. package/dist/internal/sharingPublic/cryptoCache.js +0 -28
  66. package/dist/internal/sharingPublic/cryptoCache.js.map +1 -1
  67. package/dist/internal/sharingPublic/cryptoReporter.d.ts +16 -0
  68. package/dist/internal/sharingPublic/cryptoReporter.js +44 -0
  69. package/dist/internal/sharingPublic/cryptoReporter.js.map +1 -0
  70. package/dist/internal/sharingPublic/cryptoService.d.ts +3 -4
  71. package/dist/internal/sharingPublic/cryptoService.js +5 -43
  72. package/dist/internal/sharingPublic/cryptoService.js.map +1 -1
  73. package/dist/internal/sharingPublic/index.d.ts +21 -3
  74. package/dist/internal/sharingPublic/index.js +43 -12
  75. package/dist/internal/sharingPublic/index.js.map +1 -1
  76. package/dist/internal/sharingPublic/interface.d.ts +0 -1
  77. package/dist/internal/sharingPublic/nodes.d.ts +13 -0
  78. package/dist/internal/sharingPublic/nodes.js +28 -0
  79. package/dist/internal/sharingPublic/nodes.js.map +1 -0
  80. package/dist/internal/sharingPublic/session/session.d.ts +3 -3
  81. package/dist/internal/sharingPublic/session/url.test.js +3 -3
  82. package/dist/internal/sharingPublic/shares.d.ts +34 -0
  83. package/dist/internal/sharingPublic/shares.js +69 -0
  84. package/dist/internal/sharingPublic/shares.js.map +1 -0
  85. package/dist/internal/upload/apiService.d.ts +2 -2
  86. package/dist/internal/upload/apiService.js +11 -2
  87. package/dist/internal/upload/apiService.js.map +1 -1
  88. package/dist/internal/upload/controller.d.ts +8 -2
  89. package/dist/internal/upload/controller.js.map +1 -1
  90. package/dist/internal/upload/cryptoService.d.ts +2 -2
  91. package/dist/internal/upload/cryptoService.js.map +1 -1
  92. package/dist/internal/upload/fileUploader.d.ts +7 -3
  93. package/dist/internal/upload/fileUploader.js +6 -3
  94. package/dist/internal/upload/fileUploader.js.map +1 -1
  95. package/dist/internal/upload/fileUploader.test.js +23 -11
  96. package/dist/internal/upload/fileUploader.test.js.map +1 -1
  97. package/dist/internal/upload/interface.d.ts +3 -0
  98. package/dist/internal/upload/manager.d.ts +12 -11
  99. package/dist/internal/upload/manager.js +8 -2
  100. package/dist/internal/upload/manager.js.map +1 -1
  101. package/dist/internal/upload/manager.test.js +8 -0
  102. package/dist/internal/upload/manager.test.js.map +1 -1
  103. package/dist/internal/upload/streamUploader.d.ts +40 -26
  104. package/dist/internal/upload/streamUploader.js +15 -8
  105. package/dist/internal/upload/streamUploader.js.map +1 -1
  106. package/dist/internal/upload/streamUploader.test.js +11 -7
  107. package/dist/internal/upload/streamUploader.test.js.map +1 -1
  108. package/dist/protonDriveClient.d.ts +3 -3
  109. package/dist/protonDriveClient.js +4 -4
  110. package/dist/protonDriveClient.js.map +1 -1
  111. package/dist/protonDrivePhotosClient.d.ts +18 -2
  112. package/dist/protonDrivePhotosClient.js +19 -2
  113. package/dist/protonDrivePhotosClient.js.map +1 -1
  114. package/dist/protonDrivePublicLinkClient.d.ts +31 -4
  115. package/dist/protonDrivePublicLinkClient.js +52 -9
  116. package/dist/protonDrivePublicLinkClient.js.map +1 -1
  117. package/dist/transformers.d.ts +1 -1
  118. package/dist/transformers.js +1 -0
  119. package/dist/transformers.js.map +1 -1
  120. package/package.json +1 -1
  121. package/src/diagnostic/sdkDiagnostic.ts +1 -1
  122. package/src/interface/download.ts +4 -4
  123. package/src/interface/nodes.ts +4 -0
  124. package/src/interface/upload.ts +3 -3
  125. package/src/internal/apiService/apiService.test.ts +50 -0
  126. package/src/internal/apiService/apiService.ts +33 -2
  127. package/src/internal/apiService/driveTypes.ts +31 -48
  128. package/src/internal/apiService/errors.test.ts +10 -0
  129. package/src/internal/apiService/errors.ts +5 -1
  130. package/src/internal/asyncIteratorMap.test.ts +12 -0
  131. package/src/internal/asyncIteratorMap.ts +8 -0
  132. package/src/internal/download/fileDownloader.test.ts +8 -8
  133. package/src/internal/download/fileDownloader.ts +5 -5
  134. package/src/internal/nodes/apiService.test.ts +222 -16
  135. package/src/internal/nodes/apiService.ts +63 -49
  136. package/src/internal/nodes/cache.test.ts +1 -0
  137. package/src/internal/nodes/debouncer.test.ts +129 -0
  138. package/src/internal/nodes/debouncer.ts +93 -0
  139. package/src/internal/nodes/extendedAttributes.test.ts +23 -1
  140. package/src/internal/nodes/extendedAttributes.ts +26 -18
  141. package/src/internal/nodes/index.test.ts +1 -0
  142. package/src/internal/nodes/interface.ts +1 -0
  143. package/src/internal/nodes/nodesAccess.test.ts +2 -2
  144. package/src/internal/nodes/nodesAccess.ts +30 -5
  145. package/src/internal/nodes/nodesManagement.ts +1 -0
  146. package/src/internal/photos/index.ts +62 -0
  147. package/src/internal/photos/upload.ts +212 -0
  148. package/src/internal/sharingPublic/apiService.ts +5 -86
  149. package/src/internal/sharingPublic/cryptoCache.ts +0 -34
  150. package/src/internal/sharingPublic/cryptoReporter.ts +73 -0
  151. package/src/internal/sharingPublic/cryptoService.ts +4 -80
  152. package/src/internal/sharingPublic/index.ts +68 -6
  153. package/src/internal/sharingPublic/interface.ts +0 -9
  154. package/src/internal/sharingPublic/nodes.ts +37 -0
  155. package/src/internal/sharingPublic/session/apiService.ts +1 -1
  156. package/src/internal/sharingPublic/session/session.ts +3 -3
  157. package/src/internal/sharingPublic/session/url.test.ts +3 -3
  158. package/src/internal/sharingPublic/shares.ts +86 -0
  159. package/src/internal/upload/apiService.ts +15 -4
  160. package/src/internal/upload/controller.ts +2 -2
  161. package/src/internal/upload/cryptoService.ts +2 -2
  162. package/src/internal/upload/fileUploader.test.ts +25 -11
  163. package/src/internal/upload/fileUploader.ts +16 -3
  164. package/src/internal/upload/interface.ts +3 -0
  165. package/src/internal/upload/manager.test.ts +8 -0
  166. package/src/internal/upload/manager.ts +20 -10
  167. package/src/internal/upload/streamUploader.test.ts +32 -15
  168. package/src/internal/upload/streamUploader.ts +43 -30
  169. package/src/protonDriveClient.ts +4 -4
  170. package/src/protonDrivePhotosClient.ts +46 -6
  171. package/src/protonDrivePublicLinkClient.ts +93 -12
  172. package/src/transformers.ts +2 -0
  173. package/dist/internal/sharingPublic/manager.d.ts +0 -19
  174. package/dist/internal/sharingPublic/manager.js +0 -81
  175. package/dist/internal/sharingPublic/manager.js.map +0 -1
  176. package/src/internal/sharingPublic/manager.ts +0 -86
@@ -138,24 +138,24 @@ export class FileDownloader {
138
138
  }
139
139
  }
140
140
 
141
- writeToStream(stream: WritableStream, onProgress?: (downloadedBytes: number) => void): DownloadController {
141
+ downloadToStream(stream: WritableStream, onProgress?: (downloadedBytes: number) => void): DownloadController {
142
142
  if (this.controller.promise) {
143
143
  throw new Error(`Download already started`);
144
144
  }
145
- this.controller.promise = this.downloadToStream(stream, onProgress);
145
+ this.controller.promise = this.internalDownloadToStream(stream, onProgress);
146
146
  return this.controller;
147
147
  }
148
148
 
149
- unsafeWriteToStream(stream: WritableStream, onProgress?: (downloadedBytes: number) => void): DownloadController {
149
+ unsafeDownloadToStream(stream: WritableStream, onProgress?: (downloadedBytes: number) => void): DownloadController {
150
150
  if (this.controller.promise) {
151
151
  throw new Error(`Download already started`);
152
152
  }
153
153
  const ignoreIntegrityErrors = true;
154
- this.controller.promise = this.downloadToStream(stream, onProgress, ignoreIntegrityErrors);
154
+ this.controller.promise = this.internalDownloadToStream(stream, onProgress, ignoreIntegrityErrors);
155
155
  return this.controller;
156
156
  }
157
157
 
158
- private async downloadToStream(
158
+ private async internalDownloadToStream(
159
159
  stream: WritableStream,
160
160
  onProgress?: (downloadedBytes: number) => void,
161
161
  ignoreIntegrityErrors = false,
@@ -1,7 +1,7 @@
1
1
  import { MemberRole, NodeType } from '../../interface';
2
2
  import { getMockLogger } from '../../tests/logger';
3
3
  import { DriveAPIService, ErrorCode, InvalidRequirementsAPIError } from '../apiService';
4
- import { NodeAPIService } from './apiService';
4
+ import { NodeAPIService, groupNodeUidsByVolumeAndIteratePerBatch } from './apiService';
5
5
  import { NodeOutOfSyncError } from './errors';
6
6
 
7
7
  function generateAPIFileNode(linkOverrides = {}, overrides = {}) {
@@ -143,6 +143,7 @@ function generateNode() {
143
143
 
144
144
  shareId: undefined,
145
145
  isShared: false,
146
+ isSharedPublicly: false,
146
147
  directRole: MemberRole.Admin,
147
148
  membership: undefined,
148
149
 
@@ -255,6 +256,7 @@ describe('nodeAPIService', () => {
255
256
  generateFolderNode(
256
257
  {
257
258
  isShared: true,
259
+ isSharedPublicly: false,
258
260
  shareId: 'shareId',
259
261
  directRole: MemberRole.Admin,
260
262
  membership: {
@@ -296,6 +298,7 @@ describe('nodeAPIService', () => {
296
298
  generateFolderNode(
297
299
  {
298
300
  isShared: true,
301
+ isSharedPublicly: false,
299
302
  shareId: 'shareId',
300
303
  directRole: MemberRole.Viewer,
301
304
  membership: {
@@ -317,6 +320,26 @@ describe('nodeAPIService', () => {
317
320
  );
318
321
  });
319
322
 
323
+ it('should get publicly shared node', async () => {
324
+ await testIterateNodes(
325
+ generateAPIFolderNode(
326
+ {},
327
+ {
328
+ Sharing: {
329
+ ShareID: 'shareId',
330
+ ShareURLID: 'shareUrlId',
331
+ },
332
+ },
333
+ ),
334
+ generateFolderNode({
335
+ isShared: true,
336
+ isSharedPublicly: true,
337
+ shareId: 'shareId',
338
+ directRole: MemberRole.Admin,
339
+ }),
340
+ );
341
+ });
342
+
320
343
  it('should get trashed file node', async () => {
321
344
  await testIterateNodes(
322
345
  generateAPIFileNode({
@@ -453,6 +476,44 @@ describe('nodeAPIService', () => {
453
476
  { uid: 'volumeId~nodeId2', ok: false, error: 'INSUFFICIENT_SCOPE' },
454
477
  ]);
455
478
  });
479
+
480
+ it('should trash nodes in batches', async () => {
481
+ // @ts-expect-error Mocking for testing purposes
482
+ apiMock.post = jest.fn(async (_, { LinkIDs }) =>
483
+ Promise.resolve({
484
+ Responses: LinkIDs.map((linkId: string) => ({
485
+ LinkID: linkId,
486
+ Response: {
487
+ Code: ErrorCode.OK,
488
+ },
489
+ })),
490
+ }),
491
+ );
492
+
493
+ const nodeUids = Array.from({ length: 250 }, (_, i) => `volumeId1~nodeId${i}`);
494
+ const nodeIds = nodeUids.map((uid) => uid.split('~')[1]);
495
+
496
+ const results = await Array.fromAsync(api.trashNodes(nodeUids));
497
+ expect(results).toHaveLength(nodeUids.length);
498
+ expect(results.every((result) => result.ok)).toBe(true);
499
+
500
+ expect(apiMock.post).toHaveBeenCalledTimes(3);
501
+ expect(apiMock.post).toHaveBeenCalledWith(
502
+ 'drive/v2/volumes/volumeId1/trash_multiple',
503
+ { LinkIDs: nodeIds.slice(0, 100) },
504
+ undefined,
505
+ );
506
+ expect(apiMock.post).toHaveBeenCalledWith(
507
+ 'drive/v2/volumes/volumeId1/trash_multiple',
508
+ { LinkIDs: nodeIds.slice(100, 200) },
509
+ undefined,
510
+ );
511
+ expect(apiMock.post).toHaveBeenCalledWith(
512
+ 'drive/v2/volumes/volumeId1/trash_multiple',
513
+ { LinkIDs: nodeIds.slice(200, 250) },
514
+ undefined,
515
+ );
516
+ });
456
517
  });
457
518
 
458
519
  describe('restoreNodes', () => {
@@ -494,17 +555,28 @@ describe('nodeAPIService', () => {
494
555
  ]);
495
556
  });
496
557
 
497
- it('should fail restoring from multiple volumes', async () => {
498
- try {
499
- await Array.fromAsync(api.restoreNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2']));
500
- throw new Error('Should have thrown');
501
- } catch (error: any) {
502
- expect(error.message).toEqual('Restoring items from multiple sections is not allowed');
503
- }
558
+ it('should restore nodes from multiple volumes', async () => {
559
+ // @ts-expect-error Mocking for testing purposes
560
+ apiMock.put = jest.fn(async (_, { LinkIDs }) =>
561
+ Promise.resolve({
562
+ Responses: LinkIDs.map((linkId: string) => ({
563
+ LinkID: linkId,
564
+ Response: {
565
+ Code: ErrorCode.OK,
566
+ },
567
+ })),
568
+ }),
569
+ );
570
+
571
+ const result = await Array.fromAsync(api.restoreNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2']));
572
+ expect(result).toEqual([
573
+ { uid: 'volumeId1~nodeId1', ok: true },
574
+ { uid: 'volumeId2~nodeId2', ok: true },
575
+ ]);
504
576
  });
505
577
  });
506
578
 
507
- describe('deleteNOdes', () => {
579
+ describe('deleteNodes', () => {
508
580
  it('should delete nodes', async () => {
509
581
  // @ts-expect-error Mocking for testing purposes
510
582
  apiMock.post = jest.fn(async () =>
@@ -534,13 +606,24 @@ describe('nodeAPIService', () => {
534
606
  ]);
535
607
  });
536
608
 
537
- it('should fail deleting nodes from multiple volumes', async () => {
538
- try {
539
- await Array.fromAsync(api.deleteNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2']));
540
- throw new Error('Should have thrown');
541
- } catch (error: any) {
542
- expect(error.message).toEqual('Deleting items from multiple sections is not allowed');
543
- }
609
+ it('should delete nodes from multiple volumes', async () => {
610
+ // @ts-expect-error Mocking for testing purposes
611
+ apiMock.post = jest.fn(async (_, { LinkIDs }) =>
612
+ Promise.resolve({
613
+ Responses: LinkIDs.map((linkId: string) => ({
614
+ LinkID: linkId,
615
+ Response: {
616
+ Code: ErrorCode.OK,
617
+ },
618
+ })),
619
+ }),
620
+ );
621
+
622
+ const result = await Array.fromAsync(api.deleteNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2']));
623
+ expect(result).toEqual([
624
+ { uid: 'volumeId1~nodeId1', ok: true },
625
+ { uid: 'volumeId2~nodeId2', ok: true },
626
+ ]);
544
627
  });
545
628
  });
546
629
 
@@ -577,3 +660,126 @@ describe('nodeAPIService', () => {
577
660
  });
578
661
  });
579
662
  });
663
+
664
+ describe('groupNodeUidsByVolumeAndIteratePerBatch', () => {
665
+ it('should handle empty array', () => {
666
+ const result = Array.from(groupNodeUidsByVolumeAndIteratePerBatch([]));
667
+ expect(result).toEqual([]);
668
+ });
669
+
670
+ it('should handle single volume with nodes that fit in one batch', () => {
671
+ const nodeUids = ['volumeId1~nodeId1', 'volumeId1~nodeId2', 'volumeId1~nodeId3'];
672
+
673
+ const result = Array.from(groupNodeUidsByVolumeAndIteratePerBatch(nodeUids));
674
+
675
+ expect(result).toEqual([
676
+ {
677
+ volumeId: 'volumeId1',
678
+ batchNodeIds: ['nodeId1', 'nodeId2', 'nodeId3'],
679
+ batchNodeUids: ['volumeId1~nodeId1', 'volumeId1~nodeId2', 'volumeId1~nodeId3'],
680
+ },
681
+ ]);
682
+ });
683
+
684
+ it('should handle single volume with nodes that require multiple batches', () => {
685
+ // Create 250 node UIDs to test batching (API_NODES_BATCH_SIZE = 100)
686
+ const nodeUids = Array.from({ length: 250 }, (_, i) => `volumeId1~nodeId${i}`);
687
+
688
+ const result = Array.from(groupNodeUidsByVolumeAndIteratePerBatch(nodeUids));
689
+
690
+ expect(result).toHaveLength(3); // 100 + 100 + 50
691
+
692
+ // First batch
693
+ expect(result[0]).toEqual({
694
+ volumeId: 'volumeId1',
695
+ batchNodeIds: Array.from({ length: 100 }, (_, i) => `nodeId${i}`),
696
+ batchNodeUids: Array.from({ length: 100 }, (_, i) => `volumeId1~nodeId${i}`),
697
+ });
698
+
699
+ // Second batch
700
+ expect(result[1]).toEqual({
701
+ volumeId: 'volumeId1',
702
+ batchNodeIds: Array.from({ length: 100 }, (_, i) => `nodeId${i + 100}`),
703
+ batchNodeUids: Array.from({ length: 100 }, (_, i) => `volumeId1~nodeId${i + 100}`),
704
+ });
705
+
706
+ // Third batch
707
+ expect(result[2]).toEqual({
708
+ volumeId: 'volumeId1',
709
+ batchNodeIds: Array.from({ length: 50 }, (_, i) => `nodeId${i + 200}`),
710
+ batchNodeUids: Array.from({ length: 50 }, (_, i) => `volumeId1~nodeId${i + 200}`),
711
+ });
712
+ });
713
+
714
+ it('should handle multiple volumes with nodes distributed across them', () => {
715
+ const nodeUids = [
716
+ 'volumeId1~nodeId1',
717
+ 'volumeId2~nodeId2',
718
+ 'volumeId1~nodeId3',
719
+ 'volumeId3~nodeId4',
720
+ 'volumeId2~nodeId5',
721
+ ];
722
+
723
+ const result = Array.from(groupNodeUidsByVolumeAndIteratePerBatch(nodeUids));
724
+
725
+ expect(result).toHaveLength(3); // One batch per volume
726
+
727
+ // Results should be grouped by volume
728
+ const volumeId1Batch = result.find((batch) => batch.volumeId === 'volumeId1');
729
+ const volumeId2Batch = result.find((batch) => batch.volumeId === 'volumeId2');
730
+ const volumeId3Batch = result.find((batch) => batch.volumeId === 'volumeId3');
731
+
732
+ expect(volumeId1Batch).toEqual({
733
+ volumeId: 'volumeId1',
734
+ batchNodeIds: ['nodeId1', 'nodeId3'],
735
+ batchNodeUids: ['volumeId1~nodeId1', 'volumeId1~nodeId3'],
736
+ });
737
+
738
+ expect(volumeId2Batch).toEqual({
739
+ volumeId: 'volumeId2',
740
+ batchNodeIds: ['nodeId2', 'nodeId5'],
741
+ batchNodeUids: ['volumeId2~nodeId2', 'volumeId2~nodeId5'],
742
+ });
743
+
744
+ expect(volumeId3Batch).toEqual({
745
+ volumeId: 'volumeId3',
746
+ batchNodeIds: ['nodeId4'],
747
+ batchNodeUids: ['volumeId3~nodeId4'],
748
+ });
749
+ });
750
+
751
+ it('should handle multiple volumes where some require multiple batches', () => {
752
+ // Volume 1: 150 nodes (2 batches)
753
+ // Volume 2: 50 nodes (1 batch)
754
+ // Volume 3: 200 nodes (2 batches)
755
+ const volume1Nodes = Array.from({ length: 150 }, (_, i) => `volumeId1~nodeId${i}`);
756
+ const volume2Nodes = Array.from({ length: 50 }, (_, i) => `volumeId2~nodeId${i}`);
757
+ const volume3Nodes = Array.from({ length: 200 }, (_, i) => `volumeId3~nodeId${i}`);
758
+
759
+ const nodeUids = [...volume1Nodes, ...volume2Nodes, ...volume3Nodes];
760
+
761
+ const result = Array.from(groupNodeUidsByVolumeAndIteratePerBatch(nodeUids));
762
+
763
+ expect(result).toHaveLength(5); // 2 + 1 + 2 batches
764
+
765
+ // Group results by volume
766
+ const volume1Batches = result.filter((batch) => batch.volumeId === 'volumeId1');
767
+ const volume2Batches = result.filter((batch) => batch.volumeId === 'volumeId2');
768
+ const volume3Batches = result.filter((batch) => batch.volumeId === 'volumeId3');
769
+
770
+ expect(volume1Batches).toHaveLength(2);
771
+ expect(volume2Batches).toHaveLength(1);
772
+ expect(volume3Batches).toHaveLength(2);
773
+
774
+ // Verify volume 1 batches
775
+ expect(volume1Batches[0].batchNodeIds).toHaveLength(100);
776
+ expect(volume1Batches[1].batchNodeIds).toHaveLength(50);
777
+
778
+ // Verify volume 2 batch
779
+ expect(volume2Batches[0].batchNodeIds).toHaveLength(50);
780
+
781
+ // Verify volume 3 batches
782
+ expect(volume3Batches[0].batchNodeIds).toHaveLength(100);
783
+ expect(volume3Batches[1].batchNodeIds).toHaveLength(100);
784
+ });
785
+ });
@@ -128,7 +128,7 @@ export class NodeAPIService {
128
128
 
129
129
  async *iterateNodes(
130
130
  nodeUids: string[],
131
- ownVolumeId: string,
131
+ ownVolumeId: string | undefined,
132
132
  filterOptions?: FilterOptions,
133
133
  signal?: AbortSignal,
134
134
  ): AsyncGenerator<EncryptedNode> {
@@ -389,55 +389,49 @@ export class NodeAPIService {
389
389
  return makeNodeUid(volumeId, response.LinkID);
390
390
  }
391
391
 
392
- // Improvement requested: split into multiple calls for many nodes.
393
392
  async *trashNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<NodeResult> {
394
- const nodeIds = nodeUids.map(splitNodeUid);
395
- const volumeId = assertAndGetSingleVolumeId(c('Operation').t`Trashing items`, nodeIds);
396
-
397
- const response = await this.apiService.post<PostTrashNodesRequest, PostTrashNodesResponse>(
398
- `drive/v2/volumes/${volumeId}/trash_multiple`,
399
- {
400
- LinkIDs: nodeIds.map(({ nodeId }) => nodeId),
401
- },
402
- signal,
403
- );
393
+ for (const { volumeId, batchNodeIds, batchNodeUids } of groupNodeUidsByVolumeAndIteratePerBatch(nodeUids)) {
394
+ const response = await this.apiService.post<PostTrashNodesRequest, PostTrashNodesResponse>(
395
+ `drive/v2/volumes/${volumeId}/trash_multiple`,
396
+ {
397
+ LinkIDs: batchNodeIds,
398
+ },
399
+ signal,
400
+ );
404
401
 
405
- // TODO: remove `as` when backend fixes OpenAPI schema.
406
- yield* handleResponseErrors(nodeUids, volumeId, response.Responses as LinkResponse[]);
402
+ // TODO: remove `as` when backend fixes OpenAPI schema.
403
+ yield * handleResponseErrors(batchNodeUids, volumeId, response.Responses as LinkResponse[]);
404
+ }
407
405
  }
408
406
 
409
- // Improvement requested: split into multiple calls for many nodes.
410
407
  async *restoreNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<NodeResult> {
411
- const nodeIds = nodeUids.map(splitNodeUid);
412
- const volumeId = assertAndGetSingleVolumeId(c('Operation').t`Restoring items`, nodeIds);
413
-
414
- const response = await this.apiService.put<PutRestoreNodesRequest, PutRestoreNodesResponse>(
415
- `drive/v2/volumes/${volumeId}/trash/restore_multiple`,
416
- {
417
- LinkIDs: nodeIds.map(({ nodeId }) => nodeId),
418
- },
419
- signal,
420
- );
408
+ for (const { volumeId, batchNodeIds, batchNodeUids } of groupNodeUidsByVolumeAndIteratePerBatch(nodeUids)) {
409
+ const response = await this.apiService.put<PutRestoreNodesRequest, PutRestoreNodesResponse>(
410
+ `drive/v2/volumes/${volumeId}/trash/restore_multiple`,
411
+ {
412
+ LinkIDs: batchNodeIds,
413
+ },
414
+ signal,
415
+ );
421
416
 
422
- // TODO: remove `as` when backend fixes OpenAPI schema.
423
- yield* handleResponseErrors(nodeUids, volumeId, response.Responses as LinkResponse[]);
417
+ // TODO: remove `as` when backend fixes OpenAPI schema.
418
+ yield* handleResponseErrors(batchNodeUids, volumeId, response.Responses as LinkResponse[]);
419
+ }
424
420
  }
425
421
 
426
- // Improvement requested: split into multiple calls for many nodes.
427
422
  async *deleteNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<NodeResult> {
428
- const nodeIds = nodeUids.map(splitNodeUid);
429
- const volumeId = assertAndGetSingleVolumeId(c('Operation').t`Deleting items`, nodeIds);
430
-
431
- const response = await this.apiService.post<PostDeleteNodesRequest, PostDeleteNodesResponse>(
432
- `drive/v2/volumes/${volumeId}/trash/delete_multiple`,
433
- {
434
- LinkIDs: nodeIds.map(({ nodeId }) => nodeId),
435
- },
436
- signal,
437
- );
423
+ for (const { volumeId, batchNodeIds, batchNodeUids } of groupNodeUidsByVolumeAndIteratePerBatch(nodeUids)) {
424
+ const response = await this.apiService.post<PostDeleteNodesRequest, PostDeleteNodesResponse>(
425
+ `drive/v2/volumes/${volumeId}/trash/delete_multiple`,
426
+ {
427
+ LinkIDs: batchNodeIds,
428
+ },
429
+ signal,
430
+ );
438
431
 
439
- // TODO: remove `as` when backend fixes OpenAPI schema.
440
- yield* handleResponseErrors(nodeUids, volumeId, response.Responses as LinkResponse[]);
432
+ // TODO: remove `as` when backend fixes OpenAPI schema.
433
+ yield* handleResponseErrors(batchNodeUids, volumeId, response.Responses as LinkResponse[]);
434
+ }
441
435
  }
442
436
 
443
437
  async createFolder(
@@ -513,15 +507,6 @@ export class NodeAPIService {
513
507
  }
514
508
  }
515
509
 
516
- function assertAndGetSingleVolumeId(operationForErrorMessage: string, nodeIds: { volumeId: string }[]): string {
517
- const uniqueVolumeIds = new Set(nodeIds.map(({ volumeId }) => volumeId));
518
- if (uniqueVolumeIds.size !== 1) {
519
- throw new ValidationError(c('Error').t`${operationForErrorMessage} from multiple sections is not allowed`);
520
- }
521
- const volumeId = nodeIds[0].volumeId;
522
- return volumeId;
523
- }
524
-
525
510
  type LinkResponse = {
526
511
  LinkID: string;
527
512
  Response: {
@@ -577,6 +562,7 @@ function linkToEncryptedNode(
577
562
  // Sharing node metadata
578
563
  shareId: link.Sharing?.ShareID || undefined,
579
564
  isShared: !!link.Sharing,
565
+ isSharedPublicly: !!link.Sharing?.ShareURLID,
580
566
  directRole: isAdmin ? MemberRole.Admin : membershipRole,
581
567
  membership: link.Membership
582
568
  ? {
@@ -656,6 +642,34 @@ function linkToEncryptedNode(
656
642
  throw new Error(`Unknown node type: ${link.Link.Type}`);
657
643
  }
658
644
 
645
+ export function* groupNodeUidsByVolumeAndIteratePerBatch(
646
+ nodeUids: string[],
647
+ ): Generator<{ volumeId: string; batchNodeIds: string[]; batchNodeUids: string[] }> {
648
+ const allNodeIds = nodeUids.map((nodeUid: string) => {
649
+ const { volumeId, nodeId } = splitNodeUid(nodeUid);
650
+ return { volumeId, nodeIds: { nodeId, nodeUid } };
651
+ });
652
+
653
+ const nodeIdsByVolumeId = new Map<string, { nodeId: string; nodeUid: string }[]>();
654
+ for (const { volumeId, nodeIds } of allNodeIds) {
655
+ if (!nodeIdsByVolumeId.has(volumeId)) {
656
+ nodeIdsByVolumeId.set(volumeId, []);
657
+ }
658
+ nodeIdsByVolumeId.get(volumeId)?.push(nodeIds);
659
+ }
660
+
661
+ for (const [volumeId, nodeIds] of nodeIdsByVolumeId.entries()) {
662
+ for (const nodeIdsBatch of batch(nodeIds, API_NODES_BATCH_SIZE)) {
663
+ yield {
664
+ volumeId,
665
+ batchNodeIds: nodeIdsBatch.map(({ nodeId }) => nodeId),
666
+ batchNodeUids: nodeIdsBatch.map(({ nodeUid }) => nodeUid),
667
+ };
668
+ }
669
+ }
670
+ }
671
+
672
+
659
673
  function transformRevisionResponse(
660
674
  volumeId: string,
661
675
  nodeId: string,
@@ -21,6 +21,7 @@ function generateNode(
21
21
  type: NodeType.File,
22
22
  mediaType: 'text',
23
23
  isShared: false,
24
+ isSharedPublicly: false,
24
25
  creationTime: new Date(),
25
26
  trashTime: undefined,
26
27
  volumeId: 'volumeId',
@@ -0,0 +1,129 @@
1
+ import { NodesDebouncer } from './debouncer';
2
+ import { Logger } from '../../interface';
3
+
4
+ describe('NodesDebouncer', () => {
5
+ let debouncer: NodesDebouncer;
6
+ let mockLogger: jest.Mocked<Logger>;
7
+
8
+ beforeEach(() => {
9
+ mockLogger = {
10
+ debug: jest.fn(),
11
+ info: jest.fn(),
12
+ warn: jest.fn(),
13
+ error: jest.fn(),
14
+ };
15
+ debouncer = new NodesDebouncer(mockLogger);
16
+
17
+ jest.useFakeTimers();
18
+ });
19
+
20
+ afterEach(() => {
21
+ jest.useRealTimers();
22
+ debouncer.clear();
23
+ });
24
+
25
+ it('should register a node for loading and wait for it to finish', async () => {
26
+ const nodeUid = 'test-node-1';
27
+ debouncer.loadingNode(nodeUid);
28
+
29
+ // Verify that the node is registered by checking if waitForLoadingNode works
30
+ const waitPromise = debouncer.waitForLoadingNode(nodeUid);
31
+ expect(waitPromise).toBeInstanceOf(Promise);
32
+
33
+ // Finish loading to clean up
34
+ debouncer.finishedLoadingNode(nodeUid);
35
+ await waitPromise;
36
+ });
37
+
38
+ it('should allow multiple nodes to be registered', async () => {
39
+ const nodeUid1 = 'test-node-1';
40
+ const nodeUid2 = 'test-node-2';
41
+
42
+ debouncer.loadingNode(nodeUid1);
43
+ debouncer.loadingNode(nodeUid2);
44
+
45
+ const wait1 = debouncer.waitForLoadingNode(nodeUid1);
46
+ const wait2 = debouncer.waitForLoadingNode(nodeUid2);
47
+
48
+ expect(wait1).toBeInstanceOf(Promise);
49
+ expect(wait2).toBeInstanceOf(Promise);
50
+
51
+ debouncer.finishedLoadingNode(nodeUid1);
52
+ debouncer.finishedLoadingNode(nodeUid2);
53
+ await Promise.all([wait1, wait2]);
54
+ });
55
+
56
+ it('should register multiple nodes at once', async () => {
57
+ const nodeUid1 = 'test-node-1';
58
+ const nodeUid2 = 'test-node-2';
59
+
60
+ debouncer.loadingNodes([nodeUid1, nodeUid2]);
61
+
62
+ const wait1 = debouncer.waitForLoadingNode(nodeUid1);
63
+ const wait2 = debouncer.waitForLoadingNode(nodeUid2);
64
+
65
+ expect(wait1).toBeInstanceOf(Promise);
66
+ expect(wait2).toBeInstanceOf(Promise);
67
+
68
+ debouncer.finishedLoadingNode(nodeUid1);
69
+ debouncer.finishedLoadingNode(nodeUid2);
70
+ await Promise.all([wait1, wait2]);
71
+ });
72
+
73
+ it('should warn about registering the same node twice', async () => {
74
+ const nodeUid = 'test-node-1';
75
+
76
+ // Register the same node twice
77
+ debouncer.loadingNode(nodeUid);
78
+ debouncer.loadingNode(nodeUid);
79
+
80
+ expect(mockLogger.warn).toHaveBeenCalledWith(`debouncer: Loading twice for: ${nodeUid}`);
81
+ });
82
+
83
+ it('should timeout', async () => {
84
+ const nodeUid = 'test-node-1';
85
+ debouncer.loadingNode(nodeUid);
86
+
87
+ jest.advanceTimersByTime(6000);
88
+ expect(mockLogger.warn).toHaveBeenCalledWith(`debouncer: Timeout for: ${nodeUid}`);
89
+ await expect(debouncer.waitForLoadingNode(nodeUid)).resolves.toBeUndefined();
90
+ });
91
+
92
+ describe('finishedLoadingNode', () => {
93
+ it('should handle non-existent node gracefully', async () => {
94
+ const nodeUid = 'non-existent-node';
95
+
96
+ expect(() => debouncer.finishedLoadingNode(nodeUid)).not.toThrow();
97
+ });
98
+
99
+ it('should remove node from internal map after finishing', async () => {
100
+ const nodeUid = 'test-node-1';
101
+ debouncer.loadingNode(nodeUid);
102
+ debouncer.finishedLoadingNode(nodeUid);
103
+
104
+ const waitPromise = debouncer.waitForLoadingNode(nodeUid);
105
+ await expect(waitPromise).resolves.toBe(undefined);
106
+ });
107
+ });
108
+
109
+ describe('waitForLoadingNode', () => {
110
+ it('should return immediately for non-registered node', async () => {
111
+ const nodeUid = 'non-existent-node';
112
+
113
+ const result = await debouncer.waitForLoadingNode(nodeUid);
114
+ expect(result).toBeUndefined();
115
+ expect(mockLogger.debug).not.toHaveBeenCalled();
116
+ });
117
+
118
+ it('should wait for registered node and log debug message', async () => {
119
+ const nodeUid = 'test-node-1';
120
+ debouncer.loadingNode(nodeUid);
121
+
122
+ const waitPromise = debouncer.waitForLoadingNode(nodeUid);
123
+
124
+ expect(mockLogger.debug).toHaveBeenCalledWith(`debouncer: Wait for: ${nodeUid}`);
125
+ debouncer.finishedLoadingNode(nodeUid);
126
+ await waitPromise;
127
+ });
128
+ });
129
+ });