@protontech/drive-sdk 0.13.1 → 0.14.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 (54) hide show
  1. package/dist/interface/telemetry.d.ts +5 -4
  2. package/dist/interface/telemetry.js +1 -0
  3. package/dist/interface/telemetry.js.map +1 -1
  4. package/dist/internal/download/telemetry.js +2 -1
  5. package/dist/internal/download/telemetry.js.map +1 -1
  6. package/dist/internal/nodes/cryptoReporter.js +2 -2
  7. package/dist/internal/nodes/cryptoReporter.js.map +1 -1
  8. package/dist/internal/photos/addToAlbum.d.ts +6 -0
  9. package/dist/internal/photos/addToAlbum.js +1 -0
  10. package/dist/internal/photos/addToAlbum.js.map +1 -1
  11. package/dist/internal/photos/albumsManager.d.ts +4 -1
  12. package/dist/internal/photos/albumsManager.js +22 -2
  13. package/dist/internal/photos/albumsManager.js.map +1 -1
  14. package/dist/internal/photos/albumsManager.test.js +41 -1
  15. package/dist/internal/photos/albumsManager.test.js.map +1 -1
  16. package/dist/internal/photos/apiService.d.ts +1 -0
  17. package/dist/internal/photos/apiService.js +59 -5
  18. package/dist/internal/photos/apiService.js.map +1 -1
  19. package/dist/internal/photos/apiService.test.js +137 -0
  20. package/dist/internal/photos/apiService.test.js.map +1 -1
  21. package/dist/internal/photos/errors.d.ts +5 -0
  22. package/dist/internal/photos/errors.js +10 -1
  23. package/dist/internal/photos/errors.js.map +1 -1
  24. package/dist/internal/photos/index.js +1 -1
  25. package/dist/internal/photos/index.js.map +1 -1
  26. package/dist/internal/photos/photosManager.d.ts +1 -0
  27. package/dist/internal/photos/photosManager.js +42 -4
  28. package/dist/internal/photos/photosManager.js.map +1 -1
  29. package/dist/internal/photos/photosManager.test.js +35 -0
  30. package/dist/internal/photos/photosManager.test.js.map +1 -1
  31. package/dist/internal/photos/photosTransferPayloadBuilder.d.ts +1 -0
  32. package/dist/internal/photos/photosTransferPayloadBuilder.js +1 -0
  33. package/dist/internal/photos/photosTransferPayloadBuilder.js.map +1 -1
  34. package/dist/internal/upload/telemetry.js +2 -1
  35. package/dist/internal/upload/telemetry.js.map +1 -1
  36. package/dist/protonDrivePhotosClient.d.ts +4 -3
  37. package/dist/protonDrivePhotosClient.js +3 -3
  38. package/dist/protonDrivePhotosClient.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/interface/telemetry.ts +5 -4
  41. package/src/internal/download/telemetry.ts +2 -2
  42. package/src/internal/nodes/cryptoReporter.ts +4 -2
  43. package/src/internal/photos/addToAlbum.ts +1 -1
  44. package/src/internal/photos/albumsManager.test.ts +61 -1
  45. package/src/internal/photos/albumsManager.ts +23 -3
  46. package/src/internal/photos/apiService.test.ts +155 -0
  47. package/src/internal/photos/apiService.ts +92 -3
  48. package/src/internal/photos/errors.ts +11 -0
  49. package/src/internal/photos/index.ts +1 -1
  50. package/src/internal/photos/photosManager.test.ts +43 -1
  51. package/src/internal/photos/photosManager.ts +61 -3
  52. package/src/internal/photos/photosTransferPayloadBuilder.ts +3 -1
  53. package/src/internal/upload/telemetry.ts +2 -2
  54. package/src/protonDrivePhotosClient.ts +4 -4
@@ -5,6 +5,7 @@ import { PhotosAPIService } from './apiService';
5
5
  import { AlbumsCryptoService } from './albumsCrypto';
6
6
  import { PhotosNodesAccess } from './nodes';
7
7
  import { DecryptedPhotoNode } from './interface';
8
+ import { MissingRelatedPhotosError } from './errors';
8
9
 
