@open-webapp/drive-sync 0.1.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.
@@ -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. */
@@ -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
  };
@@ -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';
@@ -1,2 +1,3 @@
1
1
  export { createGisFake } from './gisFake.js';
2
2
  export { createDriveFake } from './driveFake.js';
3
+ export { createPickerFake } from './pickerFake.js';
@@ -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;