@protontech/drive-sdk 0.9.9 → 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 (213) hide show
  1. package/dist/crypto/driveCrypto.d.ts +19 -16
  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/crypto/hmac.d.ts +3 -3
  7. package/dist/crypto/hmac.js.map +1 -1
  8. package/dist/crypto/interface.d.ts +45 -25
  9. package/dist/crypto/interface.js.map +1 -1
  10. package/dist/crypto/openPGPCrypto.d.ts +37 -37
  11. package/dist/crypto/openPGPCrypto.js.map +1 -1
  12. package/dist/crypto/utils.d.ts +1 -1
  13. package/dist/diagnostic/telemetry.js +3 -0
  14. package/dist/diagnostic/telemetry.js.map +1 -1
  15. package/dist/interface/index.d.ts +4 -3
  16. package/dist/interface/index.js +3 -1
  17. package/dist/interface/index.js.map +1 -1
  18. package/dist/interface/nodes.d.ts +8 -0
  19. package/dist/interface/photos.d.ts +31 -2
  20. package/dist/interface/photos.js +14 -0
  21. package/dist/interface/photos.js.map +1 -1
  22. package/dist/interface/sharing.d.ts +2 -0
  23. package/dist/interface/telemetry.d.ts +13 -1
  24. package/dist/interface/telemetry.js.map +1 -1
  25. package/dist/interface/thumbnail.d.ts +2 -2
  26. package/dist/internal/apiService/apiService.d.ts +1 -1
  27. package/dist/internal/apiService/apiService.js +27 -14
  28. package/dist/internal/apiService/apiService.js.map +1 -1
  29. package/dist/internal/apiService/apiService.test.js +33 -5
  30. package/dist/internal/apiService/apiService.test.js.map +1 -1
  31. package/dist/internal/apiService/driveTypes.d.ts +2942 -3187
  32. package/dist/internal/apiService/errors.test.js +17 -7
  33. package/dist/internal/apiService/errors.test.js.map +1 -1
  34. package/dist/internal/devices/manager.d.ts +1 -0
  35. package/dist/internal/devices/manager.js +11 -0
  36. package/dist/internal/devices/manager.js.map +1 -1
  37. package/dist/internal/download/apiService.d.ts +1 -1
  38. package/dist/internal/download/cryptoService.d.ts +4 -4
  39. package/dist/internal/download/cryptoService.js.map +1 -1
  40. package/dist/internal/download/fileDownloader.js.map +1 -1
  41. package/dist/internal/download/fileDownloader.test.js.map +1 -1
  42. package/dist/internal/download/thumbnailDownloader.js.map +1 -1
  43. package/dist/internal/nodes/apiService.js.map +1 -1
  44. package/dist/internal/nodes/cryptoService.d.ts +4 -4
  45. package/dist/internal/nodes/cryptoService.js +5 -3
  46. package/dist/internal/nodes/cryptoService.js.map +1 -1
  47. package/dist/internal/nodes/cryptoService.test.js.map +1 -1
  48. package/dist/internal/nodes/interface.d.ts +1 -1
  49. package/dist/internal/nodes/nodesAccess.js +1 -1
  50. package/dist/internal/nodes/nodesAccess.js.map +1 -1
  51. package/dist/internal/nodes/nodesManagement.js +0 -1
  52. package/dist/internal/nodes/nodesManagement.js.map +1 -1
  53. package/dist/internal/photos/addToAlbum.d.ts +42 -0
  54. package/dist/internal/photos/addToAlbum.js +178 -0
  55. package/dist/internal/photos/addToAlbum.js.map +1 -0
  56. package/dist/internal/photos/addToAlbum.test.js +409 -0
  57. package/dist/internal/photos/addToAlbum.test.js.map +1 -0
  58. package/dist/internal/photos/albumsCrypto.d.ts +20 -3
  59. package/dist/internal/photos/albumsCrypto.js +27 -0
  60. package/dist/internal/photos/albumsCrypto.js.map +1 -1
  61. package/dist/internal/photos/{albums.d.ts → albumsManager.d.ts} +6 -4
  62. package/dist/internal/photos/{albums.js → albumsManager.js} +17 -5
  63. package/dist/internal/photos/albumsManager.js.map +1 -0
  64. package/dist/internal/photos/albumsManager.test.d.ts +1 -0
  65. package/dist/internal/photos/{albums.test.js → albumsManager.test.js} +4 -3
  66. package/dist/internal/photos/albumsManager.test.js.map +1 -0
  67. package/dist/internal/photos/apiService.d.ts +22 -2
  68. package/dist/internal/photos/apiService.js +136 -5
  69. package/dist/internal/photos/apiService.js.map +1 -1
  70. package/dist/internal/photos/apiService.test.d.ts +1 -0
  71. package/dist/internal/photos/apiService.test.js +199 -0
  72. package/dist/internal/photos/apiService.test.js.map +1 -0
  73. package/dist/internal/photos/errors.d.ts +4 -0
  74. package/dist/internal/photos/errors.js +17 -0
  75. package/dist/internal/photos/errors.js.map +1 -0
  76. package/dist/internal/photos/index.d.ts +5 -3
  77. package/dist/internal/photos/index.js +5 -2
  78. package/dist/internal/photos/index.js.map +1 -1
  79. package/dist/internal/photos/interface.d.ts +6 -15
  80. package/dist/internal/photos/interface.js +0 -14
  81. package/dist/internal/photos/interface.js.map +1 -1
  82. package/dist/internal/photos/nodes.js +33 -3
  83. package/dist/internal/photos/nodes.js.map +1 -1
  84. package/dist/internal/photos/nodes.test.js +25 -5
  85. package/dist/internal/photos/nodes.test.js.map +1 -1
  86. package/dist/internal/photos/photosManager.d.ts +22 -0
  87. package/dist/internal/photos/photosManager.js +101 -0
  88. package/dist/internal/photos/photosManager.js.map +1 -0
  89. package/dist/internal/photos/photosManager.test.d.ts +1 -0
  90. package/dist/internal/photos/photosManager.test.js +222 -0
  91. package/dist/internal/photos/photosManager.test.js.map +1 -0
  92. package/dist/internal/photos/photosTransferPayloadBuilder.d.ts +57 -0
  93. package/dist/internal/photos/photosTransferPayloadBuilder.js +113 -0
  94. package/dist/internal/photos/photosTransferPayloadBuilder.js.map +1 -0
  95. package/dist/internal/photos/photosTransferPayloadBuilder.test.d.ts +1 -0
  96. package/dist/internal/photos/photosTransferPayloadBuilder.test.js +289 -0
  97. package/dist/internal/photos/photosTransferPayloadBuilder.test.js.map +1 -0
  98. package/dist/internal/photos/upload.d.ts +2 -2
  99. package/dist/internal/photos/upload.js +1 -1
  100. package/dist/internal/photos/upload.js.map +1 -1
  101. package/dist/internal/shares/apiService.js +1 -0
  102. package/dist/internal/shares/apiService.js.map +1 -1
  103. package/dist/internal/shares/interface.d.ts +1 -0
  104. package/dist/internal/sharing/apiService.d.ts +8 -1
  105. package/dist/internal/sharing/apiService.js +23 -1
  106. package/dist/internal/sharing/apiService.js.map +1 -1
  107. package/dist/internal/sharing/cryptoService.js +8 -4
  108. package/dist/internal/sharing/cryptoService.js.map +1 -1
  109. package/dist/internal/sharing/sharingManagement.d.ts +1 -0
  110. package/dist/internal/sharing/sharingManagement.js +15 -2
  111. package/dist/internal/sharing/sharingManagement.js.map +1 -1
  112. package/dist/internal/sharing/sharingManagement.test.js +30 -5
  113. package/dist/internal/sharing/sharingManagement.test.js.map +1 -1
  114. package/dist/internal/sharingPublic/nodes.d.ts +2 -2
  115. package/dist/internal/sharingPublic/nodes.js.map +1 -1
  116. package/dist/internal/upload/apiService.d.ts +3 -7
  117. package/dist/internal/upload/apiService.js +0 -4
  118. package/dist/internal/upload/apiService.js.map +1 -1
  119. package/dist/internal/upload/blockVerifier.d.ts +2 -2
  120. package/dist/internal/upload/blockVerifier.js.map +1 -1
  121. package/dist/internal/upload/chunkStreamReader.d.ts +2 -2
  122. package/dist/internal/upload/chunkStreamReader.js.map +1 -1
  123. package/dist/internal/upload/chunkStreamReader.test.js.map +1 -1
  124. package/dist/internal/upload/cryptoService.d.ts +7 -7
  125. package/dist/internal/upload/cryptoService.js +4 -4
  126. package/dist/internal/upload/cryptoService.js.map +1 -1
  127. package/dist/internal/upload/interface.d.ts +6 -6
  128. package/dist/internal/upload/manager.d.ts +1 -1
  129. package/dist/internal/upload/manager.js.map +1 -1
  130. package/dist/internal/upload/streamUploader.d.ts +1 -1
  131. package/dist/internal/upload/streamUploader.js +12 -13
  132. package/dist/internal/upload/streamUploader.js.map +1 -1
  133. package/dist/internal/upload/streamUploader.test.js +28 -4
  134. package/dist/internal/upload/streamUploader.test.js.map +1 -1
  135. package/dist/internal/utils.d.ts +1 -1
  136. package/dist/protonDriveClient.d.ts +8 -0
  137. package/dist/protonDriveClient.js +12 -1
  138. package/dist/protonDriveClient.js.map +1 -1
  139. package/dist/protonDrivePhotosClient.d.ts +35 -3
  140. package/dist/protonDrivePhotosClient.js +42 -2
  141. package/dist/protonDrivePhotosClient.js.map +1 -1
  142. package/dist/protonDrivePublicLinkClient.js +1 -1
  143. package/dist/protonDrivePublicLinkClient.js.map +1 -1
  144. package/dist/transformers.js +2 -0
  145. package/dist/transformers.js.map +1 -1
  146. package/package.json +4 -4
  147. package/src/crypto/driveCrypto.test.ts +2 -1
  148. package/src/crypto/driveCrypto.ts +50 -16
  149. package/src/crypto/hmac.ts +4 -4
  150. package/src/crypto/interface.ts +58 -27
  151. package/src/crypto/openPGPCrypto.ts +26 -26
  152. package/src/diagnostic/telemetry.ts +3 -0
  153. package/src/interface/index.ts +11 -2
  154. package/src/interface/nodes.ts +1 -0
  155. package/src/interface/photos.ts +33 -2
  156. package/src/interface/sharing.ts +2 -0
  157. package/src/interface/telemetry.ts +15 -1
  158. package/src/interface/thumbnail.ts +2 -2
  159. package/src/internal/apiService/apiService.test.ts +38 -6
  160. package/src/internal/apiService/apiService.ts +39 -14
  161. package/src/internal/apiService/driveTypes.ts +2942 -3187
  162. package/src/internal/devices/manager.ts +14 -0
  163. package/src/internal/download/apiService.ts +1 -1
  164. package/src/internal/download/cryptoService.ts +4 -4
  165. package/src/internal/download/fileDownloader.test.ts +4 -4
  166. package/src/internal/download/fileDownloader.ts +6 -6
  167. package/src/internal/download/thumbnailDownloader.ts +4 -4
  168. package/src/internal/nodes/apiService.ts +2 -2
  169. package/src/internal/nodes/cryptoService.test.ts +2 -2
  170. package/src/internal/nodes/cryptoService.ts +11 -8
  171. package/src/internal/nodes/interface.ts +1 -1
  172. package/src/internal/nodes/nodesAccess.ts +1 -1
  173. package/src/internal/nodes/nodesManagement.ts +0 -1
  174. package/src/internal/photos/addToAlbum.test.ts +515 -0
  175. package/src/internal/photos/addToAlbum.ts +234 -0
  176. package/src/internal/photos/albumsCrypto.ts +54 -3
  177. package/src/internal/photos/{albums.test.ts → albumsManager.test.ts} +22 -25
  178. package/src/internal/photos/{albums.ts → albumsManager.ts} +32 -3
  179. package/src/internal/photos/apiService.test.ts +233 -0
  180. package/src/internal/photos/apiService.ts +228 -26
  181. package/src/internal/photos/errors.ts +11 -0
  182. package/src/internal/photos/index.ts +6 -3
  183. package/src/internal/photos/interface.ts +8 -18
  184. package/src/internal/photos/nodes.test.ts +27 -6
  185. package/src/internal/photos/nodes.ts +35 -3
  186. package/src/internal/photos/photosManager.test.ts +266 -0
  187. package/src/internal/photos/photosManager.ts +144 -0
  188. package/src/internal/photos/photosTransferPayloadBuilder.test.ts +380 -0
  189. package/src/internal/photos/photosTransferPayloadBuilder.ts +203 -0
  190. package/src/internal/photos/upload.ts +8 -3
  191. package/src/internal/shares/apiService.ts +1 -0
  192. package/src/internal/shares/interface.ts +1 -0
  193. package/src/internal/sharing/apiService.ts +49 -5
  194. package/src/internal/sharing/cryptoService.ts +10 -4
  195. package/src/internal/sharing/sharingManagement.test.ts +33 -5
  196. package/src/internal/sharing/sharingManagement.ts +28 -6
  197. package/src/internal/sharingPublic/nodes.ts +1 -1
  198. package/src/internal/upload/apiService.ts +10 -12
  199. package/src/internal/upload/blockVerifier.ts +3 -3
  200. package/src/internal/upload/chunkStreamReader.test.ts +7 -7
  201. package/src/internal/upload/chunkStreamReader.ts +3 -3
  202. package/src/internal/upload/cryptoService.ts +11 -12
  203. package/src/internal/upload/interface.ts +6 -6
  204. package/src/internal/upload/manager.ts +2 -2
  205. package/src/internal/upload/streamUploader.test.ts +33 -4
  206. package/src/internal/upload/streamUploader.ts +13 -13
  207. package/src/protonDriveClient.ts +16 -4
  208. package/src/protonDrivePhotosClient.ts +73 -17
  209. package/src/protonDrivePublicLinkClient.ts +1 -1
  210. package/src/transformers.ts +2 -0
  211. package/dist/internal/photos/albums.js.map +0 -1
  212. package/dist/internal/photos/albums.test.js.map +0 -1
  213. /package/dist/internal/photos/{albums.test.d.ts → addToAlbum.test.d.ts} +0 -0
