@open-webapp/drive-sync 0.8.0 → 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 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,28 @@ 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
+
41
66
  ### Synchronous connection snapshot
42
67
 
43
68
  `p.getConnectionSync()` / `p.subscribeConnection()` give a framework store a
package/SPEC.md CHANGED
@@ -48,7 +48,7 @@ dispose();
48
48
 
49
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.
50
50
 
51
- Files implementing the surface: `index.ts` (factory + `ProjectHandle`/`FilesHandle`/`PermissionsHandle`, plus `getConnectionSync`/`subscribeConnection`), `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`).
52
52
 
53
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).
54
54
 
@@ -130,6 +130,41 @@ One behavioral consequence worth calling out: because the broadcast handler re-r
130
130
 
131
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
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
+
133
168
  ## 3. Storage layout
134
169
 
135
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 {};
@@ -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
+ }
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: REQUIRED_SCOPES,
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: REQUIRED_SCOPES,
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: REQUIRED_SCOPES,
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: REQUIRED_SCOPES,
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: REQUIRED_SCOPES,
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: REQUIRED_SCOPES,
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: REQUIRED_SCOPES,
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>;
@@ -74,6 +77,7 @@ export interface ProjectHandle {
74
77
  subscribeConnection(cb: () => void): () => void;
75
78
  files: FilesHandle;
76
79
  permissions: PermissionsHandle;
80
+ calendar: CalendarHandle;
77
81
  }
78
82
  export interface DriveSync {
79
83
  activate(): () => void;
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ 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';
9
10
  import { createConnectionSnapshotStore } from './connectionSnapshot.js';
@@ -50,6 +51,12 @@ async function revokeToken(accessToken) {
50
51
  export function createDriveSync(options) {
51
52
  const { appId, clientId, folderPath, tokenExchangeUrl } = options;
52
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 ?? [])];
53
60
  /**
54
61
  * Design choice for `.activate()` (T30): the frozen public API's
55
62
  * `activate()` takes no project argument, but the actual background
@@ -88,7 +95,7 @@ export function createDriveSync(options) {
88
95
  return getConnectionImpl({
89
96
  appId,
90
97
  projectId,
91
- requiredScopes: REQUIRED_SCOPES,
98
+ requiredScopes: EFFECTIVE_SCOPES,
92
99
  })
93
100
  .then((conn) => {
94
101
  getOrInitStore(projectId).commit(conn);
@@ -141,7 +148,7 @@ export function createDriveSync(options) {
141
148
  }
142
149
  const runWarmUps = () => {
143
150
  for (const projectId of trackedProjectIds) {
144
- void warmUpIfNeeded({ appId, projectId, clientId, tokenExchangeUrl, fetchEmail, logger }).then(() => reReadConnection(projectId), () => reReadConnection(projectId));
151
+ void warmUpIfNeeded({ appId, projectId, clientId, scopes: EFFECTIVE_SCOPES, tokenExchangeUrl, fetchEmail, logger }).then(() => reReadConnection(projectId), () => reReadConnection(projectId));
145
152
  }
146
153
  };
147
154
  const onVisibilityChange = () => {
@@ -173,7 +180,7 @@ export function createDriveSync(options) {
173
180
  }
174
181
  function project(projectId) {
175
182
  trackProject(projectId);
176
- const base = { appId, projectId, clientId, tokenExchangeUrl, logger, fetchEmail };
183
+ const base = { appId, projectId, clientId, tokenExchangeUrl, logger, fetchEmail, requiredScopes: EFFECTIVE_SCOPES };
177
184
  const files = {
178
185
  list(opts, callOpts) {
179
186
  return filesImpl.list({ ...base, ...opts, interactive: callOpts?.interactive });
@@ -205,13 +212,18 @@ export function createDriveSync(options) {
205
212
  return permissionsImpl.revoke({ ...base, ...opts, interactive: callOpts?.interactive });
206
213
  },
207
214
  };
215
+ const calendar = {
216
+ listEvents(opts, callOpts) {
217
+ return calendarImpl.listEvents({ ...base, ...opts, interactive: callOpts?.interactive });
218
+ },
219
+ };
208
220
  return {
209
221
  async connect() {
210
222
  const connection = await connectImpl({
211
223
  appId,
212
224
  projectId,
213
225
  clientId,
214
- scopes: REQUIRED_SCOPES,
226
+ scopes: EFFECTIVE_SCOPES,
215
227
  tokenExchangeUrl,
216
228
  logger,
217
229
  fetchEmail,
@@ -230,7 +242,7 @@ export function createDriveSync(options) {
230
242
  return getConnectionImpl({
231
243
  appId,
232
244
  projectId,
233
- requiredScopes: REQUIRED_SCOPES,
245
+ requiredScopes: EFFECTIVE_SCOPES,
234
246
  });
235
247
  },
236
248
  async disconnect() {
@@ -265,7 +277,7 @@ export function createDriveSync(options) {
265
277
  appId,
266
278
  projectId,
267
279
  clientId,
268
- scopes: REQUIRED_SCOPES,
280
+ scopes: EFFECTIVE_SCOPES,
269
281
  interactive: callOpts?.interactive ?? true,
270
282
  tokenExchangeUrl,
271
283
  logger,
@@ -276,7 +288,7 @@ export function createDriveSync(options) {
276
288
  appId,
277
289
  projectId,
278
290
  clientId,
279
- scopes: REQUIRED_SCOPES,
291
+ scopes: EFFECTIVE_SCOPES,
280
292
  interactive: true,
281
293
  tokenExchangeUrl,
282
294
  logger,
@@ -308,6 +320,7 @@ export function createDriveSync(options) {
308
320
  },
309
321
  files,
310
322
  permissions,
323
+ calendar,
311
324
  };
312
325
  }
313
326
  return {
@@ -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;
@@ -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: REQUIRED_SCOPES,
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: REQUIRED_SCOPES,
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: REQUIRED_SCOPES,
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: REQUIRED_SCOPES,
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: REQUIRED_SCOPES,
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: REQUIRED_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: REQUIRED_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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-webapp/drive-sync",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",