@protontech/drive-sdk 0.10.0 → 0.12.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 (125) hide show
  1. package/dist/crypto/driveCrypto.d.ts +20 -2
  2. package/dist/crypto/driveCrypto.js +72 -14
  3. package/dist/crypto/driveCrypto.js.map +1 -1
  4. package/dist/crypto/driveCrypto.test.js +3 -2
  5. package/dist/crypto/driveCrypto.test.js.map +1 -1
  6. package/dist/crypto/interface.d.ts +22 -8
  7. package/dist/crypto/openPGPCrypto.d.ts +30 -7
  8. package/dist/crypto/openPGPCrypto.js +46 -8
  9. package/dist/crypto/openPGPCrypto.js.map +1 -1
  10. package/dist/diagnostic/telemetry.js +3 -0
  11. package/dist/diagnostic/telemetry.js.map +1 -1
  12. package/dist/interface/featureFlags.d.ts +4 -1
  13. package/dist/interface/featureFlags.js +5 -0
  14. package/dist/interface/featureFlags.js.map +1 -1
  15. package/dist/interface/index.d.ts +2 -0
  16. package/dist/interface/index.js +5 -1
  17. package/dist/interface/index.js.map +1 -1
  18. package/dist/interface/photos.d.ts +13 -1
  19. package/dist/interface/photos.js +14 -0
  20. package/dist/interface/photos.js.map +1 -1
  21. package/dist/interface/telemetry.d.ts +12 -1
  22. package/dist/interface/telemetry.js.map +1 -1
  23. package/dist/internal/apiService/apiService.d.ts +1 -1
  24. package/dist/internal/apiService/apiService.js +2 -2
  25. package/dist/internal/apiService/apiService.js.map +1 -1
  26. package/dist/internal/nodes/apiService.js.map +1 -1
  27. package/dist/internal/nodes/nodesAccess.js +1 -1
  28. package/dist/internal/nodes/nodesAccess.js.map +1 -1
  29. package/dist/internal/photos/addToAlbum.d.ts +1 -5
  30. package/dist/internal/photos/addToAlbum.js +8 -87
  31. package/dist/internal/photos/addToAlbum.js.map +1 -1
  32. package/dist/internal/photos/{albums.d.ts → albumsManager.d.ts} +1 -1
  33. package/dist/internal/photos/{albums.js → albumsManager.js} +4 -4
  34. package/dist/internal/photos/albumsManager.js.map +1 -0
  35. package/dist/internal/photos/{albums.test.js → albumsManager.test.js} +3 -3
  36. package/dist/internal/photos/albumsManager.test.js.map +1 -0
  37. package/dist/internal/photos/apiService.d.ts +8 -4
  38. package/dist/internal/photos/apiService.js +38 -6
  39. package/dist/internal/photos/apiService.js.map +1 -1
  40. package/dist/internal/photos/index.d.ts +7 -5
  41. package/dist/internal/photos/index.js +7 -4
  42. package/dist/internal/photos/index.js.map +1 -1
  43. package/dist/internal/photos/interface.d.ts +3 -26
  44. package/dist/internal/photos/interface.js +0 -14
  45. package/dist/internal/photos/interface.js.map +1 -1
  46. package/dist/internal/photos/nodes.js +1 -1
  47. package/dist/internal/photos/nodes.js.map +1 -1
  48. package/dist/internal/photos/photosManager.d.ts +22 -0
  49. package/dist/internal/photos/photosManager.js +101 -0
  50. package/dist/internal/photos/photosManager.js.map +1 -0
  51. package/dist/internal/photos/photosManager.test.d.ts +1 -0
  52. package/dist/internal/photos/photosManager.test.js +222 -0
  53. package/dist/internal/photos/photosManager.test.js.map +1 -0
  54. package/dist/internal/photos/photosTransferPayloadBuilder.d.ts +57 -0
  55. package/dist/internal/photos/photosTransferPayloadBuilder.js +113 -0
  56. package/dist/internal/photos/photosTransferPayloadBuilder.js.map +1 -0
  57. package/dist/internal/photos/photosTransferPayloadBuilder.test.d.ts +1 -0
  58. package/dist/internal/photos/photosTransferPayloadBuilder.test.js +289 -0
  59. package/dist/internal/photos/photosTransferPayloadBuilder.test.js.map +1 -0
  60. package/dist/internal/photos/upload.d.ts +2 -2
  61. package/dist/internal/photos/upload.js +3 -3
  62. package/dist/internal/photos/upload.js.map +1 -1
  63. package/dist/internal/sharingPublic/nodes.js +11 -4
  64. package/dist/internal/sharingPublic/nodes.js.map +1 -1
  65. package/dist/internal/upload/apiService.d.ts +0 -4
  66. package/dist/internal/upload/apiService.js +0 -4
  67. package/dist/internal/upload/apiService.js.map +1 -1
  68. package/dist/internal/upload/cryptoService.d.ts +4 -2
  69. package/dist/internal/upload/cryptoService.js +18 -6
  70. package/dist/internal/upload/cryptoService.js.map +1 -1
  71. package/dist/internal/upload/index.d.ts +2 -2
  72. package/dist/internal/upload/index.js +2 -2
  73. package/dist/internal/upload/index.js.map +1 -1
  74. package/dist/internal/upload/interface.d.ts +1 -1
  75. package/dist/internal/upload/streamUploader.d.ts +1 -1
  76. package/dist/internal/upload/streamUploader.js +12 -13
  77. package/dist/internal/upload/streamUploader.js.map +1 -1
  78. package/dist/internal/upload/streamUploader.test.js +28 -4
  79. package/dist/internal/upload/streamUploader.test.js.map +1 -1
  80. package/dist/protonDriveClient.js +2 -2
  81. package/dist/protonDriveClient.js.map +1 -1
  82. package/dist/protonDrivePhotosClient.d.ts +20 -3
  83. package/dist/protonDrivePhotosClient.js +27 -3
  84. package/dist/protonDrivePhotosClient.js.map +1 -1
  85. package/dist/protonDrivePublicLinkClient.d.ts +3 -2
  86. package/dist/protonDrivePublicLinkClient.js +7 -3
  87. package/dist/protonDrivePublicLinkClient.js.map +1 -1
  88. package/package.json +1 -1
  89. package/src/crypto/driveCrypto.test.ts +3 -1
  90. package/src/crypto/driveCrypto.ts +82 -7
  91. package/src/crypto/interface.ts +21 -8
  92. package/src/crypto/openPGPCrypto.ts +68 -8
  93. package/src/diagnostic/telemetry.ts +3 -0
  94. package/src/interface/featureFlags.ts +5 -1
  95. package/src/interface/index.ts +2 -0
  96. package/src/interface/photos.ts +14 -1
  97. package/src/interface/telemetry.ts +14 -1
  98. package/src/internal/apiService/apiService.ts +6 -2
  99. package/src/internal/nodes/apiService.ts +2 -2
  100. package/src/internal/nodes/nodesAccess.ts +1 -1
  101. package/src/internal/photos/addToAlbum.ts +29 -136
  102. package/src/internal/photos/{albums.test.ts → albumsManager.test.ts} +3 -3
  103. package/src/internal/photos/{albums.ts → albumsManager.ts} +1 -1
  104. package/src/internal/photos/apiService.ts +73 -16
  105. package/src/internal/photos/index.ts +9 -4
  106. package/src/internal/photos/interface.ts +3 -28
  107. package/src/internal/photos/nodes.ts +1 -1
  108. package/src/internal/photos/photosManager.test.ts +266 -0
  109. package/src/internal/photos/photosManager.ts +144 -0
  110. package/src/internal/photos/photosTransferPayloadBuilder.test.ts +380 -0
  111. package/src/internal/photos/photosTransferPayloadBuilder.ts +203 -0
  112. package/src/internal/photos/upload.ts +20 -7
  113. package/src/internal/sharingPublic/nodes.ts +11 -4
  114. package/src/internal/upload/apiService.ts +7 -9
  115. package/src/internal/upload/cryptoService.ts +28 -7
  116. package/src/internal/upload/index.ts +3 -2
  117. package/src/internal/upload/interface.ts +1 -1
  118. package/src/internal/upload/streamUploader.test.ts +33 -4
  119. package/src/internal/upload/streamUploader.ts +13 -13
  120. package/src/protonDriveClient.ts +2 -1
  121. package/src/protonDrivePhotosClient.ts +39 -2
  122. package/src/protonDrivePublicLinkClient.ts +9 -1
  123. package/dist/internal/photos/albums.js.map +0 -1
  124. package/dist/internal/photos/albums.test.js.map +0 -1
  125. /package/dist/internal/photos/{albums.test.d.ts → albumsManager.test.d.ts} +0 -0