9
10
  function createMockPhotoNode(uid: string, overrides: Partial<DecryptedPhotoNode> = {}): DecryptedPhotoNode {
10
11
  return {
@@ -46,9 +47,19 @@ async function collectUpdateResults(manager: PhotosManager, photos: UpdatePhotoS
46
47
  return results;
47
48
  }
48
49
 
50
+ async function collectSaveToTimelineResults(manager: PhotosManager, nodeUids: string[], signal?: AbortSignal) {
51
+ const results = [];
52
+ for await (const result of manager.saveToTimeline(nodeUids, signal)) {
53
+ results.push(result);
54
+ }
55
+ return results;
56
+ }
57
+
49
58
  describe('PhotosManager', () => {
50
59
  let logger: ReturnType<typeof getMockLogger>;
51
- let apiService: jest.Mocked<Pick<PhotosAPIService, 'addPhotoTags' | 'removePhotoTags' | 'setPhotoFavorite'>>;
60
+ let apiService: jest.Mocked<
61
+ Pick<PhotosAPIService, 'addPhotoTags' | 'removePhotoTags' | 'setPhotoFavorite' | 'transferPhotos'>
62
+ >;
52
63
  let cryptoService: jest.Mocked<Pick<AlbumsCryptoService, 'encryptPhotoForAlbum'>>;
53
64
  let nodesService: jest.Mocked<
54
65
  Pick<
@@ -80,6 +91,7 @@ describe('PhotosManager', () => {
80
91
  addPhotoTags: jest.fn().mockResolvedValue(undefined),
81
92
  removePhotoTags: jest.fn().mockResolvedValue(undefined),
82
93
  setPhotoFavorite: jest.fn().mockResolvedValue(undefined),
94
+ transferPhotos: jest.fn().mockImplementation(async function* () {}),
83
95
  };
84
96
 
85
97
  cryptoService = {
@@ -263,4 +275,34 @@ describe('PhotosManager', () => {
263
275
  });
264
276
  });
265
277
  });
278
+
279
+ describe('saveToTimeline', () => {
280
+ it('re-queues once on MissingRelatedPhotosError then succeeds without yielding the retry error', async () => {
281
+ const missingRelatedUid = 'volume1~related1';
282
+ let transferCall = 0;
283
+ apiService.transferPhotos.mockImplementation(async function* (_rootUid, payloads) {
284
+ transferCall++;
285
+ for (const payload of payloads) {
286
+ if (transferCall === 1) {
287
+ yield {
288
+ uid: payload.nodeUid,
289
+ ok: false,
290
+ error: new MissingRelatedPhotosError([missingRelatedUid]),
291
+ };
292
+ } else {
293
+ yield { uid: payload.nodeUid, ok: true };
294
+ }
295
+ }
296
+ });
297
+
298
+ const results = await collectSaveToTimelineResults(manager, ['volume1~photo1']);
299
+
300
+ expect(results).toEqual([{ uid: 'volume1~photo1', ok: true }]);
301
+ expect(apiService.transferPhotos).toHaveBeenCalledTimes(2);
302
+ expect(logger.info).toHaveBeenCalledWith(
303
+ `Missing related photos for saving volume1~photo1, re-queuing: ${missingRelatedUid}`,
304
+ );
305
+ expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo1');
306
+ });
307
+ });
266
308
  });
@@ -1,13 +1,19 @@
1
1
  import { c } from 'ttag';
2
2
 
3
+ import { AbortError } from '../../errors';
3
4
  import { Logger, NodeResultWithError, PhotoTag } from '../../interface';
5
+ import { batch } from '../batch';
4
6
  import { PhotosAPIService } from './apiService';
