@protontech/drive-sdk 0.9.9 → 0.10.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 (165) hide show
  1. package/dist/crypto/driveCrypto.d.ts +15 -15
  2. package/dist/crypto/driveCrypto.js.map +1 -1
  3. package/dist/crypto/hmac.d.ts +3 -3
  4. package/dist/crypto/hmac.js.map +1 -1
  5. package/dist/crypto/interface.d.ts +45 -25
  6. package/dist/crypto/interface.js.map +1 -1
  7. package/dist/crypto/openPGPCrypto.d.ts +37 -37
  8. package/dist/crypto/openPGPCrypto.js.map +1 -1
  9. package/dist/crypto/utils.d.ts +1 -1
  10. package/dist/interface/index.d.ts +3 -3
  11. package/dist/interface/index.js.map +1 -1
  12. package/dist/interface/nodes.d.ts +8 -0
  13. package/dist/interface/photos.d.ts +18 -1
  14. package/dist/interface/sharing.d.ts +2 -0
  15. package/dist/interface/telemetry.d.ts +1 -0
  16. package/dist/interface/telemetry.js.map +1 -1
  17. package/dist/interface/thumbnail.d.ts +2 -2
  18. package/dist/internal/apiService/apiService.js +25 -12
  19. package/dist/internal/apiService/apiService.js.map +1 -1
  20. package/dist/internal/apiService/apiService.test.js +33 -5
  21. package/dist/internal/apiService/apiService.test.js.map +1 -1
  22. package/dist/internal/apiService/driveTypes.d.ts +2942 -3187
  23. package/dist/internal/apiService/errors.test.js +17 -7
  24. package/dist/internal/apiService/errors.test.js.map +1 -1
  25. package/dist/internal/devices/manager.d.ts +1 -0
  26. package/dist/internal/devices/manager.js +11 -0
  27. package/dist/internal/devices/manager.js.map +1 -1
  28. package/dist/internal/download/apiService.d.ts +1 -1
  29. package/dist/internal/download/cryptoService.d.ts +4 -4
  30. package/dist/internal/download/cryptoService.js.map +1 -1
  31. package/dist/internal/download/fileDownloader.js.map +1 -1
  32. package/dist/internal/download/fileDownloader.test.js.map +1 -1
  33. package/dist/internal/download/thumbnailDownloader.js.map +1 -1
  34. package/dist/internal/nodes/cryptoService.d.ts +4 -4
  35. package/dist/internal/nodes/cryptoService.js +5 -3
  36. package/dist/internal/nodes/cryptoService.js.map +1 -1
  37. package/dist/internal/nodes/cryptoService.test.js.map +1 -1
  38. package/dist/internal/nodes/interface.d.ts +1 -1
  39. package/dist/internal/nodes/nodesManagement.js +0 -1
  40. package/dist/internal/nodes/nodesManagement.js.map +1 -1
  41. package/dist/internal/photos/addToAlbum.d.ts +46 -0
  42. package/dist/internal/photos/addToAlbum.js +257 -0
  43. package/dist/internal/photos/addToAlbum.js.map +1 -0
  44. package/dist/internal/photos/addToAlbum.test.d.ts +1 -0
  45. package/dist/internal/photos/addToAlbum.test.js +409 -0
  46. package/dist/internal/photos/addToAlbum.test.js.map +1 -0
  47. package/dist/internal/photos/albums.d.ts +5 -3
  48. package/dist/internal/photos/albums.js +13 -1
  49. package/dist/internal/photos/albums.js.map +1 -1
  50. package/dist/internal/photos/albums.test.js +2 -1
  51. package/dist/internal/photos/albums.test.js.map +1 -1
  52. package/dist/internal/photos/albumsCrypto.d.ts +20 -3
  53. package/dist/internal/photos/albumsCrypto.js +27 -0
  54. package/dist/internal/photos/albumsCrypto.js.map +1 -1
  55. package/dist/internal/photos/apiService.d.ts +19 -3
  56. package/dist/internal/photos/apiService.js +104 -5
  57. package/dist/internal/photos/apiService.js.map +1 -1
  58. package/dist/internal/photos/apiService.test.d.ts +1 -0
  59. package/dist/internal/photos/apiService.test.js +199 -0
  60. package/dist/internal/photos/apiService.test.js.map +1 -0
  61. package/dist/internal/photos/errors.d.ts +4 -0
  62. package/dist/internal/photos/errors.js +17 -0
  63. package/dist/internal/photos/errors.js.map +1 -0
  64. package/dist/internal/photos/index.js +1 -1
  65. package/dist/internal/photos/index.js.map +1 -1
  66. package/dist/internal/photos/interface.d.ts +15 -1
  67. package/dist/internal/photos/interface.js.map +1 -1
  68. package/dist/internal/photos/nodes.js +32 -2
  69. package/dist/internal/photos/nodes.js.map +1 -1
  70. package/dist/internal/photos/nodes.test.js +25 -5
  71. package/dist/internal/photos/nodes.test.js.map +1 -1
  72. package/dist/internal/photos/upload.d.ts +2 -2
  73. package/dist/internal/photos/upload.js.map +1 -1
  74. package/dist/internal/shares/apiService.js +1 -0
  75. package/dist/internal/shares/apiService.js.map +1 -1
  76. package/dist/internal/shares/interface.d.ts +1 -0
  77. package/dist/internal/sharing/apiService.d.ts +8 -1
  78. package/dist/internal/sharing/apiService.js +23 -1
  79. package/dist/internal/sharing/apiService.js.map +1 -1
  80. package/dist/internal/sharing/cryptoService.js +8 -4
  81. package/dist/internal/sharing/cryptoService.js.map +1 -1
  82. package/dist/internal/sharing/sharingManagement.d.ts +1 -0
  83. package/dist/internal/sharing/sharingManagement.js +15 -2
  84. package/dist/internal/sharing/sharingManagement.js.map +1 -1
  85. package/dist/internal/sharing/sharingManagement.test.js +30 -5
  86. package/dist/internal/sharing/sharingManagement.test.js.map +1 -1
  87. package/dist/internal/sharingPublic/nodes.d.ts +2 -2
  88. package/dist/internal/sharingPublic/nodes.js.map +1 -1
  89. package/dist/internal/upload/apiService.d.ts +5 -5
  90. package/dist/internal/upload/apiService.js.map +1 -1
  91. package/dist/internal/upload/blockVerifier.d.ts +2 -2
  92. package/dist/internal/upload/blockVerifier.js.map +1 -1
  93. package/dist/internal/upload/chunkStreamReader.d.ts +2 -2
  94. package/dist/internal/upload/chunkStreamReader.js.map +1 -1
  95. package/dist/internal/upload/chunkStreamReader.test.js.map +1 -1
  96. package/dist/internal/upload/cryptoService.d.ts +7 -7
  97. package/dist/internal/upload/cryptoService.js.map +1 -1
  98. package/dist/internal/upload/interface.d.ts +6 -6
  99. package/dist/internal/upload/manager.d.ts +1 -1
  100. package/dist/internal/upload/manager.js.map +1 -1
  101. package/dist/internal/upload/streamUploader.d.ts +1 -1
  102. package/dist/internal/utils.d.ts +1 -1
  103. package/dist/protonDriveClient.d.ts +8 -0
  104. package/dist/protonDriveClient.js +11 -0
  105. package/dist/protonDriveClient.js.map +1 -1
  106. package/dist/protonDrivePhotosClient.d.ts +17 -2
  107. package/dist/protonDrivePhotosClient.js +21 -1
  108. package/dist/protonDrivePhotosClient.js.map +1 -1
  109. package/dist/transformers.js +2 -0
  110. package/dist/transformers.js.map +1 -1
  111. package/package.json +4 -4
  112. package/src/crypto/driveCrypto.ts +15 -15
  113. package/src/crypto/hmac.ts +4 -4
  114. package/src/crypto/interface.ts +58 -27
  115. package/src/crypto/openPGPCrypto.ts +26 -26
  116. package/src/interface/index.ts +10 -2
  117. package/src/interface/nodes.ts +1 -0
  118. package/src/interface/photos.ts +19 -1
  119. package/src/interface/sharing.ts +2 -0
  120. package/src/interface/telemetry.ts +1 -0
  121. package/src/interface/thumbnail.ts +2 -2
  122. package/src/internal/apiService/apiService.test.ts +38 -6
  123. package/src/internal/apiService/apiService.ts +33 -12
  124. package/src/internal/apiService/driveTypes.ts +2942 -3187
  125. package/src/internal/devices/manager.ts +14 -0
  126. package/src/internal/download/apiService.ts +1 -1
  127. package/src/internal/download/cryptoService.ts +4 -4
  128. package/src/internal/download/fileDownloader.test.ts +4 -4
  129. package/src/internal/download/fileDownloader.ts +6 -6
  130. package/src/internal/download/thumbnailDownloader.ts +4 -4
  131. package/src/internal/nodes/cryptoService.test.ts +2 -2
  132. package/src/internal/nodes/cryptoService.ts +11 -8
  133. package/src/internal/nodes/interface.ts +1 -1
  134. package/src/internal/nodes/nodesManagement.ts +0 -1
  135. package/src/internal/photos/addToAlbum.test.ts +515 -0
  136. package/src/internal/photos/addToAlbum.ts +341 -0
  137. package/src/internal/photos/albums.test.ts +20 -23
  138. package/src/internal/photos/albums.ts +31 -2
  139. package/src/internal/photos/albumsCrypto.ts +54 -3
  140. package/src/internal/photos/apiService.test.ts +233 -0
  141. package/src/internal/photos/apiService.ts +172 -27
  142. package/src/internal/photos/errors.ts +11 -0
  143. package/src/internal/photos/index.ts +1 -1
  144. package/src/internal/photos/interface.ts +18 -3
  145. package/src/internal/photos/nodes.test.ts +27 -6
  146. package/src/internal/photos/nodes.ts +34 -2
  147. package/src/internal/photos/upload.ts +2 -2
  148. package/src/internal/shares/apiService.ts +1 -0
  149. package/src/internal/shares/interface.ts +1 -0
  150. package/src/internal/sharing/apiService.ts +49 -5
  151. package/src/internal/sharing/cryptoService.ts +10 -4
  152. package/src/internal/sharing/sharingManagement.test.ts +33 -5
  153. package/src/internal/sharing/sharingManagement.ts +28 -6
  154. package/src/internal/sharingPublic/nodes.ts +1 -1
  155. package/src/internal/upload/apiService.ts +5 -5
  156. package/src/internal/upload/blockVerifier.ts +3 -3
  157. package/src/internal/upload/chunkStreamReader.test.ts +7 -7
  158. package/src/internal/upload/chunkStreamReader.ts +3 -3
  159. package/src/internal/upload/cryptoService.ts +9 -9
  160. package/src/internal/upload/interface.ts +6 -6
  161. package/src/internal/upload/manager.ts +2 -2
  162. package/src/internal/upload/streamUploader.ts +1 -1
  163. package/src/protonDriveClient.ts +15 -3
  164. package/src/protonDrivePhotosClient.ts +39 -15
  165. package/src/transformers.ts +2 -0