@@ -44,6 +44,10 @@ function generateAPIAlbumNode(linkOverrides = {}, overrides = {}) {
44
44
  ...node,
45
45
  Link: { ...node.Link, Type: 3, ...linkOverrides },
46
46
  Photo: null,
47
+ Album: {
48
+ PhotoCount: 1,
49
+ CoverLinkID: 'coverLinkId',
50
+ },
47
51
  Folder: null,
48
52
  ...overrides,
49
53
  };
@@ -103,6 +107,8 @@ describe('PhotosNodesAPIService', () => {
103
107
  const nodes = await Array.fromAsync(api.iterateNodes(['volumeId~nodeId'], 'volumeId'));
104
108
  expect(nodes).toHaveLength(1);
105
109
  expect(nodes[0].type).toBe(expectedType);
110
+
111
+ return nodes;
106
112
  }
107
113
 
108
114
  it('should convert folder (type 1) to folder node', async () => {
@@ -110,16 +116,16 @@ describe('PhotosNodesAPIService', () => {
110
116
  });
111
117
 
112
118
  it('should convert album (type 3) to album node', async () => {
113
- await testIterateNodes(generateAPIAlbumNode(), NodeType.Album);
119
+ const nodes = await testIterateNodes(generateAPIAlbumNode(), NodeType.Album);
120
+
121
+ expect(nodes[0].album).toBeDefined();
122
+ expect(nodes[0].album?.photoCount).toEqual(1);
123
+ expect(nodes[0].album?.coverPhotoNodeUid).toBe('volumeId~coverLinkId');
114
124
  });
115
125
 
116
126
  it('should convert photo (type 2) to photo node with photo attributes', async () => {
117
- apiMock.post = jest.fn().mockResolvedValue({ Links: [generateAPIPhotoNode()] });
118
-
119
- const nodes = await Array.fromAsync(api.iterateNodes(['volumeId~nodeId'], 'volumeId'));
127
+ const nodes = await testIterateNodes(generateAPIPhotoNode(), NodeType.Photo);
120
128
 
121
- expect(nodes).toHaveLength(1);
122
- expect(nodes[0].type).toBe(NodeType.Photo);
123
129
  expect(nodes[0].photo).toBeDefined();
124
130
  expect(nodes[0].photo?.captureTime).toEqual(new Date(1700000000 * 1000));
125
131
  expect(nodes[0].photo?.tags).toEqual([1, 2]);
@@ -161,6 +167,10 @@ describe('PhotosNodesCache', () => {
161
167
  },
162
168
  ],
163
169
  },
170
+ album: {
171
+ photoCount: 1,
172
+ coverPhotoNodeUid: 'volumeId~coverLinkId',
173
+ },
164
174
  });
165
175
 
166
176
  const node = cache.deserialiseNode(serialisedNode);
@@ -170,6 +180,9 @@ describe('PhotosNodesCache', () => {
170
180
  expect(node.photo?.captureTime).toEqual(new Date('2023-11-14T22:13:20.000Z'));
171
181
  expect(node.photo?.albums[0].additionTime).toBeInstanceOf(Date);
172
182
  expect(node.photo?.albums[0].additionTime).toEqual(new Date('2023-11-15T10:00:00.000Z'));
183
+ expect(node.album).toBeDefined();
184
+ expect(node.album?.photoCount).toEqual(1);
185
+ expect(node.album?.coverPhotoNodeUid).toBe('volumeId~coverLinkId');
173
186
  });
174
187
 
175
188
  it('should handle node without photo attributes', () => {
@@ -187,6 +200,7 @@ describe('PhotosNodesCache', () => {
187
200
  const node = cache.deserialiseNode(serialisedNode);
188
201
 
189
202
  expect(node.photo).toBeUndefined();
203
+ expect(node.album).toBeUndefined();
190
204
  });
191
205
  });
192
206
  });
