@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.
- package/dist/crypto/driveCrypto.d.ts +15 -15
- package/dist/crypto/driveCrypto.js.map +1 -1
- package/dist/crypto/hmac.d.ts +3 -3
- package/dist/crypto/hmac.js.map +1 -1
- package/dist/crypto/interface.d.ts +45 -25
- package/dist/crypto/interface.js.map +1 -1
- package/dist/crypto/openPGPCrypto.d.ts +37 -37
- package/dist/crypto/openPGPCrypto.js.map +1 -1
- package/dist/crypto/utils.d.ts +1 -1
- package/dist/interface/index.d.ts +3 -3
- package/dist/interface/index.js.map +1 -1
- package/dist/interface/nodes.d.ts +8 -0
- package/dist/interface/photos.d.ts +18 -1
- package/dist/interface/sharing.d.ts +2 -0
- package/dist/interface/telemetry.d.ts +1 -0
- package/dist/interface/telemetry.js.map +1 -1
- package/dist/interface/thumbnail.d.ts +2 -2
- package/dist/internal/apiService/apiService.js +25 -12
- package/dist/internal/apiService/apiService.js.map +1 -1
- package/dist/internal/apiService/apiService.test.js +33 -5
- package/dist/internal/apiService/apiService.test.js.map +1 -1
- package/dist/internal/apiService/driveTypes.d.ts +2942 -3187
- package/dist/internal/apiService/errors.test.js +17 -7
- package/dist/internal/apiService/errors.test.js.map +1 -1
- package/dist/internal/devices/manager.d.ts +1 -0
- package/dist/internal/devices/manager.js +11 -0
- package/dist/internal/devices/manager.js.map +1 -1
- package/dist/internal/download/apiService.d.ts +1 -1
- package/dist/internal/download/cryptoService.d.ts +4 -4
- package/dist/internal/download/cryptoService.js.map +1 -1
- package/dist/internal/download/fileDownloader.js.map +1 -1
- package/dist/internal/download/fileDownloader.test.js.map +1 -1
- package/dist/internal/download/thumbnailDownloader.js.map +1 -1
- package/dist/internal/nodes/cryptoService.d.ts +4 -4
- package/dist/internal/nodes/cryptoService.js +5 -3
- package/dist/internal/nodes/cryptoService.js.map +1 -1
- package/dist/internal/nodes/cryptoService.test.js.map +1 -1
- package/dist/internal/nodes/interface.d.ts +1 -1
- package/dist/internal/nodes/nodesManagement.js +0 -1
- package/dist/internal/nodes/nodesManagement.js.map +1 -1
- package/dist/internal/photos/addToAlbum.d.ts +46 -0
- package/dist/internal/photos/addToAlbum.js +257 -0
- package/dist/internal/photos/addToAlbum.js.map +1 -0
- package/dist/internal/photos/addToAlbum.test.d.ts +1 -0
- package/dist/internal/photos/addToAlbum.test.js +409 -0
- package/dist/internal/photos/addToAlbum.test.js.map +1 -0
- package/dist/internal/photos/albums.d.ts +5 -3
- package/dist/internal/photos/albums.js +13 -1
- package/dist/internal/photos/albums.js.map +1 -1
- package/dist/internal/photos/albums.test.js +2 -1
- package/dist/internal/photos/albums.test.js.map +1 -1
- package/dist/internal/photos/albumsCrypto.d.ts +20 -3
- package/dist/internal/photos/albumsCrypto.js +27 -0
- package/dist/internal/photos/albumsCrypto.js.map +1 -1
- package/dist/internal/photos/apiService.d.ts +19 -3
- package/dist/internal/photos/apiService.js +104 -5
- package/dist/internal/photos/apiService.js.map +1 -1
- package/dist/internal/photos/apiService.test.d.ts +1 -0
- package/dist/internal/photos/apiService.test.js +199 -0
- package/dist/internal/photos/apiService.test.js.map +1 -0
- package/dist/internal/photos/errors.d.ts +4 -0
- package/dist/internal/photos/errors.js +17 -0
- package/dist/internal/photos/errors.js.map +1 -0
- package/dist/internal/photos/index.js +1 -1
- package/dist/internal/photos/index.js.map +1 -1
- package/dist/internal/photos/interface.d.ts +15 -1
- package/dist/internal/photos/interface.js.map +1 -1
- package/dist/internal/photos/nodes.js +32 -2
- package/dist/internal/photos/nodes.js.map +1 -1
- package/dist/internal/photos/nodes.test.js +25 -5
- package/dist/internal/photos/nodes.test.js.map +1 -1
- package/dist/internal/photos/upload.d.ts +2 -2
- package/dist/internal/photos/upload.js.map +1 -1
- package/dist/internal/shares/apiService.js +1 -0
- package/dist/internal/shares/apiService.js.map +1 -1
- package/dist/internal/shares/interface.d.ts +1 -0
- package/dist/internal/sharing/apiService.d.ts +8 -1
- package/dist/internal/sharing/apiService.js +23 -1
- package/dist/internal/sharing/apiService.js.map +1 -1
- package/dist/internal/sharing/cryptoService.js +8 -4
- package/dist/internal/sharing/cryptoService.js.map +1 -1
- package/dist/internal/sharing/sharingManagement.d.ts +1 -0
- package/dist/internal/sharing/sharingManagement.js +15 -2
- package/dist/internal/sharing/sharingManagement.js.map +1 -1
- package/dist/internal/sharing/sharingManagement.test.js +30 -5
- package/dist/internal/sharing/sharingManagement.test.js.map +1 -1
- package/dist/internal/sharingPublic/nodes.d.ts +2 -2
- package/dist/internal/sharingPublic/nodes.js.map +1 -1
- package/dist/internal/upload/apiService.d.ts +5 -5
- package/dist/internal/upload/apiService.js.map +1 -1
- package/dist/internal/upload/blockVerifier.d.ts +2 -2
- package/dist/internal/upload/blockVerifier.js.map +1 -1
- package/dist/internal/upload/chunkStreamReader.d.ts +2 -2
- package/dist/internal/upload/chunkStreamReader.js.map +1 -1
- package/dist/internal/upload/chunkStreamReader.test.js.map +1 -1
- package/dist/internal/upload/cryptoService.d.ts +7 -7
- package/dist/internal/upload/cryptoService.js.map +1 -1
- package/dist/internal/upload/interface.d.ts +6 -6
- package/dist/internal/upload/manager.d.ts +1 -1
- package/dist/internal/upload/manager.js.map +1 -1
- package/dist/internal/upload/streamUploader.d.ts +1 -1
- package/dist/internal/utils.d.ts +1 -1
- package/dist/protonDriveClient.d.ts +8 -0
- package/dist/protonDriveClient.js +11 -0
- package/dist/protonDriveClient.js.map +1 -1
- package/dist/protonDrivePhotosClient.d.ts +17 -2
- package/dist/protonDrivePhotosClient.js +21 -1
- package/dist/protonDrivePhotosClient.js.map +1 -1
- package/dist/transformers.js +2 -0
- package/dist/transformers.js.map +1 -1
- package/package.json +4 -4
- package/src/crypto/driveCrypto.ts +15 -15
- package/src/crypto/hmac.ts +4 -4
- package/src/crypto/interface.ts +58 -27
- package/src/crypto/openPGPCrypto.ts +26 -26
- package/src/interface/index.ts +10 -2
- package/src/interface/nodes.ts +1 -0
- package/src/interface/photos.ts +19 -1
- package/src/interface/sharing.ts +2 -0
- package/src/interface/telemetry.ts +1 -0
- package/src/interface/thumbnail.ts +2 -2
- package/src/internal/apiService/apiService.test.ts +38 -6
- package/src/internal/apiService/apiService.ts +33 -12
- package/src/internal/apiService/driveTypes.ts +2942 -3187
- package/src/internal/devices/manager.ts +14 -0
- package/src/internal/download/apiService.ts +1 -1
- package/src/internal/download/cryptoService.ts +4 -4
- package/src/internal/download/fileDownloader.test.ts +4 -4
- package/src/internal/download/fileDownloader.ts +6 -6
- package/src/internal/download/thumbnailDownloader.ts +4 -4
- package/src/internal/nodes/cryptoService.test.ts +2 -2
- package/src/internal/nodes/cryptoService.ts +11 -8
- package/src/internal/nodes/interface.ts +1 -1
- package/src/internal/nodes/nodesManagement.ts +0 -1
- package/src/internal/photos/addToAlbum.test.ts +515 -0
- package/src/internal/photos/addToAlbum.ts +341 -0
- package/src/internal/photos/albums.test.ts +20 -23
- package/src/internal/photos/albums.ts +31 -2
- package/src/internal/photos/albumsCrypto.ts +54 -3
- package/src/internal/photos/apiService.test.ts +233 -0
- package/src/internal/photos/apiService.ts +172 -27
- package/src/internal/photos/errors.ts +11 -0
- package/src/internal/photos/index.ts +1 -1
- package/src/internal/photos/interface.ts +18 -3
- package/src/internal/photos/nodes.test.ts +27 -6
- package/src/internal/photos/nodes.ts +34 -2
- package/src/internal/photos/upload.ts +2 -2
- package/src/internal/shares/apiService.ts +1 -0
- package/src/internal/shares/interface.ts +1 -0
- package/src/internal/sharing/apiService.ts +49 -5
- package/src/internal/sharing/cryptoService.ts +10 -4
- package/src/internal/sharing/sharingManagement.test.ts +33 -5
- package/src/internal/sharing/sharingManagement.ts +28 -6
- package/src/internal/sharingPublic/nodes.ts +1 -1
- package/src/internal/upload/apiService.ts +5 -5
- package/src/internal/upload/blockVerifier.ts +3 -3
- package/src/internal/upload/chunkStreamReader.test.ts +7 -7
- package/src/internal/upload/chunkStreamReader.ts +3 -3
- package/src/internal/upload/cryptoService.ts +9 -9
- package/src/internal/upload/interface.ts +6 -6
- package/src/internal/upload/manager.ts +2 -2
- package/src/internal/upload/streamUploader.ts +1 -1
- package/src/protonDriveClient.ts +15 -3
- package/src/protonDrivePhotosClient.ts +39 -15
- 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 {
|
|
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 {
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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<
|
|
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
|
|
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 (
|
|
344
|
-
|
|
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 (
|
|
350
|
-
yield { uid, ok: false, error
|
|
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
|
-
|
|
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
|
});
|