@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
@@ -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,14 @@
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, PhotoTag } 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 { MissingRelatedPhotosError } from './errors';
9
10
  import { AlbumItem } from './interface';
11
+ import { TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder';
10
12
 
11
13
  type GetPhotoShareResponse =
12
14
  drivePaths['/drive/v2/shares/photos']['get']['responses']['200']['content']['application/json'];
@@ -46,6 +48,20 @@ type PostPhotoDuplicateRequest = Extract<
46
48
  type PostPhotoDuplicateResponse =
47
49
  drivePaths['/drive/volumes/{volumeID}/photos/duplicates']['post']['responses']['200']['content']['application/json'];
48
50
 
51
+ type PostAddPhotosToAlbumRequest = Extract<
52
+ drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}/add-multiple']['post']['requestBody'],
53
+ { content: object }
54
+ >['content']['application/json'];
55
+ type PostAddPhotosToAlbumResponse =
56
+ drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}/add-multiple']['post']['responses']['200']['content']['application/json'];
57
+
58
+ type PostCopyLinkRequest = Extract<
59
+ drivePaths['/drive/volumes/{volumeID}/links/{linkID}/copy']['post']['requestBody'],
60
+ { content: object }
61
+ >['content']['application/json'];
62
+ type PostCopyLinkResponse =
63
+ drivePaths['/drive/volumes/{volumeID}/links/{linkID}/copy']['post']['responses']['200']['content']['application/json'];
64
+
49
65
  type PostRemovePhotosFromAlbumRequest = Extract<
50
66
  drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}/remove-multiple']['post']['requestBody'],
51
67
  { content: object }
@@ -53,6 +69,19 @@ type PostRemovePhotosFromAlbumRequest = Extract<
53
69
  type PostRemovePhotosFromAlbumResponse =
54
70
  drivePaths['/drive/photos/volumes/{volumeID}/albums/{linkID}/remove-multiple']['post']['responses']['200']['content']['application/json'];
55
71
 
72
+ type PostAddPhotoTagsRequest = Extract<
73
+ drivePaths['/drive/photos/volumes/{volumeID}/links/{linkID}/tags']['post']['requestBody'],
74
+ { content: object }
75
+ >['content']['application/json'];
76
+ type PostRemovePhotoTagsRequest = Extract<
77
+ drivePaths['/drive/photos/volumes/{volumeID}/links/{linkID}/tags']['delete']['requestBody'],
78
+ { content: object }
79
+ >['content']['application/json'];
80
+ type PostFavoritePhotoRequest = Extract<
81
+ drivePaths['/drive/photos/volumes/{volumeID}/links/{linkID}/favorite']['post']['requestBody'],
82
+ { content: object }
83
+ >['content']['application/json'];
84
+
56
85
  const ALBUM_CONTAINS_PHOTOS_NOT_IN_TIMELINE_ERROR_CODE = 200302;
57
86
 
