@protontech/drive-sdk 0.13.0 → 0.14.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/interface/events.d.ts +1 -1
- package/dist/interface/telemetry.d.ts +9 -4
- package/dist/interface/telemetry.js +1 -0
- package/dist/interface/telemetry.js.map +1 -1
- package/dist/internal/apiService/driveTypes.d.ts +52 -0
- package/dist/internal/download/telemetry.js +6 -1
- package/dist/internal/download/telemetry.js.map +1 -1
- package/dist/internal/download/telemetry.test.js +5 -0
- package/dist/internal/download/telemetry.test.js.map +1 -1
- package/dist/internal/events/index.js +2 -2
- package/dist/internal/events/index.js.map +1 -1
- package/dist/internal/events/interface.d.ts +1 -1
- package/dist/internal/nodes/cryptoReporter.js +2 -2
- package/dist/internal/nodes/cryptoReporter.js.map +1 -1
- package/dist/internal/photos/addToAlbum.d.ts +6 -0
- package/dist/internal/photos/addToAlbum.js +1 -0
- package/dist/internal/photos/addToAlbum.js.map +1 -1
- package/dist/internal/photos/albumsManager.d.ts +4 -1
- package/dist/internal/photos/albumsManager.js +22 -2
- package/dist/internal/photos/albumsManager.js.map +1 -1
- package/dist/internal/photos/albumsManager.test.js +41 -1
- package/dist/internal/photos/albumsManager.test.js.map +1 -1
- package/dist/internal/photos/apiService.d.ts +1 -0
- package/dist/internal/photos/apiService.js +59 -5
- package/dist/internal/photos/apiService.js.map +1 -1
- package/dist/internal/photos/apiService.test.js +137 -0
- package/dist/internal/photos/apiService.test.js.map +1 -1
- package/dist/internal/photos/errors.d.ts +5 -0
- package/dist/internal/photos/errors.js +10 -1
- package/dist/internal/photos/errors.js.map +1 -1
- package/dist/internal/photos/index.js +1 -1
- package/dist/internal/photos/index.js.map +1 -1
- package/dist/internal/photos/photosManager.d.ts +1 -0
- package/dist/internal/photos/photosManager.js +42 -4
- package/dist/internal/photos/photosManager.js.map +1 -1
- package/dist/internal/photos/photosManager.test.js +35 -0
- package/dist/internal/photos/photosManager.test.js.map +1 -1
- package/dist/internal/photos/photosTransferPayloadBuilder.d.ts +1 -0
- package/dist/internal/photos/photosTransferPayloadBuilder.js +1 -0
- package/dist/internal/photos/photosTransferPayloadBuilder.js.map +1 -1
- package/dist/internal/sharing/apiService.js +1 -1
- package/dist/internal/sharing/apiService.js.map +1 -1
- package/dist/internal/upload/manager.js +3 -2
- package/dist/internal/upload/manager.js.map +1 -1
- package/dist/internal/upload/manager.test.js +1 -1
- package/dist/internal/upload/manager.test.js.map +1 -1
- package/dist/internal/upload/telemetry.js +4 -1
- package/dist/internal/upload/telemetry.js.map +1 -1
- package/dist/internal/upload/telemetry.test.js +6 -0
- package/dist/internal/upload/telemetry.test.js.map +1 -1
- package/dist/protonDrivePhotosClient.d.ts +4 -3
- package/dist/protonDrivePhotosClient.js +3 -3
- package/dist/protonDrivePhotosClient.js.map +1 -1
- package/dist/telemetry.d.ts +1 -0
- package/dist/telemetry.js +21 -0
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.d.ts +1 -0
- package/dist/telemetry.test.js +37 -0
- package/dist/telemetry.test.js.map +1 -0
- package/package.json +1 -1
- package/src/interface/events.ts +1 -1
- package/src/interface/telemetry.ts +9 -4
- package/src/internal/apiService/driveTypes.ts +52 -0
- package/src/internal/download/telemetry.test.ts +5 -0
- package/src/internal/download/telemetry.ts +7 -3
- package/src/internal/events/index.ts +2 -2
- package/src/internal/events/interface.ts +1 -1
- package/src/internal/nodes/cryptoReporter.ts +4 -2
- package/src/internal/photos/addToAlbum.ts +1 -1
- package/src/internal/photos/albumsManager.test.ts +61 -1
- package/src/internal/photos/albumsManager.ts +23 -3
- package/src/internal/photos/apiService.test.ts +155 -0
- package/src/internal/photos/apiService.ts +92 -3
- package/src/internal/photos/errors.ts +11 -0
- package/src/internal/photos/index.ts +1 -1
- package/src/internal/photos/photosManager.test.ts +43 -1
- package/src/internal/photos/photosManager.ts +61 -3
- package/src/internal/photos/photosTransferPayloadBuilder.ts +3 -1
- package/src/internal/sharing/apiService.ts +5 -5
- package/src/internal/upload/manager.test.ts +1 -1
- package/src/internal/upload/manager.ts +3 -2
- package/src/internal/upload/telemetry.test.ts +6 -0
- package/src/internal/upload/telemetry.ts +5 -3
- package/src/protonDrivePhotosClient.ts +4 -4
- package/src/telemetry.test.ts +40 -0
- package/src/telemetry.ts +22 -0
|
@@ -28,6 +28,7 @@ describe('photosAPIService', () => {
|
|
|
28
28
|
nodeUid: 'volumeId1~photoNodeId1',
|
|
29
29
|
contentHash: 'contentHash1',
|
|
30
30
|
nameHash: 'nameHash1',
|
|
31
|
+
originalNameHash: 'originalNameHash1',
|
|
31
32
|
encryptedName: 'encryptedName1',
|
|
32
33
|
nameSignatureEmail: 'nameSignatureEmail1',
|
|
33
34
|
nodePassphrase: 'nodePassphrase1',
|
|
@@ -38,6 +39,7 @@ describe('photosAPIService', () => {
|
|
|
38
39
|
nodeUid: 'volumeId1~photoNodeId2',
|
|
39
40
|
contentHash: 'contentHash2',
|
|
40
41
|
nameHash: 'nameHash2',
|
|
42
|
+
originalNameHash: 'originalNameHash2',
|
|
41
43
|
encryptedName: 'encryptedName2',
|
|
42
44
|
nameSignatureEmail: 'nameSignatureEmail2',
|
|
43
45
|
nodePassphrase: 'nodePassphrase2',
|
|
@@ -151,6 +153,7 @@ describe('photosAPIService', () => {
|
|
|
151
153
|
nodeUid: 'volumeId2~photoNodeId1',
|
|
152
154
|
contentHash: 'contentHash1',
|
|
153
155
|
nameHash: 'nameHash1',
|
|
156
|
+
originalNameHash: 'originalNameHash1',
|
|
154
157
|
encryptedName: 'encryptedName1',
|
|
155
158
|
nameSignatureEmail: 'nameSignatureEmail1',
|
|
156
159
|
nodePassphrase: 'nodePassphrase1',
|
|
@@ -161,6 +164,7 @@ describe('photosAPIService', () => {
|
|
|
161
164
|
nodeUid: 'volumeId2~photoNodeId2',
|
|
162
165
|
contentHash: 'contentHash2',
|
|
163
166
|
nameHash: 'nameHash2',
|
|
167
|
+
originalNameHash: 'originalNameHash2',
|
|
164
168
|
encryptedName: 'encryptedName2',
|
|
165
169
|
nameSignatureEmail: 'nameSignatureEmail2',
|
|
166
170
|
nodePassphrase: 'nodePassphrase2',
|
|
@@ -230,4 +234,155 @@ describe('photosAPIService', () => {
|
|
|
230
234
|
await expect(promise).rejects.toThrow(error);
|
|
231
235
|
});
|
|
232
236
|
});
|
|
237
|
+
|
|
238
|
+
describe('transferPhotos', () => {
|
|
239
|
+
const photoPayloads = [
|
|
240
|
+
{
|
|
241
|
+
nodeUid: 'volumeId1~photoNodeId1',
|
|
242
|
+
contentHash: 'contentHash1',
|
|
243
|
+
nameHash: 'nameHash1',
|
|
244
|
+
originalNameHash: 'originalNameHash1',
|
|
245
|
+
encryptedName: 'encryptedName1',
|
|
246
|
+
nameSignatureEmail: 'nameSignatureEmail1',
|
|
247
|
+
nodePassphrase: 'nodePassphrase1',
|
|
248
|
+
nodePassphraseSignature: 'nodePassphraseSignature1',
|
|
249
|
+
signatureEmail: 'signatureEmail1',
|
|
250
|
+
relatedPhotos: [
|
|
251
|
+
{
|
|
252
|
+
nodeUid: 'volumeId1~photoNodeId2',
|
|
253
|
+
contentHash: 'contentHash2',
|
|
254
|
+
nameHash: 'nameHash2',
|
|
255
|
+
originalNameHash: 'originalNameHash2',
|
|
256
|
+
encryptedName: 'encryptedName2',
|
|
257
|
+
nameSignatureEmail: 'nameSignatureEmail2',
|
|
258
|
+
nodePassphrase: 'nodePassphrase2',
|
|
259
|
+
nodePassphraseSignature: 'nodePassphraseSignature2',
|
|
260
|
+
signatureEmail: 'signatureEmail2',
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
},
|
|
264
|
+
];
|
|
265
|
+
|
|
266
|
+
it('should transfer photos', async () => {
|
|
267
|
+
apiMock.put = jest.fn().mockResolvedValue({
|
|
268
|
+
Code: 1000,
|
|
269
|
+
Responses: [
|
|
270
|
+
{
|
|
271
|
+
LinkID: 'photoNodeId1',
|
|
272
|
+
Response: {
|
|
273
|
+
Code: 1000,
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const result = await Array.fromAsync(api.transferPhotos(albumNodeUid, photoPayloads));
|
|
280
|
+
|
|
281
|
+
expect(result).toEqual([
|
|
282
|
+
{
|
|
283
|
+
uid: 'volumeId1~photoNodeId1',
|
|
284
|
+
ok: true,
|
|
285
|
+
},
|
|
286
|
+
]);
|
|
287
|
+
expect(apiMock.put).toHaveBeenCalledWith(
|
|
288
|
+
`drive/photos/volumes/volumeId1/links/transfer-multiple`,
|
|
289
|
+
{
|
|
290
|
+
ParentLinkID: 'albumNodeId',
|
|
291
|
+
Links: [
|
|
292
|
+
expect.objectContaining({
|
|
293
|
+
LinkID: 'photoNodeId1',
|
|
294
|
+
Hash: 'nameHash1',
|
|
295
|
+
OriginalHash: 'originalNameHash1',
|
|
296
|
+
Name: 'encryptedName1',
|
|
297
|
+
NodePassphrase: 'nodePassphrase1',
|
|
298
|
+
ContentHash: 'contentHash1',
|
|
299
|
+
NodePassphraseSignature: null,
|
|
300
|
+
}),
|
|
301
|
+
expect.objectContaining({
|
|
302
|
+
LinkID: 'photoNodeId2',
|
|
303
|
+
Hash: 'nameHash2',
|
|
304
|
+
OriginalHash: 'originalNameHash2',
|
|
305
|
+
Name: 'encryptedName2',
|
|
306
|
+
NodePassphrase: 'nodePassphrase2',
|
|
307
|
+
ContentHash: 'contentHash2',
|
|
308
|
+
NodePassphraseSignature: null,
|
|
309
|
+
}),
|
|
310
|
+
],
|
|
311
|
+
NameSignatureEmail: 'nameSignatureEmail1',
|
|
312
|
+
SignatureEmail: null,
|
|
313
|
+
},
|
|
314
|
+
undefined,
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('should return MissingRelatedPhotosError if related photos are missing', async () => {
|
|
319
|
+
apiMock.put = jest.fn().mockResolvedValue({
|
|
320
|
+
Code: 1000,
|
|
321
|
+
Responses: [
|
|
322
|
+
{
|
|
323
|
+
LinkID: 'photoNodeId1',
|
|
324
|
+
Response: {
|
|
325
|
+
Code: 2000,
|
|
326
|
+
Details: {
|
|
327
|
+
Missing: ['photoNodeId3'],
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
],
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const result = await Array.fromAsync(api.transferPhotos(albumNodeUid, photoPayloads));
|
|
335
|
+
|
|
336
|
+
expect(result).toEqual([
|
|
337
|
+
{
|
|
338
|
+
uid: 'volumeId1~photoNodeId1',
|
|
339
|
+
ok: false,
|
|
340
|
+
error: new MissingRelatedPhotosError([]),
|
|
341
|
+
},
|
|
342
|
+
]);
|
|
343
|
+
expect((result[0] as any).error.missingNodeUids).toEqual(['volumeId1~photoNodeId3']);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('should return error for unknown error', async () => {
|
|
347
|
+
apiMock.put = jest.fn().mockResolvedValue({
|
|
348
|
+
Code: 1000,
|
|
349
|
+
Responses: [
|
|
350
|
+
{
|
|
351
|
+
LinkID: 'photoNodeId1',
|
|
352
|
+
Response: {
|
|
353
|
+
Code: 3000,
|
|
354
|
+
Error: 'Some error',
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const result = await Array.fromAsync(api.transferPhotos(albumNodeUid, photoPayloads));
|
|
361
|
+
|
|
362
|
+
expect(result).toEqual([
|
|
363
|
+
{
|
|
364
|
+
uid: 'volumeId1~photoNodeId1',
|
|
365
|
+
ok: false,
|
|
366
|
+
error: new APICodeError('Some error', 3000),
|
|
367
|
+
},
|
|
368
|
+
]);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('should throw if name signature emails differ', async () => {
|
|
372
|
+
const mixedPayloads = [
|
|
373
|
+
photoPayloads[0],
|
|
374
|
+
{
|
|
375
|
+
...photoPayloads[0],
|
|
376
|
+
nodeUid: 'volumeId1~photoNodeIdOther',
|
|
377
|
+
nameSignatureEmail: 'other@example.com',
|
|
378
|
+
relatedPhotos: [],
|
|
379
|
+
},
|
|
380
|
+
];
|
|
381
|
+
|
|
382
|
+
await expect(Array.fromAsync(api.transferPhotos(albumNodeUid, mixedPayloads))).rejects.toThrow(
|
|
383
|
+
'All photos must have the same name signature email',
|
|
384
|
+
);
|
|
385
|
+
expect(apiMock.put).not.toHaveBeenCalled();
|
|
386
|
+
});
|
|
387
|
+
});
|
|
233
388
|
});
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { c } from 'ttag';
|
|
2
2
|
|
|
3
|
-
import { ValidationError } from '../../errors';
|
|
4
3
|
import { NodeResultWithError, PhotoTag } from '../../interface';
|
|
5
4
|
import { APICodeError, DriveAPIService, drivePaths, InvalidRequirementsAPIError, isCodeOk } from '../apiService';
|
|
6
5
|
import { batch } from '../batch';
|
|
7
6
|
import { EncryptedRootShare, EncryptedShareCrypto, ShareType } from '../shares/interface';
|
|
8
7
|
import { makeNodeUid, splitNodeUid } from '../uids';
|
|
9
|
-
import { MissingRelatedPhotosError } from './errors';
|
|
8
|
+
import { AlbumContainsPhotosNotInTimelineError, MissingRelatedPhotosError } from './errors';
|
|
10
9
|
import { AlbumItem } from './interface';
|
|
11
10
|
import { TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder';
|
|
12
11
|
|
|
@@ -82,6 +81,13 @@ type PostFavoritePhotoRequest = Extract<
|
|
|
82
81
|
{ content: object }
|
|
83
82
|
>['content']['application/json'];
|
|
84
83
|
|
|
84
|
+
type PutTransferPhotosRequest = Extract<
|
|
85
|
+
drivePaths['/drive/photos/volumes/{volumeID}/links/transfer-multiple']['put']['requestBody'],
|
|
86
|
+
{ content: object }
|
|
87
|
+
>['content']['application/json'];
|
|
88
|
+
type PutTransferPhotosResponse =
|
|
89
|
+
drivePaths['/drive/photos/volumes/{volumeID}/links/transfer-multiple']['put']['responses']['200']['content']['application/json'];
|
|
90
|
+
|
|
85
91
|
const ALBUM_CONTAINS_PHOTOS_NOT_IN_TIMELINE_ERROR_CODE = 200302;
|
|
86
92
|
|
|
87
93
|
/**
|
|
@@ -336,7 +342,9 @@ export class PhotosAPIService {
|
|
|
336
342
|
);
|
|
337
343
|
} catch (error) {
|
|
338
344
|
if (error instanceof APICodeError && error.code === ALBUM_CONTAINS_PHOTOS_NOT_IN_TIMELINE_ERROR_CODE) {
|
|
339
|
-
|
|
345
|
+
const childLinkIds = (error.debug as { ChildLinkIDs: string[] })?.ChildLinkIDs || [];
|
|
346
|
+
const nodeUids = childLinkIds.map((linkId) => makeNodeUid(volumeId, linkId));
|
|
347
|
+
throw new AlbumContainsPhotosNotInTimelineError(error.message, error.code, nodeUids);
|
|
340
348
|
}
|
|
341
349
|
throw error;
|
|
342
350
|
}
|
|
@@ -556,4 +564,85 @@ export class PhotosAPIService {
|
|
|
556
564
|
requestBody,
|
|
557
565
|
);
|
|
558
566
|
}
|
|
567
|
+
|
|
568
|
+
async *transferPhotos(
|
|
569
|
+
newParentNodeUid: string,
|
|
570
|
+
photoPayloads: TransferEncryptedPhotoPayload[],
|
|
571
|
+
signal?: AbortSignal,
|
|
572
|
+
): AsyncGenerator<NodeResultWithError> {
|
|
573
|
+
const { volumeId, nodeId: newParentNodeId } = splitNodeUid(newParentNodeUid);
|
|
574
|
+
|
|
575
|
+
if (photoPayloads.length === 0) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const nameSignatureEmail = photoPayloads[0].nameSignatureEmail;
|
|
580
|
+
if (photoPayloads.some((photoPayload) => photoPayload.nameSignatureEmail !== nameSignatureEmail)) {
|
|
581
|
+
throw new Error('All photos must have the same name signature email');
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const allPhotoPayloads = photoPayloads.flatMap((photoPayload) => [photoPayload, ...photoPayload.relatedPhotos]);
|
|
585
|
+
const allLinksData = allPhotoPayloads.map((photoPayload) => {
|
|
586
|
+
const { nodeId } = splitNodeUid(photoPayload.nodeUid);
|
|
587
|
+
return {
|
|
588
|
+
LinkID: nodeId,
|
|
589
|
+
Hash: photoPayload.nameHash,
|
|
590
|
+
OriginalHash: photoPayload.originalNameHash!,
|
|
591
|
+
Name: photoPayload.encryptedName,
|
|
592
|
+
NodePassphrase: photoPayload.nodePassphrase,
|
|
593
|
+
ContentHash: photoPayload.contentHash,
|
|
594
|
+
NodePassphraseSignature: null, // Required when moving an anonymous node.
|
|
595
|
+
};
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
const response = await this.apiService.put<PutTransferPhotosRequest, PutTransferPhotosResponse>(
|
|
599
|
+
`drive/photos/volumes/${volumeId}/links/transfer-multiple`,
|
|
600
|
+
{
|
|
601
|
+
ParentLinkID: newParentNodeId,
|
|
602
|
+
Links: allLinksData,
|
|
603
|
+
NameSignatureEmail: nameSignatureEmail,
|
|
604
|
+
SignatureEmail: null, // Required when moving an anonymous node.
|
|
605
|
+
},
|
|
606
|
+
signal,
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
const errors = new Map<string, Error>();
|
|
610
|
+
|
|
611
|
+
for (const r of response.Responses || []) {
|
|
612
|
+
const details = r as {
|
|
613
|
+
LinkID: string;
|
|
614
|
+
Response: {
|
|
615
|
+
Code: number;
|
|
616
|
+
Error?: string;
|
|
617
|
+
Details: { Missing: string[] };
|
|
618
|
+
};
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
if (!details.Response.Code || !isCodeOk(details.Response.Code) || details.Response?.Error) {
|
|
622
|
+
const nodeUid = makeNodeUid(volumeId, details.LinkID);
|
|
623
|
+
|
|
624
|
+
if (details.Response.Details?.Missing) {
|
|
625
|
+
const missingNodeUids = details.Response.Details.Missing.map((linkId) =>
|
|
626
|
+
makeNodeUid(volumeId, linkId),
|
|
627
|
+
);
|
|
628
|
+
errors.set(nodeUid, new MissingRelatedPhotosError(missingNodeUids));
|
|
629
|
+
} else {
|
|
630
|
+
errors.set(
|
|
631
|
+
nodeUid,
|
|
632
|
+
new APICodeError(details.Response.Error || c('Error').t`Unknown error`, details.Response.Code),
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
for (const photoPayload of photoPayloads) {
|
|
639
|
+
const uid = photoPayload.nodeUid;
|
|
640
|
+
const error = errors.get(uid);
|
|
641
|
+
if (error) {
|
|
642
|
+
yield { uid, ok: false, error };
|
|
643
|
+
} else {
|
|
644
|
+
yield { uid, ok: true };
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
559
648
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { c } from 'ttag';
|
|
2
2
|
|
|
3
|
+
import { ValidationError } from '../../errors';
|
|
4
|
+
|
|
3
5
|
export class MissingRelatedPhotosError extends Error {
|
|
4
6
|
constructor(public missingNodeUids: string[]) {
|
|
5
7
|
// We do not want to leak the technical details of the error to the user.
|
|
@@ -9,3 +11,12 @@ export class MissingRelatedPhotosError extends Error {
|
|
|
9
11
|
this.name = 'MissingRelatedPhotosError';
|
|
10
12
|
}
|
|
11
13
|
}
|
|
14
|
+
|
|
15
|
+
export class AlbumContainsPhotosNotInTimelineError extends ValidationError {
|
|
16
|
+
public readonly photosOnlyInAlbumNodeUids: string[];
|
|
17
|
+
|
|
18
|
+
constructor(message: string, code: number, photosOnlyInAlbumNodeUids: string[]) {
|
|
19
|
+
super(message, code);
|
|
20
|
+
this.photosOnlyInAlbumNodeUids = photosOnlyInAlbumNodeUids;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -62,8 +62,8 @@ export function initPhotosModule(
|
|
|
62
62
|
photoShares,
|
|
63
63
|
nodesService,
|
|
64
64
|
);
|
|
65
|
-
const albums = new AlbumsManager(telemetry, api, albumsCryptoService, photoShares, nodesService);
|
|
66
65
|
const photos = new PhotosManager(telemetry.getLogger('photos-update'), api, albumsCryptoService, nodesService);
|
|
66
|
+
const albums = new AlbumsManager(telemetry, api, albumsCryptoService, photoShares, nodesService, photos);
|
|
67
67
|
|
|
68
68
|
return {
|
|
69
69
|
timeline,
|
|
@@ -5,6 +5,7 @@ import { PhotosAPIService } from './apiService';
|
|
|
5
5
|
import { AlbumsCryptoService } from './albumsCrypto';
|
|
6
6
|
import { PhotosNodesAccess } from './nodes';
|
|
7
7
|
import { DecryptedPhotoNode } from './interface';
|
|
8
|
+
import { MissingRelatedPhotosError } from './errors';
|
|
8
9
|
|
|
9
10
|
function createMockPhotoNode(uid: string, overrides: Partial<DecryptedPhotoNode> = {}): DecryptedPhotoNode {
|
|
10
11
|
return {
|
|
@@ -46,9 +47,19 @@ async function collectUpdateResults(manager: PhotosManager, photos: UpdatePhotoS
|
|
|
46
47
|
return results;
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
async function collectSaveToTimelineResults(manager: PhotosManager, nodeUids: string[], signal?: AbortSignal) {
|
|
51
|
+
const results = [];
|
|
52
|
+
for await (const result of manager.saveToTimeline(nodeUids, signal)) {
|
|
53
|
+
results.push(result);
|
|
54
|
+
}
|
|
55
|
+
return results;
|
|
56
|
+
}
|
|
57
|
+
|
|
49
58
|
describe('PhotosManager', () => {
|
|
50
59
|
let logger: ReturnType<typeof getMockLogger>;
|
|
51
|
-
let apiService: jest.Mocked<
|
|
60
|
+
let apiService: jest.Mocked<
|
|
61
|
+
Pick<PhotosAPIService, 'addPhotoTags' | 'removePhotoTags' | 'setPhotoFavorite' | 'transferPhotos'>
|
|
62
|
+
>;
|
|
52
63
|
let cryptoService: jest.Mocked<Pick<AlbumsCryptoService, 'encryptPhotoForAlbum'>>;
|
|
53
64
|
let nodesService: jest.Mocked<
|
|
54
65
|
Pick<
|
|
@@ -80,6 +91,7 @@ describe('PhotosManager', () => {
|
|
|
80
91
|
addPhotoTags: jest.fn().mockResolvedValue(undefined),
|
|
81
92
|
removePhotoTags: jest.fn().mockResolvedValue(undefined),
|
|
82
93
|
setPhotoFavorite: jest.fn().mockResolvedValue(undefined),
|
|
94
|
+
transferPhotos: jest.fn().mockImplementation(async function* () {}),
|
|
83
95
|
};
|
|
84
96
|
|
|
85
97
|
cryptoService = {
|
|
@@ -263,4 +275,34 @@ describe('PhotosManager', () => {
|
|
|
263
275
|
});
|
|
264
276
|
});
|
|
265
277
|
});
|
|
278
|
+
|
|
279
|
+
describe('saveToTimeline', () => {
|
|
280
|
+
it('re-queues once on MissingRelatedPhotosError then succeeds without yielding the retry error', async () => {
|
|
281
|
+
const missingRelatedUid = 'volume1~related1';
|
|
282
|
+
let transferCall = 0;
|
|
283
|
+
apiService.transferPhotos.mockImplementation(async function* (_rootUid, payloads) {
|
|
284
|
+
transferCall++;
|
|
285
|
+
for (const payload of payloads) {
|
|
286
|
+
if (transferCall === 1) {
|
|
287
|
+
yield {
|
|
288
|
+
uid: payload.nodeUid,
|
|
289
|
+
ok: false,
|
|
290
|
+
error: new MissingRelatedPhotosError([missingRelatedUid]),
|
|
291
|
+
};
|
|
292
|
+
} else {
|
|
293
|
+
yield { uid: payload.nodeUid, ok: true };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const results = await collectSaveToTimelineResults(manager, ['volume1~photo1']);
|
|
299
|
+
|
|
300
|
+
expect(results).toEqual([{ uid: 'volume1~photo1', ok: true }]);
|
|
301
|
+
expect(apiService.transferPhotos).toHaveBeenCalledTimes(2);
|
|
302
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
303
|
+
`Missing related photos for saving volume1~photo1, re-queuing: ${missingRelatedUid}`,
|
|
304
|
+
);
|
|
305
|
+
expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo1');
|
|
306
|
+
});
|
|
307
|
+
});
|
|
266
308
|
});
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import { c } from 'ttag';
|
|
2
2
|
|
|
3
|
+
import { AbortError } from '../../errors';
|
|
3
4
|
import { Logger, NodeResultWithError, PhotoTag } from '../../interface';
|
|
5
|
+
import { batch } from '../batch';
|
|
4
6
|
import { PhotosAPIService } from './apiService';
|
|
5
7
|
import { PhotoAlreadyInTargetError, PhotoTransferPayloadBuilder, TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder';
|
|
6
8
|
import { PhotosNodesAccess } from './nodes';
|
|
7
9
|
import { AlbumsCryptoService } from './albumsCrypto';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
10
|
+
import { createBatches } from './addToAlbum';
|
|
11
|
+
import { MissingRelatedPhotosError } from './errors';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The number of photos that are loaded in parallel to prepare the payloads.
|
|
15
|
+
*/
|
|
16
|
+
const BATCH_LOADING_SIZE = 20;
|
|
11
17
|
|
|
12
18
|
export type UpdatePhotoSettings = {
|
|
13
19
|
nodeUid: string;
|
|
@@ -31,6 +37,58 @@ export class PhotosManager {
|
|
|
31
37
|
this.payloadBuilder = new PhotoTransferPayloadBuilder(albumsCryptoService, nodesService);
|
|
32
38
|
}
|
|
33
39
|
|
|
40
|
+
async *saveToTimeline(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<NodeResultWithError> {
|
|
41
|
+
const rootNode = await this.nodesService.getVolumeRootFolder();
|
|
42
|
+
const volumeRootKeys = await this.nodesService.getNodeKeys(rootNode.uid);
|
|
43
|
+
const signingKeys = await this.nodesService.getNodeSigningKeys({ nodeUid: rootNode.uid });
|
|
44
|
+
|
|
45
|
+
const queue: {
|
|
46
|
+
photoNodeUid: string;
|
|
47
|
+
additionalRelatedPhotoNodeUids: string[];
|
|
48
|
+
}[] = nodeUids.map((nodeUid) => ({ photoNodeUid: nodeUid, additionalRelatedPhotoNodeUids: [] }));
|
|
49
|
+
const retriedPhotoUids = new Set<string>();
|
|
50
|
+
|
|
51
|
+
while (queue.length > 0) {
|
|
52
|
+
const items = queue.splice(0, BATCH_LOADING_SIZE);
|
|
53
|
+
const { payloads, errors } = await this.payloadBuilder.preparePhotoPayloads(
|
|
54
|
+
items,
|
|
55
|
+
rootNode.uid,
|
|
56
|
+
volumeRootKeys,
|
|
57
|
+
signingKeys,
|
|
58
|
+
signal,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
for (const [uid, error] of errors) {
|
|
62
|
+
yield { uid, ok: false, error };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const batch of createBatches(payloads)) {
|
|
66
|
+
for await (const result of this.apiService.transferPhotos(rootNode.uid, batch, signal)) {
|
|
67
|
+
if (
|
|
68
|
+
!result.ok &&
|
|
69
|
+
result.error instanceof MissingRelatedPhotosError &&
|
|
70
|
+
!retriedPhotoUids.has(result.uid)
|
|
71
|
+
) {
|
|
72
|
+
retriedPhotoUids.add(result.uid);
|
|
73
|
+
this.logger.info(
|
|
74
|
+
`Missing related photos for saving ${result.uid}, re-queuing: ${result.error.missingNodeUids.join(', ')}`,
|
|
75
|
+
);
|
|
76
|
+
queue.push({
|
|
77
|
+
photoNodeUid: result.uid,
|
|
78
|
+
additionalRelatedPhotoNodeUids: result.error.missingNodeUids,
|
|
79
|
+
});
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (result.ok) {
|
|
84
|
+
await this.nodesService.notifyNodeChanged(result.uid);
|
|
85
|
+
}
|
|
86
|
+
yield result;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
34
92
|
async *updatePhotos(photos: UpdatePhotoSettings[], signal?: AbortSignal): AsyncGenerator<NodeResultWithError> {
|
|
35
93
|
for await (const {
|
|
36
94
|
photoSettings: { nodeUid, tagsToAdd, tagsToRemove },
|
|
@@ -14,12 +14,13 @@ type TransferEncryptedRelatedPhotoPayload = {
|
|
|
14
14
|
nodeUid: string;
|
|
15
15
|
contentHash: string;
|
|
16
16
|
nameHash: string;
|
|
17
|
+
originalNameHash: string | undefined;
|
|
17
18
|
encryptedName: string;
|
|
18
19
|
nameSignatureEmail: string;
|
|
19
20
|
nodePassphrase: string;
|
|
20
21
|
nodePassphraseSignature?: string;
|
|
21
22
|
signatureEmail?: string;
|
|
22
|
-
}
|
|
23
|
+
};
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Item representing a photo to build a payload for.
|
|
@@ -186,6 +187,7 @@ export class PhotoTransferPayloadBuilder {
|
|
|
186
187
|
nodeUid: photoNode.uid,
|
|
187
188
|
contentHash: encryptedCrypto.contentHash,
|
|
188
189
|
nameHash: encryptedCrypto.hash,
|
|
190
|
+
originalNameHash: photoNode.hash,
|
|
189
191
|
encryptedName: encryptedCrypto.encryptedName,
|
|
190
192
|
nameSignatureEmail: encryptedCrypto.nameSignatureEmail,
|
|
191
193
|
nodePassphrase: encryptedCrypto.armoredNodePassphrase,
|
|
@@ -79,12 +79,12 @@ type PostCreateShareResponse =
|
|
|
79
79
|
drivePaths['/drive/volumes/{volumeID}/shares']['post']['responses']['200']['content']['application/json'];
|
|
80
80
|
|
|
81
81
|
type PostChangeSharePropertiesRequest = Extract<
|
|
82
|
-
drivePaths['/drive/shares/{shareID}/
|
|
82
|
+
drivePaths['/drive/shares/{shareID}/editors-can-share']['put']['requestBody'],
|
|
83
83
|
{ content: object }
|
|
84
84
|
>['content']['application/json'];
|
|
85
85
|
|
|
86
86
|
type PostChangeSharePropertiesResponse =
|
|
87
|
-
drivePaths['/drive/shares/{shareID}/
|
|
87
|
+
drivePaths['/drive/shares/{shareID}/editors-can-share']['put']['responses']['200']['content']['application/json'];
|
|
88
88
|
|
|
89
89
|
type PostInviteProtonUserRequest = Extract<
|
|
90
90
|
drivePaths['/drive/v2/shares/{shareID}/invitations']['post']['requestBody'],
|
|
@@ -423,9 +423,9 @@ export class SharingAPIService {
|
|
|
423
423
|
}
|
|
424
424
|
|
|
425
425
|
async changeShareProperties(shareId: string, { editorsCanShare }: { editorsCanShare: boolean }) {
|
|
426
|
-
await this.apiService.
|
|
427
|
-
`drive/shares/${shareId}/
|
|
428
|
-
{
|
|
426
|
+
await this.apiService.put<PostChangeSharePropertiesRequest, PostChangeSharePropertiesResponse>(
|
|
427
|
+
`drive/shares/${shareId}/editors-can-share`,
|
|
428
|
+
{ Value: editorsCanShare },
|
|
429
429
|
);
|
|
430
430
|
}
|
|
431
431
|
|
|
@@ -133,7 +133,7 @@ describe('UploadManager', () => {
|
|
|
133
133
|
armoredEncryptedName: 'newNode:encryptedName',
|
|
134
134
|
hash: 'newNode:hash',
|
|
135
135
|
mediaType: 'myMimeType',
|
|
136
|
-
intendedUploadSize:
|
|
136
|
+
intendedUploadSize: 100_000,
|
|
137
137
|
armoredNodeKey: 'newNode:armoredKey',
|
|
138
138
|
armoredNodePassphrase: 'newNode:armoredPassphrase',
|
|
139
139
|
armoredNodePassphraseSignature: 'newNode:armoredPassphraseSignature',
|
|
@@ -9,6 +9,7 @@ import { UploadAPIService } from './apiService';
|
|
|
9
9
|
import { UploadCryptoService } from './cryptoService';
|
|
10
10
|
import { NodeRevisionDraft, NodesService, NodeCrypto } from './interface';
|
|
11
11
|
import { makeNodeUid, splitNodeUid } from '../uids';
|
|
12
|
+
import { reduceSizePrecision } from '../../telemetry';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* UploadManager is responsible for creating and deleting draft nodes
|
|
@@ -129,7 +130,7 @@ export class UploadManager {
|
|
|
129
130
|
armoredEncryptedName: generatedNodeCrypto.encryptedNode.encryptedName,
|
|
130
131
|
hash: generatedNodeCrypto.encryptedNode.hash,
|
|
131
132
|
mediaType: metadata.mediaType,
|
|
132
|
-
intendedUploadSize: metadata.expectedSize,
|
|
133
|
+
intendedUploadSize: reduceSizePrecision(metadata.expectedSize),
|
|
133
134
|
armoredNodeKey: generatedNodeCrypto.nodeKeys.encrypted.armoredKey,
|
|
134
135
|
armoredNodePassphrase: generatedNodeCrypto.nodeKeys.encrypted.armoredPassphrase,
|
|
135
136
|
armoredNodePassphraseSignature: generatedNodeCrypto.nodeKeys.encrypted.armoredPassphraseSignature,
|
|
@@ -349,7 +350,7 @@ export class UploadManager {
|
|
|
349
350
|
|
|
350
351
|
const { nodeRevisionUid } = await this.apiService.createDraftRevision(nodeUid, {
|
|
351
352
|
currentRevisionUid: node.activeRevision.value.uid,
|
|
352
|
-
intendedUploadSize: metadata.expectedSize,
|
|
353
|
+
intendedUploadSize: reduceSizePrecision(metadata.expectedSize),
|
|
353
354
|
});
|
|
354
355
|
|
|
355
356
|
return {
|
|
@@ -38,7 +38,9 @@ describe('UploadTelemetry', () => {
|
|
|
38
38
|
eventName: 'upload',
|
|
39
39
|
volumeType: 'own_volume',
|
|
40
40
|
uploadedSize: 0,
|
|
41
|
+
approximateUploadedSize: 0,
|
|
41
42
|
expectedSize: 1000,
|
|
43
|
+
approximateExpectedSize: 4095,
|
|
42
44
|
error: 'unknown',
|
|
43
45
|
originalError: error,
|
|
44
46
|
});
|
|
@@ -52,7 +54,9 @@ describe('UploadTelemetry', () => {
|
|
|
52
54
|
eventName: 'upload',
|
|
53
55
|
volumeType: 'own_volume',
|
|
54
56
|
uploadedSize: 500,
|
|
57
|
+
approximateUploadedSize: 4095,
|
|
55
58
|
expectedSize: 1000,
|
|
59
|
+
approximateExpectedSize: 4095,
|
|
56
60
|
error: 'unknown',
|
|
57
61
|
originalError: error,
|
|
58
62
|
});
|
|
@@ -65,7 +69,9 @@ describe('UploadTelemetry', () => {
|
|
|
65
69
|
eventName: 'upload',
|
|
66
70
|
volumeType: 'own_volume',
|
|
67
71
|
uploadedSize: 1000,
|
|
72
|
+
approximateUploadedSize: 4095,
|
|
68
73
|
expectedSize: 1000,
|
|
74
|
+
approximateExpectedSize: 4095,
|
|
69
75
|
});
|
|
70
76
|
});
|
|
71
77
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { RateLimitedError, ValidationError, IntegrityError } from '../../errors';
|
|
2
|
-
import { ProtonDriveTelemetry, MetricsUploadErrorType, Logger } from '../../interface';
|
|
3
|
-
import { LoggerWithPrefix } from '../../telemetry';
|
|
2
|
+
import { ProtonDriveTelemetry, MetricsUploadErrorType, Logger, MetricVolumeType } from '../../interface';
|
|
3
|
+
import { LoggerWithPrefix, reduceSizePrecision } from '../../telemetry';
|
|
4
4
|
import { APIHTTPError } from '../apiService';
|
|
5
5
|
import { splitNodeUid, splitNodeRevisionUid } from '../uids';
|
|
6
6
|
import { SharesService } from './interface';
|
|
@@ -85,7 +85,7 @@ export class UploadTelemetry {
|
|
|
85
85
|
originalError?: unknown;
|
|
86
86
|
},
|
|
87
87
|
) {
|
|
88
|
-
let volumeType;
|
|
88
|
+
let volumeType = MetricVolumeType.Unknown;
|
|
89
89
|
try {
|
|
90
90
|
volumeType = await this.sharesService.getVolumeMetricContext(volumeId);
|
|
91
91
|
} catch (error: unknown) {
|
|
@@ -95,6 +95,8 @@ export class UploadTelemetry {
|
|
|
95
95
|
this.telemetry.recordMetric({
|
|
96
96
|
eventName: 'upload',
|
|
97
97
|
volumeType,
|
|
98
|
+
approximateUploadedSize: reduceSizePrecision(options.uploadedSize),
|
|
99
|
+
approximateExpectedSize: reduceSizePrecision(options.expectedSize),
|
|
98
100
|
...options,
|
|
99
101
|
});
|
|
100
102
|
}
|
|
@@ -559,9 +559,9 @@ export class ProtonDrivePhotosClient {
|
|
|
559
559
|
*
|
|
560
560
|
* Photos in the timeline will not be deleted. If the album has photos
|
|
561
561
|
* that are not in the timeline (uploaded by another user), the method
|
|
562
|
-
* will throw an error.
|
|
563
|
-
*
|
|
564
|
-
* not in the timeline as well.
|
|
562
|
+
* will throw an error. Then, either the photos must be saved to the
|
|
563
|
+
* timelines with `saveToTimeline` option, or the album must be deleted
|
|
564
|
+
* with `force` option that deletes the photos not in the timeline as well.
|
|
565
565
|
*
|
|
566
566
|
* This operation is irreversible. Both the album and the photos will be
|
|
567
567
|
* permanently deleted, skipping the trash.
|
|
@@ -569,7 +569,7 @@ export class ProtonDrivePhotosClient {
|
|
|
569
569
|
* @param nodeUid - The UID of the album to delete.
|
|
570
570
|
* @param force - Whether to force the deletion.
|
|
571
571
|
*/
|
|
572
|
-
async deleteAlbum(nodeUid: NodeOrUid, options: { force?: boolean } = {}): Promise<void> {
|
|
572
|
+
async deleteAlbum(nodeUid: NodeOrUid, options: { force?: boolean; saveToTimeline?: boolean } = {}): Promise<void> {
|
|
573
573
|
this.logger.info(`Deleting album ${getUid(nodeUid)}`);
|
|
574
574
|
await this.photos.albums.deleteAlbum(getUid(nodeUid), options);
|
|
575
575
|
}
|