granola-toolkit 0.34.5 → 0.35.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.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.js +503 -176
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -21,6 +21,7 @@ The published package exposes both `granola` and `granola-toolkit` as executable
21
21
 
22
22
  ```bash
23
23
  granola auth login
24
+ granola sync
24
25
  granola folder list
25
26
  granola meeting list --limit 10
26
27
  granola notes --folder Team
package/dist/cli.js CHANGED
@@ -29,6 +29,7 @@ const granolaTransportPaths = {
29
29
  meetings: "/meetings",
30
30
  root: "/",
31
31
  serverInfo: "/server/info",
32
+ syncRun: "/sync",
32
33
  state: "/state"
33
34
  };
34
35
  function appendSearchParams(path, params) {
@@ -177,6 +178,9 @@ var GranolaServerClient = class GranolaServerClient {
177
178
  async inspectAuth() {
178
179
  return await this.requestJson(granolaTransportPaths.authStatus);
179
180
  }
181
+ async inspectSync() {
182
+ return cloneValue(this.#state.sync);
183
+ }
180
184
  async loginAuth(options = {}) {
181
185
  return await this.requestJson(granolaTransportPaths.authLogin, {
182
186
  body: JSON.stringify(options),
@@ -197,6 +201,13 @@ var GranolaServerClient = class GranolaServerClient {
197
201
  method: "POST"
198
202
  });
199
203
  }
204
+ async sync(options = {}) {
205
+ return await this.requestJson(granolaTransportPaths.syncRun, {
206
+ body: JSON.stringify(options),
207
+ headers: { "content-type": "application/json" },
208
+ method: "POST"
209
+ });
210
+ }
200
211
  async listFolders(options = {}) {
201
212
  return await this.requestJson(granolaFoldersPath(options));
202
213
  }
@@ -2370,7 +2381,8 @@ function defaultGranolaToolkitPersistenceLayout(options = {}) {
2370
2381
  exportJobsFile: join(dataDirectory, "export-jobs.json"),
2371
2382
  meetingIndexFile: join(dataDirectory, "meeting-index.json"),
2372
2383
  sessionFile: join(dataDirectory, "session.json"),
2373
- sessionStoreKind: targetPlatform === "darwin" ? "keychain" : "file"
2384
+ sessionStoreKind: targetPlatform === "darwin" ? "keychain" : "file",
2385
+ syncStateFile: join(dataDirectory, "sync-state.json")
2374
2386
  };
2375
2387
  }
2376
2388
  //#endregion
@@ -3252,6 +3264,162 @@ function createDefaultMeetingIndexStore() {
3252
3264
  return new FileMeetingIndexStore();
3253
3265
  }
3254
3266
  //#endregion
3267
+ //#region src/sync-state.ts
3268
+ const SYNC_STATE_VERSION = 1;
3269
+ const MAX_STORED_CHANGES = 50;
3270
+ function cloneSyncChange$1(change) {
3271
+ return { ...change };
3272
+ }
3273
+ function cloneSyncSummary(summary) {
3274
+ return summary ? { ...summary } : void 0;
3275
+ }
3276
+ function normaliseSyncState(filePath, file) {
3277
+ return {
3278
+ filePath,
3279
+ lastChanges: (file?.lastChanges ?? []).slice(0, MAX_STORED_CHANGES).map(cloneSyncChange$1),
3280
+ lastCompletedAt: file?.lastCompletedAt,
3281
+ lastError: file?.lastError,
3282
+ lastFailedAt: file?.lastFailedAt,
3283
+ lastStartedAt: file?.lastStartedAt,
3284
+ running: false,
3285
+ summary: cloneSyncSummary(file?.summary)
3286
+ };
3287
+ }
3288
+ var FileSyncStateStore = class {
3289
+ constructor(filePath = defaultSyncStateFilePath()) {
3290
+ this.filePath = filePath;
3291
+ }
3292
+ async readState() {
3293
+ try {
3294
+ const parsed = parseJsonString(await readFile(this.filePath, "utf8"));
3295
+ if (!parsed || parsed.version !== SYNC_STATE_VERSION) return normaliseSyncState(this.filePath);
3296
+ return normaliseSyncState(this.filePath, parsed);
3297
+ } catch {
3298
+ return normaliseSyncState(this.filePath);
3299
+ }
3300
+ }
3301
+ async writeState(state) {
3302
+ await mkdir(dirname(this.filePath), { recursive: true });
3303
+ const payload = {
3304
+ lastChanges: state.lastChanges.slice(0, MAX_STORED_CHANGES).map(cloneSyncChange$1),
3305
+ lastCompletedAt: state.lastCompletedAt,
3306
+ lastError: state.lastError,
3307
+ lastFailedAt: state.lastFailedAt,
3308
+ lastStartedAt: state.lastStartedAt,
3309
+ summary: cloneSyncSummary(state.summary),
3310
+ version: SYNC_STATE_VERSION
3311
+ };
3312
+ await writeFile(this.filePath, `${JSON.stringify(payload, null, 2)}\n`, {
3313
+ encoding: "utf8",
3314
+ mode: 384
3315
+ });
3316
+ }
3317
+ };
3318
+ function defaultSyncStateFilePath() {
3319
+ return defaultGranolaToolkitPersistenceLayout().syncStateFile;
3320
+ }
3321
+ function createDefaultSyncStateStore() {
3322
+ return new FileSyncStateStore();
3323
+ }
3324
+ //#endregion
3325
+ //#region src/sync.ts
3326
+ function normaliseMeeting(meeting) {
3327
+ return {
3328
+ createdAt: meeting.createdAt,
3329
+ folders: meeting.folders.map((folder) => ({
3330
+ createdAt: folder.createdAt,
3331
+ description: folder.description,
3332
+ documentCount: folder.documentCount,
3333
+ id: folder.id,
3334
+ isFavourite: folder.isFavourite,
3335
+ name: folder.name,
3336
+ updatedAt: folder.updatedAt,
3337
+ workspaceId: folder.workspaceId
3338
+ })).sort((left, right) => left.id.localeCompare(right.id)),
3339
+ noteContentSource: meeting.noteContentSource,
3340
+ tags: [...meeting.tags].sort((left, right) => left.localeCompare(right)),
3341
+ title: meeting.title,
3342
+ transcriptLoaded: meeting.transcriptLoaded,
3343
+ transcriptSegmentCount: meeting.transcriptSegmentCount,
3344
+ updatedAt: meeting.updatedAt
3345
+ };
3346
+ }
3347
+ function meetingChanged(previous, next) {
3348
+ return JSON.stringify(normaliseMeeting(previous)) !== JSON.stringify(normaliseMeeting(next));
3349
+ }
3350
+ function diffMeetingSummaries(previous, next, folderCount) {
3351
+ const previousById = new Map(previous.map((meeting) => [meeting.id, meeting]));
3352
+ const nextById = new Map(next.map((meeting) => [meeting.id, meeting]));
3353
+ const changes = [];
3354
+ let createdCount = 0;
3355
+ let changedCount = 0;
3356
+ let removedCount = 0;
3357
+ let transcriptReadyCount = 0;
3358
+ for (const meeting of next) {
3359
+ const previousMeeting = previousById.get(meeting.id);
3360
+ if (!previousMeeting) {
3361
+ createdCount += 1;
3362
+ changes.push({
3363
+ kind: "created",
3364
+ meetingId: meeting.id,
3365
+ title: meeting.title,
3366
+ updatedAt: meeting.updatedAt
3367
+ });
3368
+ if (meeting.transcriptLoaded) {
3369
+ transcriptReadyCount += 1;
3370
+ changes.push({
3371
+ kind: "transcript-ready",
3372
+ meetingId: meeting.id,
3373
+ title: meeting.title,
3374
+ updatedAt: meeting.updatedAt
3375
+ });
3376
+ }
3377
+ continue;
3378
+ }
3379
+ if (meetingChanged(previousMeeting, meeting)) {
3380
+ changedCount += 1;
3381
+ changes.push({
3382
+ kind: "changed",
3383
+ meetingId: meeting.id,
3384
+ previousUpdatedAt: previousMeeting.updatedAt,
3385
+ title: meeting.title,
3386
+ updatedAt: meeting.updatedAt
3387
+ });
3388
+ }
3389
+ if (!previousMeeting.transcriptLoaded && meeting.transcriptLoaded) {
3390
+ transcriptReadyCount += 1;
3391
+ changes.push({
3392
+ kind: "transcript-ready",
3393
+ meetingId: meeting.id,
3394
+ previousUpdatedAt: previousMeeting.updatedAt,
3395
+ title: meeting.title,
3396
+ updatedAt: meeting.updatedAt
3397
+ });
3398
+ }
3399
+ }
3400
+ for (const meeting of previous) {
3401
+ if (nextById.has(meeting.id)) continue;
3402
+ removedCount += 1;
3403
+ changes.push({
3404
+ kind: "removed",
3405
+ meetingId: meeting.id,
3406
+ previousUpdatedAt: meeting.updatedAt,
3407
+ title: meeting.title
3408
+ });
3409
+ }
3410
+ return {
3411
+ changes,
3412
+ summary: {
3413
+ changedCount,
3414
+ createdCount,
3415
+ folderCount,
3416
+ meetingCount: next.length,
3417
+ removedCount,
3418
+ transcriptReadyCount
3419
+ }
3420
+ };
3421
+ }
3422
+ //#endregion
3255
3423
  //#region src/app/core.ts
3256
3424
  function transcriptCount(cacheData) {
3257
3425
  return Object.values(cacheData.transcripts).filter((segments) => segments.length > 0).length;
@@ -3271,6 +3439,16 @@ function cloneExportJob(job) {
3271
3439
  function cloneFolderSummary(folder) {
3272
3440
  return { ...folder };
3273
3441
  }
3442
+ function cloneSyncChange(change) {
3443
+ return { ...change };
3444
+ }
3445
+ function cloneSyncState(state) {
3446
+ return {
3447
+ ...state,
3448
+ lastChanges: state.lastChanges.map(cloneSyncChange),
3449
+ summary: state.summary ? { ...state.summary } : void 0
3450
+ };
3451
+ }
3274
3452
  function cloneMeetingSummary(meeting) {
3275
3453
  return {
3276
3454
  ...meeting,
@@ -3295,6 +3473,7 @@ function cloneState(state) {
3295
3473
  transcripts: cloneExportState(state.exports.transcripts)
3296
3474
  },
3297
3475
  index: { ...state.index },
3476
+ sync: cloneSyncState(state.sync),
3298
3477
  ui: { ...state.ui }
3299
3478
  };
3300
3479
  }
@@ -3328,6 +3507,11 @@ function defaultState(config, auth, surface) {
3328
3507
  loaded: false,
3329
3508
  meetingCount: 0
3330
3509
  },
3510
+ sync: {
3511
+ filePath: defaultSyncStateFilePath(),
3512
+ lastChanges: [],
3513
+ running: false
3514
+ },
3331
3515
  ui: {
3332
3516
  surface,
3333
3517
  view: "idle"
@@ -3357,6 +3541,14 @@ var GranolaApp = class {
3357
3541
  loadedAt: this.#meetingIndex.length > 0 ? this.nowIso() : void 0,
3358
3542
  meetingCount: this.#meetingIndex.length
3359
3543
  };
3544
+ this.#state.sync = {
3545
+ ...this.#state.sync,
3546
+ ...cloneSyncState(deps.syncState ?? {
3547
+ filePath: defaultSyncStateFilePath(),
3548
+ lastChanges: [],
3549
+ running: false
3550
+ })
3551
+ };
3360
3552
  }
3361
3553
  getState() {
3362
3554
  return cloneState(this.#state);
@@ -3378,16 +3570,39 @@ var GranolaApp = class {
3378
3570
  resetRemoteState() {
3379
3571
  this.#granolaClient = void 0;
3380
3572
  this.#folders = void 0;
3573
+ this.#documents = void 0;
3574
+ this.resetDocumentsState();
3575
+ this.resetFoldersState();
3576
+ }
3577
+ resetDocumentsState() {
3381
3578
  this.#documents = void 0;
3382
3579
  this.#state.documents = {
3383
3580
  count: 0,
3384
3581
  loaded: false
3385
3582
  };
3583
+ }
3584
+ resetFoldersState() {
3585
+ this.#folders = void 0;
3386
3586
  this.#state.folders = {
3387
3587
  count: 0,
3388
3588
  loaded: false
3389
3589
  };
3390
3590
  }
3591
+ resetCacheState() {
3592
+ this.#cacheData = void 0;
3593
+ this.#cacheResolved = false;
3594
+ this.#state.cache = {
3595
+ configured: Boolean(this.config.transcripts.cacheFile),
3596
+ documentCount: 0,
3597
+ filePath: this.config.transcripts.cacheFile || void 0,
3598
+ loaded: false,
3599
+ transcriptCount: 0
3600
+ };
3601
+ }
3602
+ async persistSyncState() {
3603
+ if (!this.deps.syncStateStore) return;
3604
+ await this.deps.syncStateStore.writeState(this.#state.sync);
3605
+ }
3391
3606
  applyAuthState(auth, options = {}) {
3392
3607
  if (options.resetDocuments) this.resetRemoteState();
3393
3608
  this.#state.auth = { ...auth };
@@ -3410,23 +3625,27 @@ var GranolaApp = class {
3410
3625
  if (this.deps.meetingIndexStore) await this.deps.meetingIndexStore.writeIndex(this.#meetingIndex);
3411
3626
  this.emitStateUpdate();
3412
3627
  }
3413
- async refreshMeetingIndexFromLiveData() {
3414
- const cacheData = await this.loadCache();
3415
- const documents = await this.listDocuments();
3416
- const folders = await this.loadFolders();
3417
- const meetings = listMeetings(documents, {
3628
+ async liveMeetingSnapshot(options = {}) {
3629
+ const cacheData = await this.loadCache({ forceRefresh: options.forceRefresh });
3630
+ const documents = await this.listDocuments({ forceRefresh: options.forceRefresh });
3631
+ const folders = await this.loadFolders({ forceRefresh: options.forceRefresh });
3632
+ return {
3418
3633
  cacheData,
3419
- foldersByDocumentId: this.buildFoldersByDocumentId(folders),
3420
- limit: documents.length || 1,
3421
- sort: "updated-desc"
3422
- });
3423
- await this.persistMeetingIndex(meetings);
3634
+ documents,
3635
+ folders,
3636
+ meetings: listMeetings(documents, {
3637
+ cacheData,
3638
+ foldersByDocumentId: this.buildFoldersByDocumentId(folders),
3639
+ limit: Math.max(documents.length, 1),
3640
+ sort: "updated-desc"
3641
+ })
3642
+ };
3424
3643
  }
3425
3644
  triggerMeetingIndexRefresh() {
3426
3645
  if (this.#refreshingMeetingIndex) return;
3427
3646
  this.#refreshingMeetingIndex = (async () => {
3428
3647
  try {
3429
- await this.refreshMeetingIndexFromLiveData();
3648
+ await this.runSync({ foreground: false });
3430
3649
  } catch {} finally {
3431
3650
  this.#refreshingMeetingIndex = void 0;
3432
3651
  }
@@ -3471,7 +3690,7 @@ var GranolaApp = class {
3471
3690
  }
3472
3691
  async loadFolders(options = {}) {
3473
3692
  if (options.forceRefresh) {
3474
- this.resetRemoteState();
3693
+ this.resetFoldersState();
3475
3694
  this.emitStateUpdate();
3476
3695
  }
3477
3696
  if (this.#folders) return this.#folders.map((folder) => ({
@@ -3565,6 +3784,9 @@ var GranolaApp = class {
3565
3784
  const auth = await this.deps.authController.inspect();
3566
3785
  return this.applyAuthState(auth, { view: "auth" });
3567
3786
  }
3787
+ async inspectSync() {
3788
+ return cloneSyncState(this.#state.sync);
3789
+ }
3568
3790
  async loginAuth(options = {}) {
3569
3791
  const controller = this.requireAuthController();
3570
3792
  try {
@@ -3614,9 +3836,56 @@ var GranolaApp = class {
3614
3836
  throw error;
3615
3837
  }
3616
3838
  }
3839
+ async runSync(options) {
3840
+ const previousMeetings = this.#meetingIndex.map((meeting) => cloneMeetingSummary(meeting));
3841
+ this.#state.sync = {
3842
+ ...this.#state.sync,
3843
+ lastError: void 0,
3844
+ lastStartedAt: this.nowIso(),
3845
+ running: true
3846
+ };
3847
+ if (options.foreground) this.setUiState({ view: "sync" });
3848
+ else this.emitStateUpdate();
3849
+ try {
3850
+ const snapshot = await this.liveMeetingSnapshot({ forceRefresh: options.forceRefresh ?? true });
3851
+ await this.persistMeetingIndex(snapshot.meetings);
3852
+ const { changes, summary } = diffMeetingSummaries(previousMeetings, snapshot.meetings, snapshot.folders?.length ?? 0);
3853
+ this.#state.sync = {
3854
+ ...this.#state.sync,
3855
+ lastChanges: changes.slice(0, 50).map(cloneSyncChange),
3856
+ lastCompletedAt: this.nowIso(),
3857
+ lastError: void 0,
3858
+ running: false,
3859
+ summary: { ...summary }
3860
+ };
3861
+ await this.persistSyncState();
3862
+ this.emitStateUpdate();
3863
+ return {
3864
+ changes: changes.map(cloneSyncChange),
3865
+ state: cloneSyncState(this.#state.sync),
3866
+ summary: { ...summary }
3867
+ };
3868
+ } catch (error) {
3869
+ this.#state.sync = {
3870
+ ...this.#state.sync,
3871
+ lastError: error instanceof Error ? error.message : String(error),
3872
+ lastFailedAt: this.nowIso(),
3873
+ running: false
3874
+ };
3875
+ await this.persistSyncState();
3876
+ this.emitStateUpdate();
3877
+ throw error;
3878
+ }
3879
+ }
3880
+ async sync(options = {}) {
3881
+ return await this.runSync({
3882
+ forceRefresh: options.forceRefresh,
3883
+ foreground: true
3884
+ });
3885
+ }
3617
3886
  async listDocuments(options = {}) {
3618
3887
  if (options.forceRefresh) {
3619
- this.resetRemoteState();
3888
+ this.resetDocumentsState();
3620
3889
  this.emitStateUpdate();
3621
3890
  }
3622
3891
  if (this.#documents) return this.#documents;
@@ -3631,6 +3900,10 @@ var GranolaApp = class {
3631
3900
  return documents;
3632
3901
  }
3633
3902
  async loadCache(options = {}) {
3903
+ if (options.forceRefresh) {
3904
+ this.resetCacheState();
3905
+ this.emitStateUpdate();
3906
+ }
3634
3907
  if (this.#cacheResolved) {
3635
3908
  if (options.required && !this.#cacheData) throw this.missingCacheError();
3636
3909
  return this.#cacheData;
@@ -3717,28 +3990,19 @@ var GranolaApp = class {
3717
3990
  source: "index"
3718
3991
  };
3719
3992
  }
3720
- const cacheData = await this.loadCache();
3721
- const documents = await this.listDocuments({ forceRefresh: options.forceRefresh });
3722
- const folders = await this.loadFolders({
3723
- forceRefresh: options.forceRefresh,
3724
- required: Boolean(options.folderId)
3725
- });
3726
- const meetings = listMeetings(documents, {
3727
- cacheData,
3993
+ const snapshot = await this.liveMeetingSnapshot({ forceRefresh: options.forceRefresh });
3994
+ if (options.folderId && !snapshot.folders) throw new Error("Granola folder API is not configured");
3995
+ const meetings = listMeetings(snapshot.documents, {
3996
+ cacheData: snapshot.cacheData,
3728
3997
  folderId: options.folderId,
3729
- foldersByDocumentId: this.buildFoldersByDocumentId(folders),
3998
+ foldersByDocumentId: this.buildFoldersByDocumentId(snapshot.folders),
3730
3999
  limit: options.limit,
3731
4000
  search: options.search,
3732
4001
  sort: options.sort,
3733
4002
  updatedFrom: options.updatedFrom,
3734
4003
  updatedTo: options.updatedTo
3735
4004
  });
3736
- await this.persistMeetingIndex(listMeetings(documents, {
3737
- cacheData,
3738
- foldersByDocumentId: this.buildFoldersByDocumentId(folders),
3739
- limit: Math.max(documents.length, 1),
3740
- sort: "updated-desc"
3741
- }));
4005
+ await this.persistMeetingIndex(snapshot.meetings);
3742
4006
  this.setUiState({
3743
4007
  folderSearch: void 0,
3744
4008
  meetingListSource: "live",
@@ -3938,6 +4202,9 @@ async function createGranolaApp(config, options = {}) {
3938
4202
  const exportJobStore = createDefaultExportJobStore();
3939
4203
  const exportJobs = await exportJobStore.readJobs();
3940
4204
  const meetingIndexStore = createDefaultMeetingIndexStore();
4205
+ const meetingIndex = await meetingIndexStore.readIndex();
4206
+ const syncStateStore = createDefaultSyncStateStore();
4207
+ const syncState = await syncStateStore.readState();
3941
4208
  return new GranolaApp(config, {
3942
4209
  auth,
3943
4210
  authController,
@@ -3945,9 +4212,11 @@ async function createGranolaApp(config, options = {}) {
3945
4212
  createGranolaClient: async (mode) => await createDefaultGranolaRuntime(config, options.logger, { preferredMode: mode }),
3946
4213
  exportJobs,
3947
4214
  exportJobStore,
3948
- meetingIndex: await meetingIndexStore.readIndex(),
4215
+ meetingIndex,
3949
4216
  meetingIndexStore,
3950
- now: options.now
4217
+ now: options.now,
4218
+ syncState,
4219
+ syncStateStore
3951
4220
  }, { surface: options.surface });
3952
4221
  }
3953
4222
  //#endregion
@@ -4414,10 +4683,115 @@ async function openExternalUrl(url, options = {}) {
4414
4683
  }))(command.file, command.args);
4415
4684
  }
4416
4685
  //#endregion
4686
+ //#region src/web/client-state.ts
4687
+ function parseWorkspaceTab(value) {
4688
+ switch (value) {
4689
+ case "metadata":
4690
+ case "raw":
4691
+ case "transcript": return value;
4692
+ default: return "notes";
4693
+ }
4694
+ }
4695
+ function startupSelectionFromSearch(search) {
4696
+ const params = new URLSearchParams(search);
4697
+ return {
4698
+ folderId: params.get("folder")?.trim() || "",
4699
+ meetingId: params.get("meeting")?.trim() || "",
4700
+ workspaceTab: parseWorkspaceTab(params.get("tab"))
4701
+ };
4702
+ }
4703
+ function buildBrowserUrlPath(currentHref, selection) {
4704
+ const url = new URL(currentHref);
4705
+ if (selection.selectedFolderId) url.searchParams.set("folder", selection.selectedFolderId);
4706
+ else url.searchParams.delete("folder");
4707
+ if (selection.selectedMeetingId) url.searchParams.set("meeting", selection.selectedMeetingId);
4708
+ else url.searchParams.delete("meeting");
4709
+ if (parseWorkspaceTab(selection.workspaceTab) !== "notes") url.searchParams.set("tab", parseWorkspaceTab(selection.workspaceTab));
4710
+ else url.searchParams.delete("tab");
4711
+ return `${url.pathname}${url.search}${url.hash}`;
4712
+ }
4713
+ function exportScopeLabel(scope) {
4714
+ return scope && scope.mode === "folder" ? `Folder: ${scope.folderName || scope.folderId}` : "Scope: All meetings";
4715
+ }
4716
+ function currentFilterSummary(filters) {
4717
+ const parts = [];
4718
+ if (filters.selectedFolderId) {
4719
+ const folder = filters.folders.find((candidate) => candidate.id === filters.selectedFolderId);
4720
+ parts.push(`folder "${folder ? folder.name : filters.selectedFolderId}"`);
4721
+ }
4722
+ if (filters.search) parts.push(`search "${filters.search}"`);
4723
+ if (filters.updatedFrom) parts.push(`from ${filters.updatedFrom}`);
4724
+ if (filters.updatedTo) parts.push(`to ${filters.updatedTo}`);
4725
+ return parts.join(", ");
4726
+ }
4727
+ function selectMeetingId(meetings, selectedMeetingId) {
4728
+ if (selectedMeetingId && meetings.some((meeting) => meeting.id === selectedMeetingId)) return selectedMeetingId;
4729
+ return meetings[0]?.id ?? null;
4730
+ }
4731
+ function buildMeetingsQuery(filters, options = {}) {
4732
+ const params = new URLSearchParams();
4733
+ params.set("limit", String(options.limit ?? 100));
4734
+ params.set("sort", filters.sort || "updated-desc");
4735
+ if (filters.search) params.set("search", filters.search);
4736
+ if (filters.updatedFrom) params.set("updatedFrom", filters.updatedFrom);
4737
+ if (filters.updatedTo) params.set("updatedTo", filters.updatedTo);
4738
+ if (filters.selectedFolderId) params.set("folderId", filters.selectedFolderId);
4739
+ if (options.refresh) params.set("refresh", "true");
4740
+ return `?${params.toString()}`;
4741
+ }
4742
+ function buildNotesExportRequest(selectedFolderId) {
4743
+ return {
4744
+ folderId: selectedFolderId || void 0,
4745
+ format: "markdown"
4746
+ };
4747
+ }
4748
+ function buildTranscriptsExportRequest(selectedFolderId) {
4749
+ return {
4750
+ folderId: selectedFolderId || void 0,
4751
+ format: "text"
4752
+ };
4753
+ }
4754
+ function nextWorkspaceTab(currentTab, key) {
4755
+ const current = parseWorkspaceTab(currentTab);
4756
+ switch (key) {
4757
+ case "1": return "notes";
4758
+ case "2": return "transcript";
4759
+ case "3": return "metadata";
4760
+ case "4": return "raw";
4761
+ case "]":
4762
+ switch (current) {
4763
+ case "notes": return "transcript";
4764
+ case "transcript": return "metadata";
4765
+ case "metadata": return "raw";
4766
+ case "raw": return "notes";
4767
+ }
4768
+ break;
4769
+ case "[":
4770
+ switch (current) {
4771
+ case "notes": return "raw";
4772
+ case "transcript": return "notes";
4773
+ case "metadata": return "transcript";
4774
+ case "raw": return "metadata";
4775
+ }
4776
+ break;
4777
+ default: return;
4778
+ }
4779
+ }
4780
+ //#endregion
4417
4781
  //#region src/web/client-script.ts
4418
4782
  const granolaWebClientScript = String.raw`