5
7
  import { PhotoAlreadyInTargetError, PhotoTransferPayloadBuilder, TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder';
6
8
  import { PhotosNodesAccess } from './nodes';
7
9
  import { AlbumsCryptoService } from './albumsCrypto';
8
- import { AbortError } from '../../errors';
9
- import { BATCH_LOADING_SIZE } from '../sharing/sharingAccess';
10
- import { batch } from '../batch';
10
+ import { createBatches } from './addToAlbum';
11
+ import { MissingRelatedPhotosError } from './errors';
12
+
13
+ /**
14
+ * The number of photos that are loaded in parallel to prepare the payloads.
15
+ */
16
+ const BATCH_LOADING_SIZE = 20;
11
17
 
12
18
  export type UpdatePhotoSettings = {
13
19
  nodeUid: string;
@@ -31,6 +37,58 @@ export class PhotosManager {
31
37
  this.payloadBuilder = new PhotoTransferPayloadBuilder(albumsCryptoService, nodesService);
32
38
  }
33
39
 
40
+ async *saveToTimeline(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<NodeResultWithError> {
41
+ const rootNode = await this.nodesService.getVolumeRootFolder();
42
+ const volumeRootKeys = await this.nodesService.getNodeKeys(rootNode.uid);
43
+ const signingKeys = await this.nodesService.getNodeSigningKeys({ nodeUid: rootNode.uid });
44
+
45
+ const queue: {
46
+ photoNodeUid: string;
47
+ additionalRelatedPhotoNodeUids: string[];
48
+ }[] = nodeUids.map((nodeUid) => ({ photoNodeUid: nodeUid, additionalRelatedPhotoNodeUids: [] }));
49
+ const retriedPhotoUids = new Set<string>();
50
+
51
+ while (queue.length > 0) {
52
+ const items = queue.splice(0, BATCH_LOADING_SIZE);
53
+ const { payloads, errors } = await this.payloadBuilder.preparePhotoPayloads(
54
+ items,
55
+ rootNode.uid,
56
+ volumeRootKeys,
57
+ signingKeys,
58
+ signal,
59
+ );
60
+
61
+ for (const [uid, error] of errors) {
62
+ yield { uid, ok: false, error };
63
+ }
64
+
65
+ for (const batch of createBatches(payloads)) {
66
+ for await (const result of this.apiService.transferPhotos(rootNode.uid, batch, signal)) {
67
+ if (
68
+ !result.ok &&
69
+ result.error instanceof MissingRelatedPhotosError &&
70
+ !retriedPhotoUids.has(result.uid)
71
+ ) {
72
+ retriedPhotoUids.add(result.uid);
73
+ this.logger.info(
74
+ `Missing related photos for saving ${result.uid}, re-queuing: ${result.error.missingNodeUids.join(', ')}`,
75
+ );
76
+ queue.push({
77
+ photoNodeUid: result.uid,
78
+ additionalRelatedPhotoNodeUids: result.error.missingNodeUids,
79
+ });
80
+ continue;
81
+ }
82
+
83
+ if (result.ok) {
84
+ await this.nodesService.notifyNodeChanged(result.uid);
85
+ }
86
+ yield result;
87
+ }
88
+ }
89
+ }
90
+ }
91
+
34
92
  async *updatePhotos(photos: UpdatePhotoSettings[], signal?: AbortSignal): AsyncGenerator<NodeResultWithError> {
35
93
  for await (const {
36
94
  photoSettings: { nodeUid, tagsToAdd, tagsToRemove },
@@ -14,12 +14,13 @@ type TransferEncryptedRelatedPhotoPayload = {
14
14
  nodeUid: string;
15
15
  contentHash: string;
16
16
  nameHash: string;
17
+ originalNameHash: string | undefined;
17
18
  encryptedName: string;
18
19
  nameSignatureEmail: string;
19
20
  nodePassphrase: string;
20
21
  nodePassphraseSignature?: string;
21
22
  signatureEmail?: string;
22
- }
23
+ };
23
24
 
24
25
  /**
25
26
  * Item representing a photo to build a payload for.
@@ -186,6 +187,7 @@ export class PhotoTransferPayloadBuilder {
186
187
  nodeUid: photoNode.uid,
187
188
  contentHash: encryptedCrypto.contentHash,
188
189
  nameHash: encryptedCrypto.hash,
190
+ originalNameHash: photoNode.hash,
189
191
  encryptedName: encryptedCrypto.encryptedName,
190
192
  nameSignatureEmail: encryptedCrypto.nameSignatureEmail,
191
193
  nodePassphrase: encryptedCrypto.armoredNodePassphrase,
@@ -1,5 +1,5 @@
1
1
  import { RateLimitedError, ValidationError, IntegrityError } from '../../errors';
2
- import { ProtonDriveTelemetry, MetricsUploadErrorType, Logger } from '../../interface';
2
+ import { ProtonDriveTelemetry, MetricsUploadErrorType, Logger, MetricVolumeType } from '../../interface';
3
3
  import { LoggerWithPrefix, reduceSizePrecision } from '../../telemetry';
4
4
  import { APIHTTPError } from '../apiService';
5
5
  import { splitNodeUid, splitNodeRevisionUid } from '../uids';
@@ -85,7 +85,7 @@ export class UploadTelemetry {
85
85
  originalError?: unknown;
86
86
  },
87
87
  ) {
88
- let volumeType;
88
+ let volumeType = MetricVolumeType.Unknown;
89
89
  try {
90
90
  volumeType = await this.sharesService.getVolumeMetricContext(volumeId);
91
91
  } catch (error: unknown) {
@@ -559,9 +559,9 @@ export class ProtonDrivePhotosClient {
559
559
  *
560
560
  * Photos in the timeline will not be deleted. If the album has photos
561
561
  * that are not in the timeline (uploaded by another user), the method
562
- * will throw an error. The photos must be moved to the timeline, or
563
- * the album must be deleted with `force` option that deletes the photos
564
- * not in the timeline as well.
562
+ * will throw an error. Then, either the photos must be saved to the
563
+ * timelines with `saveToTimeline` option, or the album must be deleted
564
+ * with `force` option that deletes the photos not in the timeline as well.
565
565
  *
566
566
  * This operation is irreversible. Both the album and the photos will be
567
567
  * permanently deleted, skipping the trash.
@@ -569,7 +569,7 @@ export class ProtonDrivePhotosClient {
569
569
  * @param nodeUid - The UID of the album to delete.
570
570
  * @param force - Whether to force the deletion.
571
571
  */
572
- async deleteAlbum(nodeUid: NodeOrUid, options: { force?: boolean } = {}): Promise<void> {
572
+ async deleteAlbum(nodeUid: NodeOrUid, options: { force?: boolean; saveToTimeline?: boolean } = {}): Promise<void> {
573
573
  this.logger.info(`Deleting album ${getUid(nodeUid)}`);
574
574
  await this.photos.albums.deleteAlbum(getUid(nodeUid), options);
575
575
  }