@@ -0,0 +1,233 @@
1
+ import { DriveAPIService } from '../apiService/apiService';
2
+ import { APICodeError, InvalidRequirementsAPIError } from '../apiService/errors';
3
+ import { PhotosAPIService } from './apiService';
4
+ import { MissingRelatedPhotosError } from './errors';
5
+
6
+ describe('photosAPIService', () => {
7
+ let apiMock: DriveAPIService;
8
+ let api: PhotosAPIService;
9
+
10
+ beforeEach(() => {
11
+ jest.clearAllMocks();
12
+
13
+ // @ts-expect-error Mocking for testing purposes
14
+ apiMock = {
15
+ get: jest.fn(),
16
+ post: jest.fn(),
17
+ put: jest.fn(),
18
+ };
19
+
20
+ api = new PhotosAPIService(apiMock);
21
+ });
22
+
23
+ const albumNodeUid = 'volumeId1~albumNodeId';
24
+
25
+ describe('addPhotosToAlbum', () => {
26
+ const photoPayloads = [
27
+ {
28
+ nodeUid: 'volumeId1~photoNodeId1',
29
+ contentHash: 'contentHash1',
30
+ nameHash: 'nameHash1',
31
+ encryptedName: 'encryptedName1',
32
+ nameSignatureEmail: 'nameSignatureEmail1',
33
+ nodePassphrase: 'nodePassphrase1',
34
+ nodePassphraseSignature: 'nodePassphraseSignature1',
35
+ signatureEmail: 'signatureEmail1',
36
+ relatedPhotos: [
37
+ {
38
+ nodeUid: 'volumeId1~photoNodeId2',
39
+ contentHash: 'contentHash2',
40
+ nameHash: 'nameHash2',
41
+ encryptedName: 'encryptedName2',
42
+ nameSignatureEmail: 'nameSignatureEmail2',
43
+ nodePassphrase: 'nodePassphrase2',
44
+ nodePassphraseSignature: 'nodePassphraseSignature2',
45
+ signatureEmail: 'signatureEmail2',
46
+ },
47
+ ],
48
+ },
49
+ ];
50
+
51
+ it('should add photos to album', async () => {
52
+ apiMock.post = jest.fn().mockResolvedValue({
53
+ Code: 1000,
54
+ Responses: [
55
+ {
56
+ LinkID: 'photoNodeId1',
57
+ Response: {
58
+ Code: 1000,
59
+ },
60
+ },
61
+ ],
62
+ });
63
+
64
+ const result = await Array.fromAsync(api.addPhotosToAlbum(albumNodeUid, photoPayloads));
65
+
66
+ expect(result).toEqual([
67
+ {
68
+ uid: 'volumeId1~photoNodeId1',
69
+ ok: true,
70
+ },
71
+ ]);
72
+ expect(apiMock.post).toHaveBeenCalledWith(
73
+ `drive/photos/volumes/volumeId1/albums/albumNodeId/add-multiple`,
74
+ {
75
+ AlbumData: [
76
+ expect.objectContaining({
77
+ LinkID: 'photoNodeId1',
78
+ Hash: 'nameHash1',
79
+ Name: 'encryptedName1',
80
+ NameSignatureEmail: 'nameSignatureEmail1',
81
+ }),
82
+ expect.objectContaining({
83
+ LinkID: 'photoNodeId2',
84
+ Hash: 'nameHash2',
85
+ Name: 'encryptedName2',
86
+ NameSignatureEmail: 'nameSignatureEmail2',
87
+ }),
88
+ ],
89
+ },
90
+ undefined,
91
+ );
92
+ });
93
+
94
+ it('should return MissingRelatedPhotosError if related photos are missing', async () => {
95
+ apiMock.post = jest.fn().mockResolvedValue({
96
+ Code: 1000,
97
+ Responses: [
98
+ {
99
+ LinkID: 'photoNodeId1',
100
+ Response: {
101
+ Code: 2000,
102
+ Details: {
103
+ Missing: ['photoNodeId3'],
104
+ },
105
+ },
106
+ },
107
+ ],
108
+ });
109
+
110
+ const result = await Array.fromAsync(api.addPhotosToAlbum(albumNodeUid, photoPayloads));
111
+
112
+ expect(result).toEqual([
113
+ {
114
+ uid: 'volumeId1~photoNodeId1',
115
+ ok: false,
116
+ error: new MissingRelatedPhotosError([]),
117
+ },
118
+ ]);
119
+ expect((result[0] as any).error.missingNodeUids).toEqual(['volumeId1~photoNodeId3']);
120
+ });
121
+
122
+ it('should return error for unknown error', async () => {
123
+ apiMock.post = jest.fn().mockResolvedValue({
124
+ Code: 1000,
125
+ Responses: [
126
+ {
127
+ LinkID: 'photoNodeId1',
128
+ Response: {
129
+ Code: 3000,
130
+ Error: 'Some error',
131
+ },
132
+ },
133
+ ],
134
+ });
135
+
136
+ const result = await Array.fromAsync(api.addPhotosToAlbum(albumNodeUid, photoPayloads));
137
+
138
+ expect(result).toEqual([
139
+ {
140
+ uid: 'volumeId1~photoNodeId1',
141
+ ok: false,
142
+ error: new APICodeError('Some error', 3000),
143
+ },
144
+ ]);
145
+ });
146
+ });
147
+
148
+ describe('copyPhotoToAlbum', () => {
149
+ const photoPayloads = [
150
+ {
151
+ nodeUid: 'volumeId2~photoNodeId1',
152
+ contentHash: 'contentHash1',
153
+ nameHash: 'nameHash1',
154
+ encryptedName: 'encryptedName1',
155
+ nameSignatureEmail: 'nameSignatureEmail1',
156
+ nodePassphrase: 'nodePassphrase1',
157
+ nodePassphraseSignature: 'nodePassphraseSignature1',
158
+ signatureEmail: 'signatureEmail1',
159
+ relatedPhotos: [
160
+ {
161
+ nodeUid: 'volumeId2~photoNodeId2',
162
+ contentHash: 'contentHash2',
163
+ nameHash: 'nameHash2',
164
+ encryptedName: 'encryptedName2',
165
+ nameSignatureEmail: 'nameSignatureEmail2',
166
+ nodePassphrase: 'nodePassphrase2',
167
+ nodePassphraseSignature: 'nodePassphraseSignature2',
168
+ signatureEmail: 'signatureEmail2',
169
+ },
170
+ ],
171
+ },
172
+ ];
173
+
174
+ it('should copy photo to album', async () => {
175
+ apiMock.post = jest.fn().mockResolvedValue({
176
+ Code: 1000,
177
+ LinkID: 'photoNodeId1',
178
+ });
179
+
180
+ const result = await api.copyPhotoToAlbum(albumNodeUid, photoPayloads[0]);
181
+
182
+ expect(result).toEqual('volumeId1~photoNodeId1');
183
+ expect(apiMock.post).toHaveBeenCalledWith(
184
+ `drive/volumes/volumeId2/links/photoNodeId1/copy`,
185
+ expect.objectContaining({
186
+ TargetVolumeID: 'volumeId1',
187
+ TargetParentLinkID: 'albumNodeId',
188
+ Hash: 'nameHash1',
189
+ Name: 'encryptedName1',
190
+ Photos: {
191
+ ContentHash: 'contentHash1',
192
+ RelatedPhotos: expect.arrayContaining([
193
+ expect.objectContaining({
194
+ LinkID: 'photoNodeId2',
195
+ Hash: 'nameHash2',
196
+ Name: 'encryptedName2',
197
+ }),
198
+ ]),
199
+ },
200
+ }),
201
+ undefined,
202
+ );
203
+ });
204
+
205
+ it('should return MissingRelatedPhotosError if related photos are missing', async () => {
206
+ apiMock.post = jest.fn().mockRejectedValue(new InvalidRequirementsAPIError(
207
+ 'Missing related photos',
208
+ 2000,
209
+ {
210
+ Missing: ['photoNodeId3'],
211
+ },
212
+ ));
213
+
214
+ const promise = api.copyPhotoToAlbum(albumNodeUid, photoPayloads[0]);
215
+
216
+ await expect(promise).rejects.toThrow(MissingRelatedPhotosError);
217
+ try {
218
+ await promise;
219
+ } catch (error) {
220
+ expect((error as MissingRelatedPhotosError).missingNodeUids).toEqual(['volumeId2~photoNodeId3']);
221
+ }
222
+ });
223
+
224
+ it('should return error for unknown error', async () => {
225
+ const error = new APICodeError('Some error', 3000);
226
+ apiMock.post = jest.fn().mockRejectedValue(error);
227
+
228
+ const promise = api.copyPhotoToAlbum(albumNodeUid, photoPayloads[0]);
229
+
230
+ await expect(promise).rejects.toThrow(error);
231
+ });
232
+ });
233
+ });
@@ -1,12 +1,13 @@
1
1
  import { c } from 'ttag';