@@ -244,6 +258,10 @@ describe('PhotosNodesAccess', () => {
244
258
  tags: [1, 2],
245
259
  albums: [],
246
260
  },
261
+ album: {
262
+ photoCount: 1,
263
+ coverPhotoNodeUid: 'volumeId~coverLinkId',
264
+ },
247
265
  };
248
266
 
249
267
  // @ts-expect-error Accessing protected method for testing
@@ -253,6 +271,9 @@ describe('PhotosNodesAccess', () => {
253
271
  expect(parsedNode.photo).toBeDefined();
254
272
  expect(parsedNode.photo?.captureTime).toEqual(new Date('2023-11-14T22:13:20.000Z'));
255
273
  expect(parsedNode.photo?.tags).toEqual([1, 2]);
274
+ expect(parsedNode.album).toBeDefined();
275
+ expect(parsedNode.album?.photoCount).toEqual(1);
276
+ expect(parsedNode.album?.coverPhotoNodeUid).toBe('volumeId~coverLinkId');
256
277
  });
257
278
  });
258
279
  });
@@ -75,11 +75,21 @@ export class PhotosNodesAPIService extends NodeAPIServiceBase<
75
75
  };
76
76
  }
77
77
 
78
- if (link.Link.Type === 3) {
78
+ if (link.Link.Type === 3 && link.Album) {
79
79
  return {
80
80
  ...baseNodeMetadata,
81
+ album: {
82
+ photoCount: link.Album.PhotoCount,
83
+ coverPhotoNodeUid: link.Album.CoverLinkID
84
+ ? makeNodeUid(volumeId, link.Album.CoverLinkID)
85
+ : undefined,
86
+ },
81
87
  encryptedCrypto: {
82
88
  ...baseCryptoNodeMetadata,
89
+ folder: {
90
+ armoredExtendedAttributes: link.Album.XAttr || undefined,
91
+ armoredHashKey: link.Album.NodeHashKey as string,
92
+ },
83
93
  },
84
94
  };
85
95
  }
@@ -111,7 +121,8 @@ export class PhotosNodesCache extends NodesCacheBase<DecryptedPhotoNode> {
111
121
  typeof node !== 'object' ||
112
122
  (typeof node.photo !== 'object' && node.photo !== undefined) ||
113
123
  (typeof node.photo?.captureTime !== 'string' && node.folder?.captureTime !== undefined) ||
114
- (typeof node.photo?.albums !== 'object' && node.photo?.albums !== undefined)
124
+ (typeof node.photo?.albums !== 'object' && node.photo?.albums !== undefined) ||
125
+ (typeof node.album !== 'object' && node.album !== undefined)
115
126
  ) {
116
127
  throw new Error(`Invalid node data: ${nodeData}`);
117
128
  }
@@ -167,7 +178,7 @@ export class PhotosNodesAccess extends NodesAccessBase<EncryptedPhotoNode, Decry
167
178
 
168
179
  // This is bug that should not happen.
169
180
  // API cannot provide node without parent or share or album.
170
- 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}`);
171
182
  }
172
183
 
173
184
  protected getDegradedUndecryptableNode(
@@ -190,6 +201,18 @@ export class PhotosNodesAccess extends NodesAccessBase<EncryptedPhotoNode, Decry
190
201
  };
191
202
  }
192
203
 
204
+ if (unparsedNode.type === NodeType.Album) {
205
+ const node = parseNodeBase(this.logger, {
206
+ ...unparsedNode,
207
+ type: NodeType.Folder,
208
+ });
209
+ return {
210
+ ...node,
211
+ album: unparsedNode.album,
212
+ type: NodeType.Album,
213
+ };
214
+ }
215
+
193
216
  return parseNodeBase(this.logger, unparsedNode);
194
217
  }
195
218
  }
@@ -210,6 +233,15 @@ export class PhotosNodesCryptoService extends NodesCryptoService {
210
233
  };
211
234
  }
212
235
 
236
+ if (decryptedNode.node.type === NodeType.Album) {
237
+ return {
238
+ node: {
239
+ ...decryptedNode.node,
240
+ album: encryptedNode.album,
241
+ },
242
+ };
243
+ }
244
+
213
245
  return decryptedNode;
214
246
  }
215
247
  }
@@ -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
+ }