@protontech/drive-sdk 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/interface/index.d.ts +1 -0
  2. package/dist/interface/index.js.map +1 -1
  3. package/dist/interface/nodes.d.ts +14 -10
  4. package/dist/interface/nodes.js +5 -8
  5. package/dist/interface/nodes.js.map +1 -1
  6. package/dist/interface/photos.d.ts +62 -0
  7. package/dist/interface/photos.js +3 -0
  8. package/dist/interface/photos.js.map +1 -0
  9. package/dist/internal/apiService/driveTypes.d.ts +1294 -517
  10. package/dist/internal/errors.d.ts +1 -0
  11. package/dist/internal/errors.js +4 -0
  12. package/dist/internal/errors.js.map +1 -1
  13. package/dist/internal/nodes/apiService.d.ts +60 -9
  14. package/dist/internal/nodes/apiService.js +125 -81
  15. package/dist/internal/nodes/apiService.js.map +1 -1
  16. package/dist/internal/nodes/apiService.test.js +2 -0
  17. package/dist/internal/nodes/apiService.test.js.map +1 -1
  18. package/dist/internal/nodes/cache.d.ts +16 -8
  19. package/dist/internal/nodes/cache.js +19 -5
  20. package/dist/internal/nodes/cache.js.map +1 -1
  21. package/dist/internal/nodes/cache.test.js +1 -0
  22. package/dist/internal/nodes/cache.test.js.map +1 -1
  23. package/dist/internal/nodes/cryptoService.d.ts +1 -1
  24. package/dist/internal/nodes/cryptoService.js +4 -4
  25. package/dist/internal/nodes/cryptoService.js.map +1 -1
  26. package/dist/internal/nodes/cryptoService.test.js +3 -3
  27. package/dist/internal/nodes/cryptoService.test.js.map +1 -1
  28. package/dist/internal/nodes/events.d.ts +2 -2
  29. package/dist/internal/nodes/events.js.map +1 -1
  30. package/dist/internal/nodes/index.test.js +1 -0
  31. package/dist/internal/nodes/index.test.js.map +1 -1
  32. package/dist/internal/nodes/interface.d.ts +1 -0
  33. package/dist/internal/nodes/nodesAccess.d.ts +29 -20
  34. package/dist/internal/nodes/nodesAccess.js +41 -29
  35. package/dist/internal/nodes/nodesAccess.js.map +1 -1
  36. package/dist/internal/nodes/nodesManagement.d.ts +32 -12
  37. package/dist/internal/nodes/nodesManagement.js +30 -13
  38. package/dist/internal/nodes/nodesManagement.js.map +1 -1
  39. package/dist/internal/nodes/nodesManagement.test.js +39 -4
  40. package/dist/internal/nodes/nodesManagement.test.js.map +1 -1
  41. package/dist/internal/nodes/nodesRevisions.d.ts +2 -2
  42. package/dist/internal/nodes/nodesRevisions.js.map +1 -1
  43. package/dist/internal/photos/albums.d.ts +2 -2
  44. package/dist/internal/photos/albums.js.map +1 -1
  45. package/dist/internal/photos/index.d.ts +19 -3
  46. package/dist/internal/photos/index.js +38 -8
  47. package/dist/internal/photos/index.js.map +1 -1
  48. package/dist/internal/photos/interface.d.ts +18 -9
  49. package/dist/internal/photos/nodes.d.ts +57 -0
  50. package/dist/internal/photos/nodes.js +166 -0
  51. package/dist/internal/photos/nodes.js.map +1 -0
  52. package/dist/internal/photos/nodes.test.d.ts +1 -0
  53. package/dist/internal/photos/nodes.test.js +233 -0
  54. package/dist/internal/photos/nodes.test.js.map +1 -0
  55. package/dist/internal/photos/timeline.d.ts +2 -2
  56. package/dist/internal/photos/timeline.js.map +1 -1
  57. package/dist/internal/photos/timeline.test.js.map +1 -1
  58. package/dist/protonDriveClient.d.ts +10 -1
  59. package/dist/protonDriveClient.js +18 -3
  60. package/dist/protonDriveClient.js.map +1 -1
  61. package/dist/protonDrivePhotosClient.d.ts +12 -8
  62. package/dist/protonDrivePhotosClient.js +15 -9
  63. package/dist/protonDrivePhotosClient.js.map +1 -1
  64. package/dist/protonDrivePublicLinkClient.d.ts +7 -1
  65. package/dist/protonDrivePublicLinkClient.js +9 -0
  66. package/dist/protonDrivePublicLinkClient.js.map +1 -1
  67. package/dist/transformers.d.ts +7 -2
  68. package/dist/transformers.js +37 -0
  69. package/dist/transformers.js.map +1 -1
  70. package/package.json +1 -1
  71. package/src/interface/index.ts +1 -0
  72. package/src/interface/nodes.ts +14 -11
  73. package/src/interface/photos.ts +67 -0
  74. package/src/internal/apiService/driveTypes.ts +1294 -517
  75. package/src/internal/errors.ts +4 -0
  76. package/src/internal/nodes/apiService.test.ts +2 -0
  77. package/src/internal/nodes/apiService.ts +187 -114
  78. package/src/internal/nodes/cache.test.ts +1 -0
  79. package/src/internal/nodes/cache.ts +32 -13
  80. package/src/internal/nodes/cryptoService.test.ts +13 -3
  81. package/src/internal/nodes/cryptoService.ts +4 -4
  82. package/src/internal/nodes/events.ts +2 -2
  83. package/src/internal/nodes/index.test.ts +1 -0
  84. package/src/internal/nodes/interface.ts +1 -0
  85. package/src/internal/nodes/nodesAccess.ts +82 -54
  86. package/src/internal/nodes/nodesManagement.test.ts +48 -4
  87. package/src/internal/nodes/nodesManagement.ts +76 -26
  88. package/src/internal/nodes/nodesRevisions.ts +3 -3
  89. package/src/internal/photos/albums.ts +2 -2
  90. package/src/internal/photos/index.ts +45 -3
  91. package/src/internal/photos/interface.ts +21 -9
  92. package/src/internal/photos/nodes.test.ts +258 -0
  93. package/src/internal/photos/nodes.ts +234 -0
  94. package/src/internal/photos/timeline.test.ts +2 -2
  95. package/src/internal/photos/timeline.ts +2 -2
  96. package/src/protonDriveClient.ts +20 -3
  97. package/src/protonDrivePhotosClient.ts +31 -23
  98. package/src/protonDrivePublicLinkClient.ts +11 -0
  99. package/src/transformers.ts +49 -2
