@protontech/drive-sdk 0.13.0 → 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 (86) hide show
  1. package/dist/interface/events.d.ts +1 -1
  2. package/dist/interface/telemetry.d.ts +9 -4
  3. package/dist/interface/telemetry.js +1 -0
  4. package/dist/interface/telemetry.js.map +1 -1
  5. package/dist/internal/apiService/driveTypes.d.ts +52 -0
  6. package/dist/internal/download/telemetry.js +6 -1
  7. package/dist/internal/download/telemetry.js.map +1 -1
  8. package/dist/internal/download/telemetry.test.js +5 -0
  9. package/dist/internal/download/telemetry.test.js.map +1 -1
  10. package/dist/internal/events/index.js +2 -2
  11. package/dist/internal/events/index.js.map +1 -1
  12. package/dist/internal/events/interface.d.ts +1 -1
  13. package/dist/internal/nodes/cryptoReporter.js +2 -2
  14. package/dist/internal/nodes/cryptoReporter.js.map +1 -1
  15. package/dist/internal/photos/addToAlbum.d.ts +6 -0
  16. package/dist/internal/photos/addToAlbum.js +1 -0
  17. package/dist/internal/photos/addToAlbum.js.map +1 -1
  18. package/dist/internal/photos/albumsManager.d.ts +4 -1
  19. package/dist/internal/photos/albumsManager.js +22 -2
  20. package/dist/internal/photos/albumsManager.js.map +1 -1
  21. package/dist/internal/photos/albumsManager.test.js +41 -1
  22. package/dist/internal/photos/albumsManager.test.js.map +1 -1
  23. package/dist/internal/photos/apiService.d.ts +1 -0
  24. package/dist/internal/photos/apiService.js +59 -5
  25. package/dist/internal/photos/apiService.js.map +1 -1
  26. package/dist/internal/photos/apiService.test.js +137 -0
  27. package/dist/internal/photos/apiService.test.js.map +1 -1
  28. package/dist/internal/photos/errors.d.ts +5 -0
  29. package/dist/internal/photos/errors.js +10 -1
  30. package/dist/internal/photos/errors.js.map +1 -1
  31. package/dist/internal/photos/index.js +1 -1
  32. package/dist/internal/photos/index.js.map +1 -1
  33. package/dist/internal/photos/photosManager.d.ts +1 -0
  34. package/dist/internal/photos/photosManager.js +42 -4
  35. package/dist/internal/photos/photosManager.js.map +1 -1
  36. package/dist/internal/photos/photosManager.test.js +35 -0
  37. package/dist/internal/photos/photosManager.test.js.map +1 -1
  38. package/dist/internal/photos/photosTransferPayloadBuilder.d.ts +1 -0
  39. package/dist/internal/photos/photosTransferPayloadBuilder.js +1 -0
  40. package/dist/internal/photos/photosTransferPayloadBuilder.js.map +1 -1
  41. package/dist/internal/sharing/apiService.js +1 -1
  42. package/dist/internal/sharing/apiService.js.map +1 -1
  43. package/dist/internal/upload/manager.js +3 -2
  44. package/dist/internal/upload/manager.js.map +1 -1
  45. package/dist/internal/upload/manager.test.js +1 -1
  46. package/dist/internal/upload/manager.test.js.map +1 -1
  47. package/dist/internal/upload/telemetry.js +4 -1
  48. package/dist/internal/upload/telemetry.js.map +1 -1
  49. package/dist/internal/upload/telemetry.test.js +6 -0
  50. package/dist/internal/upload/telemetry.test.js.map +1 -1
  51. package/dist/protonDrivePhotosClient.d.ts +4 -3
  52. package/dist/protonDrivePhotosClient.js +3 -3
  53. package/dist/protonDrivePhotosClient.js.map +1 -1
  54. package/dist/telemetry.d.ts +1 -0
  55. package/dist/telemetry.js +21 -0
  56. package/dist/telemetry.js.map +1 -1
  57. package/dist/telemetry.test.d.ts +1 -0
  58. package/dist/telemetry.test.js +37 -0
  59. package/dist/telemetry.test.js.map +1 -0
  60. package/package.json +1 -1
  61. package/src/interface/events.ts +1 -1
  62. package/src/interface/telemetry.ts +9 -4
  63. package/src/internal/apiService/driveTypes.ts +52 -0
  64. package/src/internal/download/telemetry.test.ts +5 -0
  65. package/src/internal/download/telemetry.ts +7 -3
  66. package/src/internal/events/index.ts +2 -2
  67. package/src/internal/events/interface.ts +1 -1
  68. package/src/internal/nodes/cryptoReporter.ts +4 -2
  69. package/src/internal/photos/addToAlbum.ts +1 -1
  70. package/src/internal/photos/albumsManager.test.ts +61 -1
  71. package/src/internal/photos/albumsManager.ts +23 -3
  72. package/src/internal/photos/apiService.test.ts +155 -0
  73. package/src/internal/photos/apiService.ts +92 -3
  74. package/src/internal/photos/errors.ts +11 -0
  75. package/src/internal/photos/index.ts +1 -1
  76. package/src/internal/photos/photosManager.test.ts +43 -1
  77. package/src/internal/photos/photosManager.ts +61 -3
  78. package/src/internal/photos/photosTransferPayloadBuilder.ts +3 -1
  79. package/src/internal/sharing/apiService.ts +5 -5
  80. package/src/internal/upload/manager.test.ts +1 -1
  81. package/src/internal/upload/manager.ts +3 -2
  82. package/src/internal/upload/telemetry.test.ts +6 -0
  83. package/src/internal/upload/telemetry.ts +5 -3
  84. package/src/protonDrivePhotosClient.ts +4 -4
  85. package/src/telemetry.test.ts +40 -0
  86. package/src/telemetry.ts +22 -0