@@ -1,13 +1,14 @@
1
1
  import { c } from 'ttag';
2
2
 
3
3
  import { ValidationError } from '../../errors';
4
- import { NodeResultWithError } from '../../interface';
4
+ import { NodeResultWithError, PhotoTag } from '../../interface';
5
5
  import { APICodeError, DriveAPIService, drivePaths, InvalidRequirementsAPIError, isCodeOk } from '../apiService';
6
6
  import { batch } from '../batch';
7
7
  import { EncryptedRootShare, EncryptedShareCrypto, ShareType } from '../shares/interface';
8
8
  import { makeNodeUid, splitNodeUid } from '../uids';
9
9
  import { MissingRelatedPhotosError } from './errors';
10
- import { AddToAlbumEncryptedPhotoPayload, AlbumItem } from './interface';
10
+ import { AlbumItem } from './interface';
11
+ import { TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder';
11
12
 
12
13
  type GetPhotoShareResponse =
13
14
  drivePaths['/drive/v2/shares/photos']['get']['responses']['200']['content']['application/json'];
@@ -68,6 +69,19 @@ type PostRemovePhotosFromAlbumRequest = Extract<
68
69
  type PostRemovePhotosFromAlbumResponse =
69
70
  drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}/remove-multiple']['post']['responses']['200']['content']['application/json'];
70
71
 
72
+ type PostAddPhotoTagsRequest = Extract<
73
+ drivePaths['/drive/photos/volumes/{volumeID}/links/{linkID}/tags']['post']['requestBody'],
74
+ { content: object }
75
+ >['content']['application/json'];
76
+ type PostRemovePhotoTagsRequest = Extract<
77
+ drivePaths['/drive/photos/volumes/{volumeID}/links/{linkID}/tags']['delete']['requestBody'],
78
+ { content: object }
79
+ >['content']['application/json'];
80
+ type PostFavoritePhotoRequest = Extract<
81
+ drivePaths['/drive/photos/volumes/{volumeID}/links/{linkID}/favorite']['post']['requestBody'],
82
+ { content: object }
83
+ >['content']['application/json'];
84
+
71
85
  const ALBUM_CONTAINS_PHOTOS_NOT_IN_TIMELINE_ERROR_CODE = 200302;