4419
4783
  const serverConfig = window.__GRANOLA_SERVER__ || { passwordRequired: false };
4420
4784
  const workspaceTabs = ["notes", "transcript", "metadata", "raw"];
4785
+ ${parseWorkspaceTab.toString()}
4786
+ ${startupSelectionFromSearch.toString()}
4787
+ ${buildBrowserUrlPath.toString()}
4788
+ ${exportScopeLabel.toString()}
4789
+ ${currentFilterSummary.toString()}
4790
+ ${selectMeetingId.toString()}
4791
+ ${buildMeetingsQuery.toString()}
4792
+ ${buildNotesExportRequest.toString()}
4793
+ ${buildTranscriptsExportRequest.toString()}
4794
+ ${nextWorkspaceTab.toString()}
4421
4795
 
4422
4796
  const state = {
4423
4797
  appState: null,
@@ -4466,41 +4840,12 @@ const els = {
4466
4840
  workspaceTabs: document.querySelectorAll("[data-workspace-tab]"),
4467
4841
  };
4468
4842
 
4469
- function parseWorkspaceTab(value) {
4470
- return workspaceTabs.includes(value) ? value : "notes";
4471
- }
4472
-
4473
- function startupSelection() {
4474
- const params = new URLSearchParams(window.location.search);
4475
- return {
4476
- folderId: params.get("folder")?.trim() || "",
4477
- meetingId: params.get("meeting")?.trim() || "",
4478
- workspaceTab: parseWorkspaceTab(params.get("tab")),
4479
- };
4480
- }
4481
-
4482
4843
  function syncBrowserUrl() {
4483
- const url = new URL(window.location.href);
4484
-
4485
- if (state.selectedFolderId) {
4486
- url.searchParams.set("folder", state.selectedFolderId);
4487
- } else {
4488
- url.searchParams.delete("folder");
4489
- }
4490
-
4491
- if (state.selectedMeetingId) {
4492
- url.searchParams.set("meeting", state.selectedMeetingId);
4493
- } else {
4494
- url.searchParams.delete("meeting");
4495
- }
4496
-
4497
- if (state.workspaceTab !== "notes") {
4498
- url.searchParams.set("tab", state.workspaceTab);
4499
- } else {
4500
- url.searchParams.delete("tab");
4501
- }
4502
-
4503
- const nextPath = url.pathname + url.search + url.hash;
4844
+ const nextPath = buildBrowserUrlPath(window.location.href, {
4845
+ selectedFolderId: state.selectedFolderId,
4846
+ selectedMeetingId: state.selectedMeetingId,
4847
+ workspaceTab: state.workspaceTab,
4848
+ });
4504
4849
  const currentPath = window.location.pathname + window.location.search + window.location.hash;
4505
4850
  if (nextPath !== currentPath) {
4506
4851
  history.replaceState(null, "", nextPath);
@@ -4515,12 +4860,6 @@ function escapeHtml(value) {
4515
4860
  .replaceAll('"', """);
4516
4861
  }
4517
4862
 
4518
- function exportScopeLabel(scope) {
4519
- return scope && scope.mode === "folder"
4520
- ? "Folder: " + (scope.folderName || scope.folderId)
4521
- : "Scope: All meetings";
4522
- }
4523
-
4524
4863
  function setStatus(label, tone = "idle") {
4525
4864
  els.stateBadge.textContent = label;
4526
4865
  els.stateBadge.dataset.tone = tone;
@@ -4534,29 +4873,6 @@ function syncFilterInputs() {
4534
4873
  els.updatedTo.value = state.updatedTo;
4535
4874
  }
4536
4875
 
4537
- function currentFilterSummary() {
4538
- const parts = [];
4539
-
4540
- if (state.selectedFolderId) {
4541
- const folder = state.folders.find((candidate) => candidate.id === state.selectedFolderId);
4542
- parts.push("folder " + (folder ? '"' + folder.name + '"' : '"' + state.selectedFolderId + '"'));
4543
- }
4544
-
4545
- if (state.search) {
4546
- parts.push('search "' + state.search + '"');
4547
- }
4548
-
4549
- if (state.updatedFrom) {
4550
- parts.push("from " + state.updatedFrom);
4551
- }
4552
-
4553
- if (state.updatedTo) {
4554
- parts.push("to " + state.updatedTo);
4555
- }
4556
-
4557
- return parts.join(", ");
4558
- }
4559
-
4560
4876
  function renderWorkspaceTabs() {
4561
4877
  for (const button of els.workspaceTabs) {
4562
4878
  button.dataset.selected = button.dataset.workspaceTab === state.workspaceTab ? "true" : "false";
@@ -4730,7 +5046,13 @@ function renderMeetingList() {
4730
5046
  state.selectedMeeting = null;
4731
5047
  state.selectedMeetingBundle = null;
4732
5048
  syncBrowserUrl();
4733
- const filterSummary = currentFilterSummary();
5049
+ const filterSummary = currentFilterSummary({
5050
+ folders: state.folders,
5051
+ search: state.search,
5052
+ selectedFolderId: state.selectedFolderId,
5053
+ updatedFrom: state.updatedFrom,
5054
+ updatedTo: state.updatedTo,
5055
+ });
4734
5056
  const message = filterSummary
4735
5057
  ? "No meetings match " + filterSummary + "."
4736
5058
  : "No meetings yet. Try Refresh.";
@@ -4739,10 +5061,7 @@ function renderMeetingList() {
4739
5061
  return;
4740
5062
  }
4741
5063
 
4742
- const visibleIds = new Set(state.meetings.map((meeting) => meeting.id));
4743
- if (!state.selectedMeetingId || !visibleIds.has(state.selectedMeetingId)) {
4744
- state.selectedMeetingId = state.meetings[0]?.id || null;
4745
- }
5064
+ state.selectedMeetingId = selectMeetingId(state.meetings, state.selectedMeetingId);
4746
5065
  syncBrowserUrl();
4747
5066
 
4748
5067
  els.list.innerHTML = state.meetings
@@ -4886,34 +5205,6 @@ async function fetchJson(path, init) {
4886
5205
  return payload;
4887
5206
  }
4888
5207
 
4889
- function buildMeetingsQuery(limit = 100, refresh = false) {
4890
- const params = new URLSearchParams();
4891
- params.set("limit", String(limit));
4892
- params.set("sort", state.sort);
4893
-
4894
- if (state.search) {
4895
- params.set("search", state.search);
4896
- }
4897
-
4898
- if (state.updatedFrom) {
4899
- params.set("updatedFrom", state.updatedFrom);
4900
- }
4901
-
4902
- if (state.updatedTo) {
4903
- params.set("updatedTo", state.updatedTo);
4904
- }
4905
-
4906
- if (state.selectedFolderId) {
4907
- params.set("folderId", state.selectedFolderId);
4908
- }
4909
-
4910
- if (refresh) {
4911
- params.set("refresh", "true");
4912
- }
4913
-
4914
- return "?" + params.toString();
4915
- }
4916
-
4917
5208
  async function loadFolders(options = {}) {
4918
5209
  const refresh = options.refresh === true;
4919
5210
 
@@ -4953,7 +5244,22 @@ async function loadMeetings(options = {}) {
4953
5244
 
4954
5245
  try {
4955
5246
  state.listError = "";
4956
- const payload = await fetchJson("/meetings" + buildMeetingsQuery(100, refresh));
5247
+ const payload = await fetchJson(
5248
+ "/meetings" +
5249
+ buildMeetingsQuery(
5250
+ {
5251
+ search: state.search,
5252
+ selectedFolderId: state.selectedFolderId,
5253
+ sort: state.sort,
5254
+ updatedFrom: state.updatedFrom,
5255
+ updatedTo: state.updatedTo,
5256
+ },
5257
+ {
5258
+ limit: 100,
5259
+ refresh,
5260
+ },
5261
+ ),
5262
+ );
4957
5263
  state.meetings = payload.meetings || [];
4958
5264
  state.meetingSource = payload.source || "live";
4959
5265
 
@@ -5063,10 +5369,7 @@ async function syncAuthState() {
5063
5369
  async function exportNotes() {
5064
5370
  setStatus(state.selectedFolderId ? "Exporting folder notes…" : "Exporting notes…", "busy");
5065
5371
  await fetchJson("/exports/notes", {
5066
- body: JSON.stringify({
5067
- folderId: state.selectedFolderId || undefined,
5068
- format: "markdown",
5069
- }),
5372
+ body: JSON.stringify(buildNotesExportRequest(state.selectedFolderId)),
5070
5373
  headers: { "content-type": "application/json" },
5071
5374
  method: "POST",
5072
5375
  });
@@ -5079,10 +5382,7 @@ async function exportTranscripts() {
5079
5382
  "busy",
5080
5383
  );
5081
5384
  await fetchJson("/exports/transcripts", {
5082
- body: JSON.stringify({
5083
- folderId: state.selectedFolderId || undefined,
5084
- format: "text",
5085
- }),
5385
+ body: JSON.stringify(buildTranscriptsExportRequest(state.selectedFolderId)),
5086
5386
  headers: { "content-type": "application/json" },
5087
5387
  method: "POST",
5088
5388
  });
@@ -5386,50 +5686,15 @@ document.addEventListener("keydown", (event) => {
5386
5686
  return;
5387
5687
  }
5388
5688
 
5389
- const tabs = ["notes", "transcript", "metadata", "raw"];
5390
- if (event.key === "1") {
5391
- state.workspaceTab = "notes";
5392
- syncBrowserUrl();
5393
- renderMeetingDetail();
5394
- return;
5395
- }
5396
-
5397
- if (event.key === "2") {
5398
- state.workspaceTab = "transcript";
5399
- syncBrowserUrl();
5400
- renderMeetingDetail();
5401
- return;
5402
- }
5403
-
5404
- if (event.key === "3") {
5405
- state.workspaceTab = "metadata";
5406
- syncBrowserUrl();
5407
- renderMeetingDetail();
5408
- return;
5409
- }
5410
-
5411
- if (event.key === "4") {
5412
- state.workspaceTab = "raw";
5413
- syncBrowserUrl();
5414
- renderMeetingDetail();
5415
- return;
5416
- }
5417
-
5418
- const currentIndex = tabs.indexOf(state.workspaceTab);
5419
- if (event.key === "]") {
5420
- state.workspaceTab = tabs[(currentIndex + 1) % tabs.length];
5421
- syncBrowserUrl();
5422
- renderMeetingDetail();
5423
- }
5424
-
5425
- if (event.key === "[") {
5426
- state.workspaceTab = tabs[(currentIndex + tabs.length - 1) % tabs.length];
5689
+ const nextTab = nextWorkspaceTab(state.workspaceTab, event.key);
5690
+ if (nextTab) {
5691
+ state.workspaceTab = nextTab;
5427
5692
  syncBrowserUrl();
5428
5693
  renderMeetingDetail();
5429
5694
  }
5430
5695
  });
5431
5696
 
5432
- const initialSelection = startupSelection();
5697
+ const initialSelection = startupSelectionFromSearch(window.location.search);
5433
5698
  state.selectedFolderId = initialSelection.folderId || null;
5434
5699
  state.selectedMeetingId = initialSelection.meetingId || null;
5435
5700
  state.workspaceTab = initialSelection.workspaceTab;
@@ -6294,12 +6559,14 @@ async function startGranolaServer(app, options = {}) {
6294
6559
  exports: true,
6295
6560
  folders: true,
6296
6561
  meetingOpen: true,
6562
+ sync: true,
6297
6563
  webClient: enableWebClient
6298
6564
  },
6299
6565
  persistence: {
6300
6566
  exportJobs: true,
6301
6567
  meetingIndex: true,
6302
- sessionStore: defaultGranolaToolkitPersistenceLayout().sessionStoreKind
6568
+ sessionStore: defaultGranolaToolkitPersistenceLayout().sessionStoreKind,
6569
+ syncState: true
6303
6570
  },
6304
6571
  product: "granola-toolkit",
6305
6572
  protocolVersion: 2,
@@ -6395,6 +6662,11 @@ async function startGranolaServer(app, options = {}) {
6395
6662
  sendJson(response, await app.inspectAuth(), { headers: originHeaders });
6396
6663
  return;
6397
6664
  }
6665
+ if (method === "POST" && path === granolaTransportPaths.syncRun) {
6666
+ const body = await readJsonBody(request);
6667
+ sendJson(response, await app.sync({ forceRefresh: typeof body.forceRefresh === "boolean" ? body.forceRefresh : void 0 }), { headers: originHeaders });
6668
+ return;
6669
+ }
6398
6670
  if (method === "POST" && path === granolaTransportPaths.authLock) {
6399
6671
  sendJson(response, { ok: true }, { headers: {
6400
6672
  ...originHeaders,
@@ -6624,6 +6896,7 @@ function printWebRoutes() {
6624
6896
  console.log(" POST /exports/notes");
6625
6897
  console.log(" POST /exports/jobs/:id/rerun");
6626
6898
  console.log(" POST /exports/transcripts");
6899
+ console.log(" POST /sync");
6627
6900
  }
6628
6901
  async function runGranolaWebWorkspace(app, options) {
6629
6902
  const server = await startGranolaServer(app, {
@@ -7052,6 +7325,7 @@ const serveCommand = {
7052
7325
  console.log(" POST /exports/notes");
7053
7326
  console.log(" POST /exports/jobs/:id/rerun");
7054
7327
  console.log(" POST /exports/transcripts");
7328
+ console.log(" POST /sync");
7055
7329
  console.log(`Attach: granola attach ${server.url.href}`);
7056
7330
  if (password) console.log("Attach password: add --password <value>");
7057
7331
  await waitForShutdown(async () => await server.close());
@@ -7059,6 +7333,57 @@ const serveCommand = {
7059
7333
  }
7060
7334
  };
7061
7335
  //#endregion
7336
+ //#region src/commands/sync.ts
7337
+ function syncHelp() {
7338
+ return `Granola sync
7339
+
7340
+ Usage:
7341
+ granola sync [options]
7342
+
7343
+ Options:
7344
+ --cache <path> Path to Granola cache JSON
7345
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
7346
+ --supabase <path> Path to supabase.json
7347
+ --debug Enable debug logging
7348
+ --config <path> Path to .granola.toml
7349
+ -h, --help Show help
7350
+ `;
7351
+ }
7352
+ function pluralise(count, singular, plural = singular) {
7353
+ return `${count} ${count === 1 ? singular : plural}`;
7354
+ }
7355
+ const syncCommand = {
7356
+ description: "Refresh the local meeting index and sync state",
7357
+ flags: {
7358
+ cache: { type: "string" },
7359
+ help: { type: "boolean" },
7360
+ timeout: { type: "string" }
7361
+ },
7362
+ help: syncHelp,
7363
+ name: "sync",
7364
+ async run({ commandFlags, globalFlags }) {
7365
+ const config = await loadConfig({
7366
+ globalFlags,
7367
+ subcommandFlags: commandFlags
7368
+ });
7369
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
7370
+ debug(config.debug, "supabase", config.supabase);
7371
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
7372
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
7373
+ const app = await createGranolaApp(config);
7374
+ debug(config.debug, "authMode", app.getState().auth.mode);
7375
+ const result = await app.sync();
7376
+ console.log(`✓ Synced ${pluralise(result.summary.meetingCount, "meeting", "meetings")} across ${pluralise(result.summary.folderCount, "folder", "folders")} (${pluralise(result.summary.createdCount, "created")}, ${pluralise(result.summary.changedCount, "updated")}, ${pluralise(result.summary.removedCount, "removed")}, ${pluralise(result.summary.transcriptReadyCount, "transcript ready", "transcripts ready")})`);
7377
+ const lines = result.changes.slice(0, 10).map((change) => {
7378
+ return ` ${change.kind.padEnd(16)} ${change.title} (${change.meetingId})`;
7379
+ });
7380
+ for (const line of lines) console.log(line);
7381
+ if (result.changes.length > lines.length) console.log(` ...and ${result.changes.length - lines.length} more change(s)`);
7382
+ if (result.state.lastCompletedAt) debug(config.debug, "syncCompletedAt", result.state.lastCompletedAt);
7383
+ return 0;
7384
+ }
7385
+ };
7386
+ //#endregion
7062
7387
  //#region src/commands/tui.ts
7063
7388
  function tuiHelp() {
7064
7389
  return `Granola tui
@@ -7195,6 +7520,7 @@ const commands = [
7195
7520
  meetingCommand,
7196
7521
  notesCommand,
7197
7522
  serveCommand,
7523
+ syncCommand,
7198
7524
  tuiCommand,
7199
7525
  transcriptsCommand,
7200
7526
  {
@@ -7321,6 +7647,7 @@ Global options:
7321
7647
  Examples:
7322
7648
  granola attach http://127.0.0.1:4123
7323
7649
  granola folder list
7650
+ granola sync
7324
7651
  granola notes --supabase "${granolaSupabaseCandidates()[0] ?? "/path/to/supabase.json"}"
7325
7652
  granola transcripts --cache "${granolaCacheCandidates()[0] ?? "/path/to/cache-v3.json"}"
7326
7653
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.34.5",
3
+ "version": "0.35.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",