@protontech/drive-sdk 0.13.1 → 0.14.1

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 (69) 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/sharingPublic/session/index.d.ts +1 -0
  35. package/dist/internal/sharingPublic/session/index.js +3 -1
  36. package/dist/internal/sharingPublic/session/index.js.map +1 -1
  37. package/dist/internal/sharingPublic/session/manager.d.ts +2 -0
  38. package/dist/internal/sharingPublic/session/manager.js +1 -0
  39. package/dist/internal/sharingPublic/session/manager.js.map +1 -1
  40. package/dist/internal/upload/telemetry.js +2 -1
  41. package/dist/internal/upload/telemetry.js.map +1 -1
  42. package/dist/protonDriveClient.js +2 -1
  43. package/dist/protonDriveClient.js.map +1 -1
  44. package/dist/protonDrivePhotosClient.d.ts +4 -3
  45. package/dist/protonDrivePhotosClient.js +3 -3
  46. package/dist/protonDrivePhotosClient.js.map +1 -1
  47. package/dist/protonDrivePublicLinkClient.d.ts +15 -1
  48. package/dist/protonDrivePublicLinkClient.js +7 -1
  49. package/dist/protonDrivePublicLinkClient.js.map +1 -1
  50. package/package.json +1 -1
  51. package/src/interface/telemetry.ts +5 -4
  52. package/src/internal/download/telemetry.ts +2 -2
  53. package/src/internal/nodes/cryptoReporter.ts +4 -2
  54. package/src/internal/photos/addToAlbum.ts +1 -1
  55. package/src/internal/photos/albumsManager.test.ts +61 -1
  56. package/src/internal/photos/albumsManager.ts +23 -3
  57. package/src/internal/photos/apiService.test.ts +155 -0
  58. package/src/internal/photos/apiService.ts +92 -3
  59. package/src/internal/photos/errors.ts +11 -0
  60. package/src/internal/photos/index.ts +1 -1
  61. package/src/internal/photos/photosManager.test.ts +43 -1
  62. package/src/internal/photos/photosManager.ts +61 -3
  63. package/src/internal/photos/photosTransferPayloadBuilder.ts +3 -1
  64. package/src/internal/sharingPublic/session/index.ts +1 -0
  65. package/src/internal/sharingPublic/session/manager.ts +2 -0
  66. package/src/internal/upload/telemetry.ts +2 -2
  67. package/src/protonDriveClient.ts +2 -1
  68. package/src/protonDrivePhotosClient.ts +4 -4
  69. package/src/protonDrivePublicLinkClient.ts +23 -2
