@open-webapp/drive-sync 0.2.0 → 0.3.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 +1 -0
- package/SPEC.md +5 -3
- package/dist/errors.d.ts +28 -0
- package/dist/errors.js +31 -0
- package/dist/files.d.ts +24 -1
- package/dist/files.js +170 -12
- package/dist/index.d.ts +12 -2
- package/dist/index.js +28 -0
- package/dist/picker.d.ts +95 -0
- package/dist/picker.js +140 -0
- package/dist/storage.d.ts +15 -1
- package/dist/storage.js +30 -0
- package/dist/testing/driveFake.d.ts +10 -0
- package/dist/testing/driveFake.js +12 -1
- package/dist/testing/gisFake.d.ts +6 -0
- package/dist/testing/gisFake.js +13 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/pickerFake.d.ts +46 -0
- package/dist/testing/pickerFake.js +189 -0
- package/dist/token.js +11 -0
- package/dist/types.d.ts +42 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,6 +24,7 @@ await drive.reconcile(knownProjectIds)
|
|
|
24
24
|
|
|
25
25
|
const p = drive.project(projectId)
|
|
26
26
|
await p.connect()
|
|
27
|
+
const picked = await p.pickFile({ apiKey: PICKER_API_KEY, appId: GCP_PROJECT_NUMBER })
|
|
27
28
|
const folderId = await p.ensureFolderPath()
|
|
28
29
|
await p.files.write({ folderId, name: 'data.json', content: '{}', mimeType: 'application/json' })
|
|
29
30
|
```
|
package/SPEC.md
CHANGED
|
@@ -31,8 +31,9 @@ const p = drive.project(projectId);
|
|
|
31
31
|
await p.connect(); // interactive; prompt:'consent'
|
|
32
32
|
const conn = await p.getConnection(); // { email, needsReauth, expiresAt } | null
|
|
33
33
|
|
|
34
|
+
const picked = await p.pickFile({ apiKey: PICKER_API_KEY, appId: GCP_PROJECT_NUMBER }); // file-selection via Google Picker
|
|
34
35
|
const folderId = await p.ensureFolderPath();
|
|
35
|
-
const token = await p.getAccessToken(); // raw token
|
|
36
|
+
const token = await p.getAccessToken(); // raw token for advanced/custom Picker wiring; pickFile() is the preferred path for most uses
|
|
36
37
|
const files = await p.files.list({ folderId });
|
|
37
38
|
const text = await p.files.read(fileId); // string | Blob | null (null on 404)
|
|
38
39
|
const ref = await p.files.write({ folderId, name: 'x.json', content, mimeType: 'application/json' });
|
|
@@ -45,11 +46,11 @@ dispose();
|
|
|
45
46
|
|
|
46
47
|
`createDriveSync()` itself attaches no listeners and makes no network calls. Every Drive-op call site accepts an optional `{ interactive?: boolean }` (default `false`) and resolves its own token internally — no caller ever threads a token or a `projectId` string into an HTTP call by hand.
|
|
47
48
|
|
|
48
|
-
Files implementing the surface: `index.ts` (factory + `ProjectHandle`/`FilesHandle`/`PermissionsHandle`), `connection.ts` (`connect`/`getConnection`/`disconnect`/`refreshSilently`/`getAccessToken`), `files.ts`, `permissions.ts`, `reconcile.ts`, `refresh.ts` (`activate`/warm-up), `errors.ts` (typed error classes), `types.ts` (`DriveSyncOptions`, `Connection`, `StoredToken`, `FileRef`, `DrivePermission`, `CallOptions`).
|
|
49
|
+
Files implementing the surface: `index.ts` (factory + `ProjectHandle`/`FilesHandle`/`PermissionsHandle`), `connection.ts` (`connect`/`getConnection`/`disconnect`/`refreshSilently`/`getAccessToken`), `files.ts`, `permissions.ts`, `reconcile.ts`, `refresh.ts` (`activate`/warm-up), `picker.ts` (Google Picker integration), `errors.ts` (typed error classes), `types.ts` (`DriveSyncOptions`, `Connection`, `StoredToken`, `FileRef`, `DrivePermission`, `CallOptions`).
|
|
49
50
|
|
|
50
51
|
`getAccessToken()` is the one deliberate exception to `Connection` never exposing secret material (types.ts): it exists solely so an app can feed the token to Google Picker (`setOAuthToken()`), which runs outside this library's control and has no other way to read it. Reuses a cached token while it has more than 5 minutes left; otherwise acquires one (interactive by default, since callers use this to drive a UI the user is actively interacting with).
|
|
51
52
|
|
|
52
|
-
## 2. The
|
|
53
|
+
## 2. The 35 resolved design decisions
|
|
53
54
|
|
|
54
55
|
**Bugs fixed (both source apps carried these):**
|
|
55
56
|
|
|
@@ -91,6 +92,7 @@ Files implementing the surface: `index.ts` (factory + `ProjectHandle`/`FilesHand
|
|
|
91
92
|
32. **`reconcile`/`dropProject`** — `reconcile.ts`: `reconcile(appId, knownProjectIds)` enumerates via `indexedDB.databases()` and deletes any `owa-drive-{appId}-*` DB not in the known set; `dropProject(appId, projectId)` deletes one DB eagerly and evicts its cached handle.
|
|
92
93
|
33. **No timer; warm-up on `visibilitychange`/`pageshow`** — `refresh.ts`'s `activate()` attaches both listeners (only when called; none at import time), gated on `document.visibilityState === 'visible'` / `event.persisted && !document.hidden`; `warmUpIfNeeded` only fires if a connection exists **and** the token is missing or within a 5-minute buffer (`REFRESH_BUFFER_MS`) of expiry. `index.ts`'s top-level `activate()` also layers a `trackedProjectIds` Set so one global listener pair drives warm-ups for every project ever passed to `.project(id)`.
|
|
93
94
|
34. **`interactive` option, default `false`** — every `BaseCallOptions`-shaped call in `files.ts`/`permissions.ts`/`http.ts` defaults `interactive` to falsy; a non-interactive call with no usable token throws `NeedsReauthError` rather than silently prompting.
|
|
95
|
+
35. **Google Picker integration** — `picker.ts`'s `pickFile` accepts an `apiKey` and an `appId` (the OAuth client's Cloud project number) per-call (not stored in `DriveSyncOptions`); `appId` is mandatory because drive-sync holds only a `drive.file`-scoped token and Picker rejects a scoped session it cannot attribute to an app — omitting it makes Picker drop the OAuth token, show its own sign-in prompt, and fail with "The API developer key is invalid"; `index.ts` and `connection.ts` resolve the token, but `picker.ts` only ever sees a plain string token to avoid secret exposure. Script loading is cached at module level to avoid repeated GIS-loader calls. On user cancel, `PickerCancelledError` is thrown; on success, `FileRef` is returned. Drive scope prerequisites and token refresh are handled transparently (`picker.ts` takes the token and makes the Picker call; no token-boundary complexity leaks to callers).
|
|
94
96
|
|
|
95
97
|
## 3. Storage layout
|
|
96
98
|
|
package/dist/errors.d.ts
CHANGED
|
@@ -58,7 +58,35 @@ export declare class RateLimitedError extends DriveSyncError {
|
|
|
58
58
|
export declare class TransientError extends DriveSyncError {
|
|
59
59
|
constructor(message?: string, opts?: DriveSyncErrorOptions);
|
|
60
60
|
}
|
|
61
|
+
export interface RemoteChangedErrorOptions extends DriveSyncErrorOptions {
|
|
62
|
+
fileId: string;
|
|
63
|
+
/** Version this client last restored, or null if it has never restored this file. */
|
|
64
|
+
baseVersion: string | null;
|
|
65
|
+
/** Version currently on Drive. */
|
|
66
|
+
remoteVersion: string;
|
|
67
|
+
reason: 'remote-changed' | 'never-restored';
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Thrown when a write would clobber remote content this client has not seen.
|
|
71
|
+
*
|
|
72
|
+
* There is deliberately no `force` option: the only way past this error is to
|
|
73
|
+
* restore the remote file with `files.read()`, which records the remote
|
|
74
|
+
* version as the new baseline. From there the caller may either merge the
|
|
75
|
+
* remote content into their own and write the merged result, or discard the
|
|
76
|
+
* remote content and write their local version — but both paths require
|
|
77
|
+
* having pulled the changed file first.
|
|
78
|
+
*/
|
|
79
|
+
export declare class RemoteChangedError extends DriveSyncError {
|
|
80
|
+
fileId: string;
|
|
81
|
+
baseVersion: string | null;
|
|
82
|
+
remoteVersion: string;
|
|
83
|
+
constructor(opts: RemoteChangedErrorOptions);
|
|
84
|
+
}
|
|
61
85
|
/** Thrown when the Google Identity Services script never loaded within the timeout. */
|
|
62
86
|
export declare class GisLoadError extends Error {
|
|
63
87
|
constructor(message?: string);
|
|
64
88
|
}
|
|
89
|
+
/** Thrown when the user cancels the Google Picker dialog. */
|
|
90
|
+
export declare class PickerCancelledError extends DriveSyncError {
|
|
91
|
+
constructor(message?: string, opts?: DriveSyncErrorOptions);
|
|
92
|
+
}
|
package/dist/errors.js
CHANGED
|
@@ -75,6 +75,30 @@ export class TransientError extends DriveSyncError {
|
|
|
75
75
|
this.name = 'TransientError';
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Thrown when a write would clobber remote content this client has not seen.
|
|
80
|
+
*
|
|
81
|
+
* There is deliberately no `force` option: the only way past this error is to
|
|
82
|
+
* restore the remote file with `files.read()`, which records the remote
|
|
83
|
+
* version as the new baseline. From there the caller may either merge the
|
|
84
|
+
* remote content into their own and write the merged result, or discard the
|
|
85
|
+
* remote content and write their local version — but both paths require
|
|
86
|
+
* having pulled the changed file first.
|
|
87
|
+
*/
|
|
88
|
+
export class RemoteChangedError extends DriveSyncError {
|
|
89
|
+
fileId;
|
|
90
|
+
baseVersion;
|
|
91
|
+
remoteVersion;
|
|
92
|
+
constructor(opts) {
|
|
93
|
+
super(opts.reason === 'never-restored'
|
|
94
|
+
? `File ${opts.fileId} has never been restored by this client; read() it before writing`
|
|
95
|
+
: `File ${opts.fileId} changed on Drive since it was last restored`, { status: 409, ...opts });
|
|
96
|
+
this.name = 'RemoteChangedError';
|
|
97
|
+
this.fileId = opts.fileId;
|
|
98
|
+
this.baseVersion = opts.baseVersion;
|
|
99
|
+
this.remoteVersion = opts.remoteVersion;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
78
102
|
/** Thrown when the Google Identity Services script never loaded within the timeout. */
|
|
79
103
|
export class GisLoadError extends Error {
|
|
80
104
|
constructor(message = 'Google Identity Services failed to load in time') {
|
|
@@ -82,3 +106,10 @@ export class GisLoadError extends Error {
|
|
|
82
106
|
this.name = 'GisLoadError';
|
|
83
107
|
}
|
|
84
108
|
}
|
|
109
|
+
/** Thrown when the user cancels the Google Picker dialog. */
|
|
110
|
+
export class PickerCancelledError extends DriveSyncError {
|
|
111
|
+
constructor(message = 'User cancelled the picker', opts) {
|
|
112
|
+
super(message, opts);
|
|
113
|
+
this.name = 'PickerCancelledError';
|
|
114
|
+
}
|
|
115
|
+
}
|
package/dist/files.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Logger } from './logger.js';
|
|
2
|
-
import type { FileRef } from './types.js';
|
|
2
|
+
import type { FileRef, FileState } from './types.js';
|
|
3
3
|
/** Scopes this library always requests/requires. */
|
|
4
4
|
export declare const REQUIRED_SCOPES: string[];
|
|
5
5
|
interface BaseCallOptions {
|
|
@@ -39,8 +39,31 @@ export interface WriteOptions extends BaseCallOptions {
|
|
|
39
39
|
* so file content that happens to contain a literal boundary-looking string
|
|
40
40
|
* can never corrupt the request (a known bug in a hand-rolled-boundary
|
|
41
41
|
* implementation this replaces).
|
|
42
|
+
*
|
|
43
|
+
* When no fileId is given but a name is, an existing non-trashed file with
|
|
44
|
+
* that name (within `folderId`, when given) is looked up first and updated
|
|
45
|
+
* in place. Without this, a caller that syncs by name — having no id to
|
|
46
|
+
* hand back, e.g. on a fresh page load — would mint a brand-new Drive file
|
|
47
|
+
* on every single sync instead of updating the file already in use.
|
|
48
|
+
*
|
|
49
|
+
* Every update path is guarded: if the file changed on Drive since this
|
|
50
|
+
* client last restored it (or was never restored here at all), the write is
|
|
51
|
+
* refused with a RemoteChangedError instead of clobbering someone else's
|
|
52
|
+
* edit. See that error's docs for the two ways forward.
|
|
42
53
|
*/
|
|
43
54
|
export declare function write(opts: WriteOptions): Promise<FileRef>;
|
|
55
|
+
export interface StatusOptions extends BaseCallOptions {
|
|
56
|
+
fileId: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Cheap metadata-only drift probe — the primitive an app polls to notify the
|
|
60
|
+
* user that the file in use has moved on. Downloads no content, so it is safe
|
|
61
|
+
* to call on a timer or on window focus.
|
|
62
|
+
*
|
|
63
|
+
* `exists: false` means the file is gone (or invisible to this account).
|
|
64
|
+
* `changedSinceRestore: true` means a write would currently be refused.
|
|
65
|
+
*/
|
|
66
|
+
export declare function status(opts: StatusOptions): Promise<FileState>;
|
|
44
67
|
export interface ListOptions extends BaseCallOptions {
|
|
45
68
|
folderId?: string;
|
|
46
69
|
mimeType?: string;
|
package/dist/files.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { driveFetch } from './http.js';
|
|
2
2
|
import { escapeQ } from './query.js';
|
|
3
|
-
import { NotFoundError } from './errors.js';
|
|
3
|
+
import { NotFoundError, RemoteChangedError } from './errors.js';
|
|
4
|
+
import { getFileState, setFileState, clearFileState } from './storage.js';
|
|
4
5
|
const DRIVE_BASE = 'https://www.googleapis.com/drive/v3';
|
|
5
6
|
const UPLOAD_BASE = 'https://www.googleapis.com/upload/drive/v3';
|
|
6
7
|
const FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder';
|
|
@@ -9,6 +10,44 @@ export const REQUIRED_SCOPES = [
|
|
|
9
10
|
'https://www.googleapis.com/auth/drive.file',
|
|
10
11
|
'https://www.googleapis.com/auth/userinfo.email',
|
|
11
12
|
];
|
|
13
|
+
/**
|
|
14
|
+
* Fetches just the fields needed for conflict detection. Returns null on a
|
|
15
|
+
* 404, matching read()'s treatment of a missing/invisible file.
|
|
16
|
+
*/
|
|
17
|
+
async function fetchRemoteVersion(opts) {
|
|
18
|
+
try {
|
|
19
|
+
const res = await driveFetch({
|
|
20
|
+
appId: opts.appId,
|
|
21
|
+
projectId: opts.projectId,
|
|
22
|
+
clientId: opts.clientId,
|
|
23
|
+
url: `${DRIVE_BASE}/files/${encodeURIComponent(opts.fileId)}?fields=${encodeURIComponent('id,name,version,modifiedTime')}`,
|
|
24
|
+
method: 'GET',
|
|
25
|
+
interactive: opts.interactive,
|
|
26
|
+
requiredScopes: REQUIRED_SCOPES,
|
|
27
|
+
logger: opts.logger,
|
|
28
|
+
fetchEmail: opts.fetchEmail,
|
|
29
|
+
});
|
|
30
|
+
const json = (await res.json());
|
|
31
|
+
if (json.version === undefined)
|
|
32
|
+
return null;
|
|
33
|
+
return { version: String(json.version), name: json.name, modifiedTime: json.modifiedTime };
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
if (err instanceof NotFoundError)
|
|
37
|
+
return null;
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Records `version` as this client's baseline for `fileId`. */
|
|
42
|
+
async function recordBaseline(opts, fileId, version) {
|
|
43
|
+
if (version === undefined)
|
|
44
|
+
return;
|
|
45
|
+
await setFileState(opts.appId, opts.projectId, {
|
|
46
|
+
fileId,
|
|
47
|
+
version: String(version),
|
|
48
|
+
syncedAt: Date.now(),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
12
51
|
/**
|
|
13
52
|
* Fetches a file's content. Returns `null` on a 404 rather than throwing,
|
|
14
53
|
* since Drive 404s both for a genuinely wrong id and for a file the
|
|
@@ -18,6 +57,13 @@ export const REQUIRED_SCOPES = [
|
|
|
18
57
|
*/
|
|
19
58
|
export async function read(opts) {
|
|
20
59
|
try {
|
|
60
|
+
// Sampled BEFORE the content download, not after: if someone writes to
|
|
61
|
+
// the file mid-read, the baseline recorded below is then older than
|
|
62
|
+
// what is on Drive, so the next write is refused and the caller
|
|
63
|
+
// restores again. Sampling afterwards would instead mark an edit this
|
|
64
|
+
// caller never received as "seen", which is exactly the clobber this
|
|
65
|
+
// tracking exists to prevent.
|
|
66
|
+
const meta = await fetchRemoteVersion(opts);
|
|
21
67
|
const res = await driveFetch({
|
|
22
68
|
appId: opts.appId,
|
|
23
69
|
projectId: opts.projectId,
|
|
@@ -33,7 +79,13 @@ export async function read(opts) {
|
|
|
33
79
|
const isTextual = contentType === '' ||
|
|
34
80
|
contentType.startsWith('text/') ||
|
|
35
81
|
contentType.includes('application/json');
|
|
36
|
-
|
|
82
|
+
const content = isTextual ? await res.text() : await res.blob();
|
|
83
|
+
// Restoring the file makes the version just sampled this client's new
|
|
84
|
+
// baseline: the caller has now seen whatever anyone else wrote, so a
|
|
85
|
+
// subsequent write is no longer a blind clobber. This is the ONLY way a
|
|
86
|
+
// RemoteChangedError is cleared, which is why it runs on every read.
|
|
87
|
+
await recordBaseline(opts, opts.fileId, meta?.version);
|
|
88
|
+
return content;
|
|
37
89
|
}
|
|
38
90
|
catch (err) {
|
|
39
91
|
if (err instanceof NotFoundError) {
|
|
@@ -54,10 +106,64 @@ export async function remove(opts) {
|
|
|
54
106
|
logger: opts.logger,
|
|
55
107
|
fetchEmail: opts.fetchEmail,
|
|
56
108
|
});
|
|
109
|
+
// The baseline describes a file that no longer exists; leaving it behind
|
|
110
|
+
// would make a later file reusing this id look spuriously in sync.
|
|
111
|
+
await clearFileState(opts.appId, opts.projectId, opts.fileId);
|
|
57
112
|
}
|
|
58
113
|
function toBlob(content, mimeType) {
|
|
59
114
|
return content instanceof Blob ? content : new Blob([content], { type: mimeType });
|
|
60
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Refuses the write unless this client's baseline matches what is on Drive
|
|
118
|
+
* right now. `knownRemoteVersion` lets the name-resolution path reuse the
|
|
119
|
+
* version it already got from list() instead of paying a second round trip.
|
|
120
|
+
*/
|
|
121
|
+
async function assertNotStale(opts, fileId, knownRemoteVersion) {
|
|
122
|
+
const remoteVersion = knownRemoteVersion ?? (await fetchRemoteVersion({ ...opts, fileId }))?.version;
|
|
123
|
+
// No version reported (a fake/older backend that does not expose `version`,
|
|
124
|
+
// or a file that has just vanished) — nothing to compare against, so fall
|
|
125
|
+
// through rather than blocking the caller on a check we cannot perform.
|
|
126
|
+
if (remoteVersion === undefined)
|
|
127
|
+
return;
|
|
128
|
+
const baseline = await getFileState(opts.appId, opts.projectId, fileId);
|
|
129
|
+
if (!baseline) {
|
|
130
|
+
throw new RemoteChangedError({
|
|
131
|
+
fileId,
|
|
132
|
+
baseVersion: null,
|
|
133
|
+
remoteVersion,
|
|
134
|
+
reason: 'never-restored',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (baseline.version !== remoteVersion) {
|
|
138
|
+
throw new RemoteChangedError({
|
|
139
|
+
fileId,
|
|
140
|
+
baseVersion: baseline.version,
|
|
141
|
+
remoteVersion,
|
|
142
|
+
reason: 'remote-changed',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function updateContent(opts, fileId, knownRemoteVersion) {
|
|
147
|
+
await assertNotStale(opts, fileId, knownRemoteVersion);
|
|
148
|
+
const res = await driveFetch({
|
|
149
|
+
appId: opts.appId,
|
|
150
|
+
projectId: opts.projectId,
|
|
151
|
+
clientId: opts.clientId,
|
|
152
|
+
url: `${UPLOAD_BASE}/files/${encodeURIComponent(fileId)}?uploadType=media&fields=${encodeURIComponent('id,name,version')}`,
|
|
153
|
+
method: 'PATCH',
|
|
154
|
+
headers: { 'Content-Type': opts.mimeType },
|
|
155
|
+
body: opts.content,
|
|
156
|
+
interactive: opts.interactive,
|
|
157
|
+
requiredScopes: REQUIRED_SCOPES,
|
|
158
|
+
logger: opts.logger,
|
|
159
|
+
fetchEmail: opts.fetchEmail,
|
|
160
|
+
});
|
|
161
|
+
const json = (await res.json());
|
|
162
|
+
// The version we just produced becomes the new baseline, so back-to-back
|
|
163
|
+
// writes from this client never trip the staleness guard on themselves.
|
|
164
|
+
await recordBaseline(opts, json.id, json.version);
|
|
165
|
+
return { id: json.id, name: json.name ?? opts.name };
|
|
166
|
+
}
|
|
61
167
|
/**
|
|
62
168
|
* Creates or updates a file's content. Updates (fileId provided) use the
|
|
63
169
|
* media-upload endpoint with the raw body. Creates use a `FormData`
|
|
@@ -65,24 +171,36 @@ function toBlob(content, mimeType) {
|
|
|
65
171
|
* so file content that happens to contain a literal boundary-looking string
|
|
66
172
|
* can never corrupt the request (a known bug in a hand-rolled-boundary
|
|
67
173
|
* implementation this replaces).
|
|
174
|
+
*
|
|
175
|
+
* When no fileId is given but a name is, an existing non-trashed file with
|
|
176
|
+
* that name (within `folderId`, when given) is looked up first and updated
|
|
177
|
+
* in place. Without this, a caller that syncs by name — having no id to
|
|
178
|
+
* hand back, e.g. on a fresh page load — would mint a brand-new Drive file
|
|
179
|
+
* on every single sync instead of updating the file already in use.
|
|
180
|
+
*
|
|
181
|
+
* Every update path is guarded: if the file changed on Drive since this
|
|
182
|
+
* client last restored it (or was never restored here at all), the write is
|
|
183
|
+
* refused with a RemoteChangedError instead of clobbering someone else's
|
|
184
|
+
* edit. See that error's docs for the two ways forward.
|
|
68
185
|
*/
|
|
69
186
|
export async function write(opts) {
|
|
70
187
|
if (opts.fileId) {
|
|
71
|
-
|
|
188
|
+
return updateContent(opts, opts.fileId);
|
|
189
|
+
}
|
|
190
|
+
if (opts.name) {
|
|
191
|
+
const existing = await list({
|
|
72
192
|
appId: opts.appId,
|
|
73
193
|
projectId: opts.projectId,
|
|
74
194
|
clientId: opts.clientId,
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
headers: { 'Content-Type': opts.mimeType },
|
|
78
|
-
body: opts.content,
|
|
195
|
+
folderId: opts.folderId,
|
|
196
|
+
nameEquals: opts.name,
|
|
79
197
|
interactive: opts.interactive,
|
|
80
|
-
requiredScopes: REQUIRED_SCOPES,
|
|
81
198
|
logger: opts.logger,
|
|
82
199
|
fetchEmail: opts.fetchEmail,
|
|
83
200
|
});
|
|
84
|
-
|
|
85
|
-
|
|
201
|
+
if (existing.length > 0) {
|
|
202
|
+
return updateContent(opts, existing[0].id, existing[0].version);
|
|
203
|
+
}
|
|
86
204
|
}
|
|
87
205
|
const metadata = {
|
|
88
206
|
name: opts.name,
|
|
@@ -107,7 +225,7 @@ export async function write(opts) {
|
|
|
107
225
|
appId: opts.appId,
|
|
108
226
|
projectId: opts.projectId,
|
|
109
227
|
clientId: opts.clientId,
|
|
110
|
-
url: `${UPLOAD_BASE}/files?uploadType=multipart&fields
|
|
228
|
+
url: `${UPLOAD_BASE}/files?uploadType=multipart&fields=${encodeURIComponent('id,version')}`,
|
|
111
229
|
method: 'POST',
|
|
112
230
|
headers: multipartContentType ? { 'Content-Type': multipartContentType } : undefined,
|
|
113
231
|
body: multipartBody,
|
|
@@ -117,8 +235,46 @@ export async function write(opts) {
|
|
|
117
235
|
fetchEmail: opts.fetchEmail,
|
|
118
236
|
});
|
|
119
237
|
const json = (await res.json());
|
|
238
|
+
// This client authored the file, so it starts out fully in sync.
|
|
239
|
+
await recordBaseline(opts, json.id, json.version);
|
|
120
240
|
return { id: json.id, name: opts.name };
|
|
121
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* Cheap metadata-only drift probe — the primitive an app polls to notify the
|
|
244
|
+
* user that the file in use has moved on. Downloads no content, so it is safe
|
|
245
|
+
* to call on a timer or on window focus.
|
|
246
|
+
*
|
|
247
|
+
* `exists: false` means the file is gone (or invisible to this account).
|
|
248
|
+
* `changedSinceRestore: true` means a write would currently be refused.
|
|
249
|
+
*/
|
|
250
|
+
export async function status(opts) {
|
|
251
|
+
const [meta, baseline] = await Promise.all([
|
|
252
|
+
fetchRemoteVersion(opts),
|
|
253
|
+
getFileState(opts.appId, opts.projectId, opts.fileId),
|
|
254
|
+
]);
|
|
255
|
+
if (!meta) {
|
|
256
|
+
return {
|
|
257
|
+
fileId: opts.fileId,
|
|
258
|
+
exists: false,
|
|
259
|
+
baseVersion: baseline?.version ?? null,
|
|
260
|
+
remoteVersion: null,
|
|
261
|
+
changedSinceRestore: false,
|
|
262
|
+
lastRestoredAt: baseline?.syncedAt ?? null,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
fileId: opts.fileId,
|
|
267
|
+
exists: true,
|
|
268
|
+
baseVersion: baseline?.version ?? null,
|
|
269
|
+
remoteVersion: meta.version,
|
|
270
|
+
// Never having restored the file counts as drift: this client cannot show
|
|
271
|
+
// that what it holds descends from what is on Drive, which is exactly the
|
|
272
|
+
// condition a write is refused under.
|
|
273
|
+
changedSinceRestore: !baseline || baseline.version !== meta.version,
|
|
274
|
+
remoteModifiedTime: meta.modifiedTime,
|
|
275
|
+
lastRestoredAt: baseline?.syncedAt ?? null,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
122
278
|
function buildQuery(opts) {
|
|
123
279
|
const clauses = [];
|
|
124
280
|
if (opts.folderId) {
|
|
@@ -135,7 +291,9 @@ function buildQuery(opts) {
|
|
|
135
291
|
}
|
|
136
292
|
export async function list(opts) {
|
|
137
293
|
const q = buildQuery(opts);
|
|
138
|
-
|
|
294
|
+
// `version` comes back so the name-resolution path in write() can run its
|
|
295
|
+
// staleness check without a follow-up metadata fetch.
|
|
296
|
+
const url = `${DRIVE_BASE}/files?q=${encodeURIComponent(q)}&fields=${encodeURIComponent('files(id,name,mimeType,version)')}`;
|
|
139
297
|
const res = await driveFetch({
|
|
140
298
|
appId: opts.appId,
|
|
141
299
|
projectId: opts.projectId,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { CallOptions, Connection, DriveSyncOptions, DrivePermission, FileRef } from './types.js';
|
|
2
|
-
export type { DriveSyncOptions, Connection, StoredToken, FileRef, DrivePermission, CallOptions } from './types.js';
|
|
1
|
+
import type { CallOptions, Connection, DriveSyncOptions, DrivePermission, FileRef, FileState, PickFileOptions, PickedFile } from './types.js';
|
|
2
|
+
export type { DriveSyncOptions, Connection, StoredToken, FileRef, FileState, DrivePermission, CallOptions, WorkspaceMimeShorthand, PickFileOptions, PickedFile } from './types.js';
|
|
3
3
|
export * from './errors.js';
|
|
4
4
|
export interface FilesHandle {
|
|
5
5
|
list(opts?: {
|
|
@@ -7,7 +7,16 @@ export interface FilesHandle {
|
|
|
7
7
|
mimeType?: string;
|
|
8
8
|
nameEquals?: string;
|
|
9
9
|
}, callOpts?: CallOptions): Promise<FileRef[]>;
|
|
10
|
+
/**
|
|
11
|
+
* Fetches content AND records the file's current remote version as this
|
|
12
|
+
* client's baseline — the only way to clear a RemoteChangedError.
|
|
13
|
+
*/
|
|
10
14
|
read(fileId: string, callOpts?: CallOptions): Promise<string | Blob | null>;
|
|
15
|
+
/**
|
|
16
|
+
* Metadata-only check for whether the file changed since it was last
|
|
17
|
+
* restored here. Downloads no content; safe to poll.
|
|
18
|
+
*/
|
|
19
|
+
status(fileId: string, callOpts?: CallOptions): Promise<FileState>;
|
|
11
20
|
write(opts: {
|
|
12
21
|
fileId?: string;
|
|
13
22
|
folderId?: string;
|
|
@@ -48,6 +57,7 @@ export interface ProjectHandle {
|
|
|
48
57
|
* drive a UI the user is actively interacting with.
|
|
49
58
|
*/
|
|
50
59
|
getAccessToken(callOpts?: CallOptions): Promise<string>;
|
|
60
|
+
pickFile(options: PickFileOptions): Promise<PickedFile[]>;
|
|
51
61
|
files: FilesHandle;
|
|
52
62
|
permissions: PermissionsHandle;
|
|
53
63
|
}
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { connect as connectImpl, getConnection as getConnectionImpl, disconnect
|
|
|
3
3
|
import { reconcile as reconcileImpl, dropProject as dropProjectImpl } from './reconcile.js';
|
|
4
4
|
import * as filesImpl from './files.js';
|
|
5
5
|
import * as permissionsImpl from './permissions.js';
|
|
6
|
+
import * as pickerImpl from './picker.js';
|
|
6
7
|
import { warmUpIfNeeded } from './refresh.js';
|
|
7
8
|
import { REQUIRED_SCOPES } from './files.js';
|
|
8
9
|
import { createBroadcast } from './broadcast.js';
|
|
@@ -138,6 +139,9 @@ export function createDriveSync(options) {
|
|
|
138
139
|
read(fileId, callOpts) {
|
|
139
140
|
return filesImpl.read({ ...base, fileId, interactive: callOpts?.interactive });
|
|
140
141
|
},
|
|
142
|
+
status(fileId, callOpts) {
|
|
143
|
+
return filesImpl.status({ ...base, fileId, interactive: callOpts?.interactive });
|
|
144
|
+
},
|
|
141
145
|
write(opts, callOpts) {
|
|
142
146
|
return filesImpl.write({ ...base, ...opts, interactive: callOpts?.interactive });
|
|
143
147
|
},
|
|
@@ -204,6 +208,30 @@ export function createDriveSync(options) {
|
|
|
204
208
|
logger,
|
|
205
209
|
});
|
|
206
210
|
},
|
|
211
|
+
async pickFile(options) {
|
|
212
|
+
const token = await getAccessTokenImpl({
|
|
213
|
+
appId,
|
|
214
|
+
projectId,
|
|
215
|
+
clientId,
|
|
216
|
+
scopes: REQUIRED_SCOPES,
|
|
217
|
+
interactive: true,
|
|
218
|
+
logger,
|
|
219
|
+
});
|
|
220
|
+
const picked = await pickerImpl.openPicker({
|
|
221
|
+
apiKey: options.apiKey,
|
|
222
|
+
oauthToken: token,
|
|
223
|
+
appId: options.appId,
|
|
224
|
+
mimeTypes: options.mimeTypes,
|
|
225
|
+
multiSelect: options.multiSelect,
|
|
226
|
+
parentFolderId: options.parentFolderId,
|
|
227
|
+
});
|
|
228
|
+
const results = [];
|
|
229
|
+
for (const p of picked) {
|
|
230
|
+
const content = await filesImpl.read({ ...base, fileId: p.fileId, interactive: true });
|
|
231
|
+
results.push({ fileId: p.fileId, name: p.name, mimeType: p.mimeType, content });
|
|
232
|
+
}
|
|
233
|
+
return results;
|
|
234
|
+
},
|
|
207
235
|
files,
|
|
208
236
|
permissions,
|
|
209
237
|
};
|
package/dist/picker.d.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { WorkspaceMimeShorthand } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Minimal ambient type declarations for Google Picker and GIS APIs.
|
|
4
|
+
* These allow TypeScript to recognize window.gapi and window.google.picker
|
|
5
|
+
* without importing the actual Google libraries.
|
|
6
|
+
*/
|
|
7
|
+
declare global {
|
|
8
|
+
interface Window {
|
|
9
|
+
gapi?: {
|
|
10
|
+
load(api: string, callback: () => void): void;
|
|
11
|
+
};
|
|
12
|
+
google?: {
|
|
13
|
+
picker?: {
|
|
14
|
+
PickerBuilder: new (...args: any[]) => PickerBuilder;
|
|
15
|
+
DocsView: new (...args: any[]) => DocsView;
|
|
16
|
+
Action: {
|
|
17
|
+
PICKED: string;
|
|
18
|
+
CANCEL: string;
|
|
19
|
+
};
|
|
20
|
+
ViewId: {
|
|
21
|
+
DOCS: string;
|
|
22
|
+
};
|
|
23
|
+
Feature: {
|
|
24
|
+
MULTISELECT_ENABLED: string;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
interface PickerBuilder {
|
|
31
|
+
addView(view: DocsView): PickerBuilder;
|
|
32
|
+
setOAuthToken(token: string): PickerBuilder;
|
|
33
|
+
setDeveloperKey(key: string): PickerBuilder;
|
|
34
|
+
setAppId(appId: string): PickerBuilder;
|
|
35
|
+
enableFeature(feature: string): PickerBuilder;
|
|
36
|
+
setCallback(callback: (data: PickerResponse) => void): PickerBuilder;
|
|
37
|
+
build(): PickerInstance;
|
|
38
|
+
}
|
|
39
|
+
interface DocsView {
|
|
40
|
+
setMimeTypes(types: string): DocsView;
|
|
41
|
+
setParent(folderId: string): DocsView;
|
|
42
|
+
}
|
|
43
|
+
interface PickerInstance {
|
|
44
|
+
setVisible(visible: boolean): void;
|
|
45
|
+
}
|
|
46
|
+
interface PickerResponse {
|
|
47
|
+
action?: string;
|
|
48
|
+
docs?: PickerDocument[];
|
|
49
|
+
}
|
|
50
|
+
interface PickerDocument {
|
|
51
|
+
id: string;
|
|
52
|
+
name: string;
|
|
53
|
+
mimeType: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Test-only export: resets the script load cache, allowing tests to
|
|
57
|
+
* re-inject or mock the picker script. Exported normally (not conditionally gated)
|
|
58
|
+
* so tests can import and call it directly.
|
|
59
|
+
*/
|
|
60
|
+
export declare function __resetPickerScriptCacheForTests(): void;
|
|
61
|
+
export interface OpenPickerOptions {
|
|
62
|
+
apiKey: string;
|
|
63
|
+
oauthToken: string;
|
|
64
|
+
/**
|
|
65
|
+
* The Cloud project *number* of the OAuth client that minted `oauthToken`.
|
|
66
|
+
* Required: this library requests only the `drive.file` scope, and Picker
|
|
67
|
+
* refuses to run a scoped session it cannot attribute to an app. Omit it and
|
|
68
|
+
* Picker discards the OAuth token, falls back to its own sign-in prompt, and
|
|
69
|
+
* then fails the unauthenticated developer-key check with "The API developer
|
|
70
|
+
* key is invalid" — even though the key and its referrer restrictions are fine.
|
|
71
|
+
*/
|
|
72
|
+
appId: string;
|
|
73
|
+
mimeTypes?: (string | WorkspaceMimeShorthand)[];
|
|
74
|
+
multiSelect?: boolean;
|
|
75
|
+
parentFolderId?: string;
|
|
76
|
+
}
|
|
77
|
+
export interface PickedFile {
|
|
78
|
+
fileId: string;
|
|
79
|
+
name: string;
|
|
80
|
+
mimeType: string;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Opens the Google Picker dialog for file selection, returning a promise
|
|
84
|
+
* that resolves with the selected files or rejects if the user cancels.
|
|
85
|
+
*
|
|
86
|
+
* The picker is configured with optional MIME type filters, multi-select
|
|
87
|
+
* support, and a parent folder constraint.
|
|
88
|
+
*
|
|
89
|
+
* @param opts - Configuration including API key, OAuth token, and picker options
|
|
90
|
+
* @returns Promise resolving to an array of picked files
|
|
91
|
+
* @throws PickerCancelledError if the user cancels the dialog
|
|
92
|
+
* @throws Error if the Picker script fails to load or configuration fails
|
|
93
|
+
*/
|
|
94
|
+
export declare function openPicker(opts: OpenPickerOptions): Promise<PickedFile[]>;
|
|
95
|
+
export {};
|
package/dist/picker.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { PickerCancelledError } from './errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Maps Google Workspace document type shorthands to their full MIME types.
|
|
4
|
+
*/
|
|
5
|
+
const WORKSPACE_MIME_SHORTHAND = {
|
|
6
|
+
docs: 'application/vnd.google-apps.document',
|
|
7
|
+
sheets: 'application/vnd.google-apps.spreadsheet',
|
|
8
|
+
slides: 'application/vnd.google-apps.presentation',
|
|
9
|
+
forms: 'application/vnd.google-apps.form',
|
|
10
|
+
drawings: 'application/vnd.google-apps.drawing',
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Module-level cache for the Picker script loading promise.
|
|
14
|
+
* Reused across multiple openPicker() calls to avoid re-injecting the script.
|
|
15
|
+
*/
|
|
16
|
+
let scriptLoadPromise = null;
|
|
17
|
+
/**
|
|
18
|
+
* Resolves an optional array of MIME types, expanding shorthand tokens
|
|
19
|
+
* (e.g., 'docs', 'sheets') to their full `application/vnd.google-apps.*` equivalents
|
|
20
|
+
* and passing literal MIME strings through unchanged.
|
|
21
|
+
*
|
|
22
|
+
* @param mimeTypes - Array containing strings and/or WorkspaceMimeShorthand tokens
|
|
23
|
+
* @returns Expanded array of MIME type strings, or undefined if input is undefined
|
|
24
|
+
*/
|
|
25
|
+
function resolveMimeTypes(mimeTypes) {
|
|
26
|
+
if (!mimeTypes) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
return mimeTypes.map((type) => {
|
|
30
|
+
if (type in WORKSPACE_MIME_SHORTHAND) {
|
|
31
|
+
return WORKSPACE_MIME_SHORTHAND[type];
|
|
32
|
+
}
|
|
33
|
+
return type;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Ensures the Google Picker API is loaded by:
|
|
38
|
+
* 1. Checking if window.google.picker already exists (e.g., from a test fake)
|
|
39
|
+
* 2. Reusing a pending script load if one is already in progress
|
|
40
|
+
* 3. Otherwise, injecting the picker script and waiting for gapi.load('picker')
|
|
41
|
+
*
|
|
42
|
+
* On script error, resets the cache so a retry will re-inject.
|
|
43
|
+
*
|
|
44
|
+
* @throws Error if script load fails
|
|
45
|
+
*/
|
|
46
|
+
async function ensurePickerLoaded() {
|
|
47
|
+
// Already loaded (e.g., by a test fake or prior successful load)
|
|
48
|
+
if (window.google?.picker) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// Script load already in progress; reuse the existing promise
|
|
52
|
+
if (scriptLoadPromise) {
|
|
53
|
+
return scriptLoadPromise;
|
|
54
|
+
}
|
|
55
|
+
// Inject the Picker script and set up the load promise
|
|
56
|
+
scriptLoadPromise = new Promise((resolve, reject) => {
|
|
57
|
+
const script = document.createElement('script');
|
|
58
|
+
script.src = 'https://apis.google.com/js/api.js';
|
|
59
|
+
script.onload = () => {
|
|
60
|
+
// Script loaded; now request the Picker API
|
|
61
|
+
window.gapi.load('picker', () => {
|
|
62
|
+
resolve();
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
script.onerror = () => {
|
|
66
|
+
// Script failed to load; reset cache so retry can re-inject
|
|
67
|
+
scriptLoadPromise = null;
|
|
68
|
+
reject(new Error('Failed to load Google Picker script'));
|
|
69
|
+
};
|
|
70
|
+
document.head.appendChild(script);
|
|
71
|
+
});
|
|
72
|
+
return scriptLoadPromise;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Test-only export: resets the script load cache, allowing tests to
|
|
76
|
+
* re-inject or mock the picker script. Exported normally (not conditionally gated)
|
|
77
|
+
* so tests can import and call it directly.
|
|
78
|
+
*/
|
|
79
|
+
export function __resetPickerScriptCacheForTests() {
|
|
80
|
+
scriptLoadPromise = null;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Opens the Google Picker dialog for file selection, returning a promise
|
|
84
|
+
* that resolves with the selected files or rejects if the user cancels.
|
|
85
|
+
*
|
|
86
|
+
* The picker is configured with optional MIME type filters, multi-select
|
|
87
|
+
* support, and a parent folder constraint.
|
|
88
|
+
*
|
|
89
|
+
* @param opts - Configuration including API key, OAuth token, and picker options
|
|
90
|
+
* @returns Promise resolving to an array of picked files
|
|
91
|
+
* @throws PickerCancelledError if the user cancels the dialog
|
|
92
|
+
* @throws Error if the Picker script fails to load or configuration fails
|
|
93
|
+
*/
|
|
94
|
+
export async function openPicker(opts) {
|
|
95
|
+
// Ensure the Picker API is available
|
|
96
|
+
await ensurePickerLoaded();
|
|
97
|
+
// Create and configure a DocsView for file browsing
|
|
98
|
+
const docsView = new window.google.picker.DocsView();
|
|
99
|
+
// Apply MIME type filters if provided
|
|
100
|
+
const resolvedMimes = resolveMimeTypes(opts.mimeTypes);
|
|
101
|
+
if (resolvedMimes) {
|
|
102
|
+
docsView.setMimeTypes(resolvedMimes.join(','));
|
|
103
|
+
}
|
|
104
|
+
// Constrain to a specific parent folder if provided
|
|
105
|
+
if (opts.parentFolderId) {
|
|
106
|
+
docsView.setParent(opts.parentFolderId);
|
|
107
|
+
}
|
|
108
|
+
// Create and configure the PickerBuilder
|
|
109
|
+
const pickerBuilder = new window.google.picker.PickerBuilder();
|
|
110
|
+
pickerBuilder
|
|
111
|
+
.addView(docsView)
|
|
112
|
+
.setOAuthToken(opts.oauthToken)
|
|
113
|
+
.setDeveloperKey(opts.apiKey)
|
|
114
|
+
.setAppId(opts.appId);
|
|
115
|
+
// Enable multi-select if requested
|
|
116
|
+
if (opts.multiSelect) {
|
|
117
|
+
pickerBuilder.enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED);
|
|
118
|
+
}
|
|
119
|
+
// Set up the response handler and build the picker
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
121
|
+
pickerBuilder.setCallback((data) => {
|
|
122
|
+
// User picked files
|
|
123
|
+
if (data.action === window.google.picker.Action.PICKED && data.docs) {
|
|
124
|
+
const picked = data.docs.map((doc) => ({
|
|
125
|
+
fileId: doc.id,
|
|
126
|
+
name: doc.name,
|
|
127
|
+
mimeType: doc.mimeType,
|
|
128
|
+
}));
|
|
129
|
+
resolve(picked);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// User cancelled
|
|
133
|
+
if (data.action === window.google.picker.Action.CANCEL) {
|
|
134
|
+
reject(new PickerCancelledError());
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
pickerBuilder.build().setVisible(true);
|
|
139
|
+
});
|
|
140
|
+
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -6,10 +6,21 @@ export interface ConnRecord {
|
|
|
6
6
|
grantedScopes: string[];
|
|
7
7
|
connectedAt: number;
|
|
8
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* Per-file sync baseline: the Drive `version` this client last restored (via
|
|
11
|
+
* files.read()) or last successfully wrote. A write is only allowed when the
|
|
12
|
+
* file's current remote version still matches this — see files.ts.
|
|
13
|
+
*/
|
|
14
|
+
export interface FileStateRecord {
|
|
15
|
+
fileId: string;
|
|
16
|
+
version: string;
|
|
17
|
+
/** Epoch ms at which this baseline was recorded. */
|
|
18
|
+
syncedAt: number;
|
|
19
|
+
}
|
|
9
20
|
interface AuthDbSchema extends DBSchema {
|
|
10
21
|
auth: {
|
|
11
22
|
key: string;
|
|
12
|
-
value: ConnRecord | StoredToken;
|
|
23
|
+
value: ConnRecord | StoredToken | FileStateRecord;
|
|
13
24
|
};
|
|
14
25
|
}
|
|
15
26
|
export declare function openAuthDb(appId: string, projectId: string): Promise<IDBPDatabase<AuthDbSchema>>;
|
|
@@ -24,4 +35,7 @@ export declare function clearConn(appId: string, projectId: string): Promise<voi
|
|
|
24
35
|
export declare function getToken(appId: string, projectId: string): Promise<StoredToken | undefined>;
|
|
25
36
|
export declare function setToken(appId: string, projectId: string, token: StoredToken): Promise<void>;
|
|
26
37
|
export declare function clearToken(appId: string, projectId: string): Promise<void>;
|
|
38
|
+
export declare function getFileState(appId: string, projectId: string, fileId: string): Promise<FileStateRecord | undefined>;
|
|
39
|
+
export declare function setFileState(appId: string, projectId: string, state: FileStateRecord): Promise<void>;
|
|
40
|
+
export declare function clearFileState(appId: string, projectId: string, fileId: string): Promise<void>;
|
|
27
41
|
export {};
|
package/dist/storage.js
CHANGED
|
@@ -2,6 +2,18 @@ import { openDB } from 'idb';
|
|
|
2
2
|
const AUTH_STORE = 'auth';
|
|
3
3
|
const CONN_KEY = 'conn';
|
|
4
4
|
const TOKEN_KEY = 'token';
|
|
5
|
+
/**
|
|
6
|
+
* File baselines live in the existing 'auth' store under a namespaced key
|
|
7
|
+
* rather than in a store of their own, deliberately: adding a store means
|
|
8
|
+
* bumping the DB version, and an upgrade cannot run while ANY other
|
|
9
|
+
* connection still holds the old version open. Older tabs run older code
|
|
10
|
+
* that has no way to know it should close, so the upgrade would block
|
|
11
|
+
* indefinitely and every Drive call — which needs this DB for its token —
|
|
12
|
+
* would hang rather than fail. Keeping the schema at v1 avoids that entirely.
|
|
13
|
+
*/
|
|
14
|
+
function fileKey(fileId) {
|
|
15
|
+
return `file:${fileId}`;
|
|
16
|
+
}
|
|
5
17
|
function dbName(appId, projectId) {
|
|
6
18
|
return `owa-drive-${appId}-${projectId}`;
|
|
7
19
|
}
|
|
@@ -19,6 +31,11 @@ export function openAuthDb(appId, projectId) {
|
|
|
19
31
|
db.createObjectStore(AUTH_STORE);
|
|
20
32
|
}
|
|
21
33
|
},
|
|
34
|
+
// Defence for any FUTURE version bump: close this connection as soon as
|
|
35
|
+
// another tab needs to upgrade, so it is never the thing blocking.
|
|
36
|
+
blocking() {
|
|
37
|
+
void evictDbHandle(appId, projectId);
|
|
38
|
+
},
|
|
22
39
|
});
|
|
23
40
|
dbCache.set(key, handle);
|
|
24
41
|
}
|
|
@@ -68,3 +85,16 @@ export async function clearToken(appId, projectId) {
|
|
|
68
85
|
const db = await openAuthDb(appId, projectId);
|
|
69
86
|
await db.delete(AUTH_STORE, TOKEN_KEY);
|
|
70
87
|
}
|
|
88
|
+
export async function getFileState(appId, projectId, fileId) {
|
|
89
|
+
const db = await openAuthDb(appId, projectId);
|
|
90
|
+
const value = await db.get(AUTH_STORE, fileKey(fileId));
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
export async function setFileState(appId, projectId, state) {
|
|
94
|
+
const db = await openAuthDb(appId, projectId);
|
|
95
|
+
await db.put(AUTH_STORE, state, fileKey(state.fileId));
|
|
96
|
+
}
|
|
97
|
+
export async function clearFileState(appId, projectId, fileId) {
|
|
98
|
+
const db = await openAuthDb(appId, projectId);
|
|
99
|
+
await db.delete(AUTH_STORE, fileKey(fileId));
|
|
100
|
+
}
|
|
@@ -20,6 +20,11 @@ export interface DriveFakeFile {
|
|
|
20
20
|
content: string;
|
|
21
21
|
contentType?: string;
|
|
22
22
|
trashed?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Drive's monotonic per-file change counter, bumped on every mutation.
|
|
25
|
+
* Optional so tests may seed files without it; treated as 1 when absent.
|
|
26
|
+
*/
|
|
27
|
+
version?: number;
|
|
23
28
|
}
|
|
24
29
|
export interface DriveFakePermission {
|
|
25
30
|
id: string;
|
|
@@ -44,6 +49,11 @@ export interface DriveFake {
|
|
|
44
49
|
reset(): void;
|
|
45
50
|
/** Inspect current in-memory files, keyed by id. */
|
|
46
51
|
readonly files: Map<string, DriveFakeFile>;
|
|
52
|
+
/**
|
|
53
|
+
* Simulate another client editing the file: replaces content and bumps
|
|
54
|
+
* `version`, exactly as a real out-of-band Drive write would.
|
|
55
|
+
*/
|
|
56
|
+
externalEdit(fileId: string, content: string): void;
|
|
47
57
|
/** Inspect current in-memory permissions, keyed by fileId then permission id. */
|
|
48
58
|
readonly permissions: Map<string, Map<string, DriveFakePermission>>;
|
|
49
59
|
}
|
|
@@ -142,7 +142,7 @@ export function createDriveFake() {
|
|
|
142
142
|
return override;
|
|
143
143
|
}
|
|
144
144
|
function fileToMetadata(f) {
|
|
145
|
-
return { id: f.id, name: f.name, mimeType: f.mimeType, parents: f.parents };
|
|
145
|
+
return { id: f.id, name: f.name, mimeType: f.mimeType, parents: f.parents, version: String(f.version ?? 1) };
|
|
146
146
|
}
|
|
147
147
|
async function handleFilesList(url) {
|
|
148
148
|
const q = url.searchParams.get('q') ?? '';
|
|
@@ -218,6 +218,7 @@ export function createDriveFake() {
|
|
|
218
218
|
parents: Array.isArray(metadata.parents) ? metadata.parents : [],
|
|
219
219
|
content,
|
|
220
220
|
contentType: mediaType,
|
|
221
|
+
version: 1,
|
|
221
222
|
};
|
|
222
223
|
files.set(id, file);
|
|
223
224
|
if (mimeType === FOLDER_MIME_TYPE) {
|
|
@@ -237,6 +238,7 @@ export function createDriveFake() {
|
|
|
237
238
|
const contentType = getHeader(init, 'Content-Type');
|
|
238
239
|
if (contentType)
|
|
239
240
|
file.contentType = contentType;
|
|
241
|
+
file.version = (file.version ?? 1) + 1;
|
|
240
242
|
return jsonResponse(fileToMetadata(file));
|
|
241
243
|
}
|
|
242
244
|
if (uploadType === 'multipart') {
|
|
@@ -254,6 +256,7 @@ export function createDriveFake() {
|
|
|
254
256
|
file.content = parsed.mediaContent;
|
|
255
257
|
if (parsed.mediaType)
|
|
256
258
|
file.contentType = parsed.mediaType;
|
|
259
|
+
file.version = (file.version ?? 1) + 1;
|
|
257
260
|
}
|
|
258
261
|
return jsonResponse(fileToMetadata(file));
|
|
259
262
|
}
|
|
@@ -285,6 +288,7 @@ export function createDriveFake() {
|
|
|
285
288
|
const removeSet = new Set(removeParents.split(','));
|
|
286
289
|
file.parents = file.parents.filter((p) => !removeSet.has(p));
|
|
287
290
|
}
|
|
291
|
+
file.version = (file.version ?? 1) + 1;
|
|
288
292
|
return jsonResponse(fileToMetadata(file));
|
|
289
293
|
}
|
|
290
294
|
async function handleFileDelete(id) {
|
|
@@ -411,6 +415,13 @@ export function createDriveFake() {
|
|
|
411
415
|
remaining: opts?.times ?? 1,
|
|
412
416
|
});
|
|
413
417
|
},
|
|
418
|
+
externalEdit(fileId, content) {
|
|
419
|
+
const file = files.get(fileId);
|
|
420
|
+
if (!file)
|
|
421
|
+
throw new Error(`driveFake.externalEdit: unknown file ${fileId}`);
|
|
422
|
+
file.content = content;
|
|
423
|
+
file.version = (file.version ?? 1) + 1;
|
|
424
|
+
},
|
|
414
425
|
reset() {
|
|
415
426
|
files.clear();
|
|
416
427
|
permissions.clear();
|
|
@@ -50,6 +50,12 @@ export interface GisFake {
|
|
|
50
50
|
calls: GisRecordedCall[];
|
|
51
51
|
/** Queue a response to be delivered to the next `requestAccessToken` call. */
|
|
52
52
|
queueResponse(response: GisTokenResponse): void;
|
|
53
|
+
/**
|
|
54
|
+
* Queue a popup-level failure for the next `requestAccessToken` call,
|
|
55
|
+
* delivered via `error_callback` — the channel the real GIS client uses
|
|
56
|
+
* for a blocked or dismissed popup, which never reaches `callback`.
|
|
57
|
+
*/
|
|
58
|
+
queuePopupError(type: string): void;
|
|
53
59
|
/** Stub `window.google.accounts.oauth2.initTokenClient` with this fake. */
|
|
54
60
|
install(): void;
|
|
55
61
|
/** Remove the stub installed by `install()`, restoring prior state. */
|
package/dist/testing/gisFake.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
export function createGisFake() {
|
|
17
17
|
const responseQueue = [];
|
|
18
|
+
const popupErrorQueue = [];
|
|
18
19
|
const calls = [];
|
|
19
20
|
let previousGoogle;
|
|
20
21
|
let hadGoogle = false;
|
|
@@ -32,6 +33,14 @@ export function createGisFake() {
|
|
|
32
33
|
const hint = overrideConfig?.hint ?? config.hint;
|
|
33
34
|
const scope = overrideConfig?.scope ?? config.scope ?? '';
|
|
34
35
|
calls.push({ prompt, hint, scope });
|
|
36
|
+
const popupError = popupErrorQueue.shift();
|
|
37
|
+
if (popupError) {
|
|
38
|
+
const errorCallback = config.error_callback;
|
|
39
|
+
queueMicrotask(() => {
|
|
40
|
+
errorCallback?.({ type: popupError });
|
|
41
|
+
});
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
35
44
|
const response = nextResponse();
|
|
36
45
|
const callback = config.callback;
|
|
37
46
|
// Deliver asynchronously (microtask), matching the real GIS client's
|
|
@@ -54,6 +63,9 @@ export function createGisFake() {
|
|
|
54
63
|
queueResponse(response) {
|
|
55
64
|
responseQueue.push(response);
|
|
56
65
|
},
|
|
66
|
+
queuePopupError(type) {
|
|
67
|
+
popupErrorQueue.push(type);
|
|
68
|
+
},
|
|
57
69
|
install() {
|
|
58
70
|
const w = globalThis;
|
|
59
71
|
hadGoogle = Object.prototype.hasOwnProperty.call(w, 'google');
|
|
@@ -80,6 +92,7 @@ export function createGisFake() {
|
|
|
80
92
|
},
|
|
81
93
|
reset() {
|
|
82
94
|
responseQueue.length = 0;
|
|
95
|
+
popupErrorQueue.length = 0;
|
|
83
96
|
calls.length = 0;
|
|
84
97
|
},
|
|
85
98
|
};
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -2,3 +2,5 @@ export { createGisFake } from './gisFake.js';
|
|
|
2
2
|
export type { GisFake, GisTokenResponse, GisRecordedCall } from './gisFake.js';
|
|
3
3
|
export { createDriveFake } from './driveFake.js';
|
|
4
4
|
export type { DriveFake, DriveFakeFile, DriveFakePermission, StatusOverrideOptions } from './driveFake.js';
|
|
5
|
+
export { createPickerFake } from './pickerFake.js';
|
|
6
|
+
export type { PickerFake, PickerFakeFile, PickerRecordedCall } from './pickerFake.js';
|
package/dist/testing/index.js
CHANGED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A scriptable double for Google Picker (google.picker),
|
|
3
|
+
* for use in tests of code that calls `window.google.picker.PickerBuilder`
|
|
4
|
+
* and related APIs.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const pickerFake = createPickerFake()
|
|
10
|
+
* pickerFake.install()
|
|
11
|
+
* // ... exercise code under test that calls openPicker() ...
|
|
12
|
+
* pickerFake.simulatePick([{ fileId: '123', name: 'doc.txt', mimeType: 'text/plain' }])
|
|
13
|
+
* await expect(openPickerPromise).resolves.toEqual([...])
|
|
14
|
+
* expect(pickerFake.calls).toHaveLength(1)
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export interface PickerFakeFile {
|
|
18
|
+
fileId: string;
|
|
19
|
+
name: string;
|
|
20
|
+
mimeType: string;
|
|
21
|
+
}
|
|
22
|
+
export interface PickerRecordedCall {
|
|
23
|
+
oauthToken?: string;
|
|
24
|
+
developerKey?: string;
|
|
25
|
+
appId?: string;
|
|
26
|
+
views: Array<{
|
|
27
|
+
mimeTypes?: string;
|
|
28
|
+
parentId?: string;
|
|
29
|
+
}>;
|
|
30
|
+
features: string[];
|
|
31
|
+
}
|
|
32
|
+
export interface PickerFake {
|
|
33
|
+
/** All PickerBuilder constructions and their configurations, in order. */
|
|
34
|
+
calls: PickerRecordedCall[];
|
|
35
|
+
/** Simulate user picking files; invokes the picker's callback asynchronously. */
|
|
36
|
+
simulatePick(files: PickerFakeFile[]): void;
|
|
37
|
+
/** Simulate user cancelling; invokes the picker's callback asynchronously. */
|
|
38
|
+
simulateCancel(): void;
|
|
39
|
+
/** Stub window.gapi and window.google.picker with this fake. */
|
|
40
|
+
install(): void;
|
|
41
|
+
/** Remove the stub installed by `install()`, restoring prior state. */
|
|
42
|
+
uninstall(): void;
|
|
43
|
+
/** Clear call history and stored callback state. */
|
|
44
|
+
reset(): void;
|
|
45
|
+
}
|
|
46
|
+
export declare function createPickerFake(): PickerFake;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A scriptable double for Google Picker (google.picker),
|
|
3
|
+
* for use in tests of code that calls `window.google.picker.PickerBuilder`
|
|
4
|
+
* and related APIs.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const pickerFake = createPickerFake()
|
|
10
|
+
* pickerFake.install()
|
|
11
|
+
* // ... exercise code under test that calls openPicker() ...
|
|
12
|
+
* pickerFake.simulatePick([{ fileId: '123', name: 'doc.txt', mimeType: 'text/plain' }])
|
|
13
|
+
* await expect(openPickerPromise).resolves.toEqual([...])
|
|
14
|
+
* expect(pickerFake.calls).toHaveLength(1)
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export function createPickerFake() {
|
|
18
|
+
const calls = [];
|
|
19
|
+
let storedCallback = null;
|
|
20
|
+
let previousGapi;
|
|
21
|
+
let hadGapi = false;
|
|
22
|
+
let previousGoogle;
|
|
23
|
+
let hadGoogle = false;
|
|
24
|
+
let previousGooglePicker;
|
|
25
|
+
let hadGooglePicker = false;
|
|
26
|
+
/**
|
|
27
|
+
* Fake DocsView class for stubbing window.google.picker.DocsView.
|
|
28
|
+
* Stores MIME types and parent folder ID, and is chainable.
|
|
29
|
+
*/
|
|
30
|
+
class FakeDocsView {
|
|
31
|
+
mimeTypes;
|
|
32
|
+
parentId;
|
|
33
|
+
setMimeTypes(types) {
|
|
34
|
+
this.mimeTypes = types;
|
|
35
|
+
return this;
|
|
36
|
+
}
|
|
37
|
+
setParent(folderId) {
|
|
38
|
+
this.parentId = folderId;
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Fake PickerInstance (returned by PickerBuilder.build()).
|
|
44
|
+
* When setVisible(true) is called, it's recorded but callback is not fired
|
|
45
|
+
* until simulatePick or simulateCancel is called.
|
|
46
|
+
*/
|
|
47
|
+
class FakePickerInstance {
|
|
48
|
+
setVisible(visible) {
|
|
49
|
+
// Visibility change is recorded implicitly by the builder construction.
|
|
50
|
+
// The callback will be triggered by simulatePick/simulateCancel.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Fake PickerBuilder class for stubbing window.google.picker.PickerBuilder.
|
|
55
|
+
* Chainable methods all return `this`, and build() returns a FakePickerInstance.
|
|
56
|
+
*/
|
|
57
|
+
class FakePickerBuilder {
|
|
58
|
+
currentCall = {
|
|
59
|
+
views: [],
|
|
60
|
+
features: [],
|
|
61
|
+
};
|
|
62
|
+
addView(view) {
|
|
63
|
+
this.currentCall.views.push({
|
|
64
|
+
mimeTypes: view.mimeTypes,
|
|
65
|
+
parentId: view.parentId,
|
|
66
|
+
});
|
|
67
|
+
return this;
|
|
68
|
+
}
|
|
69
|
+
setOAuthToken(token) {
|
|
70
|
+
this.currentCall.oauthToken = token;
|
|
71
|
+
return this;
|
|
72
|
+
}
|
|
73
|
+
setDeveloperKey(key) {
|
|
74
|
+
this.currentCall.developerKey = key;
|
|
75
|
+
return this;
|
|
76
|
+
}
|
|
77
|
+
setAppId(appId) {
|
|
78
|
+
this.currentCall.appId = appId;
|
|
79
|
+
return this;
|
|
80
|
+
}
|
|
81
|
+
enableFeature(feature) {
|
|
82
|
+
this.currentCall.features.push(feature);
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
setCallback(callback) {
|
|
86
|
+
storedCallback = callback;
|
|
87
|
+
return this;
|
|
88
|
+
}
|
|
89
|
+
build() {
|
|
90
|
+
// Record the call once build() is invoked (not before, so all config is captured)
|
|
91
|
+
calls.push(this.currentCall);
|
|
92
|
+
return new FakePickerInstance();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
calls,
|
|
97
|
+
simulatePick(files) {
|
|
98
|
+
if (!storedCallback) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const callback = storedCallback;
|
|
102
|
+
queueMicrotask(() => {
|
|
103
|
+
callback({
|
|
104
|
+
action: 'picked',
|
|
105
|
+
docs: files.map((file) => ({
|
|
106
|
+
id: file.fileId,
|
|
107
|
+
name: file.name,
|
|
108
|
+
mimeType: file.mimeType,
|
|
109
|
+
})),
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
simulateCancel() {
|
|
114
|
+
if (!storedCallback) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const callback = storedCallback;
|
|
118
|
+
queueMicrotask(() => {
|
|
119
|
+
callback({
|
|
120
|
+
action: 'cancel',
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
install() {
|
|
125
|
+
const w = globalThis;
|
|
126
|
+
// Save and replace window.gapi
|
|
127
|
+
hadGapi = Object.prototype.hasOwnProperty.call(w, 'gapi');
|
|
128
|
+
previousGapi = w.gapi;
|
|
129
|
+
w.gapi = {
|
|
130
|
+
load(api, callback) {
|
|
131
|
+
if (api === 'picker') {
|
|
132
|
+
queueMicrotask(callback);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
// Save and replace window.google
|
|
137
|
+
hadGoogle = Object.prototype.hasOwnProperty.call(w, 'google');
|
|
138
|
+
previousGoogle = w.google;
|
|
139
|
+
if (!w.google) {
|
|
140
|
+
w.google = {};
|
|
141
|
+
}
|
|
142
|
+
// Save and replace window.google.picker
|
|
143
|
+
hadGooglePicker = Object.prototype.hasOwnProperty.call(w.google, 'picker');
|
|
144
|
+
previousGooglePicker = w.google.picker;
|
|
145
|
+
w.google.picker = {
|
|
146
|
+
PickerBuilder: FakePickerBuilder,
|
|
147
|
+
DocsView: FakeDocsView,
|
|
148
|
+
Action: {
|
|
149
|
+
PICKED: 'picked',
|
|
150
|
+
CANCEL: 'cancel',
|
|
151
|
+
},
|
|
152
|
+
ViewId: {
|
|
153
|
+
DOCS: 'docs',
|
|
154
|
+
},
|
|
155
|
+
Feature: {
|
|
156
|
+
MULTISELECT_ENABLED: 'multiselect',
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
uninstall() {
|
|
161
|
+
const w = globalThis;
|
|
162
|
+
// Restore window.gapi
|
|
163
|
+
if (hadGapi) {
|
|
164
|
+
w.gapi = previousGapi;
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
delete w.gapi;
|
|
168
|
+
}
|
|
169
|
+
// Restore window.google.picker
|
|
170
|
+
if (hadGooglePicker) {
|
|
171
|
+
w.google.picker = previousGooglePicker;
|
|
172
|
+
}
|
|
173
|
+
else if (w.google) {
|
|
174
|
+
delete w.google.picker;
|
|
175
|
+
}
|
|
176
|
+
// Restore window.google (if it was only created by install())
|
|
177
|
+
if (!hadGoogle) {
|
|
178
|
+
delete w.google;
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
w.google = previousGoogle;
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
reset() {
|
|
185
|
+
calls.length = 0;
|
|
186
|
+
storedCallback = null;
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
package/dist/token.js
CHANGED
|
@@ -114,6 +114,17 @@ async function acquireTokenUncoalesced(opts) {
|
|
|
114
114
|
}
|
|
115
115
|
resolve(res);
|
|
116
116
|
},
|
|
117
|
+
// Without this, a popup that the browser blocks or the user closes
|
|
118
|
+
// settles NOTHING: GIS reports those through error_callback only, so
|
|
119
|
+
// the promise below would stay pending forever and every awaiting
|
|
120
|
+
// Drive call would hang until the caller's own timeout (if any).
|
|
121
|
+
error_callback: (err) => {
|
|
122
|
+
reject(new NeedsReauthError(err?.type === 'popup_failed_to_open'
|
|
123
|
+
? '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' }));
|
|
127
|
+
},
|
|
117
128
|
});
|
|
118
129
|
opts.logger?.debug('drive-sync: requesting access token', {
|
|
119
130
|
projectId: opts.projectId,
|
package/dist/types.d.ts
CHANGED
|
@@ -27,6 +27,26 @@ export interface StoredToken {
|
|
|
27
27
|
export interface FileRef {
|
|
28
28
|
id: string;
|
|
29
29
|
name?: string;
|
|
30
|
+
/** Drive's monotonic change counter, when the call requested it. */
|
|
31
|
+
version?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Sync state of one file relative to what this client last restored.
|
|
35
|
+
* Returned by `files.status()`.
|
|
36
|
+
*/
|
|
37
|
+
export interface FileState {
|
|
38
|
+
fileId: string;
|
|
39
|
+
/** False when the file is missing or not visible to the connected account. */
|
|
40
|
+
exists: boolean;
|
|
41
|
+
/** Version last restored/written by this client; null if never. */
|
|
42
|
+
baseVersion: string | null;
|
|
43
|
+
/** Version currently on Drive; null when the file does not exist. */
|
|
44
|
+
remoteVersion: string | null;
|
|
45
|
+
/** True when a write would be refused with a RemoteChangedError. */
|
|
46
|
+
changedSinceRestore: boolean;
|
|
47
|
+
remoteModifiedTime?: string;
|
|
48
|
+
/** Epoch ms of the last restore/write by this client; null if never. */
|
|
49
|
+
lastRestoredAt: number | null;
|
|
30
50
|
}
|
|
31
51
|
/** Drive permission shape as returned/accepted by the Drive Permissions API. */
|
|
32
52
|
export interface DrivePermission {
|
|
@@ -35,6 +55,28 @@ export interface DrivePermission {
|
|
|
35
55
|
role: string;
|
|
36
56
|
emailAddress?: string;
|
|
37
57
|
}
|
|
58
|
+
/** Google Workspace MIME type shorthands for common document types. */
|
|
59
|
+
export type WorkspaceMimeShorthand = 'docs' | 'sheets' | 'slides' | 'forms' | 'drawings';
|
|
60
|
+
/** Options for opening the Google Picker to select files. */
|
|
61
|
+
export interface PickFileOptions {
|
|
62
|
+
apiKey: string;
|
|
63
|
+
/**
|
|
64
|
+
* Cloud project *number* of the OAuth client configured on
|
|
65
|
+
* `DriveSyncOptions.clientId`. Passed to Picker's `setAppId()`; required
|
|
66
|
+
* because drive-sync only ever holds a `drive.file`-scoped token.
|
|
67
|
+
*/
|
|
68
|
+
appId: string;
|
|
69
|
+
mimeTypes?: (string | WorkspaceMimeShorthand)[];
|
|
70
|
+
multiSelect?: boolean;
|
|
71
|
+
parentFolderId?: string;
|
|
72
|
+
}
|
|
73
|
+
/** File information returned after selection from Google Picker. */
|
|
74
|
+
export interface PickedFile {
|
|
75
|
+
fileId: string;
|
|
76
|
+
name: string;
|
|
77
|
+
mimeType: string;
|
|
78
|
+
content: string | Blob | null;
|
|
79
|
+
}
|
|
38
80
|
/** Options accepted on every Drive-op call site. */
|
|
39
81
|
export interface CallOptions {
|
|
40
82
|
/** Whether an interactive (popup/redirect) auth flow may be triggered. Defaults to false. */
|