2
2
 
3
3
  import { ValidationError } from '../../errors';
4
- import { NodeResult } from '../../interface';
5
- import { APICodeError, DriveAPIService, drivePaths } from '../apiService';
4
+ import { NodeResultWithError } from '../../interface';
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
- import { AlbumItem } from './interface';
9
+ import { MissingRelatedPhotosError } from './errors';
10
+ import { AddToAlbumEncryptedPhotoPayload, AlbumItem } from './interface';
10
11
 
11
12
  type GetPhotoShareResponse =
12
13
  drivePaths['/drive/v2/shares/photos']['get']['responses']['200']['content']['application/json'];
@@ -46,6 +47,20 @@ type PostPhotoDuplicateRequest = Extract<
46
47
  type PostPhotoDuplicateResponse =
47
48
  drivePaths['/drive/volumes/{volumeID}/photos/duplicates']['post']['responses']['200']['content']['application/json'];
48
49
 
50
+ type PostAddPhotosToAlbumRequest = Extract<
51
+ drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}/add-multiple']['post']['requestBody'],
52
+ { content: object }
53
+ >['content']['application/json'];
54
+ type PostAddPhotosToAlbumResponse =
55
+ drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}/add-multiple']['post']['responses']['200']['content']['application/json'];
56
+
57
+ type PostCopyLinkRequest = Extract<
58
+ drivePaths['/drive/volumes/{volumeID}/links/{linkID}/copy']['post']['requestBody'],
59
+ { content: object }
60
+ >['content']['application/json'];
61
+ type PostCopyLinkResponse =
62
+ drivePaths['/drive/volumes/{volumeID}/links/{linkID}/copy']['post']['responses']['200']['content']['application/json'];
63
+
49
64
  type PostRemovePhotosFromAlbumRequest = Extract<
