@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.
- package/dist/crypto/driveCrypto.d.ts +19 -16
- package/dist/crypto/driveCrypto.js +23 -1
- package/dist/crypto/driveCrypto.js.map +1 -1
- package/dist/crypto/driveCrypto.test.js +2 -1
- package/dist/crypto/driveCrypto.test.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/diagnostic/telemetry.js +3 -0
- package/dist/diagnostic/telemetry.js.map +1 -1
- package/dist/interface/index.d.ts +4 -3
- package/dist/interface/index.js +3 -1
- package/dist/interface/index.js.map +1 -1
- package/dist/interface/nodes.d.ts +8 -0
- package/dist/interface/photos.d.ts +31 -2
- package/dist/interface/photos.js +14 -0
- package/dist/interface/photos.js.map +1 -1
- package/dist/interface/sharing.d.ts +2 -0
- package/dist/interface/telemetry.d.ts +13 -1
- package/dist/interface/telemetry.js.map +1 -1
- package/dist/interface/thumbnail.d.ts +2 -2
- package/dist/internal/apiService/apiService.d.ts +1 -1
- package/dist/internal/apiService/apiService.js +27 -14
- 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/apiService.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/nodesAccess.js +1 -1
- package/dist/internal/nodes/nodesAccess.js.map +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 +42 -0
- package/dist/internal/photos/addToAlbum.js +178 -0
- package/dist/internal/photos/addToAlbum.js.map +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/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/{albums.d.ts → albumsManager.d.ts} +6 -4
- package/dist/internal/photos/{albums.js → albumsManager.js} +17 -5
- package/dist/internal/photos/albumsManager.js.map +1 -0
- package/dist/internal/photos/albumsManager.test.d.ts +1 -0
- package/dist/internal/photos/{albums.test.js → albumsManager.test.js} +4 -3
- package/dist/internal/photos/albumsManager.test.js.map +1 -0
- package/dist/internal/photos/apiService.d.ts +22 -2
- package/dist/internal/photos/apiService.js +136 -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.d.ts +5 -3
- package/dist/internal/photos/index.js +5 -2
- package/dist/internal/photos/index.js.map +1 -1
- package/dist/internal/photos/interface.d.ts +6 -15
- package/dist/internal/photos/interface.js +0 -14
- package/dist/internal/photos/interface.js.map +1 -1
- package/dist/internal/photos/nodes.js +33 -3
- 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/photosManager.d.ts +22 -0
- package/dist/internal/photos/photosManager.js +101 -0
- package/dist/internal/photos/photosManager.js.map +1 -0
- package/dist/internal/photos/photosManager.test.d.ts +1 -0
- package/dist/internal/photos/photosManager.test.js +222 -0
- package/dist/internal/photos/photosManager.test.js.map +1 -0
- package/dist/internal/photos/photosTransferPayloadBuilder.d.ts +57 -0
- package/dist/internal/photos/photosTransferPayloadBuilder.js +113 -0
- package/dist/internal/photos/photosTransferPayloadBuilder.js.map +1 -0
- package/dist/internal/photos/photosTransferPayloadBuilder.test.d.ts +1 -0
- package/dist/internal/photos/photosTransferPayloadBuilder.test.js +289 -0
- package/dist/internal/photos/photosTransferPayloadBuilder.test.js.map +1 -0
- package/dist/internal/photos/upload.d.ts +2 -2
- package/dist/internal/photos/upload.js +1 -1
- 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 +3 -7
- package/dist/internal/upload/apiService.js +0 -4
- 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 +4 -4
- 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/upload/streamUploader.js +12 -13
- package/dist/internal/upload/streamUploader.js.map +1 -1
- package/dist/internal/upload/streamUploader.test.js +28 -4
- package/dist/internal/upload/streamUploader.test.js.map +1 -1
- package/dist/internal/utils.d.ts +1 -1
- package/dist/protonDriveClient.d.ts +8 -0
- package/dist/protonDriveClient.js +12 -1
- package/dist/protonDriveClient.js.map +1 -1
- package/dist/protonDrivePhotosClient.d.ts +35 -3
- package/dist/protonDrivePhotosClient.js +42 -2
- package/dist/protonDrivePhotosClient.js.map +1 -1
- package/dist/protonDrivePublicLinkClient.js +1 -1
- package/dist/protonDrivePublicLinkClient.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.test.ts +2 -1
- package/src/crypto/driveCrypto.ts +50 -16
- package/src/crypto/hmac.ts +4 -4
- package/src/crypto/interface.ts +58 -27
- package/src/crypto/openPGPCrypto.ts +26 -26
- package/src/diagnostic/telemetry.ts +3 -0
- package/src/interface/index.ts +11 -2
- package/src/interface/nodes.ts +1 -0
- package/src/interface/photos.ts +33 -2
- package/src/interface/sharing.ts +2 -0
- package/src/interface/telemetry.ts +15 -1
- package/src/interface/thumbnail.ts +2 -2
- package/src/internal/apiService/apiService.test.ts +38 -6
- package/src/internal/apiService/apiService.ts +39 -14
- 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/apiService.ts +2 -2
- 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/nodesAccess.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 +234 -0
- package/src/internal/photos/albumsCrypto.ts +54 -3
- package/src/internal/photos/{albums.test.ts → albumsManager.test.ts} +22 -25
- package/src/internal/photos/{albums.ts → albumsManager.ts} +32 -3
- package/src/internal/photos/apiService.test.ts +233 -0
- package/src/internal/photos/apiService.ts +228 -26
- package/src/internal/photos/errors.ts +11 -0
- package/src/internal/photos/index.ts +6 -3
- package/src/internal/photos/interface.ts +8 -18
- package/src/internal/photos/nodes.test.ts +27 -6
- package/src/internal/photos/nodes.ts +35 -3
- package/src/internal/photos/photosManager.test.ts +266 -0
- package/src/internal/photos/photosManager.ts +144 -0
- package/src/internal/photos/photosTransferPayloadBuilder.test.ts +380 -0
- package/src/internal/photos/photosTransferPayloadBuilder.ts +203 -0
- package/src/internal/photos/upload.ts +8 -3
- 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 +10 -12
- 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 +11 -12
- package/src/internal/upload/interface.ts +6 -6
- package/src/internal/upload/manager.ts +2 -2
- package/src/internal/upload/streamUploader.test.ts +33 -4
- package/src/internal/upload/streamUploader.ts +13 -13
- package/src/protonDriveClient.ts +16 -4
- package/src/protonDrivePhotosClient.ts +73 -17
- package/src/protonDrivePublicLinkClient.ts +1 -1
- package/src/transformers.ts +2 -0
- package/dist/internal/photos/albums.js.map +0 -1
- package/dist/internal/photos/albums.test.js.map +0 -1
- /package/dist/internal/photos/{albums.test.d.ts → addToAlbum.test.d.ts} +0 -0
|
@@ -44,6 +44,10 @@ function generateAPIAlbumNode(linkOverrides = {}, overrides = {}) {
|
|
|
44
44
|
...node,
|
|
45
45
|
Link: { ...node.Link, Type: 3, ...linkOverrides },
|
|
46
46
|
Photo: null,
|
|
47
|
+
Album: {
|
|
48
|
+
PhotoCount: 1,
|
|
49
|
+
CoverLinkID: 'coverLinkId',
|
|
50
|
+
},
|
|
47
51
|
Folder: null,
|
|
48
52
|
...overrides,
|
|
49
53
|
};
|
|
@@ -103,6 +107,8 @@ describe('PhotosNodesAPIService', () => {
|
|
|
103
107
|
const nodes = await Array.fromAsync(api.iterateNodes(['volumeId~nodeId'], 'volumeId'));
|
|
104
108
|
expect(nodes).toHaveLength(1);
|
|
105
109
|
expect(nodes[0].type).toBe(expectedType);
|
|
110
|
+
|
|
111
|
+
return nodes;
|
|
106
112
|
}
|
|
107
113
|
|
|
108
114
|
it('should convert folder (type 1) to folder node', async () => {
|
|
@@ -110,16 +116,16 @@ describe('PhotosNodesAPIService', () => {
|
|
|
110
116
|
});
|
|
111
117
|
|
|
112
118
|
it('should convert album (type 3) to album node', async () => {
|
|
113
|
-
await testIterateNodes(generateAPIAlbumNode(), NodeType.Album);
|
|
119
|
+
const nodes = await testIterateNodes(generateAPIAlbumNode(), NodeType.Album);
|
|
120
|
+
|
|
121
|
+
expect(nodes[0].album).toBeDefined();
|
|
122
|
+
expect(nodes[0].album?.photoCount).toEqual(1);
|
|
123
|
+
expect(nodes[0].album?.coverPhotoNodeUid).toBe('volumeId~coverLinkId');
|
|
114
124
|
});
|
|
115
125
|
|
|
116
126
|
it('should convert photo (type 2) to photo node with photo attributes', async () => {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const nodes = await Array.fromAsync(api.iterateNodes(['volumeId~nodeId'], 'volumeId'));
|
|
127
|
+
const nodes = await testIterateNodes(generateAPIPhotoNode(), NodeType.Photo);
|
|
120
128
|
|
|
121
|
-
expect(nodes).toHaveLength(1);
|
|
122
|
-
expect(nodes[0].type).toBe(NodeType.Photo);
|
|
123
129
|
expect(nodes[0].photo).toBeDefined();
|
|
124
130
|
expect(nodes[0].photo?.captureTime).toEqual(new Date(1700000000 * 1000));
|
|
125
131
|
expect(nodes[0].photo?.tags).toEqual([1, 2]);
|
|
@@ -161,6 +167,10 @@ describe('PhotosNodesCache', () => {
|
|
|
161
167
|
},
|
|
162
168
|
],
|
|
163
169
|
},
|
|
170
|
+
album: {
|
|
171
|
+
photoCount: 1,
|
|
172
|
+
coverPhotoNodeUid: 'volumeId~coverLinkId',
|
|
173
|
+
},
|
|
164
174
|
});
|
|
165
175
|
|
|
166
176
|
const node = cache.deserialiseNode(serialisedNode);
|
|
@@ -170,6 +180,9 @@ describe('PhotosNodesCache', () => {
|
|
|
170
180
|
expect(node.photo?.captureTime).toEqual(new Date('2023-11-14T22:13:20.000Z'));
|
|
171
181
|
expect(node.photo?.albums[0].additionTime).toBeInstanceOf(Date);
|
|
172
182
|
expect(node.photo?.albums[0].additionTime).toEqual(new Date('2023-11-15T10:00:00.000Z'));
|
|
183
|
+
expect(node.album).toBeDefined();
|
|
184
|
+
expect(node.album?.photoCount).toEqual(1);
|
|
185
|
+
expect(node.album?.coverPhotoNodeUid).toBe('volumeId~coverLinkId');
|
|
173
186
|
});
|
|
174
187
|
|
|
175
188
|
it('should handle node without photo attributes', () => {
|
|
@@ -187,6 +200,7 @@ describe('PhotosNodesCache', () => {
|
|
|
187
200
|
const node = cache.deserialiseNode(serialisedNode);
|
|
188
201
|
|
|
189
202
|
expect(node.photo).toBeUndefined();
|
|
203
|
+
expect(node.album).toBeUndefined();
|
|
190
204
|
});
|
|
191
205
|
});
|
|
192
206
|
});
|
|
@@ -244,6 +258,10 @@ describe('PhotosNodesAccess', () => {
|
|
|
244
258
|
tags: [1, 2],
|
|
245
259
|
albums: [],
|
|
246
260
|
},
|
|
261
|
+
album: {
|
|
262
|
+
photoCount: 1,
|
|
263
|
+
coverPhotoNodeUid: 'volumeId~coverLinkId',
|
|
264
|
+
},
|
|
247
265
|
};
|
|
248
266
|
|
|
249
267
|
// @ts-expect-error Accessing protected method for testing
|
|
@@ -253,6 +271,9 @@ describe('PhotosNodesAccess', () => {
|
|
|
253
271
|
expect(parsedNode.photo).toBeDefined();
|
|
254
272
|
expect(parsedNode.photo?.captureTime).toEqual(new Date('2023-11-14T22:13:20.000Z'));
|
|
255
273
|
expect(parsedNode.photo?.tags).toEqual([1, 2]);
|
|
274
|
+
expect(parsedNode.album).toBeDefined();
|
|
275
|
+
expect(parsedNode.album?.photoCount).toEqual(1);
|
|
276
|
+
expect(parsedNode.album?.coverPhotoNodeUid).toBe('volumeId~coverLinkId');
|
|
256
277
|
});
|
|
257
278
|
});
|
|
258
279
|
});
|
|
@@ -75,11 +75,21 @@ export class PhotosNodesAPIService extends NodeAPIServiceBase<
|
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
if (link.Link.Type === 3) {
|
|
78
|
+
if (link.Link.Type === 3 && link.Album) {
|
|
79
79
|
return {
|
|
80
80
|
...baseNodeMetadata,
|
|
81
|
+
album: {
|
|
82
|
+
photoCount: link.Album.PhotoCount,
|
|
83
|
+
coverPhotoNodeUid: link.Album.CoverLinkID
|
|
84
|
+
? makeNodeUid(volumeId, link.Album.CoverLinkID)
|
|
85
|
+
: undefined,
|
|
86
|
+
},
|
|
81
87
|
encryptedCrypto: {
|
|
82
88
|
...baseCryptoNodeMetadata,
|
|
89
|
+
folder: {
|
|
90
|
+
armoredExtendedAttributes: link.Album.XAttr || undefined,
|
|
91
|
+
armoredHashKey: link.Album.NodeHashKey as string,
|
|
92
|
+
},
|
|
83
93
|
},
|
|
84
94
|
};
|
|
85
95
|
}
|
|
@@ -111,7 +121,8 @@ export class PhotosNodesCache extends NodesCacheBase<DecryptedPhotoNode> {
|
|
|
111
121
|
typeof node !== 'object' ||
|
|
112
122
|
(typeof node.photo !== 'object' && node.photo !== undefined) ||
|
|
113
123
|
(typeof node.photo?.captureTime !== 'string' && node.folder?.captureTime !== undefined) ||
|
|
114
|
-
(typeof node.photo?.albums !== 'object' && node.photo?.albums !== undefined)
|
|
124
|
+
(typeof node.photo?.albums !== 'object' && node.photo?.albums !== undefined) ||
|
|
125
|
+
(typeof node.album !== 'object' && node.album !== undefined)
|
|
115
126
|
) {
|
|
116
127
|
throw new Error(`Invalid node data: ${nodeData}`);
|
|
117
128
|
}
|
|
@@ -167,7 +178,7 @@ export class PhotosNodesAccess extends NodesAccessBase<EncryptedPhotoNode, Decry
|
|
|
167
178
|
|
|
168
179
|
// This is bug that should not happen.
|
|
169
180
|
// API cannot provide node without parent or share or album.
|
|
170
|
-
throw new Error(
|
|
181
|
+
throw new Error(`Node has neither parent node nor share nor album: ${node.uid}`);
|
|
171
182
|
}
|
|
172
183
|
|
|
173
184
|
protected getDegradedUndecryptableNode(
|
|
@@ -190,6 +201,18 @@ export class PhotosNodesAccess extends NodesAccessBase<EncryptedPhotoNode, Decry
|
|
|
190
201
|
};
|
|
191
202
|
}
|
|
192
203
|
|
|
204
|
+
if (unparsedNode.type === NodeType.Album) {
|
|
205
|
+
const node = parseNodeBase(this.logger, {
|
|
206
|
+
...unparsedNode,
|
|
207
|
+
type: NodeType.Folder,
|
|
208
|
+
});
|
|
209
|
+
return {
|
|
210
|
+
...node,
|
|
211
|
+
album: unparsedNode.album,
|
|
212
|
+
type: NodeType.Album,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
193
216
|
return parseNodeBase(this.logger, unparsedNode);
|
|
194
217
|
}
|
|
195
218
|
}
|
|
@@ -210,6 +233,15 @@ export class PhotosNodesCryptoService extends NodesCryptoService {
|
|
|
210
233
|
};
|
|
211
234
|
}
|
|
212
235
|
|
|
236
|
+
if (decryptedNode.node.type === NodeType.Album) {
|
|
237
|
+
return {
|
|
238
|
+
node: {
|
|
239
|
+
...decryptedNode.node,
|
|
240
|
+
album: encryptedNode.album,
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
213
245
|
return decryptedNode;
|
|
214
246
|
}
|
|
215
247
|
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { resultOk, PhotoTag } from '../../interface';
|
|
2
|
+
import { getMockLogger } from '../../tests/logger';
|
|
3
|
+
import { PhotosManager, UpdatePhotoSettings } from './photosManager';
|
|
4
|
+
import { PhotosAPIService } from './apiService';
|
|
5
|
+
import { AlbumsCryptoService } from './albumsCrypto';
|
|
6
|
+
import { PhotosNodesAccess } from './nodes';
|
|
7
|
+
import { DecryptedPhotoNode } from './interface';
|
|
8
|
+
|
|
9
|
+
function createMockPhotoNode(uid: string, overrides: Partial<DecryptedPhotoNode> = {}): DecryptedPhotoNode {
|
|
10
|
+
return {
|
|
11
|
+
uid,
|
|
12
|
+
parentUid: 'volume1~parent',
|
|
13
|
+
hash: 'hash',
|
|
14
|
+
name: resultOk('photo.jpg'),
|
|
15
|
+
photo: {
|
|
16
|
+
captureTime: new Date(),
|
|
17
|
+
mainPhotoNodeUid: undefined,
|
|
18
|
+
relatedPhotoNodeUids: [],
|
|
19
|
+
tags: [],
|
|
20
|
+
albums: [],
|
|
21
|
+
},
|
|
22
|
+
activeRevision: {
|
|
23
|
+
ok: true,
|
|
24
|
+
value: {
|
|
25
|
+
uid: 'rev1',
|
|
26
|
+
state: 'active' as const,
|
|
27
|
+
creationTime: new Date(),
|
|
28
|
+
storageSize: 100,
|
|
29
|
+
signatureEmail: 'test@example.com',
|
|
30
|
+
claimedModificationTime: new Date(),
|
|
31
|
+
claimedSize: 100,
|
|
32
|
+
claimedDigests: { sha1: 'sha1hash' },
|
|
33
|
+
claimedBlockSizes: [100],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
keyAuthor: { ok: true, value: 'test@example.com' },
|
|
37
|
+
...overrides,
|
|
38
|
+
} as DecryptedPhotoNode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function collectUpdateResults(manager: PhotosManager, photos: UpdatePhotoSettings[], signal?: AbortSignal) {
|
|
42
|
+
const results = [];
|
|
43
|
+
for await (const result of manager.updatePhotos(photos, signal)) {
|
|
44
|
+
results.push(result);
|
|
45
|
+
}
|
|
46
|
+
return results;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('PhotosManager', () => {
|
|
50
|
+
let logger: ReturnType<typeof getMockLogger>;
|
|
51
|
+
let apiService: jest.Mocked<Pick<PhotosAPIService, 'addPhotoTags' | 'removePhotoTags' | 'setPhotoFavorite'>>;
|
|
52
|
+
let cryptoService: jest.Mocked<Pick<AlbumsCryptoService, 'encryptPhotoForAlbum'>>;
|
|
53
|
+
let nodesService: jest.Mocked<
|
|
54
|
+
Pick<
|
|
55
|
+
PhotosNodesAccess,
|
|
56
|
+
| 'getVolumeRootFolder'
|
|
57
|
+
| 'getNodeKeys'
|
|
58
|
+
| 'getNodeSigningKeys'
|
|
59
|
+
| 'iterateNodes'
|
|
60
|
+
| 'getNodePrivateAndSessionKeys'
|
|
61
|
+
| 'notifyNodeChanged'
|
|
62
|
+
>
|
|
63
|
+
>;
|
|
64
|
+
let manager: PhotosManager;
|
|
65
|
+
|
|
66
|
+
const volumeRootKeys = {
|
|
67
|
+
key: 'rootKey' as any,
|
|
68
|
+
hashKey: new Uint8Array([1, 2, 3]),
|
|
69
|
+
};
|
|
70
|
+
const signingKeys = {
|
|
71
|
+
type: 'userAddress' as const,
|
|
72
|
+
email: 'test@example.com',
|
|
73
|
+
addressId: 'addressId',
|
|
74
|
+
key: 'signingKey' as any,
|
|
75
|
+
};
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
logger = getMockLogger();
|
|
78
|
+
|
|
79
|
+
apiService = {
|
|
80
|
+
addPhotoTags: jest.fn().mockResolvedValue(undefined),
|
|
81
|
+
removePhotoTags: jest.fn().mockResolvedValue(undefined),
|
|
82
|
+
setPhotoFavorite: jest.fn().mockResolvedValue(undefined),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
cryptoService = {
|
|
86
|
+
encryptPhotoForAlbum: jest.fn().mockResolvedValue({
|
|
87
|
+
contentHash: 'contentHash',
|
|
88
|
+
hash: 'nameHash',
|
|
89
|
+
encryptedName: 'encryptedName',
|
|
90
|
+
nameSignatureEmail: 'test@example.com',
|
|
91
|
+
armoredNodePassphrase: 'passphrase',
|
|
92
|
+
armoredNodePassphraseSignature: 'signature',
|
|
93
|
+
signatureEmail: 'test@example.com',
|
|
94
|
+
}),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
nodesService = {
|
|
98
|
+
getVolumeRootFolder: jest.fn().mockResolvedValue({ uid: 'volume1~root' }),
|
|
99
|
+
getNodeKeys: jest.fn().mockResolvedValue(volumeRootKeys),
|
|
100
|
+
getNodeSigningKeys: jest.fn().mockResolvedValue(signingKeys),
|
|
101
|
+
iterateNodes: jest.fn().mockImplementation(async function* (uids: string[]) {
|
|
102
|
+
for (const uid of uids) {
|
|
103
|
+
yield createMockPhotoNode(uid);
|
|
104
|
+
}
|
|
105
|
+
}),
|
|
106
|
+
getNodePrivateAndSessionKeys: jest.fn().mockResolvedValue({
|
|
107
|
+
key: 'nodeKey' as any,
|
|
108
|
+
nameSessionKey: 'sessionKey' as any,
|
|
109
|
+
passphrase: 'passphrase',
|
|
110
|
+
passphraseSessionKey: 'passphraseSessionKey' as any,
|
|
111
|
+
}),
|
|
112
|
+
notifyNodeChanged: jest.fn().mockResolvedValue(undefined),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
manager = new PhotosManager(logger, apiService as any, cryptoService as any, nodesService as any);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('updatePhotos', () => {
|
|
119
|
+
describe('add tags only', () => {
|
|
120
|
+
it('calls addPhotoTags and notifyNodeChanged for each photo', async () => {
|
|
121
|
+
const results = await collectUpdateResults(manager, [
|
|
122
|
+
{ nodeUid: 'volume1~photo1', tagsToAdd: [PhotoTag.Screenshots], tagsToRemove: [] },
|
|
123
|
+
{ nodeUid: 'volume1~photo2', tagsToAdd: [PhotoTag.LivePhotos], tagsToRemove: [] },
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
expect(results).toEqual([
|
|
127
|
+
{ uid: 'volume1~photo1', ok: true },
|
|
128
|
+
{ uid: 'volume1~photo2', ok: true },
|
|
129
|
+
]);
|
|
130
|
+
expect(apiService.addPhotoTags).toHaveBeenCalledTimes(2);
|
|
131
|
+
expect(apiService.addPhotoTags).toHaveBeenCalledWith('volume1~photo1', [PhotoTag.Screenshots]);
|
|
132
|
+
expect(apiService.addPhotoTags).toHaveBeenCalledWith('volume1~photo2', [PhotoTag.LivePhotos]);
|
|
133
|
+
expect(nodesService.getVolumeRootFolder).not.toHaveBeenCalled();
|
|
134
|
+
expect(apiService.setPhotoFavorite).not.toHaveBeenCalled();
|
|
135
|
+
expect(nodesService.notifyNodeChanged).toHaveBeenCalledTimes(2);
|
|
136
|
+
expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo1');
|
|
137
|
+
expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo2');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('filters Favorites from addTags and calls setPhotoFavorite with payload', async () => {
|
|
141
|
+
const results = await collectUpdateResults(manager, [
|
|
142
|
+
{ nodeUid: 'volume1~photo1', tagsToAdd: [PhotoTag.Favorites], tagsToRemove: [] },
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
expect(results).toEqual([{ uid: 'volume1~photo1', ok: true }]);
|
|
146
|
+
expect(nodesService.getVolumeRootFolder).toHaveBeenCalled();
|
|
147
|
+
expect(nodesService.getNodeKeys).toHaveBeenCalledWith('volume1~root');
|
|
148
|
+
expect(nodesService.getNodeSigningKeys).toHaveBeenCalledWith({ nodeUid: 'volume1~root' });
|
|
149
|
+
expect(apiService.setPhotoFavorite).toHaveBeenCalledTimes(1);
|
|
150
|
+
expect(apiService.setPhotoFavorite).toHaveBeenCalledWith(
|
|
151
|
+
'volume1~photo1',
|
|
152
|
+
expect.objectContaining({
|
|
153
|
+
nodeUid: 'volume1~photo1',
|
|
154
|
+
contentHash: 'contentHash',
|
|
155
|
+
nameHash: 'nameHash',
|
|
156
|
+
relatedPhotos: [],
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
expect(apiService.addPhotoTags).not.toHaveBeenCalled();
|
|
160
|
+
expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo1');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('calls setPhotoFavorite and addPhotoTags when addTags includes Favorites and other tags', async () => {
|
|
164
|
+
const results = await collectUpdateResults(manager, [
|
|
165
|
+
{
|
|
166
|
+
nodeUid: 'volume1~photo1',
|
|
167
|
+
tagsToAdd: [PhotoTag.Favorites, PhotoTag.Screenshots],
|
|
168
|
+
tagsToRemove: [],
|
|
169
|
+
},
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
expect(results).toEqual([{ uid: 'volume1~photo1', ok: true }]);
|
|
173
|
+
expect(apiService.setPhotoFavorite).toHaveBeenCalledWith('volume1~photo1', expect.any(Object));
|
|
174
|
+
expect(apiService.addPhotoTags).toHaveBeenCalledWith('volume1~photo1', [PhotoTag.Screenshots]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('calls setPhotoFavorite when payload builder returns PhotoAlreadyInTargetError (photo already in root)', async () => {
|
|
178
|
+
nodesService.iterateNodes.mockImplementation(async function* (uids: string[]) {
|
|
179
|
+
for (const uid of uids) {
|
|
180
|
+
yield createMockPhotoNode(uid, { parentUid: 'volume1~root' });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const results = await collectUpdateResults(manager, [
|
|
185
|
+
{ nodeUid: 'volume1~photo1', tagsToAdd: [PhotoTag.Favorites], tagsToRemove: [] },
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
expect(results).toEqual([{ uid: 'volume1~photo1', ok: true }]);
|
|
189
|
+
expect(apiService.setPhotoFavorite).toHaveBeenCalledWith('volume1~photo1', undefined);
|
|
190
|
+
expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo1');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('remove tags only', () => {
|
|
195
|
+
it('calls removePhotoTags and notifyNodeChanged for each photo', async () => {
|
|
196
|
+
const results = await collectUpdateResults(manager, [
|
|
197
|
+
{ nodeUid: 'volume1~photo1', tagsToAdd: [], tagsToRemove: [PhotoTag.Screenshots] },
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
expect(results).toEqual([{ uid: 'volume1~photo1', ok: true }]);
|
|
201
|
+
expect(apiService.removePhotoTags).toHaveBeenCalledWith('volume1~photo1', [PhotoTag.Screenshots]);
|
|
202
|
+
expect(apiService.addPhotoTags).not.toHaveBeenCalled();
|
|
203
|
+
expect(nodesService.getVolumeRootFolder).not.toHaveBeenCalled();
|
|
204
|
+
expect(apiService.setPhotoFavorite).not.toHaveBeenCalled();
|
|
205
|
+
expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo1');
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('add and remove tags together', () => {
|
|
210
|
+
it('calls addPhotoTags and removePhotoTags and notifyNodeChanged', async () => {
|
|
211
|
+
const results = await collectUpdateResults(manager, [
|
|
212
|
+
{
|
|
213
|
+
nodeUid: 'volume1~photo1',
|
|
214
|
+
tagsToAdd: [PhotoTag.Panoramas],
|
|
215
|
+
tagsToRemove: [PhotoTag.Screenshots],
|
|
216
|
+
},
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
expect(results).toEqual([{ uid: 'volume1~photo1', ok: true }]);
|
|
220
|
+
expect(apiService.addPhotoTags).toHaveBeenCalledWith('volume1~photo1', [PhotoTag.Panoramas]);
|
|
221
|
+
expect(apiService.removePhotoTags).toHaveBeenCalledWith('volume1~photo1', [PhotoTag.Screenshots]);
|
|
222
|
+
expect(apiService.setPhotoFavorite).not.toHaveBeenCalled();
|
|
223
|
+
expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('volume1~photo1');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('API failures', () => {
|
|
228
|
+
it('yields error result and logs when setPhotoFavorite fails', async () => {
|
|
229
|
+
const apiError = new Error('Favorite API failed');
|
|
230
|
+
apiService.setPhotoFavorite.mockRejectedValue(apiError);
|
|
231
|
+
|
|
232
|
+
const results = await collectUpdateResults(manager, [
|
|
233
|
+
{ nodeUid: 'volume1~photo1', tagsToAdd: [PhotoTag.Favorites], tagsToRemove: [] },
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
expect(results).toEqual([{ uid: 'volume1~photo1', ok: false, error: apiError }]);
|
|
237
|
+
expect(logger.error).toHaveBeenCalledWith('Update photos failed for volume1~photo1', apiError);
|
|
238
|
+
expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('yields error result when addPhotoTags fails', async () => {
|
|
242
|
+
const apiError = new Error('Add tags failed');
|
|
243
|
+
apiService.addPhotoTags.mockRejectedValue(apiError);
|
|
244
|
+
|
|
245
|
+
const results = await collectUpdateResults(manager, [
|
|
246
|
+
{ nodeUid: 'volume1~photo1', tagsToAdd: [PhotoTag.Screenshots], tagsToRemove: [] },
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
expect(results).toEqual([{ uid: 'volume1~photo1', ok: false, error: apiError }]);
|
|
250
|
+
expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('yields error result when removePhotoTags fails', async () => {
|
|
254
|
+
const apiError = new Error('Remove tags failed');
|
|
255
|
+
apiService.removePhotoTags.mockRejectedValue(apiError);
|
|
256
|
+
|
|
257
|
+
const results = await collectUpdateResults(manager, [
|
|
258
|
+
{ nodeUid: 'volume1~photo1', tagsToAdd: [], tagsToRemove: [PhotoTag.Videos] },
|
|
259
|
+
]);
|
|
260
|
+
|
|
261
|
+
expect(results).toEqual([{ uid: 'volume1~photo1', ok: false, error: apiError }]);
|
|
262
|
+
expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { c } from 'ttag';
|
|
2
|
+
|
|
3
|
+
import { Logger, NodeResultWithError, PhotoTag } from '../../interface';
|
|
4
|
+
import { PhotosAPIService } from './apiService';
|
|
5
|
+
import { PhotoAlreadyInTargetError, PhotoTransferPayloadBuilder, TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder';
|
|
6
|
+
import { PhotosNodesAccess } from './nodes';
|
|
7
|
+
import { AlbumsCryptoService } from './albumsCrypto';
|
|
8
|
+
import { AbortError } from '../../errors';
|
|
9
|
+
import { BATCH_LOADING_SIZE } from '../sharing/sharingAccess';
|
|
10
|
+
import { batch } from '../batch';
|
|
11
|
+
|
|
12
|
+
export type UpdatePhotoSettings = {
|
|
13
|
+
nodeUid: string;
|
|
14
|
+
tagsToAdd: PhotoTag[];
|
|
15
|
+
tagsToRemove: PhotoTag[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Manages updating photos: adding/removing tags and favoriting.
|
|
20
|
+
* Uses the same encrypted payload as add-to-album/copy for the favorite endpoint.
|
|
21
|
+
*/
|
|
22
|
+
export class PhotosManager {
|
|
23
|
+
private readonly payloadBuilder: PhotoTransferPayloadBuilder;
|
|
24
|
+
|
|
25
|
+
constructor(
|
|
26
|
+
private readonly logger: Logger,
|
|
27
|
+
private readonly apiService: PhotosAPIService,
|
|
28
|
+
albumsCryptoService: AlbumsCryptoService,
|
|
29
|
+
private readonly nodesService: PhotosNodesAccess,
|
|
30
|
+
) {
|
|
31
|
+
this.payloadBuilder = new PhotoTransferPayloadBuilder(albumsCryptoService, nodesService);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async *updatePhotos(photos: UpdatePhotoSettings[], signal?: AbortSignal): AsyncGenerator<NodeResultWithError> {
|
|
35
|
+
for await (const {
|
|
36
|
+
photoSettings: { nodeUid, tagsToAdd, tagsToRemove },
|
|
37
|
+
payloadForFavorite,
|
|
38
|
+
error,
|
|
39
|
+
} of this.iterateNodeUidsWithFavoritePayloads(photos, signal)) {
|
|
40
|
+
if (signal?.aborted) {
|
|
41
|
+
throw new AbortError();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (error) {
|
|
45
|
+
yield { uid: nodeUid, ok: false, error };
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
if (tagsToAdd.includes(PhotoTag.Favorites)) {
|
|
51
|
+
await this.apiService.setPhotoFavorite(nodeUid, payloadForFavorite);
|
|
52
|
+
}
|
|
53
|
+
const addTags = tagsToAdd.filter((tag) => tag !== PhotoTag.Favorites);
|
|
54
|
+
if (addTags.length) {
|
|
55
|
+
await this.apiService.addPhotoTags(nodeUid, addTags);
|
|
56
|
+
}
|
|
57
|
+
if (tagsToRemove.length) {
|
|
58
|
+
await this.apiService.removePhotoTags(nodeUid, tagsToRemove);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await this.nodesService.notifyNodeChanged(nodeUid);
|
|
62
|
+
yield { uid: nodeUid, ok: true };
|
|
63
|
+
} catch (error) {
|
|
64
|
+
this.logger.error(`Update photos failed for ${nodeUid}`, error);
|
|
65
|
+
yield {
|
|
66
|
+
uid: nodeUid,
|
|
67
|
+
ok: false,
|
|
68
|
+
error: error instanceof Error ? error : new Error(c('Error').t`Unknown error`, { cause: error }),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private async *iterateNodeUidsWithFavoritePayloads(
|
|
75
|
+
photosSettings: UpdatePhotoSettings[],
|
|
76
|
+
signal?: AbortSignal,
|
|
77
|
+
): AsyncGenerator<{
|
|
78
|
+
photoSettings: UpdatePhotoSettings;
|
|
79
|
+
payloadForFavorite?: TransferEncryptedPhotoPayload;
|
|
80
|
+
error?: Error;
|
|
81
|
+
}> {
|
|
82
|
+
const photosSettingsWithoutFavorite = photosSettings.filter(
|
|
83
|
+
(photoSettings) => !photoSettings.tagsToAdd?.includes(PhotoTag.Favorites),
|
|
84
|
+
);
|
|
85
|
+
const photosSettingsWithFavorite = photosSettings.filter((photoSettings) =>
|
|
86
|
+
photoSettings.tagsToAdd?.includes(PhotoTag.Favorites),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
for (const photoSettings of photosSettingsWithoutFavorite) {
|
|
90
|
+
yield { photoSettings };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!photosSettingsWithFavorite.length) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const rootNode = await this.nodesService.getVolumeRootFolder();
|
|
98
|
+
const volumeRootKeys = await this.nodesService.getNodeKeys(rootNode.uid);
|
|
99
|
+
const signingKeys = await this.nodesService.getNodeSigningKeys({ nodeUid: rootNode.uid });
|
|
100
|
+
|
|
101
|
+
// Batch iteration to fetch metadata for preparing the payloads in parallel.
|
|
102
|
+
for (const photoSettingsBatch of batch(photosSettingsWithFavorite, BATCH_LOADING_SIZE)) {
|
|
103
|
+
if (signal?.aborted) {
|
|
104
|
+
throw new AbortError();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const result = await this.payloadBuilder.preparePhotoPayloads(
|
|
108
|
+
photoSettingsBatch.map(({ nodeUid }) => ({ photoNodeUid: nodeUid })),
|
|
109
|
+
rootNode.uid,
|
|
110
|
+
volumeRootKeys,
|
|
111
|
+
signingKeys,
|
|
112
|
+
signal,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
for (const [nodeUid, error] of result.errors) {
|
|
116
|
+
const photoSettings = photosSettingsWithFavorite.find(
|
|
117
|
+
(photoSettings) => photoSettings.nodeUid === nodeUid,
|
|
118
|
+
);
|
|
119
|
+
if (!photoSettings) {
|
|
120
|
+
this.logger.error(`Photo settings not found for ${nodeUid}, unexpected error`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// If the photo is already in the root node, we only set the favorite tag.
|
|
125
|
+
if (error instanceof PhotoAlreadyInTargetError) {
|
|
126
|
+
yield { photoSettings };
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
yield { photoSettings, error };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const payloadForFavorite of result.payloads) {
|
|
133
|
+
const photoSettings = photosSettingsWithFavorite.find(
|
|
134
|
+
(photoSettings) => photoSettings.nodeUid === payloadForFavorite.nodeUid,
|
|
135
|
+
);
|
|
136
|
+
if (!photoSettings) {
|
|
137
|
+
this.logger.error(`Photo settings not found for ${payloadForFavorite.nodeUid}, unexpected payload`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
yield { photoSettings, payloadForFavorite };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|