@protontech/drive-sdk 0.10.0 → 0.11.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 (105) hide show
  1. package/dist/crypto/driveCrypto.d.ts +4 -1
  2. package/dist/crypto/driveCrypto.js +23 -1
  3. package/dist/crypto/driveCrypto.js.map +1 -1
  4. package/dist/crypto/driveCrypto.test.js +2 -1
  5. package/dist/crypto/driveCrypto.test.js.map +1 -1
  6. package/dist/diagnostic/telemetry.js +3 -0
  7. package/dist/diagnostic/telemetry.js.map +1 -1
  8. package/dist/interface/index.d.ts +1 -0
  9. package/dist/interface/index.js +3 -1
  10. package/dist/interface/index.js.map +1 -1
  11. package/dist/interface/photos.d.ts +13 -1
  12. package/dist/interface/photos.js +14 -0
  13. package/dist/interface/photos.js.map +1 -1
  14. package/dist/interface/telemetry.d.ts +12 -1
  15. package/dist/interface/telemetry.js.map +1 -1
  16. package/dist/internal/apiService/apiService.d.ts +1 -1
  17. package/dist/internal/apiService/apiService.js +2 -2
  18. package/dist/internal/apiService/apiService.js.map +1 -1
  19. package/dist/internal/nodes/apiService.js.map +1 -1
  20. package/dist/internal/nodes/nodesAccess.js +1 -1
  21. package/dist/internal/nodes/nodesAccess.js.map +1 -1
  22. package/dist/internal/photos/addToAlbum.d.ts +1 -5
  23. package/dist/internal/photos/addToAlbum.js +8 -87
  24. package/dist/internal/photos/addToAlbum.js.map +1 -1
  25. package/dist/internal/photos/{albums.d.ts → albumsManager.d.ts} +1 -1
  26. package/dist/internal/photos/{albums.js → albumsManager.js} +4 -4
  27. package/dist/internal/photos/albumsManager.js.map +1 -0
  28. package/dist/internal/photos/{albums.test.js → albumsManager.test.js} +3 -3
  29. package/dist/internal/photos/albumsManager.test.js.map +1 -0
  30. package/dist/internal/photos/apiService.d.ts +8 -4
  31. package/dist/internal/photos/apiService.js +38 -6
  32. package/dist/internal/photos/apiService.js.map +1 -1
  33. package/dist/internal/photos/index.d.ts +5 -3
  34. package/dist/internal/photos/index.js +5 -2
  35. package/dist/internal/photos/index.js.map +1 -1
  36. package/dist/internal/photos/interface.d.ts +3 -26
  37. package/dist/internal/photos/interface.js +0 -14
  38. package/dist/internal/photos/interface.js.map +1 -1
  39. package/dist/internal/photos/nodes.js +1 -1
  40. package/dist/internal/photos/nodes.js.map +1 -1
  41. package/dist/internal/photos/photosManager.d.ts +22 -0
  42. package/dist/internal/photos/photosManager.js +101 -0
  43. package/dist/internal/photos/photosManager.js.map +1 -0
  44. package/dist/internal/photos/photosManager.test.d.ts +1 -0
  45. package/dist/internal/photos/photosManager.test.js +222 -0
  46. package/dist/internal/photos/photosManager.test.js.map +1 -0
  47. package/dist/internal/photos/photosTransferPayloadBuilder.d.ts +57 -0
  48. package/dist/internal/photos/photosTransferPayloadBuilder.js +113 -0
  49. package/dist/internal/photos/photosTransferPayloadBuilder.js.map +1 -0
  50. package/dist/internal/photos/photosTransferPayloadBuilder.test.d.ts +1 -0
  51. package/dist/internal/photos/photosTransferPayloadBuilder.test.js +289 -0
  52. package/dist/internal/photos/photosTransferPayloadBuilder.test.js.map +1 -0
  53. package/dist/internal/photos/upload.js +1 -1
  54. package/dist/internal/photos/upload.js.map +1 -1
  55. package/dist/internal/upload/apiService.d.ts +0 -4
  56. package/dist/internal/upload/apiService.js +0 -4
  57. package/dist/internal/upload/apiService.js.map +1 -1
  58. package/dist/internal/upload/cryptoService.js +4 -4
  59. package/dist/internal/upload/cryptoService.js.map +1 -1
  60. package/dist/internal/upload/interface.d.ts +1 -1
  61. package/dist/internal/upload/streamUploader.d.ts +1 -1
  62. package/dist/internal/upload/streamUploader.js +12 -13
  63. package/dist/internal/upload/streamUploader.js.map +1 -1
  64. package/dist/internal/upload/streamUploader.test.js +28 -4
  65. package/dist/internal/upload/streamUploader.test.js.map +1 -1
  66. package/dist/protonDriveClient.js +1 -1
  67. package/dist/protonDriveClient.js.map +1 -1
  68. package/dist/protonDrivePhotosClient.d.ts +19 -2
  69. package/dist/protonDrivePhotosClient.js +21 -1
  70. package/dist/protonDrivePhotosClient.js.map +1 -1
  71. package/dist/protonDrivePublicLinkClient.js +1 -1
  72. package/dist/protonDrivePublicLinkClient.js.map +1 -1
  73. package/package.json +1 -1
  74. package/src/crypto/driveCrypto.test.ts +2 -1
  75. package/src/crypto/driveCrypto.ts +36 -2
  76. package/src/diagnostic/telemetry.ts +3 -0
  77. package/src/interface/index.ts +1 -0
  78. package/src/interface/photos.ts +14 -1
  79. package/src/interface/telemetry.ts +14 -1
  80. package/src/internal/apiService/apiService.ts +6 -2
  81. package/src/internal/nodes/apiService.ts +2 -2
  82. package/src/internal/nodes/nodesAccess.ts +1 -1
  83. package/src/internal/photos/addToAlbum.ts +29 -136
  84. package/src/internal/photos/{albums.test.ts → albumsManager.test.ts} +3 -3
  85. package/src/internal/photos/{albums.ts → albumsManager.ts} +1 -1
  86. package/src/internal/photos/apiService.ts +73 -16
  87. package/src/internal/photos/index.ts +6 -3
  88. package/src/internal/photos/interface.ts +3 -28
  89. package/src/internal/photos/nodes.ts +1 -1
  90. package/src/internal/photos/photosManager.test.ts +266 -0
  91. package/src/internal/photos/photosManager.ts +144 -0
  92. package/src/internal/photos/photosTransferPayloadBuilder.test.ts +380 -0
  93. package/src/internal/photos/photosTransferPayloadBuilder.ts +203 -0
  94. package/src/internal/photos/upload.ts +6 -1
  95. package/src/internal/upload/apiService.ts +7 -9
  96. package/src/internal/upload/cryptoService.ts +4 -5
  97. package/src/internal/upload/interface.ts +1 -1
  98. package/src/internal/upload/streamUploader.test.ts +33 -4
  99. package/src/internal/upload/streamUploader.ts +13 -13
  100. package/src/protonDriveClient.ts +1 -1
  101. package/src/protonDrivePhotosClient.ts +34 -2
  102. package/src/protonDrivePublicLinkClient.ts +1 -1
  103. package/dist/internal/photos/albums.js.map +0 -1
  104. package/dist/internal/photos/albums.test.js.map +0 -1
  105. /package/dist/internal/photos/{albums.test.d.ts → albumsManager.test.d.ts} +0 -0