@@ -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,
@@ -79,12 +79,12 @@ type PostCreateShareResponse =
79
79
  drivePaths['/drive/volumes/{volumeID}/shares']['post']['responses']['200']['content']['application/json'];
80
80
 
81
81
  type PostChangeSharePropertiesRequest = Extract<
82
- drivePaths['/drive/shares/{shareID}/property']['post']['requestBody'],
82
+ drivePaths['/drive/shares/{shareID}/editors-can-share']['put']['requestBody'],
83
83
  { content: object }
84
84
  >['content']['application/json'];
85
85
 
86
86
  type PostChangeSharePropertiesResponse =
87
- drivePaths['/drive/shares/{shareID}/property']['post']['responses']['200']['content']['application/json'];
87
+ drivePaths['/drive/shares/{shareID}/editors-can-share']['put']['responses']['200']['content']['application/json'];
88
88
 
89
89
  type PostInviteProtonUserRequest = Extract<
90
90
  drivePaths['/drive/v2/shares/{shareID}/invitations']['post']['requestBody'],
@@ -423,9 +423,9 @@ export class SharingAPIService {
423
423
  }
424
424
 
425
425
  async changeShareProperties(shareId: string, { editorsCanShare }: { editorsCanShare: boolean }) {
426
- await this.apiService.post<PostChangeSharePropertiesRequest, PostChangeSharePropertiesResponse>(
427
- `drive/shares/${shareId}/property`,
428
- { EditorsCanShare: editorsCanShare },
426
+ await this.apiService.put<PostChangeSharePropertiesRequest, PostChangeSharePropertiesResponse>(
427
+ `drive/shares/${shareId}/editors-can-share`,
428
+ { Value: editorsCanShare },
429
429
  );
430
430
  }
431
431
 
@@ -133,7 +133,7 @@ describe('UploadManager', () => {
133
133
  armoredEncryptedName: 'newNode:encryptedName',
134
134
  hash: 'newNode:hash',
135
135
  mediaType: 'myMimeType',
136
- intendedUploadSize: 123456,
136
+ intendedUploadSize: 100_000,
137
137
  armoredNodeKey: 'newNode:armoredKey',
138
138
  armoredNodePassphrase: 'newNode:armoredPassphrase',
139
139
  armoredNodePassphraseSignature: 'newNode:armoredPassphraseSignature',
@@ -9,6 +9,7 @@ import { UploadAPIService } from './apiService';
9
9
  import { UploadCryptoService } from './cryptoService';
10
10
  import { NodeRevisionDraft, NodesService, NodeCrypto } from './interface';
11
11
  import { makeNodeUid, splitNodeUid } from '../uids';
12
+ import { reduceSizePrecision } from '../../telemetry';
12
13
 
13
14
  /**
14
15
  * UploadManager is responsible for creating and deleting draft nodes
@@ -129,7 +130,7 @@ export class UploadManager {
129
130
  armoredEncryptedName: generatedNodeCrypto.encryptedNode.encryptedName,
130
131
  hash: generatedNodeCrypto.encryptedNode.hash,
131
132
  mediaType: metadata.mediaType,
132
- intendedUploadSize: metadata.expectedSize,
133
+ intendedUploadSize: reduceSizePrecision(metadata.expectedSize),
133
134
  armoredNodeKey: generatedNodeCrypto.nodeKeys.encrypted.armoredKey,
134
135
  armoredNodePassphrase: generatedNodeCrypto.nodeKeys.encrypted.armoredPassphrase,
135
136
  armoredNodePassphraseSignature: generatedNodeCrypto.nodeKeys.encrypted.armoredPassphraseSignature,
@@ -349,7 +350,7 @@ export class UploadManager {
349
350
 
350
351
  const { nodeRevisionUid } = await this.apiService.createDraftRevision(nodeUid, {
351
352
  currentRevisionUid: node.activeRevision.value.uid,
352
- intendedUploadSize: metadata.expectedSize,
353
+ intendedUploadSize: reduceSizePrecision(metadata.expectedSize),
353
354
  });
354
355
 
355
356
  return {
@@ -38,7 +38,9 @@ describe('UploadTelemetry', () => {
38
38
  eventName: 'upload',
39
39
  volumeType: 'own_volume',
40
40
  uploadedSize: 0,
41
+ approximateUploadedSize: 0,
41
42
  expectedSize: 1000,
43
+ approximateExpectedSize: 4095,
42
44
  error: 'unknown',
43
45
  originalError: error,
44
46
  });
@@ -52,7 +54,9 @@ describe('UploadTelemetry', () => {
52
54
  eventName: 'upload',
53
55
  volumeType: 'own_volume',
54
56
  uploadedSize: 500,
57
+ approximateUploadedSize: 4095,
55
58
  expectedSize: 1000,
59
+ approximateExpectedSize: 4095,
56
60
  error: 'unknown',
57
61
  originalError: error,
58
62
  });
@@ -65,7 +69,9 @@ describe('UploadTelemetry', () => {
65
69
  eventName: 'upload',
66
70
  volumeType: 'own_volume',
67
71
  uploadedSize: 1000,
72
+ approximateUploadedSize: 4095,
68
73
  expectedSize: 1000,
74
+ approximateExpectedSize: 4095,
69
75
  });
70
76
  });
71
77
 
@@ -1,6 +1,6 @@
1
1
  import { RateLimitedError, ValidationError, IntegrityError } from '../../errors';
2
- import { ProtonDriveTelemetry, MetricsUploadErrorType, Logger } from '../../interface';
3
- import { LoggerWithPrefix } from '../../telemetry';
2
+ import { ProtonDriveTelemetry, MetricsUploadErrorType, Logger, MetricVolumeType } from '../../interface';
3
+ import { LoggerWithPrefix, reduceSizePrecision } from '../../telemetry';
4
4
  import { APIHTTPError } from '../apiService';
5
5
  import { splitNodeUid, splitNodeRevisionUid } from '../uids';
6
6
  import { SharesService } from './interface';
@@ -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) {
@@ -95,6 +95,8 @@ export class UploadTelemetry {
95
95
  this.telemetry.recordMetric({
96
96
  eventName: 'upload',
97
97
  volumeType,
98
+ approximateUploadedSize: reduceSizePrecision(options.uploadedSize),
99
+ approximateExpectedSize: reduceSizePrecision(options.expectedSize),
98
100
  ...options,
99
101
  });
100
102
  }
@@ -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
  }