@open-webapp/drive-sync 0.7.1 → 0.9.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 +41 -0
- package/SPEC.md +57 -1
- package/dist/calendar.d.ts +58 -0
- package/dist/calendar.js +84 -0
- package/dist/connectionSnapshot.d.ts +17 -0
- package/dist/connectionSnapshot.js +48 -0
- package/dist/files.d.ts +5 -0
- package/dist/files.js +10 -7
- package/dist/index.d.ts +20 -2
- package/dist/index.js +82 -8
- package/dist/permissions.d.ts +5 -0
- package/dist/permissions.js +4 -5
- package/dist/refresh.d.ts +2 -0
- package/dist/refresh.js +3 -4
- package/dist/types.d.ts +35 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,6 +17,9 @@ const drive = createDriveSync({
|
|
|
17
17
|
appId: 'my-app',
|
|
18
18
|
clientId: 'xxx.apps.googleusercontent.com',
|
|
19
19
|
folderPath: ['MyApp', 'Data'],
|
|
20
|
+
// additionalScopes?: string[] — request extra OAuth scopes alongside the
|
|
21
|
+
// library's own Drive scopes. Omitted/empty = identical behavior to before
|
|
22
|
+
// this option existed.
|
|
20
23
|
})
|
|
21
24
|
|
|
22
25
|
const dispose = drive.activate()
|
|
@@ -38,6 +41,44 @@ const ref = await p.files.update(fileId, {
|
|
|
38
41
|
})
|
|
39
42
|
```
|
|
40
43
|
|
|
44
|
+
### Calendar events (read-only)
|
|
45
|
+
|
|
46
|
+
Reuses the same connected Google account — no second OAuth flow — as long as
|
|
47
|
+
`additionalScopes: ['https://www.googleapis.com/auth/calendar.readonly']` was
|
|
48
|
+
passed to `createDriveSync`. The caller supplies the time window; this
|
|
49
|
+
library hardcodes no date range:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
const events = await p.calendar.listEvents({
|
|
53
|
+
timeMin: new Date(Date.now() - 7 * 86400_000).toISOString(),
|
|
54
|
+
timeMax: new Date(Date.now() + 30 * 86400_000).toISOString(),
|
|
55
|
+
})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Read-only (no create/update/delete). Non-interactive by default, like
|
|
59
|
+
`p.files.*` — a background poll never pops a consent screen; pass
|
|
60
|
+
`{ interactive: true }` to allow one. Every event in range is returned,
|
|
61
|
+
unfiltered — callers decide what to show/hide, including declined events.
|
|
62
|
+
Each `CalendarEvent` includes `selfResponseStatus`, a best-effort `joinUrl`,
|
|
63
|
+
and `isAllDay`-aware `start`/`end` (an all-day event has `date` set and
|
|
64
|
+
`dateTime` left `undefined`).
|
|
65
|
+
|
|
66
|
+
### Synchronous connection snapshot
|
|
67
|
+
|
|
68
|
+
`p.getConnectionSync()` / `p.subscribeConnection()` give a framework store a
|
|
69
|
+
synchronous, referentially-stable `Connection | null` without polling:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const store = {
|
|
73
|
+
get: () => p.getConnectionSync(),
|
|
74
|
+
subscribe: (onChange: () => void) => p.subscribeConnection(onChange),
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
This pairing is the intended backing for `@open-webapp/drive-connect`'s React
|
|
79
|
+
hook (`useDriveConnection`), and works equally well for any other
|
|
80
|
+
`useSyncExternalStore`-shaped store.
|
|
81
|
+
|
|
41
82
|
See `SPEC.md` for the full design: the 36 resolved decisions, storage layout,
|
|
42
83
|
and refresh state machine. `SPEC.md` is descriptive, written from the shipped
|
|
43
84
|
code — if it ever disagrees with the source, the source wins.
|
package/SPEC.md
CHANGED
|
@@ -30,6 +30,8 @@ await drive.reconcile(knownProjectIds); // drop orphaned per-project au
|
|
|
30
30
|
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
|
+
const snap = p.getConnectionSync(); // Connection | null, synchronous, referentially stable
|
|
34
|
+
const unsub = p.subscribeConnection(() => {}); // fires when the snapshot reference changes
|
|
33
35
|
|
|
34
36
|
const picked = await p.pickFile({ apiKey: PICKER_API_KEY, appId: GCP_PROJECT_NUMBER }); // file-selection via Google Picker
|
|
35
37
|
const folderId = await p.ensureFolderPath();
|
|
@@ -46,10 +48,27 @@ dispose();
|
|
|
46
48
|
|
|
47
49
|
`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.
|
|
48
50
|
|
|
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`).
|
|
51
|
+
Files implementing the surface: `index.ts` (factory + `ProjectHandle`/`FilesHandle`/`PermissionsHandle`/`CalendarHandle`, plus `getConnectionSync`/`subscribeConnection`), `connection.ts` (`connect`/`getConnection`/`disconnect`/`refreshSilently`/`getAccessToken`), `files.ts`, `permissions.ts`, `calendar.ts` (read-only Calendar events — see §2a), `reconcile.ts`, `refresh.ts` (`activate`/warm-up), `picker.ts` (Google Picker integration), `errors.ts` (typed error classes), `types.ts` (`DriveSyncOptions` incl. `additionalScopes`, `Connection`, `StoredToken`, `FileRef`, `DrivePermission`, `CallOptions`, `CalendarEvent`, `CalendarEventDateTime`, `ListEventsOptions`).
|
|
50
52
|
|
|
51
53
|
`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).
|
|
52
54
|
|
|
55
|
+
### Connection snapshot
|
|
56
|
+
|
|
57
|
+
`index.ts` holds one in-memory `Connection | null` per `projectId`, in a `snapshotStores` Map inside the `createDriveSync` closure (alongside `trackedProjectIds`) — one `ConnectionSnapshotStore` (`connectionSnapshot.ts`) per project, created lazily on first access. Each store shallow-compares the incoming `Connection` on `email`/`needsReauth`/`expiresAt` before replacing its held reference, so `get()` returns a referentially-stable value suitable for `useSyncExternalStore`.
|
|
58
|
+
|
|
59
|
+
`null` from `getConnectionSync()` is ambiguous by design: it means either "disconnected" or "not yet hydrated" (the first background re-read from IndexedDB hasn't resolved). Callers cannot distinguish the two from the return value alone.
|
|
60
|
+
|
|
61
|
+
The snapshot is (re-)read from IndexedDB via `getConnection()` in several places, all but one of them fire-and-forget:
|
|
62
|
+
|
|
63
|
+
- **Lazily, on first access** — the first call to `getConnectionSync()` or `subscribeConnection()` for a project kicks off a `void`-ed re-read.
|
|
64
|
+
- **On a background warm-up** — `refresh.ts`'s `warmUpIfNeeded`, run from `activate()`'s `visibilitychange`/`pageshow` handlers.
|
|
65
|
+
- **On a cross-tab `logout`/`token` broadcast** — `handleBroadcast` re-reads the snapshot for the affected project. This only fires while `activate()` has been called (broadcast listening starts there).
|
|
66
|
+
- **For every tracked project, at `activate()` itself** — so already-registered projects get an immediate re-read when activation starts.
|
|
67
|
+
|
|
68
|
+
**The one exception:** after `connect()`/`disconnect()`, the re-read is `await`ed *before* the call resolves. This is the one place callers can rely on synchronous-after-await freshness — the moment `await p.connect()` (or `disconnect()`) returns, `p.getConnectionSync()` already reflects the new state. Every other re-read above is intentionally fire-and-forget, since nothing is awaiting them to observe the snapshot synchronously.
|
|
69
|
+
|
|
70
|
+
One behavioral consequence worth calling out: because the broadcast handler re-reads on `logout`, a `disconnect()` in one tab becomes observable to `subscribeConnection()` listeners in every other open tab — a capability the async-only `getConnection()` never had (nothing pushes to it).
|
|
71
|
+
|
|
53
72
|
## 2. The 41 resolved design decisions
|
|
54
73
|
|
|
55
74
|
**Bugs fixed (both source apps carried these):**
|
|
@@ -109,6 +128,43 @@ Files implementing the surface: `index.ts` (factory + `ProjectHandle`/`FilesHand
|
|
|
109
128
|
|
|
110
129
|
42. **`list()` passes through `thumbnailLink` + `imageMediaMetadata`, unfiltered and verbatim** — `files.ts`'s `list()` extends the same `fields` mask touched in #36's `modifiedTime` change to also request `thumbnailLink` and `imageMediaMetadata(width,height,rotation)`; `FileRef` (`types.ts`) gains three optional fields — `mimeType?` (already fetched, previously just untyped), `thumbnailLink?`, and `imageMediaMetadata?: { width?; height?; rotation? }` — all `fields`-gated and may be absent on older or partial responses. `list()` stays unfiltered: it returns every file of any MIME type with no `image/` check and no opt-in flag, and `thumbnailLink` is passed through exactly as Drive returns it — no blob fetch, no URL rewrite, no `=s220` size munging, and no `files.thumbnail()` helper. Caveat: `thumbnailLink` is a short-lived URL (good for only ~hours) that can require the browser to be carrying Google auth context for the file's owning account, so a cross-origin bare `<img src>` may 403; rendering is the consuming app's responsibility, and it can fall back to `getAccessToken()` + fetch-to-blob itself. (Label is `42` though this is only the 41st entry — the section carries a duplicate `7.` label and a merged `25–27.` entry, so the labels have always run one ahead of the item count; no existing entry is renumbered.)
|
|
111
130
|
|
|
131
|
+
43. **Synchronous connection snapshot, per project** — `index.ts`'s `ProjectHandle` gains `getConnectionSync(): Connection | null` and `subscribeConnection(cb): () => void`, backed by a new `connectionSnapshot.ts` (`createConnectionSnapshotStore`) held per `projectId` in the `snapshotStores` Map alongside `trackedProjectIds`. The store shallow-compares `email`/`needsReauth`/`expiresAt` so its reference stays stable across no-op re-reads (`useSyncExternalStore`-friendly). It is populated lazily on first access, kept warm by the same warm-up/broadcast/`activate()` paths that already existed, and — the one synchronous-after-await guarantee in the package — re-read and `await`ed to completion inside `connect()`/`disconnect()` before those calls resolve. A side effect: cross-tab `logout` broadcasts now push a visible state change to `subscribeConnection()` listeners, which the async-only `getConnection()` never did.
|
|
132
|
+
|
|
133
|
+
## 2a. Configurable scopes and read-only Calendar events (v0.9.0)
|
|
134
|
+
|
|
135
|
+
**`additionalScopes` option.** `DriveSyncOptions.additionalScopes?: string[]` (`types.ts`) lets a consumer request extra OAuth scopes alongside the library's own Drive scopes (`REQUIRED_SCOPES` — `drive.file` + `userinfo.email`, `files.ts`). `createDriveSync` computes `EFFECTIVE_SCOPES = [...REQUIRED_SCOPES, ...(options.additionalScopes ?? [])]` once, and threads that single array into every scope-consuming call site: `connect()`, `getConnection()`, `getAccessToken()`, `pickFile()`, the connection-snapshot re-read, `refresh.ts`'s `warmUpIfNeeded` (via a new `scopes` field on `ActivateOptions`), and every `files.ts`/`permissions.ts` call (via a new `requiredScopes: string[]` field on their shared `BaseCallOptions`, populated from `index.ts`'s per-project `base` object). **Backward compatibility guarantee:** when `additionalScopes` is omitted or empty, `EFFECTIVE_SCOPES` is referentially the same value set as the old bare `REQUIRED_SCOPES` constant, so every request this library makes is byte-identical to before this option existed. `connection.ts`'s existing scope-coverage check (`needsReauth`, §"resolved decisions" #19-ish / `connection.ts:233`) means a token missing a newly-added scope is genuinely treated as needing reauth, not silently ignored.
|
|
136
|
+
|
|
137
|
+
**Calendar capability.** `ProjectHandle.calendar.listEvents({ timeMin, timeMax }, callOpts?)` (new `calendar.ts`, wired onto `ProjectHandle` in `index.ts` alongside `files`/`permissions`, sharing the same per-project `base` object and therefore the same `EFFECTIVE_SCOPES`) issues `GET https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin=...&timeMax=...&singleEvents=true&orderBy=startTime` through the same `driveFetch` (`http.ts`) every Drive call uses — `driveFetch` is generic over the request URL/host and only hardcodes Drive-specific behavior in its error-message text, so it needed no changes to serve a non-Drive Google API. The library hardcodes no date range; the caller supplies `timeMin`/`timeMax` as ISO 8601 strings. Read-only: no create/update/delete Calendar operation exists in this package. Every event Google returns in range is mapped and returned unfiltered, in Google's own `orderBy: startTime` order — no RSVP filtering or declined-event dropping happens in the library; that is an app-side decision.
|
|
138
|
+
|
|
139
|
+
Like every `files.*`/`permissions.*` call, `listEvents` defaults `interactive` to `false` (NOT `getAccessToken()`'s interactive-by-default exception) — a background calendar poll never triggers a consent popup. Pass `{ interactive: true }` to allow one.
|
|
140
|
+
|
|
141
|
+
**Normalized `CalendarEvent` shape** (`types.ts`):
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
interface CalendarEventDateTime {
|
|
145
|
+
dateTime?: string; // present for timed events
|
|
146
|
+
date?: string; // present for all-day events (YYYY-MM-DD), mutually exclusive with dateTime
|
|
147
|
+
timeZone?: string;
|
|
148
|
+
}
|
|
149
|
+
interface CalendarEvent {
|
|
150
|
+
id: string;
|
|
151
|
+
summary: string;
|
|
152
|
+
start: CalendarEventDateTime;
|
|
153
|
+
end: CalendarEventDateTime;
|
|
154
|
+
isAllDay: boolean; // true iff start.date is present with no start.dateTime
|
|
155
|
+
selfResponseStatus: 'accepted' | 'tentative' | 'needsAction' | 'declined';
|
|
156
|
+
joinUrl: string | null;
|
|
157
|
+
organizer: { displayName?: string; email?: string; self: boolean };
|
|
158
|
+
htmlLink: string;
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**`selfResponseStatus` derivation** (`calendar.ts`'s `deriveSelfResponseStatus`): if the raw event has an `attendees` array, find the entry with `self === true` and pass its `responseStatus` through verbatim (Google's enum already matches this type; defaults to `'needsAction'` if a non-empty `attendees` array somehow has no self entry). If there is no `attendees` array at all (a self-only event, or an organizer with nothing to respond to), the status is `'accepted'`.
|
|
163
|
+
|
|
164
|
+
**`joinUrl` derivation** (`calendar.ts`'s `extractJoinUrl`), in precedence order: (1) `event.hangoutLink` if present; (2) the first `event.conferenceData.entryPoints` entry with `entryPointType === 'video'`, its `uri`; (3) a best-effort regex scan, `event.location` then `event.description`, for the first URL matching `zoom.us`, `meet.google.com`, or `teams.microsoft.com` (may false-negative on oddly formatted text — accepted, since the two structured sources above cover the common case); (4) `null` if nothing matches.
|
|
165
|
+
|
|
166
|
+
**All-day events.** `isAllDay = Boolean(raw.start?.date && !raw.start?.dateTime)`. The mapper (`calendar.ts`'s `mapEvent`) reads `start.date`/`end.date` when present and never assumes `start.dateTime` exists — an all-day event's `dateTime` field stays `undefined` on the normalized `CalendarEvent`, it is never coerced to `''` or allowed to throw.
|
|
167
|
+
|
|
112
168
|
## 3. Storage layout
|
|
113
169
|
|
|
114
170
|
Each project gets its own IndexedDB database: **`owa-drive-{appId}-{projectId}`**, version 1, containing one object store, `auth` (`storage.ts`). The store holds up to three keys (`conn`/`token` always; `envelope` only in server-facilitated token-exchange mode):
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Logger } from './logger.js';
|
|
2
|
+
import type { CalendarEvent, ListEventsOptions } from './types.js';
|
|
3
|
+
interface RawGoogleCalendarEventDateTime {
|
|
4
|
+
dateTime?: string;
|
|
5
|
+
date?: string;
|
|
6
|
+
timeZone?: string;
|
|
7
|
+
}
|
|
8
|
+
interface RawGoogleCalendarEvent {
|
|
9
|
+
id: string;
|
|
10
|
+
summary?: string;
|
|
11
|
+
start?: RawGoogleCalendarEventDateTime;
|
|
12
|
+
end?: RawGoogleCalendarEventDateTime;
|
|
13
|
+
attendees?: {
|
|
14
|
+
self?: boolean;
|
|
15
|
+
responseStatus?: string;
|
|
16
|
+
}[];
|
|
17
|
+
organizer?: {
|
|
18
|
+
displayName?: string;
|
|
19
|
+
email?: string;
|
|
20
|
+
self?: boolean;
|
|
21
|
+
};
|
|
22
|
+
htmlLink: string;
|
|
23
|
+
hangoutLink?: string;
|
|
24
|
+
conferenceData?: {
|
|
25
|
+
entryPoints?: {
|
|
26
|
+
entryPointType?: string;
|
|
27
|
+
uri?: string;
|
|
28
|
+
}[];
|
|
29
|
+
};
|
|
30
|
+
location?: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
}
|
|
33
|
+
export declare function deriveSelfResponseStatus(raw: RawGoogleCalendarEvent): CalendarEvent['selfResponseStatus'];
|
|
34
|
+
export declare function extractJoinUrl(raw: RawGoogleCalendarEvent): string | null;
|
|
35
|
+
export declare function mapEvent(raw: RawGoogleCalendarEvent): CalendarEvent;
|
|
36
|
+
export interface ListEventsCallOptions extends ListEventsOptions {
|
|
37
|
+
appId: string;
|
|
38
|
+
projectId: string;
|
|
39
|
+
clientId: string;
|
|
40
|
+
interactive?: boolean;
|
|
41
|
+
logger?: Logger;
|
|
42
|
+
fetchEmail?: (accessToken: string) => Promise<string>;
|
|
43
|
+
tokenExchangeUrl?: string;
|
|
44
|
+
requiredScopes: string[];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Read-only listing of the connected account's primary calendar events in
|
|
48
|
+
* `[timeMin, timeMax)`. Reuses `driveFetch` (generic enough for any Google
|
|
49
|
+
* API host, not Drive-specific — it only hardcodes Drive-specific behavior
|
|
50
|
+
* in its error-message text, not its request path) for the same
|
|
51
|
+
* token-acquisition / retry / 401-recovery plumbing `files.ts` uses.
|
|
52
|
+
*
|
|
53
|
+
* Non-interactive by default (like `files.*`, NOT `getAccessToken`'s
|
|
54
|
+
* interactive-by-default exception) so a background poll never pops a
|
|
55
|
+
* consent screen.
|
|
56
|
+
*/
|
|
57
|
+
export declare function listEvents(opts: ListEventsCallOptions): Promise<CalendarEvent[]>;
|
|
58
|
+
export {};
|
package/dist/calendar.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { driveFetch } from './http.js';
|
|
2
|
+
const CALENDAR_BASE = 'https://www.googleapis.com/calendar/v3';
|
|
3
|
+
/**
|
|
4
|
+
* Best-effort scan for a meeting link in free-text fields. May false-negative
|
|
5
|
+
* on oddly formatted text — acceptable, since `hangoutLink`/`conferenceData`
|
|
6
|
+
* (checked first, in `extractJoinUrl`) cover the common case.
|
|
7
|
+
*/
|
|
8
|
+
const JOIN_URL_REGEX = /https?:\/\/[^\s<>"']*(?:zoom\.us|meet\.google\.com|teams\.microsoft\.com)[^\s<>"']*/i;
|
|
9
|
+
export function deriveSelfResponseStatus(raw) {
|
|
10
|
+
if (!Array.isArray(raw.attendees)) {
|
|
11
|
+
return 'accepted';
|
|
12
|
+
}
|
|
13
|
+
const self = raw.attendees.find((a) => a.self === true);
|
|
14
|
+
const status = self?.responseStatus;
|
|
15
|
+
if (status === 'accepted' || status === 'tentative' || status === 'needsAction' || status === 'declined') {
|
|
16
|
+
return status;
|
|
17
|
+
}
|
|
18
|
+
return 'needsAction';
|
|
19
|
+
}
|
|
20
|
+
export function extractJoinUrl(raw) {
|
|
21
|
+
if (raw.hangoutLink) {
|
|
22
|
+
return raw.hangoutLink;
|
|
23
|
+
}
|
|
24
|
+
const videoEntryPoint = raw.conferenceData?.entryPoints?.find((ep) => ep.entryPointType === 'video');
|
|
25
|
+
if (videoEntryPoint?.uri) {
|
|
26
|
+
return videoEntryPoint.uri;
|
|
27
|
+
}
|
|
28
|
+
const locationMatch = raw.location?.match(JOIN_URL_REGEX);
|
|
29
|
+
if (locationMatch) {
|
|
30
|
+
return locationMatch[0];
|
|
31
|
+
}
|
|
32
|
+
const descriptionMatch = raw.description?.match(JOIN_URL_REGEX);
|
|
33
|
+
if (descriptionMatch) {
|
|
34
|
+
return descriptionMatch[0];
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
export function mapEvent(raw) {
|
|
39
|
+
const start = raw.start ?? {};
|
|
40
|
+
const end = raw.end ?? {};
|
|
41
|
+
return {
|
|
42
|
+
id: raw.id,
|
|
43
|
+
summary: raw.summary ?? '',
|
|
44
|
+
start: { dateTime: start.dateTime, date: start.date, timeZone: start.timeZone },
|
|
45
|
+
end: { dateTime: end.dateTime, date: end.date, timeZone: end.timeZone },
|
|
46
|
+
isAllDay: Boolean(start.date && !start.dateTime),
|
|
47
|
+
selfResponseStatus: deriveSelfResponseStatus(raw),
|
|
48
|
+
joinUrl: extractJoinUrl(raw),
|
|
49
|
+
organizer: {
|
|
50
|
+
displayName: raw.organizer?.displayName,
|
|
51
|
+
email: raw.organizer?.email,
|
|
52
|
+
self: Boolean(raw.organizer?.self),
|
|
53
|
+
},
|
|
54
|
+
htmlLink: raw.htmlLink,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Read-only listing of the connected account's primary calendar events in
|
|
59
|
+
* `[timeMin, timeMax)`. Reuses `driveFetch` (generic enough for any Google
|
|
60
|
+
* API host, not Drive-specific — it only hardcodes Drive-specific behavior
|
|
61
|
+
* in its error-message text, not its request path) for the same
|
|
62
|
+
* token-acquisition / retry / 401-recovery plumbing `files.ts` uses.
|
|
63
|
+
*
|
|
64
|
+
* Non-interactive by default (like `files.*`, NOT `getAccessToken`'s
|
|
65
|
+
* interactive-by-default exception) so a background poll never pops a
|
|
66
|
+
* consent screen.
|
|
67
|
+
*/
|
|
68
|
+
export async function listEvents(opts) {
|
|
69
|
+
const url = `${CALENDAR_BASE}/calendars/primary/events?timeMin=${encodeURIComponent(opts.timeMin)}&timeMax=${encodeURIComponent(opts.timeMax)}&singleEvents=true&orderBy=startTime`;
|
|
70
|
+
const res = await driveFetch({
|
|
71
|
+
appId: opts.appId,
|
|
72
|
+
projectId: opts.projectId,
|
|
73
|
+
clientId: opts.clientId,
|
|
74
|
+
url,
|
|
75
|
+
method: 'GET',
|
|
76
|
+
interactive: opts.interactive ?? false,
|
|
77
|
+
requiredScopes: opts.requiredScopes,
|
|
78
|
+
logger: opts.logger,
|
|
79
|
+
fetchEmail: opts.fetchEmail,
|
|
80
|
+
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
81
|
+
});
|
|
82
|
+
const json = (await res.json());
|
|
83
|
+
return (json.items ?? []).map(mapEvent);
|
|
84
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Connection } from './types.js';
|
|
2
|
+
export interface ConnectionSnapshotStore {
|
|
3
|
+
get(): Connection | null;
|
|
4
|
+
commit(next: Connection | null): void;
|
|
5
|
+
subscribe(fn: () => void): () => void;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Tiny non-React store suitable for use with useSyncExternalStore, holding
|
|
9
|
+
* the durable Connection snapshot (or null when disconnected).
|
|
10
|
+
*
|
|
11
|
+
* - `get()` returns a stable reference; the internal snapshot is only
|
|
12
|
+
* replaced when at least one of the 3 fields differs (shallow compare).
|
|
13
|
+
* - `commit` notifies listeners only when the snapshot reference changed.
|
|
14
|
+
* - `subscribe` returns an unsubscribe fn; unsubscribing during a notify
|
|
15
|
+
* pass is safe (listeners are iterated over a copy).
|
|
16
|
+
*/
|
|
17
|
+
export declare function createConnectionSnapshotStore(): ConnectionSnapshotStore;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
function shallowEqualConn(a, b) {
|
|
2
|
+
if (a === null && b === null)
|
|
3
|
+
return true;
|
|
4
|
+
if (a === null || b === null)
|
|
5
|
+
return false;
|
|
6
|
+
return (a.email === b.email &&
|
|
7
|
+
a.needsReauth === b.needsReauth &&
|
|
8
|
+
a.expiresAt === b.expiresAt);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Tiny non-React store suitable for use with useSyncExternalStore, holding
|
|
12
|
+
* the durable Connection snapshot (or null when disconnected).
|
|
13
|
+
*
|
|
14
|
+
* - `get()` returns a stable reference; the internal snapshot is only
|
|
15
|
+
* replaced when at least one of the 3 fields differs (shallow compare).
|
|
16
|
+
* - `commit` notifies listeners only when the snapshot reference changed.
|
|
17
|
+
* - `subscribe` returns an unsubscribe fn; unsubscribing during a notify
|
|
18
|
+
* pass is safe (listeners are iterated over a copy).
|
|
19
|
+
*/
|
|
20
|
+
export function createConnectionSnapshotStore() {
|
|
21
|
+
let snapshot = null;
|
|
22
|
+
const listeners = new Set();
|
|
23
|
+
function notify() {
|
|
24
|
+
for (const fn of [...listeners]) {
|
|
25
|
+
fn();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function commit(next) {
|
|
29
|
+
if (shallowEqualConn(snapshot, next))
|
|
30
|
+
return;
|
|
31
|
+
snapshot = next;
|
|
32
|
+
notify();
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
get() {
|
|
36
|
+
return snapshot;
|
|
37
|
+
},
|
|
38
|
+
commit(next) {
|
|
39
|
+
commit(next);
|
|
40
|
+
},
|
|
41
|
+
subscribe(fn) {
|
|
42
|
+
listeners.add(fn);
|
|
43
|
+
return () => {
|
|
44
|
+
listeners.delete(fn);
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
package/dist/files.d.ts
CHANGED
|
@@ -15,6 +15,11 @@ interface BaseCallOptions {
|
|
|
15
15
|
* through `refreshEnvelope` (see http.ts). Absent for the legacy GIS path.
|
|
16
16
|
*/
|
|
17
17
|
tokenExchangeUrl?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Scopes required for this call, threaded in by index.ts as the caller's
|
|
20
|
+
* effective scope set (`REQUIRED_SCOPES` plus any `additionalScopes`).
|
|
21
|
+
*/
|
|
22
|
+
requiredScopes: string[];
|
|
18
23
|
}
|
|
19
24
|
export interface ReadOptions extends BaseCallOptions {
|
|
20
25
|
fileId: string;
|
package/dist/files.js
CHANGED
|
@@ -23,7 +23,7 @@ async function fetchRemoteVersion(opts) {
|
|
|
23
23
|
url: `${DRIVE_BASE}/files/${encodeURIComponent(opts.fileId)}?fields=${encodeURIComponent('id,name,version,modifiedTime')}`,
|
|
24
24
|
method: 'GET',
|
|
25
25
|
interactive: opts.interactive,
|
|
26
|
-
requiredScopes:
|
|
26
|
+
requiredScopes: opts.requiredScopes,
|
|
27
27
|
logger: opts.logger,
|
|
28
28
|
fetchEmail: opts.fetchEmail,
|
|
29
29
|
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
@@ -74,7 +74,7 @@ export async function read(opts) {
|
|
|
74
74
|
url: `${DRIVE_BASE}/files/${encodeURIComponent(opts.fileId)}?alt=media`,
|
|
75
75
|
method: 'GET',
|
|
76
76
|
interactive: opts.interactive,
|
|
77
|
-
requiredScopes:
|
|
77
|
+
requiredScopes: opts.requiredScopes,
|
|
78
78
|
logger: opts.logger,
|
|
79
79
|
fetchEmail: opts.fetchEmail,
|
|
80
80
|
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
@@ -106,7 +106,7 @@ export async function remove(opts) {
|
|
|
106
106
|
url: `${DRIVE_BASE}/files/${encodeURIComponent(opts.fileId)}`,
|
|
107
107
|
method: 'DELETE',
|
|
108
108
|
interactive: opts.interactive,
|
|
109
|
-
requiredScopes:
|
|
109
|
+
requiredScopes: opts.requiredScopes,
|
|
110
110
|
logger: opts.logger,
|
|
111
111
|
fetchEmail: opts.fetchEmail,
|
|
112
112
|
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
@@ -159,7 +159,7 @@ async function updateContent(opts, fileId, knownRemoteVersion) {
|
|
|
159
159
|
headers: { 'Content-Type': opts.mimeType },
|
|
160
160
|
body: opts.content,
|
|
161
161
|
interactive: opts.interactive,
|
|
162
|
-
requiredScopes:
|
|
162
|
+
requiredScopes: opts.requiredScopes,
|
|
163
163
|
logger: opts.logger,
|
|
164
164
|
fetchEmail: opts.fetchEmail,
|
|
165
165
|
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
@@ -204,6 +204,7 @@ export async function write(opts) {
|
|
|
204
204
|
logger: opts.logger,
|
|
205
205
|
fetchEmail: opts.fetchEmail,
|
|
206
206
|
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
207
|
+
requiredScopes: opts.requiredScopes,
|
|
207
208
|
});
|
|
208
209
|
if (existing.length > 0) {
|
|
209
210
|
return updateContent(opts, existing[0].id, existing[0].version);
|
|
@@ -243,7 +244,7 @@ export async function write(opts) {
|
|
|
243
244
|
headers: multipartContentType ? { 'Content-Type': multipartContentType } : undefined,
|
|
244
245
|
body: multipartBody,
|
|
245
246
|
interactive: opts.interactive,
|
|
246
|
-
requiredScopes:
|
|
247
|
+
requiredScopes: opts.requiredScopes,
|
|
247
248
|
logger: opts.logger,
|
|
248
249
|
fetchEmail: opts.fetchEmail,
|
|
249
250
|
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
@@ -319,7 +320,7 @@ export async function list(opts) {
|
|
|
319
320
|
url,
|
|
320
321
|
method: 'GET',
|
|
321
322
|
interactive: opts.interactive,
|
|
322
|
-
requiredScopes:
|
|
323
|
+
requiredScopes: opts.requiredScopes,
|
|
323
324
|
logger: opts.logger,
|
|
324
325
|
fetchEmail: opts.fetchEmail,
|
|
325
326
|
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
@@ -345,7 +346,7 @@ async function createFolder(opts) {
|
|
|
345
346
|
parents: opts.parentId ? [opts.parentId] : undefined,
|
|
346
347
|
}),
|
|
347
348
|
interactive: opts.interactive,
|
|
348
|
-
requiredScopes:
|
|
349
|
+
requiredScopes: opts.requiredScopes,
|
|
349
350
|
logger: opts.logger,
|
|
350
351
|
fetchEmail: opts.fetchEmail,
|
|
351
352
|
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
@@ -380,6 +381,7 @@ export async function ensureFolderPath(opts) {
|
|
|
380
381
|
logger: opts.logger,
|
|
381
382
|
fetchEmail: opts.fetchEmail,
|
|
382
383
|
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
384
|
+
requiredScopes: opts.requiredScopes,
|
|
383
385
|
folderId: parentId,
|
|
384
386
|
mimeType: FOLDER_MIME_TYPE,
|
|
385
387
|
nameEquals: name,
|
|
@@ -396,6 +398,7 @@ export async function ensureFolderPath(opts) {
|
|
|
396
398
|
logger: opts.logger,
|
|
397
399
|
fetchEmail: opts.fetchEmail,
|
|
398
400
|
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
401
|
+
requiredScopes: opts.requiredScopes,
|
|
399
402
|
name,
|
|
400
403
|
parentId,
|
|
401
404
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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, Envelope, EnvelopePayload } from './types.js';
|
|
1
|
+
import type { CallOptions, CalendarEvent, Connection, DriveSyncOptions, DrivePermission, FileRef, FileState, ListEventsOptions, PickFileOptions, PickedFile } from './types.js';
|
|
2
|
+
export type { DriveSyncOptions, Connection, StoredToken, FileRef, FileState, DrivePermission, CallOptions, WorkspaceMimeShorthand, PickFileOptions, PickedFile, Envelope, EnvelopePayload, CalendarEvent, CalendarEventDateTime, ListEventsOptions } from './types.js';
|
|
3
3
|
export * from './errors.js';
|
|
4
4
|
export interface FilesHandle {
|
|
5
5
|
list(opts?: {
|
|
@@ -44,6 +44,9 @@ export interface PermissionsHandle {
|
|
|
44
44
|
permissionId: string;
|
|
45
45
|
}, callOpts?: CallOptions): Promise<void>;
|
|
46
46
|
}
|
|
47
|
+
export interface CalendarHandle {
|
|
48
|
+
listEvents(opts: ListEventsOptions, callOpts?: CallOptions): Promise<CalendarEvent[]>;
|
|
49
|
+
}
|
|
47
50
|
export interface ProjectHandle {
|
|
48
51
|
connect(): Promise<Connection>;
|
|
49
52
|
getConnection(): Promise<Connection | null>;
|
|
@@ -58,8 +61,23 @@ export interface ProjectHandle {
|
|
|
58
61
|
*/
|
|
59
62
|
getAccessToken(callOpts?: CallOptions): Promise<string>;
|
|
60
63
|
pickFile(options: PickFileOptions): Promise<PickedFile[]>;
|
|
64
|
+
/**
|
|
65
|
+
* Synchronous snapshot of the current Connection, backed by the
|
|
66
|
+
* per-project connectionSnapshot store (see connectionSnapshot.ts).
|
|
67
|
+
* Never returns a Promise and never throws. `null` means either
|
|
68
|
+
* disconnected, or not-yet-hydrated (the first background re-read from
|
|
69
|
+
* IndexedDB hasn't resolved yet) — callers cannot distinguish the two
|
|
70
|
+
* from this alone.
|
|
71
|
+
*/
|
|
72
|
+
getConnectionSync(): Connection | null;
|
|
73
|
+
/**
|
|
74
|
+
* Subscribes to changes in the synchronous Connection snapshot (suitable
|
|
75
|
+
* for `useSyncExternalStore`). Returns an unsubscribe function.
|
|
76
|
+
*/
|
|
77
|
+
subscribeConnection(cb: () => void): () => void;
|
|
61
78
|
files: FilesHandle;
|
|
62
79
|
permissions: PermissionsHandle;
|
|
80
|
+
calendar: CalendarHandle;
|
|
63
81
|
}
|
|
64
82
|
export interface DriveSync {
|
|
65
83
|
activate(): () => void;
|
package/dist/index.js
CHANGED
|
@@ -4,8 +4,10 @@ import { reconcile as reconcileImpl, dropProject as dropProjectImpl } from './re
|
|
|
4
4
|
import * as filesImpl from './files.js';
|
|
5
5
|
import * as permissionsImpl from './permissions.js';
|
|
6
6
|
import * as pickerImpl from './picker.js';
|
|
7
|
+
import * as calendarImpl from './calendar.js';
|
|
7
8
|
import { warmUpIfNeeded } from './refresh.js';
|
|
8
9
|
import { REQUIRED_SCOPES } from './files.js';
|
|
10
|
+
import { createConnectionSnapshotStore } from './connectionSnapshot.js';
|
|
9
11
|
import { createBroadcast } from './broadcast.js';
|
|
10
12
|
import { evictDbHandle } from './storage.js';
|
|
11
13
|
import { notifyExternalTokenRefresh } from './token.js';
|
|
@@ -49,6 +51,12 @@ async function revokeToken(accessToken) {
|
|
|
49
51
|
export function createDriveSync(options) {
|
|
50
52
|
const { appId, clientId, folderPath, tokenExchangeUrl } = options;
|
|
51
53
|
const logger = options.logger ?? noOpLogger;
|
|
54
|
+
/**
|
|
55
|
+
* Base Drive scopes plus any caller-requested extras (e.g.
|
|
56
|
+
* `calendar.readonly`). Omitted `additionalScopes` -> identical to
|
|
57
|
+
* `REQUIRED_SCOPES` alone, so a consumer that never sets it is unaffected.
|
|
58
|
+
*/
|
|
59
|
+
const EFFECTIVE_SCOPES = [...REQUIRED_SCOPES, ...(options.additionalScopes ?? [])];
|
|
52
60
|
/**
|
|
53
61
|
* Design choice for `.activate()` (T30): the frozen public API's
|
|
54
62
|
* `activate()` takes no project argument, but the actual background
|
|
@@ -68,6 +76,43 @@ export function createDriveSync(options) {
|
|
|
68
76
|
function trackProject(projectId) {
|
|
69
77
|
trackedProjectIds.add(projectId);
|
|
70
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Per-project synchronous Connection snapshot stores (see
|
|
81
|
+
* connectionSnapshot.ts). Populated lazily by `getOrInitStore` the first
|
|
82
|
+
* time either `getConnectionSync()` or `subscribeConnection()` is called
|
|
83
|
+
* for a given project; a later task (T3) will also commit into these
|
|
84
|
+
* stores after connect()/disconnect() resolve.
|
|
85
|
+
*/
|
|
86
|
+
const snapshotStores = new Map();
|
|
87
|
+
/**
|
|
88
|
+
* Re-reads the durable Connection for `projectId` from IndexedDB (via
|
|
89
|
+
* connection.ts's `getConnection`) and commits the result into that
|
|
90
|
+
* project's snapshot store. Returns the underlying promise (rather than
|
|
91
|
+
* `void`-ing it here) so a LATER task (T3) can `await` it after
|
|
92
|
+
* connect()/disconnect(); call sites in THIS task fire it with `void`.
|
|
93
|
+
*/
|
|
94
|
+
function reReadConnection(projectId) {
|
|
95
|
+
return getConnectionImpl({
|
|
96
|
+
appId,
|
|
97
|
+
projectId,
|
|
98
|
+
requiredScopes: EFFECTIVE_SCOPES,
|
|
99
|
+
})
|
|
100
|
+
.then((conn) => {
|
|
101
|
+
getOrInitStore(projectId).commit(conn);
|
|
102
|
+
})
|
|
103
|
+
.catch((err) => {
|
|
104
|
+
logger.warn('drive-sync: connection snapshot re-read failed', { err });
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
function getOrInitStore(projectId) {
|
|
108
|
+
let store = snapshotStores.get(projectId);
|
|
109
|
+
if (!store) {
|
|
110
|
+
store = createConnectionSnapshotStore();
|
|
111
|
+
snapshotStores.set(projectId, store);
|
|
112
|
+
void reReadConnection(projectId);
|
|
113
|
+
}
|
|
114
|
+
return store;
|
|
115
|
+
}
|
|
71
116
|
/**
|
|
72
117
|
* Handles a cross-tab broadcast (see broadcast.ts) received while active.
|
|
73
118
|
* `logout`: another tab's disconnect() already cleared BOTH IndexedDB
|
|
@@ -87,19 +132,23 @@ export function createDriveSync(options) {
|
|
|
87
132
|
function handleBroadcast(msg) {
|
|
88
133
|
if (msg.type === 'logout') {
|
|
89
134
|
void evictDbHandle(appId, msg.projectId);
|
|
135
|
+
reReadConnection(msg.projectId);
|
|
90
136
|
}
|
|
91
137
|
else if (msg.type === 'token') {
|
|
92
138
|
notifyExternalTokenRefresh(msg.projectId);
|
|
139
|
+
reReadConnection(msg.projectId);
|
|
93
140
|
}
|
|
94
141
|
}
|
|
95
142
|
function activate() {
|
|
96
143
|
const disposeBroadcast = createBroadcast(appId).onMessage(handleBroadcast);
|
|
144
|
+
for (const projectId of trackedProjectIds)
|
|
145
|
+
reReadConnection(projectId);
|
|
97
146
|
if (typeof document === 'undefined') {
|
|
98
147
|
return disposeBroadcast;
|
|
99
148
|
}
|
|
100
149
|
const runWarmUps = () => {
|
|
101
150
|
for (const projectId of trackedProjectIds) {
|
|
102
|
-
void warmUpIfNeeded({ appId, projectId, clientId, tokenExchangeUrl, fetchEmail, logger });
|
|
151
|
+
void warmUpIfNeeded({ appId, projectId, clientId, scopes: EFFECTIVE_SCOPES, tokenExchangeUrl, fetchEmail, logger }).then(() => reReadConnection(projectId), () => reReadConnection(projectId));
|
|
103
152
|
}
|
|
104
153
|
};
|
|
105
154
|
const onVisibilityChange = () => {
|
|
@@ -131,7 +180,7 @@ export function createDriveSync(options) {
|
|
|
131
180
|
}
|
|
132
181
|
function project(projectId) {
|
|
133
182
|
trackProject(projectId);
|
|
134
|
-
const base = { appId, projectId, clientId, tokenExchangeUrl, logger, fetchEmail };
|
|
183
|
+
const base = { appId, projectId, clientId, tokenExchangeUrl, logger, fetchEmail, requiredScopes: EFFECTIVE_SCOPES };
|
|
135
184
|
const files = {
|
|
136
185
|
list(opts, callOpts) {
|
|
137
186
|
return filesImpl.list({ ...base, ...opts, interactive: callOpts?.interactive });
|
|
@@ -163,23 +212,37 @@ export function createDriveSync(options) {
|
|
|
163
212
|
return permissionsImpl.revoke({ ...base, ...opts, interactive: callOpts?.interactive });
|
|
164
213
|
},
|
|
165
214
|
};
|
|
215
|
+
const calendar = {
|
|
216
|
+
listEvents(opts, callOpts) {
|
|
217
|
+
return calendarImpl.listEvents({ ...base, ...opts, interactive: callOpts?.interactive });
|
|
218
|
+
},
|
|
219
|
+
};
|
|
166
220
|
return {
|
|
167
|
-
connect() {
|
|
168
|
-
|
|
221
|
+
async connect() {
|
|
222
|
+
const connection = await connectImpl({
|
|
169
223
|
appId,
|
|
170
224
|
projectId,
|
|
171
225
|
clientId,
|
|
172
|
-
scopes:
|
|
226
|
+
scopes: EFFECTIVE_SCOPES,
|
|
173
227
|
tokenExchangeUrl,
|
|
174
228
|
logger,
|
|
175
229
|
fetchEmail,
|
|
176
230
|
});
|
|
231
|
+
// Awaited (not fire-and-forget): callers of `await connect()` must
|
|
232
|
+
// see `getConnectionSync()` already reflect this new connection the
|
|
233
|
+
// moment the promise settles — see reReadConnection's docstring.
|
|
234
|
+
// Contrast with warm-up/broadcast/lazy-kick re-reads elsewhere in
|
|
235
|
+
// this file, which are intentionally fire-and-forget (`void
|
|
236
|
+
// reReadConnection(...)`) since nothing is awaiting them to observe
|
|
237
|
+
// the snapshot synchronously.
|
|
238
|
+
await reReadConnection(projectId);
|
|
239
|
+
return connection;
|
|
177
240
|
},
|
|
178
241
|
getConnection() {
|
|
179
242
|
return getConnectionImpl({
|
|
180
243
|
appId,
|
|
181
244
|
projectId,
|
|
182
|
-
requiredScopes:
|
|
245
|
+
requiredScopes: EFFECTIVE_SCOPES,
|
|
183
246
|
});
|
|
184
247
|
},
|
|
185
248
|
async disconnect() {
|
|
@@ -201,6 +264,10 @@ export function createDriveSync(options) {
|
|
|
201
264
|
},
|
|
202
265
|
};
|
|
203
266
|
await disconnectImpl(disconnectOpts);
|
|
267
|
+
// Awaited, same reasoning as connect() above: `await disconnect()`
|
|
268
|
+
// must not settle until `getConnectionSync()` reflects the
|
|
269
|
+
// post-disconnect snapshot.
|
|
270
|
+
await reReadConnection(projectId);
|
|
204
271
|
},
|
|
205
272
|
ensureFolderPath() {
|
|
206
273
|
return filesImpl.ensureFolderPath({ ...base, folderPath });
|
|
@@ -210,7 +277,7 @@ export function createDriveSync(options) {
|
|
|
210
277
|
appId,
|
|
211
278
|
projectId,
|
|
212
279
|
clientId,
|
|
213
|
-
scopes:
|
|
280
|
+
scopes: EFFECTIVE_SCOPES,
|
|
214
281
|
interactive: callOpts?.interactive ?? true,
|
|
215
282
|
tokenExchangeUrl,
|
|
216
283
|
logger,
|
|
@@ -221,7 +288,7 @@ export function createDriveSync(options) {
|
|
|
221
288
|
appId,
|
|
222
289
|
projectId,
|
|
223
290
|
clientId,
|
|
224
|
-
scopes:
|
|
291
|
+
scopes: EFFECTIVE_SCOPES,
|
|
225
292
|
interactive: true,
|
|
226
293
|
tokenExchangeUrl,
|
|
227
294
|
logger,
|
|
@@ -245,8 +312,15 @@ export function createDriveSync(options) {
|
|
|
245
312
|
}
|
|
246
313
|
return results;
|
|
247
314
|
},
|
|
315
|
+
getConnectionSync() {
|
|
316
|
+
return getOrInitStore(projectId).get();
|
|
317
|
+
},
|
|
318
|
+
subscribeConnection(cb) {
|
|
319
|
+
return getOrInitStore(projectId).subscribe(cb);
|
|
320
|
+
},
|
|
248
321
|
files,
|
|
249
322
|
permissions,
|
|
323
|
+
calendar,
|
|
250
324
|
};
|
|
251
325
|
}
|
|
252
326
|
return {
|
package/dist/permissions.d.ts
CHANGED
|
@@ -13,6 +13,11 @@ interface BaseCallOptions {
|
|
|
13
13
|
* through `refreshEnvelope` (see http.ts). Absent for the legacy GIS path.
|
|
14
14
|
*/
|
|
15
15
|
tokenExchangeUrl?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Scopes required for this call, threaded in by index.ts as the caller's
|
|
18
|
+
* effective scope set (`REQUIRED_SCOPES` plus any `additionalScopes`).
|
|
19
|
+
*/
|
|
20
|
+
requiredScopes: string[];
|
|
16
21
|
}
|
|
17
22
|
export interface ListPermissionsOptions extends BaseCallOptions {
|
|
18
23
|
fileId: string;
|
package/dist/permissions.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { driveFetch } from './http.js';
|
|
2
|
-
import { REQUIRED_SCOPES } from './files.js';
|
|
3
2
|
const DRIVE_BASE = 'https://www.googleapis.com/drive/v3';
|
|
4
3
|
export async function list(opts) {
|
|
5
4
|
const res = await driveFetch({
|
|
@@ -9,7 +8,7 @@ export async function list(opts) {
|
|
|
9
8
|
url: `${DRIVE_BASE}/files/${encodeURIComponent(opts.fileId)}/permissions?fields=${encodeURIComponent('permissions(id,type,role,emailAddress)')}`,
|
|
10
9
|
method: 'GET',
|
|
11
10
|
interactive: opts.interactive,
|
|
12
|
-
requiredScopes:
|
|
11
|
+
requiredScopes: opts.requiredScopes,
|
|
13
12
|
logger: opts.logger,
|
|
14
13
|
fetchEmail: opts.fetchEmail,
|
|
15
14
|
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
@@ -35,7 +34,7 @@ export async function grant(opts) {
|
|
|
35
34
|
emailAddress: opts.type === 'user' ? opts.emailAddress : undefined,
|
|
36
35
|
}),
|
|
37
36
|
interactive: opts.interactive,
|
|
38
|
-
requiredScopes:
|
|
37
|
+
requiredScopes: opts.requiredScopes,
|
|
39
38
|
logger: opts.logger,
|
|
40
39
|
fetchEmail: opts.fetchEmail,
|
|
41
40
|
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
@@ -52,7 +51,7 @@ export async function update(opts) {
|
|
|
52
51
|
headers: { 'Content-Type': 'application/json' },
|
|
53
52
|
body: JSON.stringify({ role: opts.role }),
|
|
54
53
|
interactive: opts.interactive,
|
|
55
|
-
requiredScopes:
|
|
54
|
+
requiredScopes: opts.requiredScopes,
|
|
56
55
|
logger: opts.logger,
|
|
57
56
|
fetchEmail: opts.fetchEmail,
|
|
58
57
|
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
@@ -67,7 +66,7 @@ export async function revoke(opts) {
|
|
|
67
66
|
url: `${DRIVE_BASE}/files/${encodeURIComponent(opts.fileId)}/permissions/${encodeURIComponent(opts.permissionId)}`,
|
|
68
67
|
method: 'DELETE',
|
|
69
68
|
interactive: opts.interactive,
|
|
70
|
-
requiredScopes:
|
|
69
|
+
requiredScopes: opts.requiredScopes,
|
|
71
70
|
logger: opts.logger,
|
|
72
71
|
fetchEmail: opts.fetchEmail,
|
|
73
72
|
tokenExchangeUrl: opts.tokenExchangeUrl,
|
package/dist/refresh.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export interface ActivateOptions {
|
|
|
4
4
|
appId: string;
|
|
5
5
|
projectId: string;
|
|
6
6
|
clientId: string;
|
|
7
|
+
/** Effective scope set (base scopes plus any `additionalScopes`) to warm up. */
|
|
8
|
+
scopes: string[];
|
|
7
9
|
/**
|
|
8
10
|
* Resolves the connected account's email from a fresh access token. When
|
|
9
11
|
* supplied, the warm-up's non-interactive refresh goes through
|
package/dist/refresh.js
CHANGED
|
@@ -2,7 +2,6 @@ import { getConnection, refreshSilently } from './connection.js';
|
|
|
2
2
|
import { getToken } from './storage.js';
|
|
3
3
|
import { acquireToken } from './token.js';
|
|
4
4
|
import { refreshEnvelope } from './envelope.js';
|
|
5
|
-
import { REQUIRED_SCOPES } from './files.js';
|
|
6
5
|
export const REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
|
7
6
|
/**
|
|
8
7
|
* Per-project token warm-up check: if a connection exists AND its cached
|
|
@@ -19,7 +18,7 @@ export async function warmUpIfNeeded(opts) {
|
|
|
19
18
|
const conn = await getConnection({
|
|
20
19
|
appId: opts.appId,
|
|
21
20
|
projectId: opts.projectId,
|
|
22
|
-
requiredScopes:
|
|
21
|
+
requiredScopes: opts.scopes,
|
|
23
22
|
});
|
|
24
23
|
if (!conn) {
|
|
25
24
|
// No connection at all -> no warm-up.
|
|
@@ -43,7 +42,7 @@ export async function warmUpIfNeeded(opts) {
|
|
|
43
42
|
appId: opts.appId,
|
|
44
43
|
projectId: opts.projectId,
|
|
45
44
|
clientId: opts.clientId,
|
|
46
|
-
scopes:
|
|
45
|
+
scopes: opts.scopes,
|
|
47
46
|
expectedEmail: conn.email,
|
|
48
47
|
fetchEmail: opts.fetchEmail,
|
|
49
48
|
logger: opts.logger,
|
|
@@ -54,7 +53,7 @@ export async function warmUpIfNeeded(opts) {
|
|
|
54
53
|
appId: opts.appId,
|
|
55
54
|
projectId: opts.projectId,
|
|
56
55
|
clientId: opts.clientId,
|
|
57
|
-
scopes:
|
|
56
|
+
scopes: opts.scopes,
|
|
58
57
|
interactive: false,
|
|
59
58
|
hint: conn.email,
|
|
60
59
|
logger: opts.logger,
|
package/dist/types.d.ts
CHANGED
|
@@ -10,6 +10,11 @@ export interface DriveSyncOptions {
|
|
|
10
10
|
* the legacy implicit flow is used unchanged.
|
|
11
11
|
*/
|
|
12
12
|
tokenExchangeUrl?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Extra OAuth scopes requested alongside the library's own Drive scopes.
|
|
15
|
+
* Empty/omitted = identical behavior to before this option existed.
|
|
16
|
+
*/
|
|
17
|
+
additionalScopes?: string[];
|
|
13
18
|
}
|
|
14
19
|
/**
|
|
15
20
|
* Decoded contents of an {@link Envelope}'s `payload`. Mirrors the fields of
|
|
@@ -122,3 +127,33 @@ export interface CallOptions {
|
|
|
122
127
|
/** Whether an interactive (popup/redirect) auth flow may be triggered. Defaults to false. */
|
|
123
128
|
interactive?: boolean;
|
|
124
129
|
}
|
|
130
|
+
/** A Calendar event's start/end. Exactly one of `dateTime`/`date` is present. */
|
|
131
|
+
export interface CalendarEventDateTime {
|
|
132
|
+
/** Present for timed events. */
|
|
133
|
+
dateTime?: string;
|
|
134
|
+
/** Present for all-day events (YYYY-MM-DD); mutually exclusive with `dateTime`. */
|
|
135
|
+
date?: string;
|
|
136
|
+
timeZone?: string;
|
|
137
|
+
}
|
|
138
|
+
/** Normalized read-only representation of a Google Calendar event. */
|
|
139
|
+
export interface CalendarEvent {
|
|
140
|
+
id: string;
|
|
141
|
+
summary: string;
|
|
142
|
+
start: CalendarEventDateTime;
|
|
143
|
+
end: CalendarEventDateTime;
|
|
144
|
+
/** True iff `start.date` is present with no `start.dateTime`. */
|
|
145
|
+
isAllDay: boolean;
|
|
146
|
+
selfResponseStatus: 'accepted' | 'tentative' | 'needsAction' | 'declined';
|
|
147
|
+
joinUrl: string | null;
|
|
148
|
+
organizer: {
|
|
149
|
+
displayName?: string;
|
|
150
|
+
email?: string;
|
|
151
|
+
self: boolean;
|
|
152
|
+
};
|
|
153
|
+
htmlLink: string;
|
|
154
|
+
}
|
|
155
|
+
/** Options for `ProjectHandle.calendar.listEvents()`. Caller supplies the time window. */
|
|
156
|
+
export interface ListEventsOptions {
|
|
157
|
+
timeMin: string;
|
|
158
|
+
timeMax: string;
|
|
159
|
+
}
|