@open-webapp/drive-sync 0.4.1 → 0.5.1

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/README.md CHANGED
@@ -27,6 +27,15 @@ await p.connect()
27
27
  const picked = await p.pickFile({ apiKey: PICKER_API_KEY, appId: GCP_PROJECT_NUMBER })
28
28
  const folderId = await p.ensureFolderPath()
29
29
  await p.files.write({ folderId, name: 'data.json', content: '{}', mimeType: 'application/json' })
30
+
31
+ // ensureFolderPath() with subPath for nested folder navigation
32
+ const nestedFolderId = await p.ensureFolderPath({ subPath: ['Archive', 'Q1'] })
33
+
34
+ // files.update() for metadata-only or baseline-preserving updates
35
+ const ref = await p.files.update(fileId, {
36
+ name: 'renamed.json', // metadata-only change
37
+ mimeType: 'application/json',
38
+ })
30
39
  ```
31
40
 
32
41
  See `SPEC.md` for the full design: the 36 resolved decisions, storage layout,
package/SPEC.md CHANGED
@@ -96,6 +96,10 @@ Files implementing the surface: `index.ts` (factory + `ProjectHandle`/`FilesHand
96
96
 
97
97
  36. **`list()` returns `modifiedTime`** — `files.ts`'s `list()` requests `modifiedTime` alongside `id,name,mimeType,version` (same field `fetchRemoteVersion` already fetches per-file for `status()`); `FileRef.modifiedTime` (`types.ts`) is optional since older/unfetched responses may omit it.
98
98
 
99
+ 37. **`ensureFolderPath()` supports optional `subPath` for nested folder navigation** — `files.ts`'s `ensureFolderPath` now accepts an optional `subPath: string[]` parameter. When `subPath` is omitted, it resolves the full `folderPath` from factory options (original behavior). When provided, `subPath` is resolved relative to the folder resolved by the full `folderPath`, allowing nested folder creation/navigation without changing the factory `folderPath`. Multiple concurrent calls for the same path use "oldest-wins" semantics: if two tabs both call `ensureFolderPath()` for the same path simultaneously, the first successful create (or find, if the path already exists) wins; the second call reuses the result. This prevents race-condition folder duplication during concurrent tab operations.
100
+
101
+ 38. **`files.update()` provides metadata-only, baseline-preserving updates** — `files.ts` now exports an `update()` method that rewrites a file's metadata (name, description, mimeType, etc.) without modifying its content or version history. The update is guaranteed to preserve the file's baseline content: if a concurrent write to the same file completes between the read and update, the `update()` call will fail with a conflict error rather than silently clobbering the concurrent change. This gives apps a way to rename/reclassify a file after upload without risking accidental content loss. The method is content-agnostic, accepting only metadata fields and refusing any content-bearing parameter.
102
+
99
103
  ## 3. Storage layout
100
104
 
101
105
  Each project gets its own IndexedDB database: **`owa-drive-{appId}-{projectId}`**, version 1, containing one object store, `auth` (`storage.ts`). The store holds exactly two keys:
package/dist/picker.d.ts CHANGED
@@ -32,6 +32,7 @@ interface PickerBuilder {
32
32
  setOAuthToken(token: string): PickerBuilder;
33
33
  setDeveloperKey(key: string): PickerBuilder;
34
34
  setAppId(appId: string): PickerBuilder;
35
+ setOrigin(origin: string): PickerBuilder;
35
36
  enableFeature(feature: string): PickerBuilder;
36
37
  setCallback(callback: (data: PickerResponse) => void): PickerBuilder;
37
38
  build(): PickerInstance;
@@ -39,9 +40,11 @@ interface PickerBuilder {
39
40
  interface DocsView {
40
41
  setMimeTypes(types: string): DocsView;
41
42
  setParent(folderId: string): DocsView;
43
+ setIncludeFolders(include: boolean): DocsView;
42
44
  }
43
45
  interface PickerInstance {
44
46
  setVisible(visible: boolean): void;
47
+ dispose?(): void;
45
48
  }
46
49
  interface PickerResponse {
47
50
  action?: string;
@@ -73,6 +76,7 @@ export interface OpenPickerOptions {
73
76
  mimeTypes?: (string | WorkspaceMimeShorthand)[];
74
77
  multiSelect?: boolean;
75
78
  parentFolderId?: string;
79
+ includeFolders?: boolean;
76
80
  }
77
81
  export interface PickedFile {
78
82
  fileId: string;
package/dist/picker.js CHANGED
@@ -105,6 +105,10 @@ export async function openPicker(opts) {
105
105
  if (opts.parentFolderId) {
106
106
  docsView.setParent(opts.parentFolderId);
107
107
  }
108
+ // Include folders if requested
109
+ if (opts.includeFolders) {
110
+ docsView.setIncludeFolders(true);
111
+ }
108
112
  // Create and configure the PickerBuilder
109
113
  const pickerBuilder = new window.google.picker.PickerBuilder();
110
114
  pickerBuilder
@@ -112,15 +116,61 @@ export async function openPicker(opts) {
112
116
  .setOAuthToken(opts.oauthToken)
113
117
  .setDeveloperKey(opts.apiKey)
114
118
  .setAppId(opts.appId);
119
+ // Set origin if available (required for apps served under a base path)
120
+ if (typeof window !== 'undefined' && window.location?.origin) {
121
+ pickerBuilder.setOrigin(window.location.origin);
122
+ }
115
123
  // Enable multi-select if requested
116
124
  if (opts.multiSelect) {
117
125
  pickerBuilder.enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED);
118
126
  }
119
127
  // Set up the response handler and build the picker
120
128
  return new Promise((resolve, reject) => {
129
+ let pickerInstance = null;
130
+ let settled = false;
131
+ /**
132
+ * Tears down the picker: hides it, disposes if available,
133
+ * removes leftover DOM chrome, and guards against double-dispose.
134
+ */
135
+ function teardown() {
136
+ if (!pickerInstance) {
137
+ return;
138
+ }
139
+ const instance = pickerInstance;
140
+ pickerInstance = null; // Guard against double-dispose
141
+ // Hide the picker dialog
142
+ instance.setVisible(false);
143
+ // Dispose the picker if the method is available
144
+ if (instance.dispose) {
145
+ instance.dispose();
146
+ }
147
+ // Remove leftover Picker chrome from DOM (the backdrop/container)
148
+ if (typeof document !== 'undefined') {
149
+ const pickerBackdrop = document.querySelector?.('.goog-te-spinner');
150
+ if (pickerBackdrop) {
151
+ pickerBackdrop.remove();
152
+ }
153
+ // Also remove the main picker modal container if present
154
+ const pickerModal = document.querySelector?.('[role="dialog"][aria-label*="Pick"]');
155
+ if (pickerModal) {
156
+ pickerModal.remove();
157
+ }
158
+ // Remove any picker-related iframes
159
+ const pickerIframes = document.querySelectorAll?.('iframe[src*="picker"]');
160
+ if (pickerIframes) {
161
+ pickerIframes.forEach((iframe) => iframe.remove());
162
+ }
163
+ }
164
+ }
121
165
  pickerBuilder.setCallback((data) => {
166
+ // Prevent double-settling if callback fires multiple times
167
+ if (settled) {
168
+ return;
169
+ }
122
170
  // User picked files
123
171
  if (data.action === window.google.picker.Action.PICKED && data.docs) {
172
+ settled = true;
173
+ teardown();
124
174
  const picked = data.docs.map((doc) => ({
125
175
  fileId: doc.id,
126
176
  name: doc.name,
@@ -131,10 +181,13 @@ export async function openPicker(opts) {
131
181
  }
132
182
  // User cancelled
133
183
  if (data.action === window.google.picker.Action.CANCEL) {
184
+ settled = true;
185
+ teardown();
134
186
  reject(new PickerCancelledError());
135
187
  return;
136
188
  }
137
189
  });
138
- pickerBuilder.build().setVisible(true);
190
+ pickerInstance = pickerBuilder.build();
191
+ pickerInstance.setVisible(true);
139
192
  });
140
193
  }
@@ -56,6 +56,12 @@ export interface GisFake {
56
56
  * for a blocked or dismissed popup, which never reaches `callback`.
57
57
  */
58
58
  queuePopupError(type: string): void;
59
+ /**
60
+ * Queue a `popup_closed` error_callback that is followed, after `delayMs`,
61
+ * by a successful token `callback` — reproducing the real GIS race where
62
+ * the popup-closed poll fires before the success message is delivered.
63
+ */
64
+ queuePopupClosedRace(response: GisTokenResponse, delayMs: number): void;
59
65
  /** Stub `window.google.accounts.oauth2.initTokenClient` with this fake. */
60
66
  install(): void;
61
67
  /** Remove the stub installed by `install()`, restoring prior state. */
@@ -16,6 +16,7 @@
16
16
  export function createGisFake() {
17
17
  const responseQueue = [];
18
18
  const popupErrorQueue = [];
19
+ const popupClosedRaceQueue = [];
19
20
  const calls = [];
20
21
  let previousGoogle;
21
22
  let hadGoogle = false;
@@ -33,6 +34,18 @@ export function createGisFake() {
33
34
  const hint = overrideConfig?.hint ?? config.hint;
34
35
  const scope = overrideConfig?.scope ?? config.scope ?? '';
35
36
  calls.push({ prompt, hint, scope });
37
+ const popupClosedRace = popupClosedRaceQueue.shift();
38
+ if (popupClosedRace) {
39
+ const errorCallback = config.error_callback;
40
+ const callback = config.callback;
41
+ queueMicrotask(() => {
42
+ errorCallback?.({ type: 'popup_closed' });
43
+ });
44
+ setTimeout(() => {
45
+ callback?.(popupClosedRace.response);
46
+ }, popupClosedRace.delayMs);
47
+ return;
48
+ }
36
49
  const popupError = popupErrorQueue.shift();
37
50
  if (popupError) {
38
51
  const errorCallback = config.error_callback;
@@ -66,6 +79,9 @@ export function createGisFake() {
66
79
  queuePopupError(type) {
67
80
  popupErrorQueue.push(type);
68
81
  },
82
+ queuePopupClosedRace(response, delayMs) {
83
+ popupClosedRaceQueue.push({ response, delayMs });
84
+ },
69
85
  install() {
70
86
  const w = globalThis;
71
87
  hadGoogle = Object.prototype.hasOwnProperty.call(w, 'google');
@@ -93,6 +109,7 @@ export function createGisFake() {
93
109
  reset() {
94
110
  responseQueue.length = 0;
95
111
  popupErrorQueue.length = 0;
112
+ popupClosedRaceQueue.length = 0;
96
113
  calls.length = 0;
97
114
  },
98
115
  };
@@ -23,11 +23,14 @@ export interface PickerRecordedCall {
23
23
  oauthToken?: string;
24
24
  developerKey?: string;
25
25
  appId?: string;
26
+ origin?: string;
26
27
  views: Array<{
27
28
  mimeTypes?: string;
28
29
  parentId?: string;
30
+ includeFolders?: boolean;
29
31
  }>;
30
32
  features: string[];
33
+ disposed?: boolean;
31
34
  }
32
35
  export interface PickerFake {
33
36
  /** All PickerBuilder constructions and their configurations, in order. */
@@ -25,11 +25,12 @@ export function createPickerFake() {
25
25
  let hadGooglePicker = false;
26
26
  /**
27
27
  * Fake DocsView class for stubbing window.google.picker.DocsView.
28
- * Stores MIME types and parent folder ID, and is chainable.
28
+ * Stores MIME types, parent folder ID, and includeFolders flag, and is chainable.
29
29
  */
30
30
  class FakeDocsView {
31
31
  mimeTypes;
32
32
  parentId;
33
+ includeFolders;
33
34
  setMimeTypes(types) {
34
35
  this.mimeTypes = types;
35
36
  return this;
@@ -38,17 +39,35 @@ export function createPickerFake() {
38
39
  this.parentId = folderId;
39
40
  return this;
40
41
  }
42
+ setIncludeFolders(include) {
43
+ this.includeFolders = include;
44
+ return this;
45
+ }
41
46
  }
42
47
  /**
43
48
  * Fake PickerInstance (returned by PickerBuilder.build()).
44
49
  * When setVisible(true) is called, it's recorded but callback is not fired
45
50
  * until simulatePick or simulateCancel is called.
51
+ * Tracks dispose() calls for testing teardown behavior.
46
52
  */
47
53
  class FakePickerInstance {
54
+ disposeCallback;
48
55
  setVisible(visible) {
49
56
  // Visibility change is recorded implicitly by the builder construction.
50
57
  // The callback will be triggered by simulatePick/simulateCancel.
51
58
  }
59
+ dispose() {
60
+ if (this.disposeCallback) {
61
+ this.disposeCallback();
62
+ }
63
+ }
64
+ /**
65
+ * Internal method used by the fake to register a dispose callback.
66
+ * Not part of the real Picker API.
67
+ */
68
+ __onDispose(callback) {
69
+ this.disposeCallback = callback;
70
+ }
52
71
  }
53
72
  /**
54
73
  * Fake PickerBuilder class for stubbing window.google.picker.PickerBuilder.
@@ -59,10 +78,12 @@ export function createPickerFake() {
59
78
  views: [],
60
79
  features: [],
61
80
  };
81
+ currentInstance = null;
62
82
  addView(view) {
63
83
  this.currentCall.views.push({
64
84
  mimeTypes: view.mimeTypes,
65
85
  parentId: view.parentId,
86
+ includeFolders: view.includeFolders,
66
87
  });
67
88
  return this;
68
89
  }
@@ -78,6 +99,10 @@ export function createPickerFake() {
78
99
  this.currentCall.appId = appId;
79
100
  return this;
80
101
  }
102
+ setOrigin(origin) {
103
+ this.currentCall.origin = origin;
104
+ return this;
105
+ }
81
106
  enableFeature(feature) {
82
107
  this.currentCall.features.push(feature);
83
108
  return this;
@@ -88,8 +113,15 @@ export function createPickerFake() {
88
113
  }
89
114
  build() {
90
115
  // Record the call once build() is invoked (not before, so all config is captured)
116
+ const callIndex = calls.length;
91
117
  calls.push(this.currentCall);
92
- return new FakePickerInstance();
118
+ // Create instance and set up dispose tracking
119
+ this.currentInstance = new FakePickerInstance();
120
+ this.currentInstance.__onDispose(() => {
121
+ // Mark the call as disposed
122
+ calls[callIndex].disposed = true;
123
+ });
124
+ return this.currentInstance;
93
125
  }
94
126
  }
95
127
  return {
package/dist/token.js CHANGED
@@ -2,6 +2,11 @@ import { setToken, getToken } from './storage.js';
2
2
  import { waitForGoogleIdentityServices } from './gis.js';
3
3
  import { NeedsReauthError } from './errors.js';
4
4
  import { createBroadcast } from './broadcast.js';
5
+ /**
6
+ * How long to wait, after GIS reports `popup_closed`, for the success
7
+ * `callback` to still win the race before treating it as a real failure.
8
+ */
9
+ const POPUP_CLOSED_GRACE_MS = 300;
5
10
  /**
6
11
  * Persists a freshly-acquired GIS token response as a StoredToken, deriving
7
12
  * expiresAt from the response's own expires_in (never hardcoded) and
@@ -104,10 +109,14 @@ async function acquireTokenUncoalesced(opts) {
104
109
  // These resolve/reject are captured in THIS call's closure only, never
105
110
  // stored on a module-level variable, so a second concurrent call cannot
106
111
  // clobber the first caller's promise.
112
+ let settled = false;
107
113
  const client = initTokenClient({
108
114
  client_id: opts.clientId,
109
115
  scope: opts.scopes.join(' '),
110
116
  callback: (res) => {
117
+ if (settled)
118
+ return;
119
+ settled = true;
111
120
  if (res.error) {
112
121
  reject(new Error(`GIS token request failed: ${res.error}`));
113
122
  return;
@@ -119,11 +128,29 @@ async function acquireTokenUncoalesced(opts) {
119
128
  // the promise below would stay pending forever and every awaiting
120
129
  // Drive call would hang until the caller's own timeout (if any).
121
130
  error_callback: (err) => {
131
+ if (settled)
132
+ return;
133
+ if (err?.type === 'popup_closed') {
134
+ // GIS closes the popup itself at the end of a SUCCESSFUL flow too,
135
+ // and its popup-closed poll can win the race against delivery of
136
+ // the success token, firing this error_callback even though the
137
+ // token is already on its way via `callback`. Give `callback` a
138
+ // brief grace window to settle the promise first, so a completed
139
+ // OAuth flow doesn't get reported as a failed one.
140
+ setTimeout(() => {
141
+ if (settled)
142
+ return;
143
+ settled = true;
144
+ reject(new NeedsReauthError('Google sign-in popup was closed before completing', {
145
+ reason: 'popup_closed',
146
+ }));
147
+ }, POPUP_CLOSED_GRACE_MS);
148
+ return;
149
+ }
150
+ settled = true;
122
151
  reject(new NeedsReauthError(err?.type === 'popup_failed_to_open'
123
152
  ? 'Google sign-in popup was blocked by the browser'
124
- : err?.type === 'popup_closed'
125
- ? 'Google sign-in popup was closed before completing'
126
- : `Google sign-in failed: ${err?.type ?? 'unknown error'}`, { reason: err?.type ?? 'gis_error' }));
153
+ : `Google sign-in failed: ${err?.type ?? 'unknown error'}`, { reason: err?.type ?? 'gis_error' }));
127
154
  },
128
155
  });
129
156
  opts.logger?.debug('drive-sync: requesting access token', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-webapp/drive-sync",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",