72
86
 
73
87
  /**
@@ -337,15 +351,12 @@ export class PhotosAPIService {
337
351
  */
338
352
  async *addPhotosToAlbum(
339
353
  albumNodeUid: string,
340
- photoPayloads: AddToAlbumEncryptedPhotoPayload[],
354
+ photoPayloads: TransferEncryptedPhotoPayload[],
341
355
  signal?: AbortSignal,
342
356
  ): AsyncGenerator<NodeResultWithError> {
343
357
  const { volumeId, nodeId: albumLinkId } = splitNodeUid(albumNodeUid);
344
358
 
345
- const allPhotoPayloads = photoPayloads.flatMap((photoPayload) => [
346
- photoPayload,
347
- ...(photoPayload.relatedPhotos || []),
348
- ]);
359
+ const allPhotoPayloads = photoPayloads.flatMap((photoPayload) => [photoPayload, ...photoPayload.relatedPhotos]);
349
360
  const allPhotoData = allPhotoPayloads.map((photoPayload) => {
350
361
  const { nodeId } = splitNodeUid(photoPayload.nodeUid);
351
362
  return {
@@ -416,7 +427,7 @@ export class PhotosAPIService {
416
427
  */
417
428
  async copyPhotoToAlbum(
418
429
  albumNodeUid: string,
419
- payload: AddToAlbumEncryptedPhotoPayload,
430
+ payload: TransferEncryptedPhotoPayload,
420
431
  signal?: AbortSignal,
421
432
  ): Promise<string> {
422
433
  const { volumeId: sourceVolumeId, nodeId: sourceLinkId } = splitNodeUid(payload.nodeUid);
@@ -438,14 +449,13 @@ export class PhotosAPIService {
438
449
  SignatureEmail: payload.signatureEmail,
439
450
  Photos: {
440
451
  ContentHash: payload.contentHash,
441
- RelatedPhotos:
442
- payload.relatedPhotos?.map((related) => ({
443
- LinkID: splitNodeUid(related.nodeUid).nodeId,
444
- Hash: related.nameHash,
445
- Name: related.encryptedName,
446
- NodePassphrase: related.nodePassphrase,
447
- ContentHash: related.contentHash,
448
- })) || [],
452
+ RelatedPhotos: payload.relatedPhotos.map((related) => ({
453
+ LinkID: splitNodeUid(related.nodeUid).nodeId,
454
+ Hash: related.nameHash,
455
+ Name: related.encryptedName,
456
+ NodePassphrase: related.nodePassphrase,
457
+ ContentHash: related.contentHash,
458
+ })),
449
459
  },
450
460
  },
451
461
  signal,
@@ -499,4 +509,51 @@ export class PhotosAPIService {
499
509
  }
500
510
  }
501
511
  }
512
+
513
+ async addPhotoTags(nodeUid: string, tags: PhotoTag[]): Promise<void> {
514
+ const { volumeId, nodeId: linkId } = splitNodeUid(nodeUid);
515
+ await this.apiService.post<PostAddPhotoTagsRequest, { Code: number }>(
516
+ `drive/photos/volumes/${volumeId}/links/${linkId}/tags`,
517
+ { Tags: tags },
518
+ );
519
+ }
520
+
521
+ async removePhotoTags(nodeUid: string, tags: PhotoTag[]): Promise<void> {
522
+ const { volumeId, nodeId: linkId } = splitNodeUid(nodeUid);
523
+ await this.apiService.delete<PostRemovePhotoTagsRequest, { Code: number }>(
524
+ `drive/photos/volumes/${volumeId}/links/${linkId}/tags`,
525
+ { Tags: tags },
526
+ );
527
+ }
528
+
529
+ async setPhotoFavorite(nodeUid: string, payload?: TransferEncryptedPhotoPayload): Promise<void> {
530
+ const { volumeId, nodeId: linkId } = splitNodeUid(nodeUid);
531
+ const requestBody = payload
532
+ ? {
533
+ PhotoData: {
534
+ Hash: payload.nameHash,
535
+ Name: payload.encryptedName,
536
+ NameSignatureEmail: payload.nameSignatureEmail,
537
+ NodePassphrase: payload.nodePassphrase,
538
+ ContentHash: payload.contentHash,
539
+ NodePassphraseSignature: payload.nodePassphraseSignature ?? null,
540
+ SignatureEmail: payload.signatureEmail ?? null,
541
+ RelatedPhotos: payload.relatedPhotos.map((related) => ({
542
+ LinkID: splitNodeUid(related.nodeUid).nodeId,
543
+ Hash: related.nameHash,
544
+ Name: related.encryptedName,
545
+ NameSignatureEmail: related.nameSignatureEmail,
546
+ NodePassphrase: related.nodePassphrase,
547
+ ContentHash: related.contentHash,
548
+ NodePassphraseSignature: related.nodePassphraseSignature ?? null,
549
+ SignatureEmail: related.signatureEmail ?? null,
550
+ })),
551
+ },
552
+ }
553
+ : undefined;
554
+ await this.apiService.post<PostFavoritePhotoRequest, { Code: number }>(
555
+ `drive/photos/volumes/${volumeId}/links/${linkId}/favorite`,
556
+ requestBody,
557
+ );
558
+ }
502
559
  }
@@ -1,5 +1,6 @@
1
1
  import { DriveCrypto } from '../../crypto';
2
2
  import {
3
+ FeatureFlagProvider,
3
4
  ProtonDriveAccount,
4
5
  ProtonDriveCryptoCache,
5
6
  ProtonDriveEntitiesCache,
@@ -18,13 +19,14 @@ import { SharesCryptoService } from '../shares/cryptoService';
18
19
  import { NodesService as UploadNodesService } from '../upload/interface';
19
20
  import { UploadTelemetry } from '../upload/telemetry';
20
21
  import { UploadQueue } from '../upload/queue';
21
- import { Albums } from './albums';
22
+ import { AlbumsManager } from './albumsManager';
22
23
  import { AlbumsCryptoService } from './albumsCrypto';
23
24
  import { PhotosAPIService } from './apiService';
24
25
  import { SharesService } from './interface';
25
26
  import { PhotosNodesAPIService, PhotosNodesAccess, PhotosNodesCache, PhotosNodesManagement } from './nodes';
26
27
  import { PhotoSharesManager } from './shares';
27
28
  import { PhotosTimeline } from './timeline';
29
+ import { PhotosManager } from './photosManager';
28
30
  import {
29
31
  PhotoFileUploader,
30
32
  PhotoUploadAPIService,
@@ -33,7 +35,7 @@ import {
33
35
  PhotoUploadMetadata,
34
36
  } from './upload';
35
37
 
36
- export type { DecryptedPhotoNode, TimelineItem, AlbumItem, PhotoTag } from './interface';
38
+ export type { DecryptedPhotoNode, TimelineItem, AlbumItem } from './interface';
37
39
 
38
40
  // Only photos and albums can be shared in photos volume.
39
41
  export const PHOTOS_SHARE_TARGET_TYPES = [ShareTargetType.Photo, ShareTargetType.Album];
@@ -60,11 +62,13 @@ export function initPhotosModule(
60
62
  photoShares,
61
63
  nodesService,
62
64
  );
63
- const albums = new Albums(telemetry, api, albumsCryptoService, photoShares, nodesService);
65
+ const albums = new AlbumsManager(telemetry, api, albumsCryptoService, photoShares, nodesService);
66
+ const photos = new PhotosManager(telemetry.getLogger('photos-update'), api, albumsCryptoService, nodesService);
64
67
 
65
68
  return {
66
69
  timeline,
67
70
  albums,
71
+ photos,
68
72
  };
69
73
  }
70
74
 
@@ -145,10 +149,11 @@ export function initPhotoUploadModule(
145
149
  driveCrypto: DriveCrypto,
146
150
  sharesService: SharesService,
147
151
  nodesService: UploadNodesService,
152
+ featureFlagProvider: FeatureFlagProvider,
148
153
  clientUid?: string,
149
154
  ) {
150
155
  const api = new PhotoUploadAPIService(apiService, clientUid);
151
- const cryptoService = new PhotoUploadCryptoService(driveCrypto, nodesService);
156
+ const cryptoService = new PhotoUploadCryptoService(telemetry, driveCrypto, nodesService, featureFlagProvider);
152
157
 
153
158
  const uploadTelemetry = new UploadTelemetry(telemetry, sharesService);
154
159
  const manager = new PhotoUploadManager(telemetry, api, cryptoService, nodesService, clientUid);
@@ -1,5 +1,5 @@
1
1
  import { PrivateKey } from '../../crypto';
2
- import { MetricVolumeType, PhotoAttributes, AlbumAttributes } from '../../interface';
2
+ import { MetricVolumeType, PhotoAttributes, AlbumAttributes, PhotoTag } from '../../interface';
3
3
  import { DecryptedNode, EncryptedNode, DecryptedUnparsedNode } from '../nodes/interface';
4
4
  import { EncryptedShare } from '../shares';
5
5
 
@@ -24,7 +24,7 @@ export interface SharesService {
24
24
  }
25
25
 
26
26
  export type EncryptedPhotoNode = EncryptedNode & {
27
- photo?: EcnryptedPhotoAttributes;
27
+ photo?: EncryptedPhotoAttributes;
28
28
  album?: AlbumAttributes;
29
29
  };
30
30
 
@@ -38,7 +38,7 @@ export type DecryptedPhotoNode = DecryptedNode & {
38
38
  album?: AlbumAttributes;
39
39
  };
40
40
 
41
- export type EcnryptedPhotoAttributes = Omit<PhotoAttributes, 'albums'> & {
41
+ export type EncryptedPhotoAttributes = Omit<PhotoAttributes, 'albums'> & {
42
42
  contentHash?: string;
43
43
  albums: (PhotoAttributes['albums'][0] & {
44
44
  nameHash?: string;
@@ -56,28 +56,3 @@ export type AlbumItem = {
56
56
  nodeUid: string;
57
57
  captureTime: Date;
58
58
  };
59
-
60
- export enum PhotoTag {
61
- Favorites = 0,
62
- Screenshots = 1,
63
- Videos = 2,
64
- LivePhotos = 3,
65
- MotionPhotos = 4,
66
- Selfies = 5,
67
- Portraits = 6,
68
- Bursts = 7,
69
- Panoramas = 8,
70
- Raw = 9,
71
- }
72
-
73
- export type AddToAlbumEncryptedPhotoPayload = {
74
- nodeUid: string;
75
- contentHash: string;
76
- nameHash: string;
77
- encryptedName: string;
78
- nameSignatureEmail: string;
79
- nodePassphrase: string;
80
- nodePassphraseSignature?: string;
81
- signatureEmail?: string;
82
- relatedPhotos?: Omit<AddToAlbumEncryptedPhotoPayload, 'relatedPhotos'>[];
83
- };
@@ -178,7 +178,7 @@ export class PhotosNodesAccess extends NodesAccessBase<EncryptedPhotoNode, Decry
178
178
 
179
179
  // This is bug that should not happen.
180
180
  // API cannot provide node without parent or share or album.
181
- throw new Error('Node has neither parent node nor share nor album');
181
+ throw new Error(`Node has neither parent node nor share nor album: ${node.uid}`);
182
182
  }
183
183
 
184
184
  protected getDegradedUndecryptableNode(
@@ -0,0 +1,266 @@
1
+ import { resultOk, PhotoTag } from '../../interface';
2
+ import { getMockLogger } from '../../tests/logger';
3
+ import { PhotosManager, UpdatePhotoSettings } from './photosManager';
4
+ import { PhotosAPIService } from './apiService';
5
+ import { AlbumsCryptoService } from './albumsCrypto';
6
+ import { PhotosNodesAccess } from './nodes';
7
+ import { DecryptedPhotoNode } from './interface';
8
+
9
+ function createMockPhotoNode(uid: string, overrides: Partial<DecryptedPhotoNode> = {}): DecryptedPhotoNode {
10
+ return {
11
+ uid,
12
+ parentUid: 'volume1~parent',
13
+ hash: 'hash',
14
+ name: resultOk('photo.jpg'),
15
+ photo: {
16
+ captureTime: new Date(),
17
+ mainPhotoNodeUid: undefined,
18
+ relatedPhotoNodeUids: [],
19
+ tags: [],
20
+ albums: [],
21
+ },
22
+ activeRevision: {
23
+ ok: true,
24
+ value: {
25
+ uid: 'rev1',
26
+ state: 'active' as const,
27
+ creationTime: new Date(),
28
+ storageSize: 100,
29
+ signatureEmail: 'test@example.com',
30
+ claimedModificationTime: new Date(),
31
+ claimedSize: 100,
32
+ claimedDigests: { sha1: 'sha1hash' },
33
+ claimedBlockSizes: [100],
34
+ },
35
+ },
36
+ keyAuthor: { ok: true, value: 'test@example.com' },
37
+ ...overrides,
38
+ } as DecryptedPhotoNode;
39
+ }
40
+
41
+ async function collectUpdateResults(manager: PhotosManager, photos: UpdatePhotoSettings[], signal?: AbortSignal) {
42
+ const results = [];
43
+ for await (const result of manager.updatePhotos(photos, signal)) {
44
+ results.push(result);
45
+ }
46
+ return results;
47
+ }
48
+
49
+ describe('PhotosManager', () => {
50
+ let logger: ReturnType<typeof getMockLogger>;
51
+ let apiService: jest.Mocked<Pick<PhotosAPIService, 'addPhotoTags' | 'removePhotoTags' | 'setPhotoFavorite'>>;
52
+ let cryptoService: jest.Mocked<Pick<AlbumsCryptoService, 'encryptPhotoForAlbum'>>;
53
+ let nodesService: jest.Mocked<
54
+ Pick<
55
+ PhotosNodesAccess,
56
+ | 'getVolumeRootFolder'
57
+ | 'getNodeKeys'
58
+ | 'getNodeSigningKeys'
59
+ | 'iterateNodes'
60
+ | 'getNodePrivateAndSessionKeys'
61
+ | 'notifyNodeChanged'
62
+ >
63
+ >;
64
+ let manager: PhotosManager;
65
+
66
+ const volumeRootKeys = {
67
+ key: 'rootKey' as any,
68
+ hashKey: new Uint8Array([1, 2, 3]),
69
+ };
70
+ const signingKeys = {
71
+ type: 'userAddress' as const,
72
+ email: 'test@example.com',
73
+ addressId: 'addressId',
74
+ key: 'signingKey' as any,
75
+ };
76
+ beforeEach(() => {
77
+ logger = getMockLogger();
78
+
79
+ apiService = {
80
+ addPhotoTags: jest.fn().mockResolvedValue(undefined),
81
+ removePhotoTags: jest.fn().mockResolvedValue(undefined),
82
+ setPhotoFavorite: jest.fn().mockResolvedValue(undefined),
83
+ };
84
+
85
+ cryptoService = {
86
+ encryptPhotoForAlbum: jest.fn().mockResolvedValue({
87
+ contentHash: 'contentHash',
88
+ hash: 'nameHash',
89
+ encryptedName: 'encryptedName',
90
+ nameSignatureEmail: 'test@example.com',
91
+ armoredNodePassphrase: 'passphrase',
92
+ armoredNodePassphraseSignature: 'signature',
93
+ signatureEmail: 'test@example.com',
94
+ }),
95
+ };
96
+
97
+ nodesService = {
98
+ getVolumeRootFolder: jest.fn().mockResolvedValue({ uid: 'volume1~root' }),
99
+ getNodeKeys: jest.fn().mockResolvedValue(volumeRootKeys),
100
+ getNodeSigningKeys: jest.fn().mockResolvedValue(signingKeys),
101
+ iterateNodes: jest.fn().mockImplementation(async function* (uids: string[]) {
102
+ for (const uid of uids) {
103
+ yield createMockPhotoNode(uid);
104
+ }
105
+ }),
106
+ getNodePrivateAndSessionKeys: jest.fn().mockResolvedValue({
107
+ key: 'nodeKey' as any,
108
+ nameSessionKey: 'sessionKey' as any,
109
+ passphrase: 'passphrase',
110
+ passphraseSessionKey: 'passphraseSessionKey' as any,
111
+ }),
112
+ notifyNodeChanged: jest.fn().mockResolvedValue(undefined),
113
+ };
114
+
115
+ manager = new PhotosManager(logger, apiService as any, cryptoService as any, nodesService as any);
116
+ });
117
+
118
+ describe('updatePhotos', () => {
119
+ describe('add tags only', () => {
120
+ it('calls addPhotoTags and notifyNodeChanged for each photo', async () => {
121
+ const results = await collectUpdateResults(manager, [
122
+ { nodeUid: 'volume1~photo1', tagsToAdd: [PhotoTag.Screenshots], tagsToRemove: [] },
123
+ { nodeUid: 'volume1~photo2', tagsToAdd: [PhotoTag.LivePhotos], tagsToRemove: [] },
124
+ ]);
125
+
126
+ expect(results).toEqual([
127
+ { uid: 'volume1~photo1', ok: true },
128
+ { uid: 'volume1~photo2', ok: true },
129
+ ]);
130
+ expect(apiService.addPhotoTags).toHaveBeenCalledTimes(2);
131
+ expect(apiService.addPhotoTags).toHaveBeenCalledWith('volume1~photo1', [PhotoTag.Screenshots]);
132
+ expect(apiService.addPhotoTags).toHaveBeenCalledWith('volume1~photo2', [PhotoTag.LivePhotos]);
133
+ expect(nodesService.getVolumeRootFolder).not.toHaveBeenCalled();
134
+ expect(apiService.setPhotoFavorite).not.toHaveBeenCalled();
135
+ expect(nodesService.notifyNodeChanged).toHaveBeenCalledTimes(2);
136
+ expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo1');
137
+ expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo2');
138
+ });
139
+
140
+ it('filters Favorites from addTags and calls setPhotoFavorite with payload', async () => {
141
+ const results = await collectUpdateResults(manager, [
142
+ { nodeUid: 'volume1~photo1', tagsToAdd: [PhotoTag.Favorites], tagsToRemove: [] },
143
+ ]);
144
+
145
+ expect(results).toEqual([{ uid: 'volume1~photo1', ok: true }]);
146
+ expect(nodesService.getVolumeRootFolder).toHaveBeenCalled();
147
+ expect(nodesService.getNodeKeys).toHaveBeenCalledWith('volume1~root');
148
+ expect(nodesService.getNodeSigningKeys).toHaveBeenCalledWith({ nodeUid: 'volume1~root' });
149
+ expect(apiService.setPhotoFavorite).toHaveBeenCalledTimes(1);
150
+ expect(apiService.setPhotoFavorite).toHaveBeenCalledWith(
151
+ 'volume1~photo1',
152
+ expect.objectContaining({
153
+ nodeUid: 'volume1~photo1',
154
+ contentHash: 'contentHash',
155
+ nameHash: 'nameHash',
156
+ relatedPhotos: [],
157
+ }),
158
+ );
159
+ expect(apiService.addPhotoTags).not.toHaveBeenCalled();
160
+ expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo1');
161
+ });
162
+
163
+ it('calls setPhotoFavorite and addPhotoTags when addTags includes Favorites and other tags', async () => {
164
+ const results = await collectUpdateResults(manager, [
165
+ {
166
+ nodeUid: 'volume1~photo1',
167
+ tagsToAdd: [PhotoTag.Favorites, PhotoTag.Screenshots],
168
+ tagsToRemove: [],
169
+ },
170
+ ]);
171
+
172
+ expect(results).toEqual([{ uid: 'volume1~photo1', ok: true }]);
173
+ expect(apiService.setPhotoFavorite).toHaveBeenCalledWith('volume1~photo1', expect.any(Object));
174
+ expect(apiService.addPhotoTags).toHaveBeenCalledWith('volume1~photo1', [PhotoTag.Screenshots]);
175
+ });
176
+
177
+ it('calls setPhotoFavorite when payload builder returns PhotoAlreadyInTargetError (photo already in root)', async () => {
178
+ nodesService.iterateNodes.mockImplementation(async function* (uids: string[]) {
179
+ for (const uid of uids) {
180
+ yield createMockPhotoNode(uid, { parentUid: 'volume1~root' });
181
+ }
182
+ });
183
+
184
+ const results = await collectUpdateResults(manager, [
185
+ { nodeUid: 'volume1~photo1', tagsToAdd: [PhotoTag.Favorites], tagsToRemove: [] },
186
+ ]);
187
+
188
+ expect(results).toEqual([{ uid: 'volume1~photo1', ok: true }]);
189
+ expect(apiService.setPhotoFavorite).toHaveBeenCalledWith('volume1~photo1', undefined);
190
+ expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo1');
191
+ });
192
+ });
193
+
194
+ describe('remove tags only', () => {
195
+ it('calls removePhotoTags and notifyNodeChanged for each photo', async () => {
196
+ const results = await collectUpdateResults(manager, [
197
+ { nodeUid: 'volume1~photo1', tagsToAdd: [], tagsToRemove: [PhotoTag.Screenshots] },
198
+ ]);
199
+
200
+ expect(results).toEqual([{ uid: 'volume1~photo1', ok: true }]);
201
+ expect(apiService.removePhotoTags).toHaveBeenCalledWith('volume1~photo1', [PhotoTag.Screenshots]);
202
+ expect(apiService.addPhotoTags).not.toHaveBeenCalled();
203
+ expect(nodesService.getVolumeRootFolder).not.toHaveBeenCalled();
204
+ expect(apiService.setPhotoFavorite).not.toHaveBeenCalled();
205
+ expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo1');
206
+ });
207
+ });
208
+
209
+ describe('add and remove tags together', () => {
210
+ it('calls addPhotoTags and removePhotoTags and notifyNodeChanged', async () => {
211
+ const results = await collectUpdateResults(manager, [
212
+ {
213
+ nodeUid: 'volume1~photo1',
214
+ tagsToAdd: [PhotoTag.Panoramas],
215
+ tagsToRemove: [PhotoTag.Screenshots],
216
+ },
217
+ ]);
218
+
219
+ expect(results).toEqual([{ uid: 'volume1~photo1', ok: true }]);
220
+ expect(apiService.addPhotoTags).toHaveBeenCalledWith('volume1~photo1', [PhotoTag.Panoramas]);
221
+ expect(apiService.removePhotoTags).toHaveBeenCalledWith('volume1~photo1', [PhotoTag.Screenshots]);
222
+ expect(apiService.setPhotoFavorite).not.toHaveBeenCalled();
223
+ expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo1');
224
+ });
225
+ });
226
+
227
+ describe('API failures', () => {
228
+ it('yields error result and logs when setPhotoFavorite fails', async () => {
229
+ const apiError = new Error('Favorite API failed');
230
+ apiService.setPhotoFavorite.mockRejectedValue(apiError);
231
+
232
+ const results = await collectUpdateResults(manager, [
233
+ { nodeUid: 'volume1~photo1', tagsToAdd: [PhotoTag.Favorites], tagsToRemove: [] },
234
+ ]);
235
+
236
+ expect(results).toEqual([{ uid: 'volume1~photo1', ok: false, error: apiError }]);
237
+ expect(logger.error).toHaveBeenCalledWith('Update photos failed for volume1~photo1', apiError);
238
+ expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled();
239
+ });
240
+
241
+ it('yields error result when addPhotoTags fails', async () => {
242
+ const apiError = new Error('Add tags failed');
243
+ apiService.addPhotoTags.mockRejectedValue(apiError);
244
+
245
+ const results = await collectUpdateResults(manager, [
246
+ { nodeUid: 'volume1~photo1', tagsToAdd: [PhotoTag.Screenshots], tagsToRemove: [] },
247
+ ]);
248
+
249
+ expect(results).toEqual([{ uid: 'volume1~photo1', ok: false, error: apiError }]);
250
+ expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled();
251
+ });
252
+
253
+ it('yields error result when removePhotoTags fails', async () => {
254
+ const apiError = new Error('Remove tags failed');
255
+ apiService.removePhotoTags.mockRejectedValue(apiError);
256
+
257
+ const results = await collectUpdateResults(manager, [
258
+ { nodeUid: 'volume1~photo1', tagsToAdd: [], tagsToRemove: [PhotoTag.Videos] },
259
+ ]);
260
+
261
+ expect(results).toEqual([{ uid: 'volume1~photo1', ok: false, error: apiError }]);
262
+ expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled();
263
+ });
264
+ });
265
+ });
266
+ });
@@ -0,0 +1,144 @@
1
+ import { c } from 'ttag';
2
+
3
+ import { Logger, NodeResultWithError, PhotoTag } from '../../interface';
4
+ import { PhotosAPIService } from './apiService';
5
+ import { PhotoAlreadyInTargetError, PhotoTransferPayloadBuilder, TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder';
6
+ import { PhotosNodesAccess } from './nodes';
7
+ import { AlbumsCryptoService } from './albumsCrypto';
8
+ import { AbortError } from '../../errors';
9
+ import { BATCH_LOADING_SIZE } from '../sharing/sharingAccess';
10
+ import { batch } from '../batch';
11
+
12
+ export type UpdatePhotoSettings = {
13
+ nodeUid: string;
14
+ tagsToAdd: PhotoTag[];
15
+ tagsToRemove: PhotoTag[];
16
+ };
17
+
18
+ /**
19
+ * Manages updating photos: adding/removing tags and favoriting.
20
+ * Uses the same encrypted payload as add-to-album/copy for the favorite endpoint.
21
+ */
22
+ export class PhotosManager {
23
+ private readonly payloadBuilder: PhotoTransferPayloadBuilder;
24
+
25
+ constructor(
26
+ private readonly logger: Logger,
27
+ private readonly apiService: PhotosAPIService,
28
+ albumsCryptoService: AlbumsCryptoService,
29
+ private readonly nodesService: PhotosNodesAccess,
30
+ ) {
31
+ this.payloadBuilder = new PhotoTransferPayloadBuilder(albumsCryptoService, nodesService);
32
+ }
33
+
34
+ async *updatePhotos(photos: UpdatePhotoSettings[], signal?: AbortSignal): AsyncGenerator<NodeResultWithError> {
35
+ for await (const {
36
+ photoSettings: { nodeUid, tagsToAdd, tagsToRemove },
37
+ payloadForFavorite,
38
+ error,
39
+ } of this.iterateNodeUidsWithFavoritePayloads(photos, signal)) {
40
+ if (signal?.aborted) {
41
+ throw new AbortError();
42
+ }
43
+
44
+ if (error) {
45
+ yield { uid: nodeUid, ok: false, error };
46
+ continue;
47
+ }
48
+
49
+ try {
50
+ if (tagsToAdd.includes(PhotoTag.Favorites)) {
51
+ await this.apiService.setPhotoFavorite(nodeUid, payloadForFavorite);
52
+ }
53
+ const addTags = tagsToAdd.filter((tag) => tag !== PhotoTag.Favorites);
54
+ if (addTags.length) {
55
+ await this.apiService.addPhotoTags(nodeUid, addTags);
56
+ }
57
+ if (tagsToRemove.length) {
58
+ await this.apiService.removePhotoTags(nodeUid, tagsToRemove);
59
+ }
60
+
61
+ await this.nodesService.notifyNodeChanged(nodeUid);
62
+ yield { uid: nodeUid, ok: true };
63
+ } catch (error) {
64
+ this.logger.error(`Update photos failed for ${nodeUid}`, error);
65
+ yield {
66
+ uid: nodeUid,
67
+ ok: false,
68
+ error: error instanceof Error ? error : new Error(c('Error').t`Unknown error`, { cause: error }),
69
+ };
70
+ }
71
+ }
72
+ }
73
+
74
+ private async *iterateNodeUidsWithFavoritePayloads(
75
+ photosSettings: UpdatePhotoSettings[],
76
+ signal?: AbortSignal,
77
+ ): AsyncGenerator<{
78
+ photoSettings: UpdatePhotoSettings;
79
+ payloadForFavorite?: TransferEncryptedPhotoPayload;
80
+ error?: Error;
81
+ }> {
82
+ const photosSettingsWithoutFavorite = photosSettings.filter(
83
+ (photoSettings) => !photoSettings.tagsToAdd?.includes(PhotoTag.Favorites),
84
+ );
85
+ const photosSettingsWithFavorite = photosSettings.filter((photoSettings) =>
86
+ photoSettings.tagsToAdd?.includes(PhotoTag.Favorites),
87
+ );
88
+
89
+ for (const photoSettings of photosSettingsWithoutFavorite) {
90
+ yield { photoSettings };
91
+ }
92
+
93
+ if (!photosSettingsWithFavorite.length) {
94
+ return;
95
+ }
96
+
97
+ const rootNode = await this.nodesService.getVolumeRootFolder();
98
+ const volumeRootKeys = await this.nodesService.getNodeKeys(rootNode.uid);
99
+ const signingKeys = await this.nodesService.getNodeSigningKeys({ nodeUid: rootNode.uid });
100
+
101
+ // Batch iteration to fetch metadata for preparing the payloads in parallel.
102
+ for (const photoSettingsBatch of batch(photosSettingsWithFavorite, BATCH_LOADING_SIZE)) {
103
+ if (signal?.aborted) {
104
+ throw new AbortError();
105
+ }
106
+
107
+ const result = await this.payloadBuilder.preparePhotoPayloads(
108
+ photoSettingsBatch.map(({ nodeUid }) => ({ photoNodeUid: nodeUid })),
109
+ rootNode.uid,
110
+ volumeRootKeys,
111
+ signingKeys,
112
+ signal,
113
+ );
114
+
115
+ for (const [nodeUid, error] of result.errors) {
116
+ const photoSettings = photosSettingsWithFavorite.find(
117
+ (photoSettings) => photoSettings.nodeUid === nodeUid,
118
+ );
119
+ if (!photoSettings) {
120
+ this.logger.error(`Photo settings not found for ${nodeUid}, unexpected error`);
121
+ continue;
122
+ }
123
+
124
+ // If the photo is already in the root node, we only set the favorite tag.
125
+ if (error instanceof PhotoAlreadyInTargetError) {
126
+ yield { photoSettings };
127
+ continue;
128
+ }
129
+ yield { photoSettings, error };
130
+ }
131
+
132
+ for (const payloadForFavorite of result.payloads) {
133
+ const photoSettings = photosSettingsWithFavorite.find(
134
+ (photoSettings) => photoSettings.nodeUid === payloadForFavorite.nodeUid,
135
+ );
136
+ if (!photoSettings) {
137
+ this.logger.error(`Photo settings not found for ${payloadForFavorite.nodeUid}, unexpected payload`);
138
+ continue;
139
+ }
140
+ yield { photoSettings, payloadForFavorite };
141
+ }
142
+ }
143
+ }
144
+ }