@@ -18,13 +18,14 @@ import { SharesCryptoService } from '../shares/cryptoService';
18
18
  import { NodesService as UploadNodesService } from '../upload/interface';
19
19
  import { UploadTelemetry } from '../upload/telemetry';
20
20
  import { UploadQueue } from '../upload/queue';
21
- import { Albums } from './albums';
21
+ import { AlbumsManager } from './albumsManager';
22
22
  import { AlbumsCryptoService } from './albumsCrypto';
23
23
  import { PhotosAPIService } from './apiService';
24
24
  import { SharesService } from './interface';
25
25
  import { PhotosNodesAPIService, PhotosNodesAccess, PhotosNodesCache, PhotosNodesManagement } from './nodes';
26
26
  import { PhotoSharesManager } from './shares';
27
27
  import { PhotosTimeline } from './timeline';
28
+ import { PhotosManager } from './photosManager';
28
29
  import {
29
30
  PhotoFileUploader,
30
31
  PhotoUploadAPIService,
@@ -33,7 +34,7 @@ import {
33
34
  PhotoUploadMetadata,
34
35
  } from './upload';
35
36
 
36
- export type { DecryptedPhotoNode, TimelineItem, AlbumItem, PhotoTag } from './interface';
37
+ export type { DecryptedPhotoNode, TimelineItem, AlbumItem } from './interface';
37
38
 
38
39
  // Only photos and albums can be shared in photos volume.
39
40
  export const PHOTOS_SHARE_TARGET_TYPES = [ShareTargetType.Photo, ShareTargetType.Album];
@@ -60,11 +61,13 @@ export function initPhotosModule(
60
61
  photoShares,
61
62
  nodesService,
62
63
  );
63
- const albums = new Albums(telemetry, api, albumsCryptoService, photoShares, nodesService);
64
+ const albums = new AlbumsManager(telemetry, api, albumsCryptoService, photoShares, nodesService);
65
+ const photos = new PhotosManager(telemetry.getLogger('photos-update'), api, albumsCryptoService, nodesService);
64
66
 
65
67
  return {
66
68
  timeline,
67
69
  albums,
70
+ photos,
68
71
  };
69
72
  }
70
73
 
@@ -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
+ }