50
65
  drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}/remove-multiple']['post']['requestBody'],
51
66
  { content: object }
@@ -186,10 +201,7 @@ export class PhotosAPIService {
186
201
  }
187
202
  }
188
203
 
189
- async *iterateAlbumChildren(
190
- albumNodeUid: string,
191
- signal?: AbortSignal,
192
- ): AsyncGenerator<AlbumItem> {
204
+ async *iterateAlbumChildren(albumNodeUid: string, signal?: AbortSignal): AsyncGenerator<AlbumItem> {
193
205
  const { volumeId, nodeId: linkId } = splitNodeUid(albumNodeUid);
194
206
  let anchor = '';
195
207
  while (true) {
@@ -289,20 +301,17 @@ export class PhotosAPIService {
289
301
  ): Promise<void> {
290
302
  const { volumeId, nodeId: linkId } = splitNodeUid(albumNodeUid);
291
303
  const coverLinkId = coverPhotoNodeUid ? splitNodeUid(coverPhotoNodeUid).nodeId : undefined;
292
- await this.apiService.put<PutUpdateAlbumRequest, void>(
293
- `drive/photos/volumes/${volumeId}/albums/${linkId}`,
294
- {
295
- CoverLinkID: coverLinkId,
296
- Link: updatedName
297
- ? {
298
- Name: updatedName.encryptedName,
299
- Hash: updatedName.hash,
300
- OriginalHash: updatedName.originalHash,
301
- NameSignatureEmail: updatedName.nameSignatureEmail,
302
- }
303
- : null,
304
- },
305
- );
304
+ await this.apiService.put<PutUpdateAlbumRequest, void>(`drive/photos/volumes/${volumeId}/albums/${linkId}`, {
305
+ CoverLinkID: coverLinkId,
306
+ Link: updatedName
307
+ ? {
308
+ Name: updatedName.encryptedName,
309
+ Hash: updatedName.hash,
310
+ OriginalHash: updatedName.originalHash,
311
+ NameSignatureEmail: updatedName.nameSignatureEmail,
312
+ }
313
+ : null,
314
+ });
306
315
  }
307
316
 
308
317
  async deleteAlbum(albumNodeUid: string, options: { force?: boolean } = {}): Promise<void> {
@@ -319,11 +328,147 @@ export class PhotosAPIService {
319
328
  }
320
329
  }
321
330
 
331
+ /**
332
+ * Add photos from the same volume to an album.
333
+ *
334
+ * To add photos from different volumes, use the {@link copyPhotoToAlbum} method.
335
+ *
336
+ * In the future, these two methods will be merged into a single one.
337
+ */
338
+ async *addPhotosToAlbum(
339
+ albumNodeUid: string,
340
+ photoPayloads: AddToAlbumEncryptedPhotoPayload[],
341
+ signal?: AbortSignal,
342
+ ): AsyncGenerator<NodeResultWithError> {
343
+ const { volumeId, nodeId: albumLinkId } = splitNodeUid(albumNodeUid);
344
+
345
+ const allPhotoPayloads = photoPayloads.flatMap((photoPayload) => [
346
+ photoPayload,
347
+ ...(photoPayload.relatedPhotos || []),
348
+ ]);
349
+ const allPhotoData = allPhotoPayloads.map((photoPayload) => {
350
+ const { nodeId } = splitNodeUid(photoPayload.nodeUid);
351
+ return {
352
+ LinkID: nodeId,
353
+ Hash: photoPayload.nameHash,
354
+ Name: photoPayload.encryptedName,
355
+ NameSignatureEmail: photoPayload.nameSignatureEmail,
356
+ NodePassphrase: photoPayload.nodePassphrase,
357
+ ContentHash: photoPayload.contentHash,
358
+ };
359
+ });
360
+
361
+ const response = await this.apiService.post<PostAddPhotosToAlbumRequest, PostAddPhotosToAlbumResponse>(
362
+ `drive/photos/volumes/${volumeId}/albums/${albumLinkId}/add-multiple`,
363
+ {
364
+ AlbumData: allPhotoData,
365
+ },
366
+ signal,
367
+ );
368
+
369
+ const errors = new Map<string, Error>();
370
+
371
+ for (const r of response.Responses || []) {
372
+ // @ts-expect-error - API definition is not correct.
373
+ const details = r as {
374
+ LinkID: string;
375
+ Response: {
376
+ Code: number;
377
+ Error?: string;
378
+ Details: { Missing: string[] };
379
+ };
380
+ };
381
+
382
+ if (!details.Response.Code || !isCodeOk(details.Response.Code) || details.Response?.Error) {
383
+ const nodeUid = makeNodeUid(volumeId, details.LinkID);
384
+
385
+ if (details.Response.Details?.Missing) {
386
+ const missingNodeUids = details.Response.Details.Missing.map((linkId) =>
387
+ makeNodeUid(volumeId, linkId),
388
+ );
389
+ errors.set(nodeUid, new MissingRelatedPhotosError(missingNodeUids));
390
+ } else {
391
+ errors.set(
392
+ nodeUid,
393
+ new APICodeError(details.Response.Error || c('Error').t`Unknown error`, details.Response.Code),
394
+ );
395
+ }
396
+ }
397
+ }
398
+
399
+ for (const photoPayload of photoPayloads) {
400
+ const uid = photoPayload.nodeUid;
401
+ const error = errors.get(uid);
402
+ if (error) {
403
+ yield { uid, ok: false, error };
404
+ } else {
405
+ yield { uid, ok: true };
406
+ }
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Copy a photo to a shared album on a different volume.
412
+ *
413
+ * To add photos from the same volume to an album, use the {@link addPhotosToAlbum} method.
414
+ *
415
+ * In the future, these two methods will be merged into a single one.
416
+ */
417
+ async copyPhotoToAlbum(
418
+ albumNodeUid: string,
419
+ payload: AddToAlbumEncryptedPhotoPayload,
420
+ signal?: AbortSignal,
421
+ ): Promise<string> {
422
+ const { volumeId: sourceVolumeId, nodeId: sourceLinkId } = splitNodeUid(payload.nodeUid);
423
+ const { volumeId: targetVolumeId, nodeId: targetAlbumLinkId } = splitNodeUid(albumNodeUid);
424
+
425
+ try {
426
+ const response = await this.apiService.post<PostCopyLinkRequest, PostCopyLinkResponse>(
427
+ `drive/volumes/${sourceVolumeId}/links/${sourceLinkId}/copy`,
428
+ {
429
+ TargetVolumeID: targetVolumeId,
430
+ TargetParentLinkID: targetAlbumLinkId,
431
+ Hash: payload.nameHash,
432
+ Name: payload.encryptedName,
433
+ NameSignatureEmail: payload.nameSignatureEmail,
434
+ NodePassphrase: payload.nodePassphrase,
435
+ // @ts-expect-error: API accepts NodePassphraseSignature as optional.
436
+ NodePassphraseSignature: payload.nodePassphraseSignature,
437
+ // @ts-expect-error: API accepts SignatureEmail as optional.
438
+ SignatureEmail: payload.signatureEmail,
439
+ Photos: {
440
+ 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
+ })) || [],
449
+ },
450
+ },
451
+ signal,
452
+ );
453
+ return makeNodeUid(targetVolumeId, response.LinkID);
454
+ } catch (error) {
455
+ if (error instanceof InvalidRequirementsAPIError) {
456
+ const { Missing: missingLinkIds } = error.details as { Missing: string[] };
457
+ if (missingLinkIds.length > 0) {
458
+ throw new MissingRelatedPhotosError(
459
+ missingLinkIds.map((linkId) => makeNodeUid(sourceVolumeId, linkId)),
460
+ );
461
+ }
462
+ }
463
+ throw error;
464
+ }
465
+ }
466
+
322
467
  async *removePhotosFromAlbum(
323
468
  albumNodeUid: string,
324
469
  photoNodeUids: string[],
325
470
  signal?: AbortSignal,
326
- ): AsyncGenerator<NodeResult> {
471
+ ): AsyncGenerator<NodeResultWithError> {
327
472
  const { volumeId, nodeId: albumLinkId } = splitNodeUid(albumNodeUid);
328
473
 
329
474
  const batchSize = 50;
@@ -331,7 +476,7 @@ export class PhotosAPIService {
331
476
  for (const photoNodeUidsBatch of batch(photoNodeUids, batchSize)) {
332
477
  const linkIds = photoNodeUidsBatch.map((nodeUid) => splitNodeUid(nodeUid).nodeId);
333
478
 
334
- let errorMessage: string | undefined;
479
+ let error: Error | undefined;
335
480
  try {
336
481
  await this.apiService.post<PostRemovePhotosFromAlbumRequest, PostRemovePhotosFromAlbumResponse>(
337
482
  `drive/photos/volumes/${volumeId}/albums/${albumLinkId}/remove-multiple`,
@@ -340,14 +485,14 @@ export class PhotosAPIService {
340
485
  },
341
486
  signal,
342
487
  );
343
- } catch (error) {
344
- errorMessage = error instanceof Error ? error.message : c('Error').t`Unknown error`;
488
+ } catch (e) {
489
+ error = e instanceof Error ? e : new Error(c('Error').t`Unknown error`);
345
490
  }
346
491
 
347
492
  // The API does not return individual results for each photo.
348
493
  for (const uid of photoNodeUidsBatch) {
349
- if (errorMessage) {
350
- yield { uid, ok: false, error: errorMessage };
494
+ if (error) {
495
+ yield { uid, ok: false, error };
351
496
  } else {
352
497
  yield { uid, ok: true };
353
498
  }
@@ -0,0 +1,11 @@
1
+ import { c } from 'ttag';
2
+
3
+ export class MissingRelatedPhotosError extends Error {
4
+ constructor(public missingNodeUids: string[]) {
5
+ // We do not want to leak the technical details of the error to the user.
6
+ // When this error happens, it is retried by the SDK, so very likely the
7
+ // user will not see this error unless the operation fails twice in a row.
8
+ super(c('Error').t`Operation failed, try again later`);
9
+ this.name = 'MissingRelatedPhotosError';
10
+ }
11
+ }
@@ -60,7 +60,7 @@ export function initPhotosModule(
60
60
  photoShares,
61
61
  nodesService,
62
62
  );
63
- const albums = new Albums(api, albumsCryptoService, photoShares, nodesService);
63
+ const albums = new Albums(telemetry, api, albumsCryptoService, photoShares, nodesService);
64
64
 
65
65
  return {
66
66
  timeline,
@@ -1,5 +1,5 @@
1
1
  import { PrivateKey } from '../../crypto';
2
- import { MetricVolumeType, PhotoAttributes } from '../../interface';
2
+ import { MetricVolumeType, PhotoAttributes, AlbumAttributes } from '../../interface';
3
3
  import { DecryptedNode, EncryptedNode, DecryptedUnparsedNode } from '../nodes/interface';
4
4
  import { EncryptedShare } from '../shares';
5
5
 
@@ -25,14 +25,17 @@ export interface SharesService {
25
25
 
26
26
  export type EncryptedPhotoNode = EncryptedNode & {
27
27
  photo?: EcnryptedPhotoAttributes;
28
+ album?: AlbumAttributes;
28
29
  };
29
30
 
30
31
  export type DecryptedUnparsedPhotoNode = DecryptedUnparsedNode & {
31
32
  photo?: PhotoAttributes;
33
+ album?: AlbumAttributes;
32
34
  };
33
35
 
34
36
  export type DecryptedPhotoNode = DecryptedNode & {
35
37
  photo?: PhotoAttributes;
38
+ album?: AlbumAttributes;
36
39
  };
37
40
 
38
41
  export type EcnryptedPhotoAttributes = Omit<PhotoAttributes, 'albums'> & {
@@ -47,12 +50,12 @@ export type TimelineItem = {
47
50
  nodeUid: string;
48
51
  captureTime: Date;
49
52
  tags: PhotoTag[];
50
- }
53
+ };
51
54
 
52
55
  export type AlbumItem = {
53
56
  nodeUid: string;
54
57
  captureTime: Date;
55
- }
58
+ };
56
59
 
57
60
  export enum PhotoTag {
58
61
  Favorites = 0,
@@ -66,3 +69,15 @@ export enum PhotoTag {
66
69
  Panoramas = 8,
67
70
  Raw = 9,
68
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
+ };
@@ -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
  });