Skip to content

Commit b51e5ed

Browse files
committed
feat: sync files from Google Drive folders into asset library folders
Adds the second asset sync provider, validating the provider abstraction from the Figma work: - Google Drive provider (ui/utils/asset-sync/gdrive.ts): connect a folder to a Drive folder URL and import its files. Enumeration uses the Drive REST files.list API (paginated, shared-drive aware); downloads use alt=media. Drive reports md5Checksum up front, so unchanged files skip the download entirely. Subfolders and native Google editors files (Docs/Sheets/Slides) are skipped in v1. - OAuth support on the provider interface (authType, interactiveLogin, loginLabel, createAuthContext): Drive auth uses the same GIS token flow and consent cache as useGapiClient with the drive.readonly scope added, so each user syncs with their own Google account and Drive's ACL decides access. The connect modal and the sync progress modal show a 'Sign in with Google' button instead of a token input for oauth providers; sign-in runs within the click gesture so the popup isn't blocked. - Rate limits (429/403 rate-limit reasons) reuse the visible-countdown + typed SyncRateLimitError handling from the Figma provider. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kv5GTb2ddUqL46zSitS6Lf
1 parent bab0d6b commit b51e5ed

8 files changed

Lines changed: 807 additions & 42 deletions

File tree

packages/root-cms/docs/asset-sync-design.md

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -362,20 +362,35 @@ download concurrency (default 4) and the provider batches `/v1/images`
362362
ids (Figma accepts large id lists per call); on 429, back off using the
363363
`Retry-After` header and resume.
364364

