@protontech/drive-sdk 0.15.0 → 0.15.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 (94) hide show
  1. package/dist/crypto/driveCrypto.d.ts +1 -1
  2. package/dist/crypto/driveCrypto.js +2 -2
  3. package/dist/crypto/driveCrypto.js.map +1 -1
  4. package/dist/index.d.ts +1 -1
  5. package/dist/internal/apiService/apiService.d.ts +1 -1
  6. package/dist/internal/apiService/apiService.js +22 -7
  7. package/dist/internal/apiService/apiService.js.map +1 -1
  8. package/dist/internal/apiService/apiService.test.js +13 -0
  9. package/dist/internal/apiService/apiService.test.js.map +1 -1
  10. package/dist/internal/errors.js +35 -2
  11. package/dist/internal/errors.js.map +1 -1
  12. package/dist/internal/events/apiService.d.ts +4 -2
  13. package/dist/internal/events/apiService.js +17 -13
  14. package/dist/internal/events/apiService.js.map +1 -1
  15. package/dist/internal/events/index.d.ts +12 -1
  16. package/dist/internal/events/index.js +17 -1
  17. package/dist/internal/events/index.js.map +1 -1
  18. package/dist/internal/events/index.test.d.ts +1 -0
  19. package/dist/internal/events/index.test.js +58 -0
  20. package/dist/internal/events/index.test.js.map +1 -0
  21. package/dist/internal/nodes/cryptoService.d.ts +1 -0
  22. package/dist/internal/nodes/cryptoService.js +4 -0
  23. package/dist/internal/nodes/cryptoService.js.map +1 -1
  24. package/dist/internal/nodes/nodesAccess.test.js +2 -2
  25. package/dist/internal/nodes/nodesAccess.test.js.map +1 -1
  26. package/dist/internal/nodes/nodesManagement.js +2 -2
  27. package/dist/internal/nodes/nodesManagement.js.map +1 -1
  28. package/dist/internal/nodes/nodesManagement.test.js +1 -1
  29. package/dist/internal/nodes/nodesManagement.test.js.map +1 -1
  30. package/dist/internal/nodes/nodesRevisions.d.ts +1 -1
  31. package/dist/internal/nodes/nodesRevisions.js +3 -0
  32. package/dist/internal/nodes/nodesRevisions.js.map +1 -1
  33. package/dist/internal/nodes/validations.d.ts +1 -1
  34. package/dist/internal/nodes/validations.js +1 -4
  35. package/dist/internal/nodes/validations.js.map +1 -1
  36. package/dist/internal/photos/addToAlbum.js +1 -1
  37. package/dist/internal/photos/addToAlbum.js.map +1 -1
  38. package/dist/internal/photos/addToAlbum.test.js +12 -12
  39. package/dist/internal/photos/addToAlbum.test.js.map +1 -1
  40. package/dist/internal/photos/albumsManager.js +1 -1
  41. package/dist/internal/photos/albumsManager.js.map +1 -1
  42. package/dist/internal/photos/albumsManager.test.js +2 -2
  43. package/dist/internal/photos/albumsManager.test.js.map +1 -1
  44. package/dist/internal/photos/apiService.d.ts +3 -3
  45. package/dist/internal/photos/apiService.js +5 -5
  46. package/dist/internal/photos/apiService.js.map +1 -1
  47. package/dist/internal/photos/apiService.test.js +4 -4
  48. package/dist/internal/photos/apiService.test.js.map +1 -1
  49. package/dist/internal/photos/photosManager.d.ts +1 -0
  50. package/dist/internal/photos/photosManager.js +38 -2
  51. package/dist/internal/photos/photosManager.js.map +1 -1
  52. package/dist/internal/photos/photosManager.test.js +26 -0
  53. package/dist/internal/photos/photosManager.test.js.map +1 -1
  54. package/dist/internal/sharing/cryptoService.js +4 -3
  55. package/dist/internal/sharing/cryptoService.js.map +1 -1
  56. package/dist/internal/sharing/cryptoService.test.js +3 -3
  57. package/dist/internal/sharing/cryptoService.test.js.map +1 -1
  58. package/dist/internal/sharingPublic/nodes.d.ts +1 -0
  59. package/dist/internal/sharingPublic/nodes.js +2 -0
  60. package/dist/internal/sharingPublic/nodes.js.map +1 -1
  61. package/dist/protonDriveClient.d.ts +14 -2
  62. package/dist/protonDriveClient.js +6 -0
  63. package/dist/protonDriveClient.js.map +1 -1
  64. package/dist/protonDrivePhotosClient.d.ts +10 -2
  65. package/dist/protonDrivePhotosClient.js +6 -0
  66. package/dist/protonDrivePhotosClient.js.map +1 -1
  67. package/package.json +1 -1
  68. package/src/crypto/driveCrypto.ts +2 -1
  69. package/src/index.ts +1 -1
  70. package/src/internal/apiService/apiService.test.ts +16 -0
  71. package/src/internal/apiService/apiService.ts +20 -2
  72. package/src/internal/errors.ts +40 -1
  73. package/src/internal/events/apiService.ts +20 -17
  74. package/src/internal/events/index.test.ts +67 -0
  75. package/src/internal/events/index.ts +20 -2
  76. package/src/internal/nodes/cryptoService.ts +6 -0
  77. package/src/internal/nodes/nodesAccess.test.ts +2 -2
  78. package/src/internal/nodes/nodesManagement.test.ts +1 -1
  79. package/src/internal/nodes/nodesManagement.ts +2 -2
  80. package/src/internal/nodes/nodesRevisions.ts +5 -1
  81. package/src/internal/nodes/validations.ts +1 -4
  82. package/src/internal/photos/addToAlbum.test.ts +12 -12
  83. package/src/internal/photos/addToAlbum.ts +1 -1
  84. package/src/internal/photos/albumsManager.test.ts +2 -2
  85. package/src/internal/photos/albumsManager.ts +1 -1
  86. package/src/internal/photos/apiService.test.ts +4 -4
  87. package/src/internal/photos/apiService.ts +6 -6
  88. package/src/internal/photos/photosManager.test.ts +36 -1
  89. package/src/internal/photos/photosManager.ts +48 -7
  90. package/src/internal/sharing/cryptoService.test.ts +3 -3
  91. package/src/internal/sharing/cryptoService.ts +4 -3
  92. package/src/internal/sharingPublic/nodes.ts +3 -0
  93. package/src/protonDriveClient.ts +18 -1
  94. package/src/protonDrivePhotosClient.ts +15 -5
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ export { OpenPGPCryptoWithCryptoProxy } from './crypto';
10
10
  export * from './errors';