@@ -26,6 +26,7 @@ function generateNode(uid: string, parentUid = 'volumeId~root', params: Partial<
26
26
  isShared: false,
27
27
  isSharedPublicly: false,
28
28
  creationTime: new Date(),
29
+ modificationTime: new Date(),
29
30
  trashTime: undefined,
30
31
  isStale: false,
31
32
  ...params,
@@ -33,6 +33,7 @@ interface BaseNode {
33
33
  type: NodeType;
34
34
  mediaType?: string;
35
35
  creationTime: Date; // created on the server
36
+ modificationTime: Date; // modified on server
36
37
  trashTime?: Date;
37
38
  totalStorageSize?: number;
38
39
 
@@ -16,8 +16,8 @@ import { asyncIteratorMap } from '../asyncIteratorMap';
16
16
  import { getErrorMessage } from '../errors';
17
17
  import { BatchLoading } from '../batchLoading';
18
18
  import { makeNodeUid, splitNodeUid } from '../uids';
19
- import { NodeAPIService } from './apiService';
20
- import { NodesCache } from './cache';
19
+ import { NodeAPIServiceBase } from './apiService';
20
+ import { NodesCacheBase } from './cache';
21
21
  import { NodesCryptoCache } from './cryptoCache';
22
22
  import { NodesCryptoService } from './cryptoService';
23
23
  import { NodesDebouncer } from './debouncer';
@@ -50,17 +50,21 @@ const DECRYPTION_CONCURRENCY = 30;
50
50
  * The node access module is responsible for fetching, decrypting and caching
51
51
  * nodes metadata.
52
52
  */
53
- export class NodesAccess {
54
- private logger: Logger;
55
- private debouncer: NodesDebouncer;
53
+ export abstract class NodesAccessBase<
54
+ TEncryptedNode extends EncryptedNode = EncryptedNode,
55
+ TDecryptedNode extends DecryptedNode = DecryptedNode,
56
+ TCryptoService extends NodesCryptoService = NodesCryptoService,
57
+ > {
58
+ protected logger: Logger;
59
+ protected debouncer: NodesDebouncer;
56
60
 
57
61
  constructor(
58
- private telemetry: ProtonDriveTelemetry,
59
- private apiService: NodeAPIService,
60
- private cache: NodesCache,
61
- private cryptoCache: NodesCryptoCache,
62
- private cryptoService: NodesCryptoService,
63
- private shareService: Pick<
62
+ protected telemetry: ProtonDriveTelemetry,
63
+ protected apiService: NodeAPIServiceBase<TEncryptedNode>,
64
+ protected cache: NodesCacheBase<TDecryptedNode>,
65
+ protected cryptoCache: NodesCryptoCache,
66
+ protected cryptoService: TCryptoService,
67
+ protected shareService: Pick<
64
68
  SharesService,
65
69
  'getRootIDs' | 'getSharePrivateKey' | 'getContextShareMemberEmailKey'
66
70
  >,
@@ -80,7 +84,7 @@ export class NodesAccess {
80
84
  return this.getNode(nodeUid);
81
85
  }
82
86
 
83
- async getNode(nodeUid: string): Promise<DecryptedNode> {
87
+ async getNode(nodeUid: string): Promise<TDecryptedNode> {
84
88
  let cachedNode;
85
89
  try {
86
90
  await this.debouncer.waitForLoadingNode(nodeUid);
@@ -101,11 +105,11 @@ export class NodesAccess {
101
105
  parentNodeUid: string,
102
106
  filterOptions?: FilterOptions,
103
107
  signal?: AbortSignal,
104
- ): AsyncGenerator<DecryptedNode> {
108
+ ): AsyncGenerator<TDecryptedNode> {
105
109
  // Ensure the parent is loaded and up-to-date.
106
110
  const parentNode = await this.getNode(parentNodeUid);
107
111
 
108
- const batchLoading = new BatchLoading<string, DecryptedNode>({
112
+ const batchLoading = new BatchLoading<string, TDecryptedNode>({
109
113
  iterateItems: (nodeUids) => this.loadNodes(nodeUids, filterOptions, signal),
110
114
  batchSize: BATCH_LOADING_SIZE,
111
115
  });
@@ -154,9 +158,9 @@ export class NodesAccess {
154
158
  }
155
159
 
156
160
  // Improvement requested: keep status of loaded trash and leverage cache.
157
- async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator<DecryptedNode> {
161
+ async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator<TDecryptedNode> {
158
162
  const { volumeId } = await this.shareService.getRootIDs();
159
- const batchLoading = new BatchLoading<string, DecryptedNode>({
163
+ const batchLoading = new BatchLoading<string, TDecryptedNode>({
160
164
  iterateItems: (nodeUids) => this.loadNodes(nodeUids, undefined, signal),
161
165
  batchSize: BATCH_LOADING_SIZE,
162
166
  });
@@ -177,8 +181,8 @@ export class NodesAccess {
177
181
  yield* batchLoading.loadRest();
178
182
  }
179
183
 
180
- async *iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<DecryptedNode | MissingNode> {
181
- const batchLoading = new BatchLoading<string, DecryptedNode | MissingNode>({
184
+ async *iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<TDecryptedNode | MissingNode> {
185
+ const batchLoading = new BatchLoading<string, TDecryptedNode | MissingNode>({
182
186
  iterateItems: (nodeUids) => this.loadNodesWithMissingReport(nodeUids, undefined, signal),
183
187
  batchSize: BATCH_LOADING_SIZE,
184
188
  });
@@ -228,7 +232,7 @@ export class NodesAccess {
228
232
  await this.cache.removeNodes([nodeUid]);
229
233
  }
230
234
 
231
- private async loadNode(nodeUid: string): Promise<{ node: DecryptedNode; keys?: DecryptedNodeKeys }> {
235
+ private async loadNode(nodeUid: string): Promise<{ node: TDecryptedNode; keys?: DecryptedNodeKeys }> {
232
236
  this.debouncer.loadingNode(nodeUid);
233
237
  try {
234
238
  const { volumeId: ownVolumeId } = await this.shareService.getRootIDs();
@@ -243,7 +247,7 @@ export class NodesAccess {
243
247
  nodeUids: string[],
244
248
  filterOptions?: FilterOptions,
245
249
  signal?: AbortSignal,
246
- ): AsyncGenerator<DecryptedNode> {
250
+ ): AsyncGenerator<TDecryptedNode> {
247
251
  for await (const result of this.loadNodesWithMissingReport(nodeUids, filterOptions, signal)) {
248
252
  if ('missingUid' in result) {
249
253
  continue;
@@ -256,7 +260,7 @@ export class NodesAccess {
256
260
  nodeUids: string[],
257
261
  filterOptions?: FilterOptions,
258
262
  signal?: AbortSignal,
259
- ): AsyncGenerator<DecryptedNode | MissingNode> {
263
+ ): AsyncGenerator<TDecryptedNode | MissingNode> {
260
264
  const returnedNodeUids: string[] = [];
261
265
  const errors = [];
262
266
 
@@ -264,13 +268,13 @@ export class NodesAccess {
264
268
 
265
269
  const apiNodesIterator = this.apiService.iterateNodes(nodeUids, ownVolumeId, filterOptions, signal);
266
270
 
267
- const debouncedNodeMapper = async (encryptedNode: EncryptedNode): Promise<EncryptedNode> => {
271
+ const debouncedNodeMapper = async (encryptedNode: TEncryptedNode): Promise<TEncryptedNode> => {
268
272
  this.debouncer.loadingNode(encryptedNode.uid);
269
273
  return encryptedNode;
270
274
  };
271
275
  const encryptedNodesIterator = asyncIteratorMap(apiNodesIterator, debouncedNodeMapper, 1);
272
276
 
273
- const decryptNodeMapper = async (encryptedNode: EncryptedNode): Promise<Result<DecryptedNode, unknown>> => {
277
+ const decryptNodeMapper = async (encryptedNode: TEncryptedNode): Promise<Result<TDecryptedNode, unknown>> => {
274
278
  returnedNodeUids.push(encryptedNode.uid);
275
279
  try {
276
280
  const { node } = await this.decryptNode(encryptedNode);
@@ -310,8 +314,8 @@ export class NodesAccess {
310
314
  }
311
315
 
312
316
  private async decryptNode(
313
- encryptedNode: EncryptedNode,
314
- ): Promise<{ node: DecryptedNode; keys?: DecryptedNodeKeys }> {
317
+ encryptedNode: TEncryptedNode,
318
+ ): Promise<{ node: TDecryptedNode; keys?: DecryptedNodeKeys }> {
315
319
  let parentKey;
316
320
  try {
317
321
  const parentKeys = await this.getParentKeys(encryptedNode);
@@ -319,38 +323,14 @@ export class NodesAccess {
319
323
  } catch (error: unknown) {
320
324
  if (error instanceof DecryptionError) {
321
325
  return {
322
- node: {
323
- ...encryptedNode,
324
- isStale: false,
325
- name: resultError(error),
326
- keyAuthor: resultError({
327
- claimedAuthor: encryptedNode.encryptedCrypto.signatureEmail,
328
- error: getErrorMessage(error),
329
- }),
330
- nameAuthor: resultError({
331
- claimedAuthor: encryptedNode.encryptedCrypto.nameSignatureEmail,
332
- error: getErrorMessage(error),
333
- }),
334
- membership: encryptedNode.membership
335
- ? {
336
- role: encryptedNode.membership.role,
337
- inviteTime: encryptedNode.membership.inviteTime,
338
- sharedBy: resultError({
339
- claimedAuthor: encryptedNode.encryptedCrypto.membership?.inviterEmail,
340
- error: getErrorMessage(error),
341
- }),
342
- }
343
- : undefined,
344
- errors: [error],
345
- treeEventScopeId: splitNodeUid(encryptedNode.uid).volumeId,
346
- },
326
+ node: this.getDegradedUndecryptableNode(encryptedNode, error),
347
327
  };
348
328
  }
349
329
  throw error;
350
330
  }
351
331
 
352
332
  const { node: unparsedNode, keys } = await this.cryptoService.decryptNode(encryptedNode, parentKey);
353
- const node = await parseNode(this.logger, unparsedNode);
333
+ const node = this.parseNode(unparsedNode);
354
334
  try {
355
335
  await this.cache.setNode(node);
356
336
  } catch (error: unknown) {
@@ -367,8 +347,45 @@ export class NodesAccess {
367
347
  return { node, keys };
368
348
  }
369
349
 
350
+ protected abstract getDegradedUndecryptableNode(
351
+ encryptedNode: TEncryptedNode,
352
+ error: DecryptionError,
353
+ ): TDecryptedNode;
354
+
355
+ protected getDegradedUndecryptableNodeBase(encryptedNode: EncryptedNode, error: DecryptionError): DecryptedNode {
356
+ return {
357
+ ...encryptedNode,
358
+ isStale: false,
359
+ name: resultError(error),
360
+ keyAuthor: resultError({
361
+ claimedAuthor: encryptedNode.encryptedCrypto.signatureEmail,
362
+ error: getErrorMessage(error),
363
+ }),
364
+ nameAuthor: resultError({
365
+ claimedAuthor: encryptedNode.encryptedCrypto.nameSignatureEmail,
366
+ error: getErrorMessage(error),
367
+ }),
368
+ membership: encryptedNode.membership
369
+ ? {
370
+ role: encryptedNode.membership.role,
371
+ inviteTime: encryptedNode.membership.inviteTime,
372
+ sharedBy: resultError({
373
+ claimedAuthor: encryptedNode.encryptedCrypto.membership?.inviterEmail,
374
+ error: getErrorMessage(error),
375
+ }),
376
+ }
377
+ : undefined,
378
+ errors: [error],
379
+ treeEventScopeId: splitNodeUid(encryptedNode.uid).volumeId,
380
+ };
381
+ }
382
+
383
+ protected abstract parseNode(
384
+ unparsedNode: Awaited<ReturnType<TCryptoService['decryptNode']>>['node'],
385
+ ): TDecryptedNode;
386
+
370
387
  async getParentKeys(
371
- node: Pick<DecryptedNode, 'uid' | 'parentUid' | 'shareId'>,
388
+ node: Pick<TDecryptedNode, 'uid' | 'parentUid' | 'shareId'>,
372
389
  ): Promise<Pick<DecryptedNodeKeys, 'key' | 'hashKey'>> {
373
390
  if (node.parentUid) {
374
391
  try {
@@ -473,13 +490,23 @@ export class NodesAccess {
473
490
  return `https://drive.proton.me/${rootNode.shareId}/${type}/${nodeId}`;
474
491
  }
475
492
 
476
- private async getRootNode(nodeUid: string): Promise<DecryptedNode> {
493
+ private async getRootNode(nodeUid: string): Promise<TDecryptedNode> {
477
494
  const node = await this.getNode(nodeUid);
478
495
  return node.parentUid ? this.getRootNode(node.parentUid) : node;
479
496
  }
480
497
  }
481
498
 
482
- export async function parseNode(logger: Logger, unparsedNode: DecryptedUnparsedNode): Promise<DecryptedNode> {
499
+ export class NodesAccess extends NodesAccessBase {
500
+ protected getDegradedUndecryptableNode(encryptedNode: EncryptedNode, error: DecryptionError): DecryptedNode {
501
+ return this.getDegradedUndecryptableNodeBase(encryptedNode, error);
502
+ }
503
+
504
+ protected parseNode(unparsedNode: DecryptedUnparsedNode): DecryptedNode {
505
+ return parseNode(this.logger, unparsedNode);
506
+ }
507
+ }
508
+
509
+ export function parseNode(logger: Logger, unparsedNode: DecryptedUnparsedNode): DecryptedNode {
483
510
  let nodeName: Result<string, Error | InvalidNameError> = unparsedNode.name;
484
511
  if (unparsedNode.name.ok) {
485
512
  try {
@@ -526,6 +553,7 @@ export async function parseNode(logger: Logger, unparsedNode: DecryptedUnparsedN
526
553
  const extendedAttributes = unparsedNode.folder?.extendedAttributes
527
554
  ? parseFolderExtendedAttributes(logger, unparsedNode.folder.extendedAttributes)
528
555
  : undefined;
556
+
529
557
  return {
530
558
  ...unparsedNode,
531
559
  name: nodeName,
@@ -6,6 +6,7 @@ import { DecryptedNode } from './interface';
6
6
  import { NodesManagement } from './nodesManagement';
7
7
  import { NodeResult } from '../../interface';
8
8
  import { NodeOutOfSyncError } from './errors';
9
+ import { ValidationError } from '../../errors';
9
10
 
10
11
  describe('NodesManagement', () => {
11
12
  let apiService: NodeAPIService;
@@ -191,7 +192,7 @@ describe('NodesManagement', () => {
191
192
  parentNodeUid: 'newParentNodeUid',
192
193
  });
193
194
  expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith(
194
- nodes.nodeUid,
195
+ nodes.nodeUid.name,
195
196
  expect.objectContaining({
196
197
  key: 'nodeUid-key',
197
198
  passphrase: 'nodeUid-passphrase',
@@ -231,7 +232,7 @@ describe('NodesManagement', () => {
231
232
  const newNode = await management.moveNode('anonymousNodeUid', 'newParentNodeUid');
232
233
 
233
234
  expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith(
234
- nodes.anonymousNodeUid,
235
+ nodes.anonymousNodeUid.name,
235
236
  expect.objectContaining({
236
237
  key: 'anonymousNodeUid-key',
237
238
  passphrase: 'anonymousNodeUid-passphrase',
@@ -289,7 +290,7 @@ describe('NodesManagement', () => {
289
290
  parentNodeUid: 'newParentNodeUid',
290
291
  });
291
292
  expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith(
292
- nodes.nodeUid,
293
+ nodes.nodeUid.name,
293
294
  expect.objectContaining({
294
295
  key: 'nodeUid-key',
295
296
  passphrase: 'nodeUid-passphrase',
@@ -324,7 +325,7 @@ describe('NodesManagement', () => {
324
325
  const newNode = await management.copyNode('anonymousNodeUid', 'newParentNodeUid');
325
326
 
326
327
  expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith(
327
- nodes.anonymousNodeUid,
328
+ nodes.anonymousNodeUid.name,
328
329
  expect.objectContaining({
329
330
  key: 'anonymousNodeUid-key',
330
331
  passphrase: 'anonymousNodeUid-passphrase',
@@ -350,6 +351,49 @@ describe('NodesManagement', () => {
350
351
  });
351
352
  });
352
353
 
354
+ it('copyNode manages copy of node with new name', async () => {
355
+ const encryptedCrypto = {
356
+ encryptedName: 'copiedArmoredNodeName',
357
+ hash: 'copiedHash',
358
+ armoredNodePassphrase: 'copiedArmoredNodePassphrase',
359
+ armoredNodePassphraseSignature: 'copiedArmoredNodePassphraseSignature',
360
+ signatureEmail: 'copiedSignatureEmail',
361
+ nameSignatureEmail: 'copiedNameSignatureEmail',
362
+ };
363
+ cryptoService.encryptNodeWithNewParent = jest.fn().mockResolvedValue(encryptedCrypto);
364
+
365
+ const newName = 'new name';
366
+ const newNode = await management.copyNode('nodeUid', 'newParentNodeUid', newName);
367
+
368
+ expect(newNode).toEqual({
369
+ ...nodes.nodeUid,
370
+ name: { ok: true, value: newName },
371
+ uid: 'newCopiedNodeUid',
372
+ parentUid: 'newParentNodeUid',
373
+ encryptedName: 'copiedArmoredNodeName',
374
+ hash: 'copiedHash',
375
+ keyAuthor: { ok: true, value: 'copiedSignatureEmail' },
376
+ nameAuthor: { ok: true, value: 'copiedNameSignatureEmail' },
377
+ });
378
+ expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith(
379
+ { ok: true, value: newName },
380
+ expect.objectContaining({
381
+ key: 'nodeUid-key',
382
+ passphrase: 'nodeUid-passphrase',
383
+ passphraseSessionKey: 'nodeUid-passphraseSessionKey',
384
+ contentKeyPacketSessionKey: 'nodeUid-contentKeyPacketSessionKey',
385
+ nameSessionKey: 'nodeUid-nameSessionKey',
386
+ }),
387
+ expect.objectContaining({ key: 'newParentNodeUid-key', hashKey: 'newParentNodeUid-hashKey' }),
388
+ { type: 'userAddress', email: 'root-email', addressId: 'root-addressId', key: 'root-key' },
389
+ );
390
+ });
391
+
392
+ it('copyNode throws error if name is invalid', async () => {
393
+ const promise = management.copyNode('nodeUid', 'newParentNodeUid', 'invalid/name');
394
+ await expect(promise).rejects.toThrow(ValidationError);
395
+ });
396
+
353
397
  it('trashes node and updates cache', async () => {
354
398
  const uids = ['v1~n1', 'v1~n2'];
355
399
  const trashed = new Set();
@@ -1,17 +1,17 @@
1
1
  import { c } from 'ttag';
2
2
 
3
- import { MemberRole, NodeType, NodeResult, NodeResultWithNewUid, resultOk } from '../../interface';
3
+ import { MemberRole, NodeType, NodeResult, NodeResultWithNewUid, resultOk, InvalidNameError } from '../../interface';
4
4
  import { AbortError, ValidationError } from '../../errors';
5
- import { getErrorMessage } from '../errors';
5
+ import { createErrorFromUnknown, getErrorMessage } from '../errors';
6
6
  import { splitNodeUid } from '../uids';
7
- import { NodeAPIService } from './apiService';
7
+ import { NodeAPIServiceBase } from './apiService';
8
8
  import { NodesCryptoCache } from './cryptoCache';
9
9
  import { NodesCryptoService } from './cryptoService';
10
10
  import { NodeOutOfSyncError } from './errors';
11
11
  import { generateFolderExtendedAttributes } from './extendedAttributes';
12
- import { DecryptedNode } from './interface';
12
+ import { DecryptedNode, EncryptedNode } from './interface';
13
13
  import { splitExtension, joinNameAndExtension } from './nodeName';
14
- import { NodesAccess } from './nodesAccess';
14
+ import { NodesAccessBase } from './nodesAccess';
15
15
  import { validateNodeName } from './validations';
16
16
 
17
17
  const AVAILABLE_NAME_BATCH_SIZE = 10;
@@ -26,12 +26,16 @@ const AVAILABLE_NAME_LIMIT = 1000;
26
26
  * This module uses other modules providing low-level operations, such
27
27
  * as API service, cache, crypto service, etc.
28
28
  */
29
- export class NodesManagement {
29
+ export abstract class NodesManagementBase<
30
+ TEncryptedNode extends EncryptedNode = EncryptedNode,
31
+ TDecryptedNode extends DecryptedNode = DecryptedNode,
32
+ TNodesCryptoService extends NodesCryptoService = NodesCryptoService,
33
+ > {
30
34
  constructor(
31
- protected apiService: NodeAPIService,
35
+ protected apiService: NodeAPIServiceBase<TEncryptedNode>,
32
36
  protected cryptoCache: NodesCryptoCache,
33
37
  protected cryptoService: NodesCryptoService,
34
- protected nodesAccess: NodesAccess,
38
+ protected nodesAccess: NodesAccessBase<TEncryptedNode, TDecryptedNode, TNodesCryptoService>,
35
39
  ) {
36
40
  this.apiService = apiService;
37
41
  this.cryptoCache = cryptoCache;
@@ -43,7 +47,7 @@ export class NodesManagement {
43
47
  nodeUid: string,
44
48
  newName: string,
45
49
  options = { allowRenameRootNode: false },
46
- ): Promise<DecryptedNode> {
50
+ ): Promise<TDecryptedNode> {
47
51
  validateNodeName(newName);
48
52
 
49
53
  const node = await this.nodesAccess.getNode(nodeUid);
@@ -91,7 +95,7 @@ export class NodesManagement {
91
95
  }
92
96
 
93
97
  await this.nodesAccess.notifyNodeChanged(nodeUid);
94
- const newNode: DecryptedNode = {
98
+ const newNode: TDecryptedNode = {
95
99
  ...node,
96
100
  name: resultOk(newName),
97
101
  encryptedName: armoredNodeName,
@@ -123,7 +127,7 @@ export class NodesManagement {
123
127
  }
124
128
  }
125
129
 
126
- async moveNode(nodeUid: string, newParentUid: string): Promise<DecryptedNode> {
130
+ async moveNode(nodeUid: string, newParentUid: string): Promise<TDecryptedNode> {
127
131
  const node = await this.nodesAccess.getNode(nodeUid);
128
132
 
129
133
  const [keys, newParentKeys, signingKeys] = await Promise.all([
@@ -140,7 +144,7 @@ export class NodesManagement {
140
144
  }
141
145
 
142
146
  const encryptedCrypto = await this.cryptoService.encryptNodeWithNewParent(
143
- node,
147
+ node.name,
144
148
  keys,
145
149
  { key: newParentKeys.key, hashKey: newParentKeys.hashKey },
146
150
  signingKeys,
@@ -172,7 +176,7 @@ export class NodesManagement {
172
176
  // TODO: When moving photos, we need to pass content hash.
173
177
  },
174
178
  );
175
- const newNode: DecryptedNode = {
179
+ const newNode: TDecryptedNode = {
176
180
  ...node,
177
181
  encryptedName: encryptedCrypto.encryptedName,
178
182
  parentUid: newParentUid,
@@ -186,16 +190,18 @@ export class NodesManagement {
186
190
 
187
191
  // Improvement requested: copy nodes in parallel using copy_multiple endpoint
188
192
  async *copyNodes(
189
- nodeUids: string[],
193
+ nodeUidsOrWithNames: (string | { uid: string; name: string })[],
190
194
  newParentNodeUid: string,
191
195
  signal?: AbortSignal,
192
196
  ): AsyncGenerator<NodeResultWithNewUid> {
193
- for (const nodeUid of nodeUids) {
197
+ for (const nodeUidOrWithName of nodeUidsOrWithNames) {
194
198
  if (signal?.aborted) {
195
199
  throw new AbortError(c('Error').t`Copy operation aborted`);
196
200
  }
201
+ const nodeUid = typeof nodeUidOrWithName === 'string' ? nodeUidOrWithName : nodeUidOrWithName.uid;
202
+ const name = typeof nodeUidOrWithName === 'string' ? undefined : nodeUidOrWithName.name;
197
203
  try {
198
- const { uid: newNodeUid } = await this.copyNode(nodeUid, newParentNodeUid);
204
+ const { uid: newNodeUid } = await this.copyNode(nodeUid, newParentNodeUid, name);
199
205
  yield {
200
206
  uid: nodeUid,
201
207
  newUid: newNodeUid,
@@ -205,14 +211,19 @@ export class NodesManagement {
205
211
  yield {
206
212
  uid: nodeUid,
207
213
  ok: false,
208
- error: getErrorMessage(error),
214
+ error: createErrorFromUnknown(error),
209
215
  };
210
216
  }
211
217
  }
212
218
  }
213
219
 
214
- async copyNode(nodeUid: string, newParentUid: string): Promise<DecryptedNode> {
220
+ async copyNode(nodeUid: string, newParentUid: string, name?: string): Promise<TDecryptedNode> {
221
+ if (name) {
222
+ validateNodeName(name);
223
+ }
224
+
215
225
  const node = await this.nodesAccess.getNode(nodeUid);
226
+ const nodeName = name ? resultOk<string, Error | InvalidNameError>(name) : node.name;
216
227
 
217
228
  const [keys, newParentKeys, signingKeys] = await Promise.all([
218
229
  this.nodesAccess.getNodePrivateAndSessionKeys(nodeUid),
@@ -225,7 +236,7 @@ export class NodesManagement {
225
236
  }
226
237
 
227
238
  const encryptedCrypto = await this.cryptoService.encryptNodeWithNewParent(
228
- node,
239
+ nodeName,
229
240
  keys,
230
241
  { key: newParentKeys.key, hashKey: newParentKeys.hashKey },
231
242
  signingKeys,
@@ -250,8 +261,9 @@ export class NodesManagement {
250
261
  nameSignatureEmail: encryptedCrypto.nameSignatureEmail,
251
262
  hash: encryptedCrypto.hash,
252
263
  });
253
- const newNode: DecryptedNode = {
264
+ const newNode: TDecryptedNode = {
254
265
  ...node,
266
+ name: nodeName,
255
267
  uid: newNodeUid,
256
268
  encryptedName: encryptedCrypto.encryptedName,
257
269
  parentUid: newParentUid,
@@ -291,7 +303,7 @@ export class NodesManagement {
291
303
  }
292
304
 
293
305
  // FIXME create test for create folder
294
- async createFolder(parentNodeUid: string, folderName: string, modificationTime?: Date): Promise<DecryptedNode> {
306
+ async createFolder(parentNodeUid: string, folderName: string, modificationTime?: Date): Promise<TDecryptedNode> {
295
307
  validateNodeName(folderName);
296
308
 
297
309
  const parentKeys = await this.nodesAccess.getNodeKeys(parentNodeUid);
@@ -320,8 +332,33 @@ export class NodesManagement {
320
332
  });
321
333
 
322
334
  await this.nodesAccess.notifyChildCreated(parentNodeUid);
335
+ const node = this.generateNodeFolder(nodeUid, parentNodeUid, folderName, encryptedCrypto);
336
+ await this.cryptoCache.setNodeKeys(nodeUid, keys);
337
+ return node;
338
+ }
323
339
 
324
- const node: DecryptedNode = {
340
+ protected abstract generateNodeFolder(
341
+ nodeUid: string,
342
+ parentUid: string,
343
+ name: string,
344
+ encryptedCrypto: {
345
+ hash: string;
346
+ encryptedName: string;
347
+ signatureEmail: string | null;
348
+ },
349
+ ): TDecryptedNode;
350
+
351
+ protected generateNodeFolderBase(
352
+ nodeUid: string,
353
+ parentNodeUid: string,
354
+ name: string,
355
+ encryptedCrypto: {
356
+ hash: string;
357
+ encryptedName: string;
358
+ signatureEmail: string | null;
359
+ },
360
+ ): DecryptedNode {
361
+ return {
325
362
  // Internal metadata
326
363
  hash: encryptedCrypto.hash,
327
364
  encryptedName: encryptedCrypto.encryptedName,
@@ -332,6 +369,7 @@ export class NodesManagement {
332
369
  type: NodeType.Folder,
333
370
  mediaType: 'Folder',
334
371
  creationTime: new Date(),
372
+ modificationTime: new Date(),
335
373
 
336
374
  // Share node metadata
337
375
  isShared: false,
@@ -342,12 +380,9 @@ export class NodesManagement {
342
380
  isStale: false,
343
381
  keyAuthor: resultOk(encryptedCrypto.signatureEmail || null),
344
382
  nameAuthor: resultOk(encryptedCrypto.signatureEmail || null),
345
- name: resultOk(folderName),
383
+ name: resultOk(name),
346
384
  treeEventScopeId: splitNodeUid(nodeUid).volumeId,
347
385
  };
348
-
349
- await this.cryptoCache.setNodeKeys(nodeUid, keys);
350
- return node;
351
386
  }
352
387
 
353
388
  async findAvailableName(parentFolderUid: string, name: string): Promise<string> {
@@ -388,3 +423,18 @@ export class NodesManagement {
388
423
  throw new ValidationError(c('Error').t`No available name found`);
389
424
  }
390
425
  }
426
+
427
+ export class NodesManagement extends NodesManagementBase {
428
+ protected generateNodeFolder(
429
+ nodeUid: string,
430
+ parentNodeUid: string,
431
+ name: string,
432
+ encryptedCrypto: {
433
+ hash: string;
434
+ encryptedName: string;
435
+ signatureEmail: string | null;
436
+ },
437
+ ): DecryptedNode {
438
+ return this.generateNodeFolderBase(nodeUid, parentNodeUid, name, encryptedCrypto);
439
+ }
440
+ }
@@ -1,6 +1,6 @@
1
1
  import { Logger } from '../../interface';
2
2
  import { makeNodeUidFromRevisionUid } from '../uids';
3
- import { NodeAPIService } from './apiService';
3
+ import { NodeAPIServiceBase } from './apiService';
4
4
  import { NodesCryptoService } from './cryptoService';
5
5
  import { NodesAccess } from './nodesAccess';
6
6
  import { parseFileExtendedAttributes } from './extendedAttributes';
@@ -12,9 +12,9 @@ import { DecryptedRevision } from './interface';
12
12
  export class NodesRevisons {
13
13
  constructor(
14
14
  private logger: Logger,
15
- private apiService: NodeAPIService,
15
+ private apiService: NodeAPIServiceBase,
16
16
  private cryptoService: NodesCryptoService,
17
- private nodesAccess: NodesAccess,
17
+ private nodesAccess: Pick<NodesAccess, 'getNodeKeys'>,
18
18
  ) {
19
19
  this.logger = logger;
20
20
  this.apiService = apiService;
@@ -1,7 +1,7 @@
1
1
  import { BatchLoading } from '../batchLoading';
2
2
  import { DecryptedNode } from '../nodes';
3
3
  import { PhotosAPIService } from './apiService';
4
- import { NodesService } from './interface';
4
+ import { PhotosNodesAccess } from './nodes';
5
5
  import { PhotoSharesManager } from './shares';
6
6
 
7
7
  const BATCH_LOADING_SIZE = 10;
@@ -13,7 +13,7 @@ export class Albums {
13
13
  constructor(
14
14
  private apiService: PhotosAPIService,
15
15
  private photoShares: PhotoSharesManager,
16
- private nodesService: NodesService,
16
+ private nodesService: PhotosNodesAccess,
17
17
  ) {
18
18
  this.apiService = apiService;
19
19
  this.photoShares = photoShares;