365-
### 5.2 Google Drive provider (future, validates the abstraction)
366-
367-
- `parseSourceUrl`: `https://drive.google.com/drive/folders/<folderId>`.
368-
- Auth: the existing `useGapiClient` OAuth flow with
369-
`drive.readonly` — the `SyncAuthContext` wraps `gapiClient.login()`,
370-
so per-user access enforcement works identically (Drive's ACL decides).
371-
- `listRemoteAssets`: `drive.files.list({q: '<folderId>' in parents})`;
372-
Drive supplies `md5Checksum` + `modifiedTime`, so `contentHash` is
373-
known **before** download and unchanged files skip download entirely
374-
(an optimization Figma can't offer).
375-
- `download`: existing `downloadFromDrive()` nearly verbatim.
376-
377-
Nothing in the engine, schema, or UI changes — which is the test the
378-
abstraction has to pass.
365+
### 5.2 Google Drive provider (`ui/utils/asset-sync/gdrive.ts`)
366+
367+
- `parseSourceUrl`: `https://drive.google.com/drive/folders/<folderId>`
368+
(plus `/drive/u/<n>/folders/…` and `open?id=…` forms).
369+
- Auth: `authType: 'oauth'` — the provider interface grew a small OAuth
370+
extension (`interactiveLogin()`, `loginLabel`, `createAuthContext()`)
371+
and the UI shows a "Sign in with Google" button instead of a token
372+
input. Sign-in uses the same GIS token flow and consent cache as
373+
`useGapiClient` (one consent covers both features), with the read-only
374+
Drive scope added; the access token is held in memory only. Per-user
375+
access enforcement works identically to Figma: Drive's ACL decides.
376+
Requires `gapi: {apiKey, clientId}` in the cmsPlugin options.
377+
- `listRemoteAssets`: Drive REST `files.list` (`'<folderId>' in parents`,
378+
paginated, shared-drive aware) via plain `fetch` + Bearer token. Drive
379+
supplies `md5Checksum`, so `contentHash` is known **before** download
380+
and unchanged files skip the download entirely (an optimization Figma
381+
can't offer). There is no cheap folder-level version, so
382+
`RemoteAssetList.version` is unset and the whole-sync fast path doesn't
383+
apply — a no-change re-sync costs one listing call, zero downloads.
384+
v1 scope: direct children only (no subfolder recursion); native Google
385+
editors files (Docs/Sheets/Slides) are skipped — no binary content.
386+
- `download`: `files/<id>?alt=media` with the Bearer token.
387+
- Rate limits (429 / 403 rate-limit reasons) reuse the Figma provider's
388+
pattern: visible countdown, `Retry-After` honored, typed
389+
`SyncRateLimitError` on exhaustion.
390+
391+
The engine, schema, and folder UI needed no changes beyond the optional
392+
OAuth fields on the provider interface — the test the abstraction had to
393+
pass.
379394

380395
## 6. Sync engine (`ui/utils/asset-sync/engine.ts`)
381396

packages/root-cms/ui/components/AssetBrowser/AssetSyncModals.tsx

Lines changed: 86 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {showNotification} from '@mantine/notifications';
33
import {
44
IconAlertTriangle,
55
IconBrandFigma,
6+
IconBrandGoogleDrive,
67
IconCloudDownload,
78
} from '@tabler/icons-preact';
89
import {ChangeEvent} from 'preact/compat';
@@ -36,6 +37,9 @@ export function SyncProviderIcon(props: {provider?: string; size?: number}) {
3637
if (props.provider === 'figma') {
3738
return <IconBrandFigma size={size} />;
3839
}
40+
if (props.provider === 'gdrive') {
41+
return <IconBrandGoogleDrive size={size} />;
42+
}
3943
return <IconCloudDownload size={size} />;
4044
}
4145

@@ -66,8 +70,11 @@ export function ConnectSyncModal(props: {
6670

6771
const parsed = useMemo(() => parseSyncSourceUrl(url), [url]);
6872
const provider: AssetSyncProvider | null = parsed?.provider || null;
69-
const hasStoredToken = provider ? !!getProviderToken(provider.id) : false;
70-
const showTokenSection = !!provider && (!hasStoredToken || showTokenInput);
73+
const isOAuth = provider?.authType === 'oauth';
74+
const hasStoredToken =
75+
provider && !isOAuth ? !!getProviderToken(provider.id) : false;
76+
const showTokenSection =
77+
!!provider && !isOAuth && (!hasStoredToken || showTokenInput);
7178
const folderPath = joinFolderPath(folder.parent, folder.name);
7279

7380
async function onSubmit() {
@@ -84,24 +91,32 @@ export function ConnectSyncModal(props: {
8491
}
8592
setSubmitting(true);
8693
try {
87-
const token = tokenInput.trim();
88-
if (token) {
89-
if (provider.validateToken) {
90-
const check = await provider.validateToken(token, parsed.source);
91-
if (!check.valid) {
92-
setError(
93-
check.error ||
94-
`The ${provider.label} token appears to be invalid. Check the token and try again.`
95-
);
96-
setSubmitting(false);
97-
return;
94+
if (isOAuth) {
95+
// Interactive sign-in must happen within the submit click gesture
96+
// so the popup isn't blocked. With prior consent this is silent.
97+
if (provider.interactiveLogin) {
98+
await provider.interactiveLogin();
99+
}
100+
} else {
101+
const token = tokenInput.trim();
102+
if (token) {
103+
if (provider.validateToken) {
104+
const check = await provider.validateToken(token, parsed.source);
105+
if (!check.valid) {
106+
setError(
107+
check.error ||
108+
`The ${provider.label} token appears to be invalid. Check the token and try again.`
109+
);
110+
setSubmitting(false);
111+
return;
112+
}
98113
}
114+
setProviderToken(provider.id, token);
115+
} else if (!hasStoredToken) {
116+
setError(`Enter your ${provider.label} access token.`);
117+
setSubmitting(false);
118+
return;
99119
}
100-
setProviderToken(provider.id, token);
101-
} else if (!hasStoredToken) {
102-
setError(`Enter your ${provider.label} access token.`);
103-
setSubmitting(false);
104-
return;
105120
}
106121
const updated = await connectFolderSync(folder, parsed.source);
107122
showNotification({
@@ -149,24 +164,30 @@ export function ConnectSyncModal(props: {
149164
}}
150165
>
151166
<div className="AssetBrowser__syncModal__text">
152-
Sync the exportable assets of a Figma file or node into this folder.
153-
Assets marked for export in Figma are imported here and can be
154-
re-synced when the designs change.
167+
Sync assets from an external source into this folder — the exportable
168+
assets of a Figma file or node, or the files of a Google Drive folder.
169+
The connection is saved with this folder and can be re-synced when the
170+
source changes.
155171
</div>
156172
<TextInput
157173
data-autofocus
158174
label="Source URL"
159-
placeholder="https://www.figma.com/design/..."
175+
placeholder="https://www.figma.com/design/… or https://drive.google.com/drive/folders/…"
160176
description={
161177
provider
162178
? `Source: ${provider.label}`
163-
: 'Link to a Figma file, or a specific node ("Copy link to selection" in Figma).'
179+
: 'Link to a Figma file/node ("Copy link to selection" in Figma) or a Google Drive folder.'
164180
}
165181
value={url}
166182
onChange={(e: ChangeEvent<HTMLInputElement>) =>
167183
setUrl(e.currentTarget.value)
168184
}
169185
/>
186+
{isOAuth && provider?.tokenHelp && (
187+
<div className="AssetBrowser__syncModal__tokenHelp">
188+
{provider.tokenHelp.text}
189+
</div>
190+
)}
170191
{showTokenSection && provider && (
171192
<div className="AssetBrowser__syncModal__token">
172193
<TextInput
@@ -328,6 +349,23 @@ export function SyncProgressModal(props: {
328349
}
329350
}
330351

352+
/** OAuth providers: interactive sign-in (within the click gesture). */
353+
async function signInAndRetry() {
354+
if (!provider?.interactiveLogin) {
355+
return;
356+
}
357+
setSavingToken(true);
358+
setError('');
359+
try {
360+
await provider.interactiveLogin();
361+
setSavingToken(false);
362+
run();
363+
} catch (err: any) {
364+
setError(String(err?.message || err));
365+
setSavingToken(false);
366+
}
367+
}
368+
331369
const running = status === 'running';
332370

333371
return (
@@ -370,7 +408,31 @@ export function SyncProgressModal(props: {
370408
</div>
371409
)}
372410

373-
{status === 'token' && provider && (
411+
{status === 'token' && provider && provider.authType === 'oauth' && (
412+
<div className="AssetBrowser__syncProgress__token">
413+
<div className="AssetBrowser__syncModal__text">
414+
{provider.tokenHelp?.text ||
415+
`Sign in to ${provider.label} to sync. Only your account's access to the source is used.`}
416+
</div>
417+
{error && (
418+
<div className="AssetBrowser__syncModal__error">{error}</div>
419+
)}
420+
<div className="AssetBrowser__syncModal__buttons">
421+
<Button variant="default" onClick={props.onClose}>
422+
Cancel
423+
</Button>
424+
<Button
425+
color="dark"
426+
loading={savingToken}
427+
onClick={() => signInAndRetry()}
428+
>
429+
{provider.loginLabel || `Sign in to ${provider.label}`}
430+
</Button>
431+
</div>
432+
</div>
433+
)}
434+
435+
{status === 'token' && provider && provider.authType !== 'oauth' && (
374436
<div className="AssetBrowser__syncProgress__token">
375437
<div className="AssetBrowser__syncModal__text">
376438
A {provider.label} access token is needed to sync. Your token is

packages/root-cms/ui/hooks/useGapiClient.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,12 @@ async function loadGapiScript() {
161161

162162
let loadGisScriptPromise: Promise<void> | null = null;
163163

164-
async function loadGisScript() {
164+
/**
165+
* Loads the Google Identity Services script (used for OAuth token flows).
166+
* Exported for non-hook consumers (e.g. the Google Drive asset sync
167+
* provider in `ui/utils/asset-sync/gdrive.ts`).
168+
*/
169+
export async function loadGisScript() {
165170
if (!loadGisScriptPromise) {
166171
loadGisScriptPromise = new Promise((resolve, reject) => {
167172
const script = document.createElement('script');

packages/root-cms/ui/utils/asset-sync/engine.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,10 @@ export async function syncFolder(
154154
if (!provider) {
155155
throw new Error(`Unknown sync provider: ${sync.provider}`);
156156
}
157-
const auth = options.auth ?? createBrowserAuthContext(provider.id);
157+
const auth =
158+
options.auth ??
159+
provider.createAuthContext?.() ??
160+
createBrowserAuthContext(provider.id);
158161
const concurrency = options.concurrency || DEFAULT_CONCURRENCY;
159162

160163
// Progress reporting. Providers report transient status (e.g. rate-limit

0 commit comments

Comments
 (0)