@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 +9 -0
- package/SPEC.md +4 -0
- package/dist/http.js +14 -1
- package/dist/picker.d.ts +4 -0
- package/dist/picker.js +54 -1
- package/dist/testing/pickerFake.d.ts +3 -0
- package/dist/testing/pickerFake.js +34 -2
- 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/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()
|
|
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
|
|
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 {
|