@@ -28,6 +28,7 @@ describe('photosAPIService', () => {
28
28
  nodeUid: 'volumeId1~photoNodeId1',
29
29
  contentHash: 'contentHash1',
30
30
  nameHash: 'nameHash1',
31
+ originalNameHash: 'originalNameHash1',
31
32
  encryptedName: 'encryptedName1',
32
33
  nameSignatureEmail: 'nameSignatureEmail1',
33
34
  nodePassphrase: 'nodePassphrase1',
@@ -38,6 +39,7 @@ describe('photosAPIService', () => {
38
39
  nodeUid: 'volumeId1~photoNodeId2',
39
40
  contentHash: 'contentHash2',
40
41
  nameHash: 'nameHash2',
42
+ originalNameHash: 'originalNameHash2',
41
43
  encryptedName: 'encryptedName2',
42
44
  nameSignatureEmail: 'nameSignatureEmail2',
43
45
  nodePassphrase: 'nodePassphrase2',
@@ -151,6 +153,7 @@ describe('photosAPIService', () => {
151
153
  nodeUid: 'volumeId2~photoNodeId1',
152
154
  contentHash: 'contentHash1',
153
155
  nameHash: 'nameHash1',
156
+ originalNameHash: 'originalNameHash1',
154
157
  encryptedName: 'encryptedName1',
155
158
  nameSignatureEmail: 'nameSignatureEmail1',
156
159
  nodePassphrase: 'nodePassphrase1',
@@ -161,6 +164,7 @@ describe('photosAPIService', () => {
161
164
  nodeUid: 'volumeId2~photoNodeId2',
162
165
  contentHash: 'contentHash2',
163
166
  nameHash: 'nameHash2',
167
+ originalNameHash: 'originalNameHash2',
164
168
  encryptedName: 'encryptedName2',
165
169
  nameSignatureEmail: 'nameSignatureEmail2',
166
170
  nodePassphrase: 'nodePassphrase2',
@@ -230,4 +234,155 @@ describe('photosAPIService', () => {
230
234
  await expect(promise).rejects.toThrow(error);
231
235
  });
232
236
  });
237
+
238
+ describe('transferPhotos', () => {
239
+ const photoPayloads = [
240
+ {
241
+ nodeUid: 'volumeId1~photoNodeId1',
242
+ contentHash: 'contentHash1',
243
+ nameHash: 'nameHash1',
244
+ originalNameHash: 'originalNameHash1',
245
+ encryptedName: 'encryptedName1',
246
+ nameSignatureEmail: 'nameSignatureEmail1',
247
+ nodePassphrase: 'nodePassphrase1',
248
+ nodePassphraseSignature: 'nodePassphraseSignature1',
249
+ signatureEmail: 'signatureEmail1',
250
+ relatedPhotos: [
251
+ {
252
+ nodeUid: 'volumeId1~photoNodeId2',
253
+ contentHash: 'contentHash2',
254
+ nameHash: 'nameHash2',
255
+ originalNameHash: 'originalNameHash2',
256
+ encryptedName: 'encryptedName2',
257
+ nameSignatureEmail: 'nameSignatureEmail2',
258
+ nodePassphrase: 'nodePassphrase2',
259
+ nodePassphraseSignature: 'nodePassphraseSignature2',
260
+ signatureEmail: 'signatureEmail2',
261
+ },
262
+ ],
263
+ },
264
+ ];
265
+
266
+ it('should transfer photos', async () => {
267
+ apiMock.put = jest.fn().mockResolvedValue({
268
+ Code: 1000,
269
+ Responses: [
270
+ {
271
+ LinkID: 'photoNodeId1',
272
+ Response: {
273
+ Code: 1000,
274
+ },
275
+ },
276
+ ],
277
+ });
278
+
279
+ const result = await Array.fromAsync(api.transferPhotos(albumNodeUid, photoPayloads));
280
+
281
+ expect(result).toEqual([
282
+ {
283
+ uid: 'volumeId1~photoNodeId1',
284
+ ok: true,
285
+ },
286
+ ]);
287
+ expect(apiMock.put).toHaveBeenCalledWith(
288
+ `drive/photos/volumes/volumeId1/links/transfer-multiple`,
289
+ {
290
+ ParentLinkID: 'albumNodeId',
291
+ Links: [
292
+ expect.objectContaining({
293
+ LinkID: 'photoNodeId1',
294
+ Hash: 'nameHash1',
295
+ OriginalHash: 'originalNameHash1',
296
+ Name: 'encryptedName1',
297
+ NodePassphrase: 'nodePassphrase1',
298
+ ContentHash: 'contentHash1',
299
+ NodePassphraseSignature: null,
300
+ }),
301
+ expect.objectContaining({
302
+ LinkID: 'photoNodeId2',
303
+ Hash: 'nameHash2',
304
+ OriginalHash: 'originalNameHash2',
305
+ Name: 'encryptedName2',
306
+ NodePassphrase: 'nodePassphrase2',
307
+ ContentHash: 'contentHash2',
308
+ NodePassphraseSignature: null,
309
+ }),
310
+ ],
311
+ NameSignatureEmail: 'nameSignatureEmail1',
312
+ SignatureEmail: null,
313
+ },
314
+ undefined,
315
+ );
316
+ });
317
+
318
+ it('should return MissingRelatedPhotosError if related photos are missing', async () => {
319
+ apiMock.put = jest.fn().mockResolvedValue({
320
+ Code: 1000,
321
+ Responses: [
322
+ {
323
+ LinkID: 'photoNodeId1',
324
+ Response: {
325
+ Code: 2000,
326
+ Details: {
327
+ Missing: ['photoNodeId3'],
328
+ },
329
+ },
330
+ },
331
+ ],
332
+ });
333
+
334
+ const result = await Array.fromAsync(api.transferPhotos(albumNodeUid, photoPayloads));
335
+
336
+ expect(result).toEqual([
337
+ {
338
+ uid: 'volumeId1~photoNodeId1',
339
+ ok: false,
340
+ error: new MissingRelatedPhotosError([]),
341
+ },
342
+ ]);
343
+ expect((result[0] as any).error.missingNodeUids).toEqual(['volumeId1~photoNodeId3']);
344
+ });
345
+
346
+ it('should return error for unknown error', async () => {
347
+ apiMock.put = jest.fn().mockResolvedValue({
348
+ Code: 1000,
349
+ Responses: [
350
+ {
351
+ LinkID: 'photoNodeId1',
352
+ Response: {
353
+ Code: 3000,
354
+ Error: 'Some error',
355
+ },
356
+ },
357
+ ],
358
+ });
359
+
360
+ const result = await Array.fromAsync(api.transferPhotos(albumNodeUid, photoPayloads));
361
+
362
+ expect(result).toEqual([
363
+ {
364
+ uid: 'volumeId1~photoNodeId1',
365
+ ok: false,
366
+ error: new APICodeError('Some error', 3000),
367
+ },
368
+ ]);
369
+ });
370
+
371
+ it('should throw if name signature emails differ', async () => {
372
+ const mixedPayloads = [
373
+ photoPayloads[0],
374
+ {
375
+ ...photoPayloads[0],
376
+ nodeUid: 'volumeId1~photoNodeIdOther',
377
+ nameSignatureEmail: 'other@example.com',
378
+ relatedPhotos: [],
379
+ },
380
+ ];
381
+
382
+ await expect(Array.fromAsync(api.transferPhotos(albumNodeUid, mixedPayloads))).rejects.toThrow(
383
+ 'All photos must have the same name signature email',
384
+ );
385
+ expect(apiMock.put).not.toHaveBeenCalled();
386
+ });
387
+ });
233
388
  });
@@ -1,12 +1,11 @@
1
1
  import { c } from 'ttag';
2
2
 
3
- import { ValidationError } from '../../errors';
4
3
  import { NodeResultWithError, PhotoTag } from '../../interface';
5
4
  import { APICodeError, DriveAPIService, drivePaths, InvalidRequirementsAPIError, isCodeOk } from '../apiService';
6
5
  import { batch } from '../batch';
7
6
  import { EncryptedRootShare, EncryptedShareCrypto, ShareType } from '../shares/interface';
8
7
  import { makeNodeUid, splitNodeUid } from '../uids';
9
- import { MissingRelatedPhotosError } from './errors';
8
+ import { AlbumContainsPhotosNotInTimelineError, MissingRelatedPhotosError } from './errors';
10
9
  import { AlbumItem } from './interface';
11
10
  import { TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder';
12
11
 
@@ -82,6 +81,13 @@ type PostFavoritePhotoRequest = Extract<
82
81
  { content: object }
83
82
  >['content']['application/json'];
84
83
 
84
+ type PutTransferPhotosRequest = Extract<
85
+ drivePaths['/drive/photos/volumes/{volumeID}/links/transfer-multiple']['put']['requestBody'],
86
+ { content: object }
87
+ >['content']['application/json'];
88
+ type PutTransferPhotosResponse =
89
+ drivePaths['/drive/photos/volumes/{volumeID}/links/transfer-multiple']['put']['responses']['200']['content']['application/json'];
90
+
85
91
  const ALBUM_CONTAINS_PHOTOS_NOT_IN_TIMELINE_ERROR_CODE = 200302;
86
92
 
87
93
  /**
@@ -336,7 +342,9 @@ export class PhotosAPIService {
336
342
  );
337
343
  } catch (error) {
338
344
  if (error instanceof APICodeError && error.code === ALBUM_CONTAINS_PHOTOS_NOT_IN_TIMELINE_ERROR_CODE) {
339
- throw new ValidationError(c('Error').t`Album contains photos not in timeline`);
345
+ const childLinkIds = (error.debug as { ChildLinkIDs: string[] })?.ChildLinkIDs || [];
346
+ const nodeUids = childLinkIds.map((linkId) => makeNodeUid(volumeId, linkId));
347
+ throw new AlbumContainsPhotosNotInTimelineError(error.message, error.code, nodeUids);
340
348
  }
341
349
  throw error;
342
350
  }
@@ -556,4 +564,85 @@ export class PhotosAPIService {
556
564
  requestBody,
557
565
  );
558
566
  }
567
+
568
+ async *transferPhotos(
569
+ newParentNodeUid: string,
570
+ photoPayloads: TransferEncryptedPhotoPayload[],
571
+ signal?: AbortSignal,
572
+ ): AsyncGenerator<NodeResultWithError> {
573
+ const { volumeId, nodeId: newParentNodeId } = splitNodeUid(newParentNodeUid);
574
+
575
+ if (photoPayloads.length === 0) {
576
+ return;
577
+ }
578
+
579
+ const nameSignatureEmail = photoPayloads[0].nameSignatureEmail;
580
+ if (photoPayloads.some((photoPayload) => photoPayload.nameSignatureEmail !== nameSignatureEmail)) {
581
+ throw new Error('All photos must have the same name signature email');
582
+ }
583
+
584
+ const allPhotoPayloads = photoPayloads.flatMap((photoPayload) => [photoPayload, ...photoPayload.relatedPhotos]);
585
+ const allLinksData = allPhotoPayloads.map((photoPayload) => {
586
+ const { nodeId } = splitNodeUid(photoPayload.nodeUid);
587
+ return {
588
+ LinkID: nodeId,
589
+ Hash: photoPayload.nameHash,
590
+ OriginalHash: photoPayload.originalNameHash!,
591
+ Name: photoPayload.encryptedName,
592
+ NodePassphrase: photoPayload.nodePassphrase,
593
+ ContentHash: photoPayload.contentHash,
594
+ NodePassphraseSignature: null, // Required when moving an anonymous node.
595
+ };
596
+ });
597
+
598
+ const response = await this.apiService.put<PutTransferPhotosRequest, PutTransferPhotosResponse>(
599
+ `drive/photos/volumes/${volumeId}/links/transfer-multiple`,
600
+ {
601
+ ParentLinkID: newParentNodeId,
602
+ Links: allLinksData,
603
+ NameSignatureEmail: nameSignatureEmail,
604
+ SignatureEmail: null, // Required when moving an anonymous node.
605
+ },
606
+ signal,
607
+ );
608
+
609
+ const errors = new Map<string, Error>();
610
+
611
+ for (const r of response.Responses || []) {
612
+ const details = r as {
613
+ LinkID: string;
614
+ Response: {
615
+ Code: number;
616
+ Error?: string;
617
+ Details: { Missing: string[] };
618
+ };
619
+ };
620
+
621
+ if (!details.Response.Code || !isCodeOk(details.Response.Code) || details.Response?.Error) {
622
+ const nodeUid = makeNodeUid(volumeId, details.LinkID);
623
+
624
+ if (details.Response.Details?.Missing) {
625
+ const missingNodeUids = details.Response.Details.Missing.map((linkId) =>
626
+ makeNodeUid(volumeId, linkId),
627
+ );
628
+ errors.set(nodeUid, new MissingRelatedPhotosError(missingNodeUids));
629
+ } else {
630
+ errors.set(
631
+ nodeUid,
632
+ new APICodeError(details.Response.Error || c('Error').t`Unknown error`, details.Response.Code),
633
+ );
634
+ }
635
+ }
636
+ }
637
+
638
+ for (const photoPayload of photoPayloads) {
639
+ const uid = photoPayload.nodeUid;
640
+ const error = errors.get(uid);
641
+ if (error) {
642
+ yield { uid, ok: false, error };
643
+ } else {
644
+ yield { uid, ok: true };
645
+ }
646
+ }
647
+ }
559
648
  }
@@ -1,5 +1,7 @@
1
1
  import { c } from 'ttag';
2
2
 
3
+ import { ValidationError } from '../../errors';
4
+
3
5
  export class MissingRelatedPhotosError extends Error {
4
6
  constructor(public missingNodeUids: string[]) {
5
7
  // We do not want to leak the technical details of the error to the user.
@@ -9,3 +11,12 @@ export class MissingRelatedPhotosError extends Error {
9
11
  this.name = 'MissingRelatedPhotosError';
10
12
  }
11
13
  }
14
+
15
+ export class AlbumContainsPhotosNotInTimelineError extends ValidationError {
16
+ public readonly photosOnlyInAlbumNodeUids: string[];
17
+
18
+ constructor(message: string, code: number, photosOnlyInAlbumNodeUids: string[]) {
19
+ super(message, code);
20
+ this.photosOnlyInAlbumNodeUids = photosOnlyInAlbumNodeUids;
21
+ }
22
+ }
@@ -62,8 +62,8 @@ export function initPhotosModule(
62
62
  photoShares,
63
63
  nodesService,
64
64
  );
65
- const albums = new AlbumsManager(telemetry, api, albumsCryptoService, photoShares, nodesService);
66
65
  const photos = new PhotosManager(telemetry.getLogger('photos-update'), api, albumsCryptoService, nodesService);
66
+ const albums = new AlbumsManager(telemetry, api, albumsCryptoService, photoShares, nodesService, photos);
67
67
 
68
68
  return {
69
69
  timeline,
@@ -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 +1,2 @@
1
1
  export { SharingPublicSessionManager } from './manager';
2
+ export { SharingPublicLinkSession } from './session';
@@ -87,6 +87,7 @@ export class SharingPublicSessionManager {
87
87
  shareKey: PrivateKey;
88
88
  rootUid: string;
89
89
  publicRole: MemberRole;
90
+ session: SharingPublicLinkSession;
90
91
  }> {
91
92
  let info = this.infosPerToken.get(token);
92
93
  if (!info) {
@@ -105,6 +106,7 @@ export class SharingPublicSessionManager {
105
106
  shareKey,
106
107
  rootUid,
107
108
  publicRole: permissionsToMemberRole(this.logger, encryptedShare.publicPermissions),
109
+ session,
108
110
  };
109
111
  }
110
112
 
@@ -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) {
@@ -232,7 +232,7 @@ export class ProtonDriveClient {
232
232
  const { token, password: urlPassword } = getTokenAndPasswordFromUrl(url);
233
233
  this.logger.info(`Authenticating public link token ${token}`);
234
234
 
235
- const { httpClient, shareKey, rootUid, publicRole } = await this.publicSessionManager.auth(
235
+ const { httpClient, shareKey, rootUid, publicRole, session } = await this.publicSessionManager.auth(
236
236
  token,
237
237
  urlPassword,
238
238
  customPassword,
@@ -250,6 +250,7 @@ export class ProtonDriveClient {
250
250
  publicRootNodeUid: rootUid,
251
251
  isAnonymousContext,
252
252
  publicRole,
253
+ session,
253
254
  });
254
255
  },
255
256
  };
@@ -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
  }