11
11
  export { NullFeatureFlagProvider } from './featureFlags';
12
12
  export * from './interface';
13
- export type { EventSubscription } from './internal/events';
13
+ export type { CoreApiEvent, EventSubscription } from './internal/events';
14
14
  export { ProtonDriveClient } from './protonDriveClient';
15
15
  export { VERSION } from './version';
16
16
 
@@ -223,6 +223,22 @@ describe('DriveAPIService', () => {
223
223
  expect(telemetry.recordMetric).not.toHaveBeenCalled();
224
224
  });
225
225
 
226
+ it('on transient socket / transport error', async () => {
227
+ const error = new Error('The socket connection was closed unexpectedly');
228
+ httpClient.fetchJson = jest
229
+ .fn()
230
+ .mockRejectedValueOnce(error)
231
+ .mockRejectedValueOnce(error)
232
+ .mockResolvedValueOnce(generateOkResponse());
233
+
234
+ const result = api.get('test');
235
+
236
+ await expect(result).resolves.toEqual({ Code: ErrorCode.OK });
237
+ expect(httpClient.fetchJson).toHaveBeenCalledTimes(3);
238
+ expectSDKEvents();
239
+ expect(telemetry.recordMetric).not.toHaveBeenCalled();
240
+ });
241
+
226
242
  it('on general error', async () => {
227
243
  const error = new Error('Error');
228
244
  httpClient.fetchJson = jest.fn().mockRejectedValueOnce(error).mockResolvedValueOnce(generateOkResponse());
@@ -3,6 +3,7 @@ import { c } from 'ttag';
3
3
  import { AbortError, ProtonDriveError, RateLimitedError, ServerError } from '../../errors';
4
4
  import { Logger, ProtonDriveHTTPClient, ProtonDriveTelemetry } from '../../interface';
5
5
  import { VERSION } from '../../version';
6
+ import { isNetworkError } from '../errors';
6
7
  import { SDKEvents } from '../sdkEvents';
7
8
  import { waitSeconds } from '../wait';
8
9
  import { HTTPErrorCode, isCodeOk, isCodeOkAsync } from './errorCodes';
@@ -23,6 +24,11 @@ const DEFAULT_STORAGE_TIMEOUT_MS = 600_000;
23
24
  */
24
25
  const MAX_TIMEOUT_ERROR_RETRY_ATTEMPTS = 3;
25
26
 
27
+ /**
28
+ * Maximum number of retry attempts for a network error.
29
+ */
30
+ const MAX_NETWORK_ERROR_RETRY_ATTEMPTS = 3;
31
+
26
32
  /**
27
33
  * How many subsequent 429 errors are allowed before we stop further requests.
28
34
  */
@@ -55,6 +61,11 @@ const TOO_MANY_SUBSEQUENT_OFFLINE_ERRORS = 10;
55
61
  */
56
62
  const SERVER_ERROR_RETRY_DELAY_SECONDS = 1;
57
63
 
64
+ /**
65
+ * After how long to re-try after network error.
66
+ */
67
+ const NETWORK_ERROR_RETRY_DELAY_SECONDS = 5;
68
+
58
69
  /**
59
70
  * After how long to re-try after offline error.
60
71
  */
@@ -80,7 +91,7 @@ const GENERAL_RETRY_DELAY_SECONDS = 1;
80
91
  *
81
92
  * * exception from HTTP client
82
93
  * * retry on offline exc. (with delay from OFFLINE_RETRY_DELAY_SECONDS)
83
- * * retry on timeout exc. (with delay from SERVER_ERROR_RETRY_DELAY_SECONDS)
94
+ * * retry on transient network exc. (with delay from SERVER_ERROR_RETRY_DELAY_SECONDS)
84
95
  * * retry ONCE on any exc. (with delay from GENERAL_RETRY_DELAY_SECONDS)
85
96
  * * HTTP status 429
86
97
  * * retry (with delay from `retry-after` header or DEFAULT_429_RETRY_DELAY_SECONDS)
@@ -318,6 +329,12 @@ export class DriveAPIService {
318
329
  await waitSeconds(SERVER_ERROR_RETRY_DELAY_SECONDS);
319
330
  return this.fetch(request, callback, { attempt: attempt + 1, previousError: error });
320
331
  }
332
+
333
+ if (isNetworkError(error) && attempt + 1 < MAX_NETWORK_ERROR_RETRY_ATTEMPTS) {
334
+ this.logger.warn(`${request.method} ${request.url}: Network error, retrying`);
335
+ await waitSeconds(NETWORK_ERROR_RETRY_DELAY_SECONDS);
336
+ return this.fetch(request, callback, { attempt: attempt + 1, previousError: error });
337
+ }
321
338
  }
322
339
  if (attempt === 0) {
323
340
  this.logger.error(`${request.method} ${request.url}: failed, retrying once`, error);
@@ -367,7 +384,8 @@ export class DriveAPIService {
367
384
  !(previousError instanceof Error) ||
368
385
  (previousError instanceof Error &&
369
386
  previousError.name !== 'TimeoutError' &&
370
- previousError.name !== 'OfflineError');
387
+ previousError.name !== 'OfflineError' &&
388
+ !isNetworkError(previousError));
371
389
 
372
390
  if (isWarning) {
373
391
  this.telemetry.recordMetric({
@@ -72,7 +72,7 @@ export function isNetworkError(error: unknown): boolean {
72
72
  if (!(error instanceof Error)) {
73
73
  return false;
74
74
  }
75
- return (
75
+ if (
76
76
  error.name === 'OfflineError' ||
77
77
  error.name === 'NetworkError' ||
78
78
  error.message?.toLowerCase() === 'network error' ||
@@ -80,5 +80,44 @@ export function isNetworkError(error: unknown): boolean {
80
80
  ['Failed to fetch', 'NetworkError when attempting to fetch resource', 'Load failed'].includes(
81
81
  error.message,
82
82
  ))
83
+ ) {
84
+ return true;
85
+ }
86
+ if (errorMessageIndicatesTransientTransportFailure(error.message) || errorHasTransientTransportCode(error)) {
87
+ return true;
88
+ }
89
+ if (error.cause instanceof Error) {
90
+ return (
91
+ errorMessageIndicatesTransientTransportFailure(error.cause.message) ||
92
+ errorHasTransientTransportCode(error.cause)
93
+ );
94
+ }
95
+ return false;
96
+ }
97
+
98
+ function errorMessageIndicatesTransientTransportFailure(message: string | undefined): boolean {
99
+ if (!message) {
100
+ return false;
101
+ }
102
+ const lower = message.toLowerCase();
103
+ return (
104
+ // Remote end closed TLS/TCP without a complete response.
105
+ lower.includes('socket connection was closed unexpectedly') ||
106
+ // Remote end sent RST or closed the write side mid-request.
107
+ lower.includes('other side closed') ||
108
+ // Remote end closed the socket abruptly.
109
+ lower.includes('socket hang up')
110
+ );
111
+ }
112
+
113
+ function errorHasTransientTransportCode(error: Error): boolean {
114
+ const code = (error as NodeJS.ErrnoException).code;
115
+ return (
116
+ // TCP RST or equivalent: common under flaky networks or after server restart.
117
+ code === 'ECONNRESET' ||
118
+ // Writing to a socket whose other end is gone (often grouped with reset/hang-up).
119
+ code === 'EPIPE' ||
120
+ // Socket-level failure after connect (e.g. unexpected close on the wire).
121
+ code === 'UND_ERR_SOCKET'
83
122
  );
84
123
  }
@@ -4,7 +4,7 @@ import { DriveEvent, DriveEventsListWithStatus, DriveEventType, NodeEvent, NodeE
4
4
 
5
5
  type GetCoreLatestEventResponse =
6
6
  corePaths['/core/{_version}/events/latest']['get']['responses']['200']['content']['application/json'];
7
- type GetCoreEventResponse =
7
+ export type CoreApiEvent =
8
8
  corePaths['/core/{_version}/events/{id}']['get']['responses']['200']['content']['application/json'];
9
9
 
10
10
  type GetVolumeLatestEventResponse =
@@ -40,28 +40,33 @@ export class EventsAPIService {
40
40
 
41
41
  async getCoreEvents(eventId: string): Promise<DriveEventsListWithStatus> {
42
42
  // TODO: Switch to v6 endpoint?
43
- const result = await this.apiService.get<GetCoreEventResponse>(`core/v5/events/${eventId}`);
43
+ const result = await this.apiService.get<CoreApiEvent>(`core/v5/events/${eventId}`);
44
+ const driveEvents = EventsAPIService.getDriveEventsFromCoreEvent(result);
44
45
  // in core/v5/events, refresh is always all apps, value 255
45
46
  const refresh = result.Refresh > 0;
46
- const events: DriveEvent[] =
47
- refresh || result.DriveShareRefresh?.Action === 2
48
- ? [
49
- {
50
- type: DriveEventType.SharedWithMeUpdated,
51
- eventId: result.EventID,
52
- treeEventScopeId: 'core',
53
- },
54
- ]
55
- : [];
56
-
57
47
  return {
58
48
  latestEventId: result.EventID,
59
49
  more: result.More === 1,
60
50
  refresh,
61
- events,
51
+ events: driveEvents,
62
52
  };
63
53
  }
64
54
 
55
+ static getDriveEventsFromCoreEvent(result: CoreApiEvent): DriveEvent[] {
56
+ // in core/v5/events, refresh is always all apps, value 255
57
+ const refresh = result.Refresh > 0;
58
+ if (refresh || result.DriveShareRefresh?.Action === 2) {
59
+ return [
60
+ {
61
+ type: DriveEventType.SharedWithMeUpdated,
62
+ eventId: result.EventID,
63
+ treeEventScopeId: 'core',
64
+ },
65
+ ];
66
+ }
67
+ return [];
68
+ }
69
+
65
70
  async getVolumeLatestEventId(volumeId: string): Promise<string> {
66
71
  const result = await this.apiService.get<GetVolumeLatestEventResponse>(
67
72
  `drive/volumes/${volumeId}/events/latest`,
@@ -81,9 +86,7 @@ export class EventsAPIService {
81
86
  const type = VOLUME_EVENT_TYPE_MAP[event.EventType];
82
87
  const uids = {
83
88
  nodeUid: makeNodeUid(volumeId, event.Link.LinkID),
84
- parentNodeUid: event.Link.ParentLinkID
85
- ? makeNodeUid(volumeId, event.Link.ParentLinkID)
86
- : undefined,
89
+ parentNodeUid: event.Link.ParentLinkID ? makeNodeUid(volumeId, event.Link.ParentLinkID) : undefined,
87
90
  };
88
91
  return {
89
92
  type,
@@ -0,0 +1,67 @@
1
+ import { getMockTelemetry } from '../../tests/telemetry';
2
+ import { DriveAPIService } from '../apiService';
3
+ import { CoreApiEvent } from './apiService';
4
+ import { DriveEventsService } from './index';
5
+ import { DriveEventType, DriveListener } from './interface';
6
+
7
+ describe('DriveEventsService', () => {
8
+ describe('processCoreEvent', () => {
9
+ function createService(cacheEventListeners: DriveListener[] = []) {
10
+ const telemetry = getMockTelemetry();
11
+ const apiService = {} as unknown as DriveAPIService;
12
+ const sharesService = { isOwnVolume: jest.fn() };
13
+ return new DriveEventsService(telemetry, apiService, sharesService, cacheEventListeners);
14
+ }
15
+
16
+ it('returns no drive events and does not notify listeners when the raw event is not a refresh', async () => {
17
+ const listener: jest.MockedFunction<DriveListener> = jest.fn().mockResolvedValue(undefined);
18
+ const service = createService([listener]);
19
+ const raw = {
20
+ EventID: 'event-no-refresh',
21
+ Refresh: 0,
22
+ } as CoreApiEvent;
23
+
24
+ const result = await service.processCoreEvent(raw);
25
+
26
+ expect(result).toEqual([]);
27
+ expect(listener).not.toHaveBeenCalled();
28
+ });
29
+
30
+ it('returns SharedWithMeUpdated when Refresh is non-zero', async () => {
31
+ const service = createService();
32
+ const raw = {
33
+ EventID: 'event-refresh',
34
+ Refresh: 255,
35
+ } as CoreApiEvent;
36
+
37
+ const result = await service.processCoreEvent(raw);
38
+
39
+ expect(result).toEqual([
40
+ {
41
+ type: DriveEventType.SharedWithMeUpdated,
42
+ eventId: 'event-refresh',
43
+ treeEventScopeId: 'core',
44
+ },
45
+ ]);
46
+ });
47
+
48
+ it('returns SharedWithMeUpdated when DriveShareRefresh.Action is 2', async () => {
49
+ const service = createService();
50
+ const raw = {
51
+ EventID: 'event-share-refresh',
52
+ Refresh: 0,
53
+ DriveShareRefresh: { Action: 2 },
54
+ } as CoreApiEvent;
55
+
56
+ const result = await service.processCoreEvent(raw);
57
+
58
+ expect(result).toEqual([
59
+ {
60
+ type: DriveEventType.SharedWithMeUpdated,
61
+ eventId: 'event-share-refresh',
62
+ treeEventScopeId: 'core',
63
+ },
64
+ ]);
65
+ });
66
+ });
67
+ });
@@ -1,11 +1,12 @@
1
1
  import { Logger, ProtonDriveTelemetry } from '../../interface';
2
2
  import { DriveAPIService } from '../apiService';
3
- import { EventsAPIService } from './apiService';
3
+ import { CoreApiEvent, EventsAPIService } from './apiService';
4
4
  import { CoreEventManager } from './coreEventManager';
5
5
  import { EventManager } from './eventManager';
6
6
  import { DriveEvent, DriveListener, EventSubscription, LatestEventIdProvider, SharesService } from './interface';
7
7
  import { VolumeEventManager } from './volumeEventManager';
8
8
 
9
+ export type { CoreApiEvent } from './apiService';
9
10
  export type { DriveEvent, DriveListener, EventSubscription } from './interface';
10
11
  export { DriveEventType } from './interface';
11
12
 
@@ -37,7 +38,9 @@ export class DriveEventsService {
37
38
  this.volumeEventManagers = {};
38
39
  }
39
40
 
40
- // FIXME: Allow to pass own core events manager from the public interface.
41
+ /**
42
+ * @deprecated Use `processCoreEvent` instead.
43
+ */
41
44
  async subscribeToCoreEvents(callback: DriveListener): Promise<EventSubscription> {
42
45
  let manager = this.coreEventManager;
43
46
  const started = !!manager;
@@ -72,6 +75,21 @@ export class DriveEventsService {
72
75
  return eventManager;
73
76
  }
74
77
 
78
+ /**
79
+ * Process a raw core API event fetched by the caller's own event loop.
80
+ * The SDK derives drive-relevant events from it, updates internal caches,
81
+ * and notifies all listeners registered via `subscribeToPushedCoreEvents`.
82
+ */
83
+ async processCoreEvent(rawEvent: CoreApiEvent): Promise<DriveEvent[]> {
84
+ const driveEvents = EventsAPIService.getDriveEventsFromCoreEvent(rawEvent);
85
+ for (const event of driveEvents) {
86
+ for (const listener of this.cacheEventListeners) {
87
+ await listener(event);
88
+ }
89
+ }
90
+ return driveEvents;
91
+ }
92
+
75
93
  /**
76
94
  * Subscribe to drive events. The treeEventScopeId can be obtained from a node.
77
95
  */
@@ -73,6 +73,8 @@ type NodesCryptoReporterNode = {
73
73
  export class NodesCryptoService {
74
74
  private logger: Logger;
75
75
 
76
+ protected allowContentKeyPacketFallbackVerification = true;
77
+
76
78
  constructor(
77
79
  telemetry: ProtonDriveTelemetry,
78
80
  protected driveCrypto: DriveCrypto,
@@ -541,6 +543,10 @@ export class NodesCryptoService {
541
543
  return result;
542
544
  }
543
545
 
546
+ if (!this.allowContentKeyPacketFallbackVerification) {
547
+ return result;
548
+ }
549
+
544
550
  const { volumeId: ownVolumeId } = await this.sharesService.getRootIDs();
545
551
  const { volumeId: nodesVolumeId } = splitNodeUid(node.uid);
546
552
 
@@ -134,11 +134,11 @@ describe('nodesAccess', () => {
134
134
  const decryptedUnparsedNode = {
135
135
  uid: 'volumeId~nodeId',
136
136
  parentUid: 'volumeId~parentNodeid',
137
- name: { ok: true, value: 'foo/bar' },
137
+ name: { ok: true, value: '' },
138
138
  } as DecryptedUnparsedNode;
139
139
  const decryptedNode = {
140
140
  ...decryptedUnparsedNode,
141
- name: { ok: false, error: { name: 'foo/bar', error: "Name must not contain the character '/'" } },
141
+ name: { ok: false, error: { name: '', error: "Name must not be empty" } },
142
142
  treeEventScopeId: 'volumeId',
143
143
  } as DecryptedNode;
144
144
  const decryptedKeys = { key: 'key' } as any as DecryptedNodeKeys;
@@ -426,7 +426,7 @@ describe('NodesManagement', () => {
426
426
  });
427
427
 
428
428
  it('copyNode throws error if name is invalid', async () => {
429
- const promise = management.copyNode('nodeUid', 'newParentNodeUid', 'invalid/name');
429
+ const promise = management.copyNode('nodeUid', 'newParentNodeUid', '');
430
430
  await expect(promise).rejects.toThrow(ValidationError);
431
431
  });
432
432
 
@@ -236,12 +236,12 @@ export abstract class NodesManagementBase<
236
236
  }
237
237
 
238
238
  async copyNode(nodeUid: string, newParentUid: string, name?: string): Promise<TDecryptedNode> {
239
- if (name) {
239
+ if (name !== undefined) {
240
240
  validateNodeName(name);
241
241
  }
242
242
 
243
243
  const node = await this.nodesAccess.getNode(nodeUid);
244
- const nodeName = name ? resultOk<string, Error | InvalidNameError>(name) : node.name;
244
+ const nodeName = name !== undefined ? resultOk<string, Error | InvalidNameError>(name) : node.name;
245
245
 
246
246
  const [keys, newParentKeys, signingKeys] = await Promise.all([
247
247
  this.nodesAccess.getNodePrivateAndSessionKeys(nodeUid),
@@ -14,7 +14,7 @@ export class NodesRevisons {
14
14
  private logger: Logger,
15
15
  private apiService: NodeAPIServiceBase,
16
16
  private cryptoService: NodesCryptoService,
17
- private nodesAccess: Pick<NodesAccess, 'getNodeKeys'>,
17
+ private nodesAccess: Pick<NodesAccess, 'getNodeKeys' | 'notifyNodeChanged'>,
18
18
  ) {
19
19
  this.logger = logger;
20
20
  this.apiService = apiService;
@@ -67,6 +67,10 @@ export class NodesRevisons {
67
67
 
68
68
  async restoreRevision(nodeRevisionUid: string): Promise<void> {
69
69
  await this.apiService.restoreRevision(nodeRevisionUid);
70
+
71
+ // Restoring a revision creates a new active revision.
72
+ const nodeUid = makeNodeUidFromRevisionUid(nodeRevisionUid);
73
+ await this.nodesAccess.notifyNodeChanged(nodeUid);
70
74
  }
71
75
 
72
76
  async deleteRevision(nodeRevisionUid: string): Promise<void> {
@@ -5,7 +5,7 @@ import { ValidationError } from '../../errors';
5
5
  const MAX_NODE_NAME_LENGTH = 255;
6
6
 
7
7
  /**
8
- * @throws Error if the name is empty, long, or includes slash in the name.
8
+ * @throws Error if the name is empty or long.
9
9
  */
10
10
  export function validateNodeName(name: string): void {
11
11
  if (!name) {
@@ -20,7 +20,4 @@ export function validateNodeName(name: string): void {
20
20
  ),
21
21
  );
22
22
  }
23
- if (name.includes('/')) {
24
- throw new ValidationError(c('Error').t`Name must not contain the character '/'`);
25
- }
26
23
  }
@@ -69,7 +69,7 @@ describe('AddToAlbumProcess', () => {
69
69
  // @ts-expect-error Mocking for testing purposes
70
70
  apiService = {
71
71
  addPhotosToAlbum: jest.fn(),
72
- copyPhotoToAlbum: jest.fn(),
72
+ copyPhoto: jest.fn(),
73
73
  };
74
74
 
75
75
  // @ts-expect-error Mocking for testing purposes
@@ -154,7 +154,7 @@ describe('AddToAlbumProcess', () => {
154
154
  });
155
155
 
156
156
  let copyToAlbumReturnedMissing = false;
157
- apiService.copyPhotoToAlbum.mockImplementation(async (albumUid, payload) => {
157
+ apiService.copyPhoto.mockImplementation(async (albumUid, payload) => {
158
158
  let error: Error | undefined;
159
159
  if (payload.nodeUid.includes('missingRelatedTwice')) {
160
160
  error = new MissingRelatedPhotosError(['volume2~missingRelatedTwice1']);
@@ -322,7 +322,7 @@ describe('AddToAlbumProcess', () => {
322
322
  const photoUids = Array.from({ length: 25 }, (_, i) => `volume2~photo${i}`);
323
323
 
324
324
  let copyPhotoCallCount = 0;
325
- apiService.copyPhotoToAlbum.mockImplementation(async (albumUid, payload) => {
325
+ apiService.copyPhoto.mockImplementation(async (albumUid, payload) => {
326
326
  copyPhotoCallCount++;
327
327
 
328
328
  // First few calls should happen before all 25 photos are prepared
@@ -351,8 +351,8 @@ describe('AddToAlbumProcess', () => {
351
351
  uid: mainPhotoUid,
352
352
  ok: true,
353
353
  }])
354
- expect(apiService.copyPhotoToAlbum).toHaveBeenCalledTimes(1);
355
- const params = apiService.copyPhotoToAlbum.mock.calls[0];
354
+ expect(apiService.copyPhoto).toHaveBeenCalledTimes(1);
355
+ const params = apiService.copyPhoto.mock.calls[0];
356
356
  expect(params[1].relatedPhotos?.length).toBe(15);
357
357
  });
358
358
 
@@ -366,7 +366,7 @@ describe('AddToAlbumProcess', () => {
366
366
  ok: true,
367
367
  }]);
368
368
  expect(nodesService.iterateNodes).toHaveBeenCalledTimes(3); // main photo + related photo + missing related photo
369
- expect(apiService.copyPhotoToAlbum).toHaveBeenCalledTimes(2); // two attempts
369
+ expect(apiService.copyPhoto).toHaveBeenCalledTimes(2); // two attempts
370
370
  });
371
371
 
372
372
  it('should return error if missing related photos error occurs twice', async () => {
@@ -380,7 +380,7 @@ describe('AddToAlbumProcess', () => {
380
380
  error: new MissingRelatedPhotosError(['volume2~missingRelatedOnce1']),
381
381
  }]);
382
382
  expect(nodesService.iterateNodes).toHaveBeenCalledTimes(3); // main photo + related photo + missing related photo
383
- expect(apiService.copyPhotoToAlbum).toHaveBeenCalledTimes(2); // two attempts
383
+ expect(apiService.copyPhoto).toHaveBeenCalledTimes(2); // two attempts
384
384
  });
385
385
 
386
386
  it('should return error when crypto service fails', async () => {
@@ -428,7 +428,7 @@ describe('AddToAlbumProcess', () => {
428
428
  it('should not notify for failed photo copies', async () => {
429
429
  const photoUid = 'volume2~photo1';
430
430
 
431
- apiService.copyPhotoToAlbum.mockRejectedValue(new Error('API error'));
431
+ apiService.copyPhoto.mockRejectedValue(new Error('API error'));
432
432
 
433
433
  const results = await executeProcess([photoUid]);
434
434
 
@@ -467,9 +467,9 @@ describe('AddToAlbumProcess', () => {
467
467
  expect(nodesService.iterateNodes.mock.calls[1][0]).toMatchObject(differentVolumeUids);
468
468
  expect(apiService.addPhotosToAlbum).toHaveBeenCalledTimes(1);
469
469
  expect(apiService.addPhotosToAlbum.mock.calls[0][1].map(({ nodeUid }) => nodeUid)).toMatchObject(sameVolumeUids);
470
- expect(apiService.copyPhotoToAlbum).toHaveBeenCalledTimes(2);
471
- expect(apiService.copyPhotoToAlbum.mock.calls[0][1].nodeUid).toBe(differentVolumeUids[0]);
472
- expect(apiService.copyPhotoToAlbum.mock.calls[1][1].nodeUid).toBe(differentVolumeUids[1]);
470
+ expect(apiService.copyPhoto).toHaveBeenCalledTimes(2);
471
+ expect(apiService.copyPhoto.mock.calls[0][1].nodeUid).toBe(differentVolumeUids[0]);
472
+ expect(apiService.copyPhoto.mock.calls[1][1].nodeUid).toBe(differentVolumeUids[1]);
473
473
  });
474
474
 
475
475
  it('should prepare payloads in parallel for both queues', async () => {
@@ -496,7 +496,7 @@ describe('AddToAlbumProcess', () => {
496
496
  expect(results[1].ok).toBe(true);
497
497
  expect(nodesService.iterateNodes).toHaveBeenCalledTimes(3 + 3); // main photo + related photo + missing related photo
498
498
  expect(apiService.addPhotosToAlbum).toHaveBeenCalledTimes(2); // two attempts
499
- expect(apiService.copyPhotoToAlbum).toHaveBeenCalledTimes(2); // two attempts
499
+ expect(apiService.copyPhoto).toHaveBeenCalledTimes(2); // two attempts
500
500
  });
501
501
 
502
502
  it('should notify correctly for both volumes', async () => {
@@ -118,7 +118,7 @@ export class AddToAlbumProcess {
118
118
 
119
119
  for (const payload of payloads) {
120
120
  try {
121
- const newPhotoNodeUid = await this.apiService.copyPhotoToAlbum(
121
+ const newPhotoNodeUid = await this.apiService.copyPhoto(
122
122
  this.albumNodeUid,
123
123
  payload,
124
124
  this.signal,
@@ -152,7 +152,7 @@ describe('Albums', () => {
152
152
  });
153
153
 
154
154
  it('throws validation error for invalid album name', async () => {
155
- await expect(albums.createAlbum('invalid/name')).rejects.toThrow(ValidationError);
155
+ await expect(albums.createAlbum('')).rejects.toThrow(ValidationError);
156
156
  });
157
157
 
158
158
  it('throws error when parent hash key is not available', async () => {
@@ -228,7 +228,7 @@ describe('Albums', () => {
228
228
  });
229
229
 
230
230
  it('throws validation error for invalid album name', async () => {
231
- await expect(albums.updateAlbum('albumNodeUid', { name: 'invalid/name' })).rejects.toThrow(ValidationError);
231
+ await expect(albums.updateAlbum('albumNodeUid', { name: '' })).rejects.toThrow(ValidationError);
232
232
  });
233
233
  });
234
234
 
@@ -133,7 +133,7 @@ export class AlbumsManager {
133
133
  coverPhotoNodeUid?: string;
134
134
  },
135
135
  ): Promise<DecryptedPhotoNode> {
136
- if (updates.name) {
136
+ if (updates.name !== undefined) {
137
137
  validateNodeName(updates.name);
138
138
  }
139
139
 
@@ -147,7 +147,7 @@ describe('photosAPIService', () => {
147
147
  });
148
148
  });
149
149
 
150
- describe('copyPhotoToAlbum', () => {
150
+ describe('copyPhoto', () => {
151
151
  const photoPayloads = [
152
152
  {
153
153
  nodeUid: 'volumeId2~photoNodeId1',
@@ -181,7 +181,7 @@ describe('photosAPIService', () => {
181
181
  LinkID: 'photoNodeId1',
182
182
  });
183
183
 
184
- const result = await api.copyPhotoToAlbum(albumNodeUid, photoPayloads[0]);
184
+ const result = await api.copyPhoto(albumNodeUid, photoPayloads[0]);
185
185
 
186
186
  expect(result).toEqual('volumeId1~photoNodeId1');
187
187
  expect(apiMock.post).toHaveBeenCalledWith(
@@ -215,7 +215,7 @@ describe('photosAPIService', () => {
215
215
  },
216
216
  ));
217
217
 
218
- const promise = api.copyPhotoToAlbum(albumNodeUid, photoPayloads[0]);
218
+ const promise = api.copyPhoto(albumNodeUid, photoPayloads[0]);
219
219
 
220
220
  await expect(promise).rejects.toThrow(MissingRelatedPhotosError);
221
221
  try {
@@ -229,7 +229,7 @@ describe('photosAPIService', () => {
229
229
  const error = new APICodeError('Some error', 3000);
230
230
  apiMock.post = jest.fn().mockRejectedValue(error);
231
231
 
232
- const promise = api.copyPhotoToAlbum(albumNodeUid, photoPayloads[0]);
232
+ const promise = api.copyPhoto(albumNodeUid, photoPayloads[0]);
233
233
 
234
234
  await expect(promise).rejects.toThrow(error);
235
235
  });
@@ -358,7 +358,7 @@ export class PhotosAPIService {
358
358
  /**
359
359
  * Add photos from the same volume to an album.
360
360
  *
361
- * To add photos from different volumes, use the {@link copyPhotoToAlbum} method.
361
+ * To add photos from different volumes, use the {@link copyPhoto} method.
362
362
  *
363
363
  * In the future, these two methods will be merged into a single one.
364
364
  */
@@ -432,26 +432,26 @@ export class PhotosAPIService {
432
432
  }
433
433
 
434
434
  /**
435
- * Copy a photo to a shared album on a different volume.
435
+ * Copy a photo from a different volume to an album or to the user's own timeline root.
436
436
  *
437
437
  * To add photos from the same volume to an album, use the {@link addPhotosToAlbum} method.
438
438
  *
439
439
  * In the future, these two methods will be merged into a single one.
440
440
  */
441
- async copyPhotoToAlbum(
442
- albumNodeUid: string,
441
+ async copyPhoto(
442
+ targetNodeUid: string,
443
443
  payload: TransferEncryptedPhotoPayload,
444
444
  signal?: AbortSignal,
445
445
  ): Promise<string> {
446
446
  const { volumeId: sourceVolumeId, nodeId: sourceLinkId } = splitNodeUid(payload.nodeUid);
447
- const { volumeId: targetVolumeId, nodeId: targetAlbumLinkId } = splitNodeUid(albumNodeUid);
447
+ const { volumeId: targetVolumeId, nodeId: targetNodeId } = splitNodeUid(targetNodeUid);
448
448
 
449
449
  try {
450
450
  const response = await this.apiService.post<PostCopyLinkRequest, PostCopyLinkResponse>(
451
451
  `drive/volumes/${sourceVolumeId}/links/${sourceLinkId}/copy`,
452
452
  {
453
453
  TargetVolumeID: targetVolumeId,
454
- TargetParentLinkID: targetAlbumLinkId,
454
+ TargetParentLinkID: targetNodeId,
455
455
  Hash: payload.nameHash,
456
456
  Name: payload.encryptedName,
457
457
  NameSignatureEmail: payload.nameSignatureEmail,