58
87
  /**
@@ -186,10 +215,7 @@ export class PhotosAPIService {
186
215
  }
187
216
  }
188
217
 
189
- async *iterateAlbumChildren(
190
- albumNodeUid: string,
191
- signal?: AbortSignal,
192
- ): AsyncGenerator<AlbumItem> {
218
+ async *iterateAlbumChildren(albumNodeUid: string, signal?: AbortSignal): AsyncGenerator<AlbumItem> {
193
219
  const { volumeId, nodeId: linkId } = splitNodeUid(albumNodeUid);
194
220
  let anchor = '';
195
221
  while (true) {
@@ -289,20 +315,17 @@ export class PhotosAPIService {
289
315
  ): Promise<void> {
290
316
  const { volumeId, nodeId: linkId } = splitNodeUid(albumNodeUid);
291
317
  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
- );
318
+ await this.apiService.put<PutUpdateAlbumRequest, void>(`drive/photos/volumes/${volumeId}/albums/${linkId}`, {
319
+ CoverLinkID: coverLinkId,
320
+ Link: updatedName
321
+ ? {
322
+ Name: updatedName.encryptedName,
323
+ Hash: updatedName.hash,
324
+ OriginalHash: updatedName.originalHash,
325
+ NameSignatureEmail: updatedName.nameSignatureEmail,
326
+ }
327
+ : null,
328
+ });
306
329
  }
307
330
 
308
331
  async deleteAlbum(albumNodeUid: string, options: { force?: boolean } = {}): Promise<void> {
@@ -319,11 +342,143 @@ export class PhotosAPIService {
319
342
  }
320
343
  }
321
344
 
345
+ /**
346
+ * Add photos from the same volume to an album.
347
+ *
348
+ * To add photos from different volumes, use the {@link copyPhotoToAlbum} method.
349
+ *
350
+ * In the future, these two methods will be merged into a single one.
351
+ */
352
+ async *addPhotosToAlbum(
353
+ albumNodeUid: string,
354
+ photoPayloads: TransferEncryptedPhotoPayload[],
355
+ signal?: AbortSignal,
356
+ ): AsyncGenerator<NodeResultWithError> {
357
+ const { volumeId, nodeId: albumLinkId } = splitNodeUid(albumNodeUid);
358
+
359
+ const allPhotoPayloads = photoPayloads.flatMap((photoPayload) => [photoPayload, ...photoPayload.relatedPhotos]);
360
+ const allPhotoData = allPhotoPayloads.map((photoPayload) => {
361
+ const { nodeId } = splitNodeUid(photoPayload.nodeUid);
362
+ return {
363
+ LinkID: nodeId,
364
+ Hash: photoPayload.nameHash,
365
+ Name: photoPayload.encryptedName,
366
+ NameSignatureEmail: photoPayload.nameSignatureEmail,
367
+ NodePassphrase: photoPayload.nodePassphrase,
368
+ ContentHash: photoPayload.contentHash,
369
+ };
370
+ });
371
+
372
+ const response = await this.apiService.post<PostAddPhotosToAlbumRequest, PostAddPhotosToAlbumResponse>(
373
+ `drive/photos/volumes/${volumeId}/albums/${albumLinkId}/add-multiple`,
374
+ {
375
+ AlbumData: allPhotoData,
376
+ },
377
+ signal,
378
+ );
379
+
380
+ const errors = new Map<string, Error>();
381
+
382
+ for (const r of response.Responses || []) {
383
+ // @ts-expect-error - API definition is not correct.
384
+ const details = r as {
385
+ LinkID: string;
386
+ Response: {
387
+ Code: number;
388
+ Error?: string;
389
+ Details: { Missing: string[] };
390
+ };
391
+ };
392
+
393
+ if (!details.Response.Code || !isCodeOk(details.Response.Code) || details.Response?.Error) {
394
+ const nodeUid = makeNodeUid(volumeId, details.LinkID);
395
+
396
+ if (details.Response.Details?.Missing) {
397
+ const missingNodeUids = details.Response.Details.Missing.map((linkId) =>
398
+ makeNodeUid(volumeId, linkId),
399
+ );
400
+ errors.set(nodeUid, new MissingRelatedPhotosError(missingNodeUids));
401
+ } else {
402
+ errors.set(
403
+ nodeUid,
404
+ new APICodeError(details.Response.Error || c('Error').t`Unknown error`, details.Response.Code),
405
+ );
406
+ }
407
+ }
408
+ }
409
+
410
+ for (const photoPayload of photoPayloads) {
411
+ const uid = photoPayload.nodeUid;
412
+ const error = errors.get(uid);
413
+ if (error) {
414
+ yield { uid, ok: false, error };
415
+ } else {
416
+ yield { uid, ok: true };
417
+ }
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Copy a photo to a shared album on a different volume.
423
+ *
424
+ * To add photos from the same volume to an album, use the {@link addPhotosToAlbum} method.
425
+ *
426
+ * In the future, these two methods will be merged into a single one.
427
+ */
428
+ async copyPhotoToAlbum(
429
+ albumNodeUid: string,
430
+ payload: TransferEncryptedPhotoPayload,
431
+ signal?: AbortSignal,
432
+ ): Promise<string> {
433
+ const { volumeId: sourceVolumeId, nodeId: sourceLinkId } = splitNodeUid(payload.nodeUid);
434
+ const { volumeId: targetVolumeId, nodeId: targetAlbumLinkId } = splitNodeUid(albumNodeUid);
435
+
436
+ try {
437
+ const response = await this.apiService.post<PostCopyLinkRequest, PostCopyLinkResponse>(
438
+ `drive/volumes/${sourceVolumeId}/links/${sourceLinkId}/copy`,
439
+ {
440
+ TargetVolumeID: targetVolumeId,
441
+ TargetParentLinkID: targetAlbumLinkId,
442
+ Hash: payload.nameHash,
443
+ Name: payload.encryptedName,
444
+ NameSignatureEmail: payload.nameSignatureEmail,
445
+ NodePassphrase: payload.nodePassphrase,
446
+ // @ts-expect-error: API accepts NodePassphraseSignature as optional.
447
+ NodePassphraseSignature: payload.nodePassphraseSignature,
448
+ // @ts-expect-error: API accepts SignatureEmail as optional.
449
+ SignatureEmail: payload.signatureEmail,
450
+ Photos: {
451
+ ContentHash: payload.contentHash,
452
+ RelatedPhotos: payload.relatedPhotos.map((related) => ({
453
+ LinkID: splitNodeUid(related.nodeUid).nodeId,
454
+ Hash: related.nameHash,
455
+ Name: related.encryptedName,
456
+ NodePassphrase: related.nodePassphrase,
457
+ ContentHash: related.contentHash,
458
+ })),
459
+ },
460
+ },
461
+ signal,
462
+ );
463
+ return makeNodeUid(targetVolumeId, response.LinkID);
464
+ } catch (error) {
465
+ if (error instanceof InvalidRequirementsAPIError) {
466
+ const { Missing: missingLinkIds } = error.details as { Missing: string[] };
467
+ if (missingLinkIds.length > 0) {
468
+ throw new MissingRelatedPhotosError(
469
+ missingLinkIds.map((linkId) => makeNodeUid(sourceVolumeId, linkId)),
470
+ );
471
+ }
472
+ }
473
+ throw error;
474
+ }
475
+ }
476
+
322
477
  async *removePhotosFromAlbum(
323
478
  albumNodeUid: string,
324
479
  photoNodeUids: string[],
325
480
  signal?: AbortSignal,
326
- ): AsyncGenerator<NodeResult> {
481
+ ): AsyncGenerator<NodeResultWithError> {
327
482
  const { volumeId, nodeId: albumLinkId } = splitNodeUid(albumNodeUid);
328
483
 
329
484
  const batchSize = 50;
@@ -331,7 +486,7 @@ export class PhotosAPIService {
331
486
  for (const photoNodeUidsBatch of batch(photoNodeUids, batchSize)) {
332
487
  const linkIds = photoNodeUidsBatch.map((nodeUid) => splitNodeUid(nodeUid).nodeId);
333
488
 
334
- let errorMessage: string | undefined;
489
+ let error: Error | undefined;
335
490
  try {
336
491
  await this.apiService.post<PostRemovePhotosFromAlbumRequest, PostRemovePhotosFromAlbumResponse>(
337
492
  `drive/photos/volumes/${volumeId}/albums/${albumLinkId}/remove-multiple`,
@@ -340,18 +495,65 @@ export class PhotosAPIService {
340
495
  },
341
496
  signal,
342
497
  );
343
- } catch (error) {
344
- errorMessage = error instanceof Error ? error.message : c('Error').t`Unknown error`;
498
+ } catch (e) {
499
+ error = e instanceof Error ? e : new Error(c('Error').t`Unknown error`);
345
500
  }
346
501
 
347
502
  // The API does not return individual results for each photo.
348
503
  for (const uid of photoNodeUidsBatch) {
349
- if (errorMessage) {
350
- yield { uid, ok: false, error: errorMessage };
504
+ if (error) {
505
+ yield { uid, ok: false, error };
351
506
  } else {
352
507
  yield { uid, ok: true };
353
508
  }
354
509
  }
355
510
  }
356
511
  }
512
+
513
+ async addPhotoTags(nodeUid: string, tags: PhotoTag[]): Promise<void> {
514
+ const { volumeId, nodeId: linkId } = splitNodeUid(nodeUid);
515
+ await this.apiService.post<PostAddPhotoTagsRequest, { Code: number }>(
516
+ `drive/photos/volumes/${volumeId}/links/${linkId}/tags`,
517
+ { Tags: tags },
518
+ );
519
+ }
520
+
521
+ async removePhotoTags(nodeUid: string, tags: PhotoTag[]): Promise<void> {
522
+ const { volumeId, nodeId: linkId } = splitNodeUid(nodeUid);
523
+ await this.apiService.delete<PostRemovePhotoTagsRequest, { Code: number }>(
524
+ `drive/photos/volumes/${volumeId}/links/${linkId}/tags`,
525
+ { Tags: tags },
526
+ );
527
+ }
528
+
529
+ async setPhotoFavorite(nodeUid: string, payload?: TransferEncryptedPhotoPayload): Promise<void> {
530
+ const { volumeId, nodeId: linkId } = splitNodeUid(nodeUid);
531
+ const requestBody = payload
532
+ ? {
533
+ PhotoData: {
534
+ Hash: payload.nameHash,
535
+ Name: payload.encryptedName,
536
+ NameSignatureEmail: payload.nameSignatureEmail,
537
+ NodePassphrase: payload.nodePassphrase,
538
+ ContentHash: payload.contentHash,
539
+ NodePassphraseSignature: payload.nodePassphraseSignature ?? null,
540
+ SignatureEmail: payload.signatureEmail ?? null,
541
+ RelatedPhotos: payload.relatedPhotos.map((related) => ({
542
+ LinkID: splitNodeUid(related.nodeUid).nodeId,
543
+ Hash: related.nameHash,
544
+ Name: related.encryptedName,
545
+ NameSignatureEmail: related.nameSignatureEmail,
546
+ NodePassphrase: related.nodePassphrase,
547
+ ContentHash: related.contentHash,
548
+ NodePassphraseSignature: related.nodePassphraseSignature ?? null,
549
+ SignatureEmail: related.signatureEmail ?? null,
550
+ })),
551
+ },
552
+ }
553
+ : undefined;
554
+ await this.apiService.post<PostFavoritePhotoRequest, { Code: number }>(
555
+ `drive/photos/volumes/${volumeId}/links/${linkId}/favorite`,
556
+ requestBody,
557
+ );
558
+ }
357
559
  }
@@ -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
+ }
@@ -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(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 } 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,18 +24,21 @@ export interface SharesService {
24
24
  }
25
25
 
26
26
  export type EncryptedPhotoNode = EncryptedNode & {
27
- photo?: EcnryptedPhotoAttributes;
27
+ photo?: EncryptedPhotoAttributes;
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
- export type EcnryptedPhotoAttributes = Omit<PhotoAttributes, 'albums'> & {
41
+ export type EncryptedPhotoAttributes = Omit<PhotoAttributes, 'albums'> & {
39
42
  contentHash?: string;
40
43
  albums: (PhotoAttributes['albums'][0] & {
41
44
  nameHash?: string;
@@ -47,22 +50,9 @@ 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
- }
56
-
57
- export enum PhotoTag {
58
- Favorites = 0,
59
- Screenshots = 1,
60
- Videos = 2,
61
- LivePhotos = 3,
62
- MotionPhotos = 4,
63
- Selfies = 5,
64
- Portraits = 6,
65
- Bursts = 7,
66
- Panoramas = 8,
67
- Raw = 9,
68
- }
58
+ };