@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 +9 -0
- package/SPEC.md +4 -0
- package/dist/picker.d.ts +4 -0
- package/dist/picker.js +54 -1
- package/dist/testing/gisFake.d.ts +6 -0
- package/dist/testing/gisFake.js +17 -0
- package/dist/testing/pickerFake.d.ts +3 -0
- package/dist/testing/pickerFake.js +34 -2
- package/dist/token.js +30 -3
- package/package.json +1 -1
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()
|
|
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. */
|
package/dist/testing/gisFake.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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', {
|