@open-webapp/drive-sync 0.4.0 → 0.5.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/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/http.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { acquireToken } from './token.js';
2
2
  import { getConnection, refreshSilently } from './connection.js';
3
- import { clearToken } from './storage.js';
3
+ import { clearToken, getToken } from './storage.js';
4
4
  import { DriveSyncError, NeedsReauthError, ScopeInsufficientError, NotFoundError, RateLimitedError, TransientError, WrongAccountError, } from './errors.js';
5
5
  const MAX_ATTEMPTS = 3;
6
6
  const BASE_DELAY_MS = 500;
7
+ /** Mirrors connection.ts's own buffer: a cached token this close to expiry is treated as unusable. */
8
+ const TOKEN_REUSE_BUFFER_MS = 5 * 60 * 1000;
7
9
  function isRetryableStatus(status) {
8
10
  return status === 429 || (status >= 500 && status <= 599);
9
11
  }
@@ -40,6 +42,17 @@ async function sleep(ms) {
40
42
  export async function driveFetch(opts) {
41
43
  const { appId, projectId, clientId, requiredScopes, logger } = opts;
42
44
  const interactive = !!opts.interactive;
45
+ // Reuse a still-valid cached token as-is on the non-interactive path,
46
+ // exactly like connection.ts's getAccessToken() already does — without
47
+ // this check every single Drive call (not just an actual token expiry)
48
+ // forced its own live, non-interactive GIS round-trip via acquireToken
49
+ // below, even when the cached token had plenty of life left.
50
+ if (!interactive) {
51
+ const cached = await getToken(appId, projectId);
52
+ if (cached && cached.expiresAt > Date.now() + TOKEN_REUSE_BUFFER_MS) {
53
+ return performFetch(opts, cached.accessToken, /* isRetryAfter401 */ false);
54
+ }
55
+ }
43
56
  // Resolve a hint email from the current connection (if any) so a
44
57
  // non-interactive silent refresh can target the right account. If there is
45
58
  // no connection at all and interactive is false, acquireToken's silent
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
  }
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-webapp/drive-sync",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",