granola-toolkit 0.30.0 → 0.32.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 +30 -0
  2. package/dist/cli.js +718 -49
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -22,6 +22,8 @@ const granolaTransportPaths = {
22
22
  exportJobs: "/exports/jobs",
23
23
  exportNotes: "/exports/notes",
24
24
  exportTranscripts: "/exports/transcripts",
25
+ folderResolve: "/folders/resolve",
26
+ folders: "/folders",
25
27
  health: "/health",
26
28
  meetingResolve: "/meetings/resolve",
27
29
  meetings: "/meetings",
@@ -48,6 +50,7 @@ function granolaMeetingResolvePath(query, options = {}) {
48
50
  }
49
51
  function granolaMeetingsPath(options = {}) {
50
52
  return appendSearchParams(granolaTransportPaths.meetings, {
53
+ folderId: options.folderId,
51
54
  limit: options.limit,
52
55
  refresh: options.forceRefresh ? "true" : void 0,
53
56
  search: options.search,
@@ -56,6 +59,19 @@ function granolaMeetingsPath(options = {}) {
56
59
  updatedTo: options.updatedTo
57
60
  });
58
61
  }
62
+ function granolaFolderPath(id) {
63
+ return `${granolaTransportPaths.folders}/${encodeURIComponent(id)}`;
64
+ }
65
+ function granolaFolderResolvePath(query) {
66
+ return appendSearchParams(granolaTransportPaths.folderResolve, { q: query });
67
+ }
68
+ function granolaFoldersPath(options = {}) {
69
+ return appendSearchParams(granolaTransportPaths.folders, {
70
+ limit: options.limit,
71
+ refresh: options.forceRefresh ? "true" : void 0,
72
+ search: options.search
73
+ });
74
+ }
59
75
  function granolaExportJobsPath(options = {}) {
60
76
  return appendSearchParams(granolaTransportPaths.exportJobs, { limit: options.limit });
61
77
  }
@@ -131,7 +147,7 @@ var GranolaServerClient = class GranolaServerClient {
131
147
  }) });
132
148
  if (!infoResponse.ok) throw await responseError(infoResponse);
133
149
  const info = await infoResponse.json();
134
- if (info.protocolVersion !== 1) throw new Error(`unsupported Granola transport protocol: expected 1, got ${info.protocolVersion}`);
150
+ if (info.protocolVersion !== 2) throw new Error(`unsupported Granola transport protocol: expected 2, got ${info.protocolVersion}`);
135
151
  const response = await fetchImpl(new URL(granolaTransportPaths.state, url), { headers: mergeHeaders({
136
152
  ...options.password?.trim() ? { "x-granola-password": options.password.trim() } : {},
137
153
  accept: "application/json"
@@ -181,6 +197,15 @@ var GranolaServerClient = class GranolaServerClient {
181
197
  method: "POST"
182
198
  });
183
199
  }
200
+ async listFolders(options = {}) {
201
+ return await this.requestJson(granolaFoldersPath(options));
202
+ }
203
+ async getFolder(id) {
204
+ return await this.requestJson(granolaFolderPath(id));
205
+ }
206
+ async findFolder(query) {
207
+ return await this.requestJson(granolaFolderResolvePath(query));
208
+ }
184
209
  async listMeetings(options = {}) {
185
210
  return await this.requestJson(granolaMeetingsPath(options));
186
211
  }
@@ -1075,6 +1100,10 @@ function matchesMeetingSearch(document, search) {
1075
1100
  ...document.tags
1076
1101
  ].some((value) => value.toLowerCase().includes(query));
1077
1102
  }
1103
+ function matchesMeetingFolders(documentId, folderId, foldersByDocumentId) {
1104
+ if (!folderId) return true;
1105
+ return (foldersByDocumentId?.get(documentId) ?? []).some((folder) => folder.id === folderId);
1106
+ }
1078
1107
  function matchesMeetingSummarySearch(meeting, search) {
1079
1108
  const query = search.trim().toLowerCase();
1080
1109
  if (!query) return true;
@@ -1084,6 +1113,9 @@ function matchesMeetingSummarySearch(meeting, search) {
1084
1113
  ...meeting.tags
1085
1114
  ].some((value) => value.toLowerCase().includes(query));
1086
1115
  }
1116
+ function meetingFolders(meeting) {
1117
+ return Array.isArray(meeting.folders) ? meeting.folders : [];
1118
+ }
1087
1119
  function parseDateFilter(value, label) {
1088
1120
  const trimmed = value?.trim();
1089
1121
  if (!trimmed) return;
@@ -1110,7 +1142,7 @@ function matchesMeetingSummaryUpdatedRange(meeting, updatedFrom, updatedTo) {
1110
1142
  if (to != null && updatedAt > to) return false;
1111
1143
  return true;
1112
1144
  }
1113
- function truncate(value, width) {
1145
+ function truncate$1(value, width) {
1114
1146
  if (value.length <= width) return value.padEnd(width);
1115
1147
  return `${value.slice(0, Math.max(0, width - 1))}…`;
1116
1148
  }
@@ -1126,11 +1158,12 @@ function formatTranscriptLines(transcript) {
1126
1158
  if (!transcript || transcript.segments.length === 0) return "";
1127
1159
  return transcript.segments.map((segment) => `[${formatTimestampForTranscript(segment.startTimestamp)}] ${segment.speaker}: ${segment.text}`).join("\n");
1128
1160
  }
1129
- function buildMeetingSummary(document, cacheData) {
1161
+ function buildMeetingSummary(document, cacheData, folders = []) {
1130
1162
  const note = buildNoteExport(document);
1131
1163
  const transcript = buildMeetingTranscript(document, cacheData);
1132
1164
  return {
1133
1165
  createdAt: document.createdAt,
1166
+ folders: folders.map((folder) => ({ ...folder })),
1134
1167
  id: document.id,
1135
1168
  noteContentSource: note.contentSource,
1136
1169
  tags: [...document.tags],
@@ -1140,12 +1173,13 @@ function buildMeetingSummary(document, cacheData) {
1140
1173
  updatedAt: latestDocumentTimestamp(document)
1141
1174
  };
1142
1175
  }
1143
- function buildMeetingRecord(document, cacheData) {
1176
+ function buildMeetingRecord(document, cacheData, folders = []) {
1144
1177
  const note = buildNoteExport(document);
1145
1178
  const transcript = buildMeetingTranscript(document, cacheData);
1146
1179
  return {
1147
1180
  meeting: {
1148
1181
  createdAt: document.createdAt,
1182
+ folders: folders.map((folder) => ({ ...folder })),
1149
1183
  id: document.id,
1150
1184
  noteContentSource: note.contentSource,
1151
1185
  tags: [...document.tags],
@@ -1163,13 +1197,14 @@ function buildMeetingRecord(document, cacheData) {
1163
1197
  function listMeetings(documents, options = {}) {
1164
1198
  const limit = options.limit ?? 20;
1165
1199
  const sort = options.sort ?? "updated-desc";
1166
- return documents.filter((document) => options.search ? matchesMeetingSearch(document, options.search) : true).filter((document) => matchesUpdatedRange(document, options.updatedFrom, options.updatedTo)).sort((left, right) => compareMeetingDocumentsBySort(left, right, sort)).slice(0, limit).map((document) => buildMeetingSummary(document, options.cacheData));
1200
+ return documents.filter((document) => options.search ? matchesMeetingSearch(document, options.search) : true).filter((document) => matchesMeetingFolders(document.id, options.folderId, options.foldersByDocumentId)).filter((document) => matchesUpdatedRange(document, options.updatedFrom, options.updatedTo)).sort((left, right) => compareMeetingDocumentsBySort(left, right, sort)).slice(0, limit).map((document) => buildMeetingSummary(document, options.cacheData, options.foldersByDocumentId?.get(document.id)));
1167
1201
  }
1168
1202
  function filterMeetingSummaries(meetings, options = {}) {
1169
1203
  const limit = options.limit ?? 20;
1170
1204
  const sort = options.sort ?? "updated-desc";
1171
- return meetings.filter((meeting) => options.search ? matchesMeetingSummarySearch(meeting, options.search) : true).filter((meeting) => matchesMeetingSummaryUpdatedRange(meeting, options.updatedFrom, options.updatedTo)).sort((left, right) => compareMeetingSummariesBySort(left, right, sort)).slice(0, limit).map((meeting) => ({
1205
+ return meetings.filter((meeting) => options.folderId ? meetingFolders(meeting).some((folder) => folder.id === options.folderId) : true).filter((meeting) => options.search ? matchesMeetingSummarySearch(meeting, options.search) : true).filter((meeting) => matchesMeetingSummaryUpdatedRange(meeting, options.updatedFrom, options.updatedTo)).sort((left, right) => compareMeetingSummariesBySort(left, right, sort)).slice(0, limit).map((meeting) => ({
1172
1206
  ...meeting,
1207
+ folders: meetingFolders(meeting).map((folder) => ({ ...folder })),
1173
1208
  tags: [...meeting.tags]
1174
1209
  }));
1175
1210
  }
@@ -1206,14 +1241,18 @@ function renderMeetingList(meetings, format = "text") {
1206
1241
  case "text": break;
1207
1242
  }
1208
1243
  if (meetings.length === 0) return "No meetings found\n";
1209
- const lines = [`${"ID".padEnd(10)} ${"DATE".padEnd(10)} ${"TITLE".padEnd(42)} ${"NOTE".padEnd(18)} TRANSCRIPT`, `${"-".repeat(10)} ${"-".repeat(10)} ${"-".repeat(42)} ${"-".repeat(18)} ${"-".repeat(10)}`];
1210
- for (const meeting of meetings) lines.push([
1211
- meeting.id.slice(0, 8).padEnd(10),
1212
- formatMeetingDate(meeting.updatedAt || meeting.createdAt).padEnd(10),
1213
- truncate(meeting.title || meeting.id, 42),
1214
- truncate(meeting.noteContentSource, 18),
1215
- formatTranscriptStatus(meeting)
1216
- ].join(" "));
1244
+ const lines = [`${"ID".padEnd(10)} ${"DATE".padEnd(10)} ${"TITLE".padEnd(34)} ${"FOLDERS".padEnd(18)} ${"NOTE".padEnd(18)} TRANSCRIPT`, `${"-".repeat(10)} ${"-".repeat(10)} ${"-".repeat(34)} ${"-".repeat(18)} ${"-".repeat(18)} ${"-".repeat(10)}`];
1245
+ for (const meeting of meetings) {
1246
+ const folderLabel = meetingFolders(meeting).length === 0 ? "-" : meetingFolders(meeting).length === 1 ? meetingFolders(meeting)[0].name : `${meetingFolders(meeting)[0].name} +${meetingFolders(meeting).length - 1}`;
1247
+ lines.push([
1248
+ meeting.id.slice(0, 8).padEnd(10),
1249
+ formatMeetingDate(meeting.updatedAt || meeting.createdAt).padEnd(10),
1250
+ truncate$1(meeting.title || meeting.id, 34),
1251
+ truncate$1(folderLabel, 18),
1252
+ truncate$1(meeting.noteContentSource, 18),
1253
+ formatTranscriptStatus(meeting)
1254
+ ].join(" "));
1255
+ }
1217
1256
  return `${lines.join("\n").trimEnd()}\n`;
1218
1257
  }
1219
1258
  function renderMeetingView(record, format = "text") {
@@ -1223,6 +1262,7 @@ function renderMeetingView(record, format = "text") {
1223
1262
  case "text": break;
1224
1263
  }
1225
1264
  const tags = record.meeting.tags.length > 0 ? record.meeting.tags.join(", ") : "(none)";
1265
+ const folders = meetingFolders(record.meeting).length > 0 ? meetingFolders(record.meeting).map((folder) => folder.name).join(", ") : "(none)";
1226
1266
  const transcriptStatus = !record.meeting.transcriptLoaded ? "cache not loaded" : record.meeting.transcriptSegmentCount === 0 ? "no transcript segments" : `${record.meeting.transcriptSegmentCount} segment(s)`;
1227
1267
  return `${[
1228
1268
  `# ${record.meeting.title || record.meeting.id}`,
@@ -1231,6 +1271,7 @@ function renderMeetingView(record, format = "text") {
1231
1271
  `Created: ${record.meeting.createdAt || "-"}`,
1232
1272
  `Updated: ${record.meeting.updatedAt || "-"}`,
1233
1273
  `Tags: ${tags}`,
1274
+ `Folders: ${folders}`,
1234
1275
  `Note source: ${record.meeting.noteContentSource}`,
1235
1276
  `Transcript: ${transcriptStatus}`,
1236
1277
  "",
@@ -2591,10 +2632,39 @@ function parseDocument(value) {
2591
2632
  updatedAt: stringValue(record.updated_at)
2592
2633
  };
2593
2634
  }
2635
+ function parseFolderDocumentIds(value) {
2636
+ if (!Array.isArray(value)) return [];
2637
+ return value.map((item) => {
2638
+ if (typeof item === "string") return item;
2639
+ const record = asRecord(item);
2640
+ if (!record) return "";
2641
+ return stringValue(record.id) || stringValue(record.document_id);
2642
+ }).filter(Boolean);
2643
+ }
2644
+ function parseFolder(value) {
2645
+ const record = asRecord(value);
2646
+ if (!record) throw new Error("folder payload is not an object");
2647
+ const createdAt = stringValue(record.created_at);
2648
+ const updatedAt = stringValue(record.updated_at) || createdAt;
2649
+ const name = stringValue(record.name) || stringValue(record.title);
2650
+ const documents = parseFolderDocumentIds(record.documents);
2651
+ const documentIds = documents.length > 0 ? documents : parseFolderDocumentIds(record.document_ids);
2652
+ return {
2653
+ createdAt,
2654
+ description: stringValue(record.description) || void 0,
2655
+ documentIds,
2656
+ id: stringValue(record.id),
2657
+ isFavourite: Boolean(record.is_favourite),
2658
+ name,
2659
+ updatedAt,
2660
+ workspaceId: stringValue(record.workspace_id) || void 0
2661
+ };
2662
+ }
2594
2663
  //#endregion
2595
2664
  //#region src/client/granola.ts
2596
2665
  const DEFAULT_CLIENT_VERSION = "5.354.0";
2597
2666
  const DOCUMENTS_URL = "https://api.granola.ai/v2/get-documents";
2667
+ const FOLDERS_URLS = ["https://api.granola.ai/v2/get-document-lists", "https://api.granola.ai/v1/get-document-lists"];
2598
2668
  function resolveClientVersion(value) {
2599
2669
  return value?.trim() || process.env.GRANOLA_CLIENT_VERSION?.trim() || DEFAULT_CLIENT_VERSION;
2600
2670
  }
@@ -2640,6 +2710,29 @@ var GranolaApiClient = class {
2640
2710
  }
2641
2711
  return documents;
2642
2712
  }
2713
+ async listFolders(options) {
2714
+ let lastError;
2715
+ for (const foldersUrl of FOLDERS_URLS) {
2716
+ const response = await this.httpClient.postJson(foldersUrl, {}, {
2717
+ headers: {
2718
+ "User-Agent": `Granola/${this.clientVersion}`,
2719
+ "X-Client-Version": this.clientVersion
2720
+ },
2721
+ timeoutMs: options.timeoutMs
2722
+ });
2723
+ if (response.status === 404) {
2724
+ lastError = /* @__PURE__ */ new Error(`failed to get folders: ${response.status} ${response.statusText}`);
2725
+ continue;
2726
+ }
2727
+ if (!response.ok) {
2728
+ const body = (await response.text()).slice(0, 500);
2729
+ throw new Error(`failed to get folders: ${response.status} ${response.statusText}${body ? `: ${body}` : ""}`);
2730
+ }
2731
+ const payload = await response.json();
2732
+ return (Array.isArray(payload) ? payload : Array.isArray(payload.lists) ? payload.lists : Array.isArray(payload.document_lists) ? payload.document_lists : []).map(parseFolder);
2733
+ }
2734
+ throw lastError ?? /* @__PURE__ */ new Error("failed to get folders");
2735
+ }
2643
2736
  };
2644
2737
  //#endregion
2645
2738
  //#region src/client/http.ts
@@ -2826,8 +2919,121 @@ function createDefaultExportJobStore() {
2826
2919
  return new FileExportJobStore();
2827
2920
  }
2828
2921
  //#endregion
2922
+ //#region src/folders.ts
2923
+ function truncate(value, width) {
2924
+ if (value.length <= width) return value.padEnd(width);
2925
+ return `${value.slice(0, Math.max(0, width - 1))}…`;
2926
+ }
2927
+ function compareFolders(left, right) {
2928
+ return right.updatedAt.localeCompare(left.updatedAt) || compareStrings(left.name || left.id, right.name || right.id) || compareStrings(left.id, right.id);
2929
+ }
2930
+ function matchesFolderSearch(folder, search) {
2931
+ const query = search.trim().toLowerCase();
2932
+ if (!query) return true;
2933
+ return [
2934
+ folder.id,
2935
+ folder.name,
2936
+ folder.description ?? "",
2937
+ folder.workspaceId ?? ""
2938
+ ].some((value) => value.toLowerCase().includes(query));
2939
+ }
2940
+ function formatFolderDate(value) {
2941
+ return value.trim().slice(0, 10) || "-";
2942
+ }
2943
+ function buildFolderSummary(folder) {
2944
+ return {
2945
+ createdAt: folder.createdAt,
2946
+ description: folder.description,
2947
+ documentCount: folder.documentIds.length,
2948
+ id: folder.id,
2949
+ isFavourite: folder.isFavourite,
2950
+ name: folder.name,
2951
+ updatedAt: folder.updatedAt,
2952
+ workspaceId: folder.workspaceId
2953
+ };
2954
+ }
2955
+ function buildFolderRecord(folder, meetings) {
2956
+ return {
2957
+ ...buildFolderSummary(folder),
2958
+ documentIds: [...folder.documentIds],
2959
+ meetings: meetings.map((meeting) => ({
2960
+ ...meeting,
2961
+ folders: meeting.folders.map((candidate) => ({ ...candidate })),
2962
+ tags: [...meeting.tags]
2963
+ }))
2964
+ };
2965
+ }
2966
+ function filterFolders(folders, options = {}) {
2967
+ const limit = options.limit ?? 20;
2968
+ return folders.filter((folder) => options.search ? matchesFolderSearch(folder, options.search) : true).sort(compareFolders).slice(0, limit).map((folder) => ({ ...folder }));
2969
+ }
2970
+ function resolveFolder(folders, id) {
2971
+ const exactMatch = folders.find((folder) => folder.id === id);
2972
+ if (exactMatch) return exactMatch;
2973
+ const matches = folders.filter((folder) => folder.id.startsWith(id));
2974
+ if (matches.length === 1) return matches[0];
2975
+ if (matches.length > 1) throw new Error(`ambiguous folder id: ${id}`);
2976
+ throw new Error(`folder not found: ${id}`);
2977
+ }
2978
+ function resolveFolderQuery(folders, query) {
2979
+ const trimmed = query.trim();
2980
+ if (!trimmed) throw new Error("folder query is required");
2981
+ const lower = trimmed.toLowerCase();
2982
+ const exactId = folders.find((folder) => folder.id === trimmed);
2983
+ if (exactId) return exactId;
2984
+ const exactNameMatches = folders.filter((folder) => folder.name.toLowerCase() === lower);
2985
+ if (exactNameMatches.length === 1) return exactNameMatches[0];
2986
+ const prefixMatches = folders.filter((folder) => folder.id.startsWith(trimmed));
2987
+ if (prefixMatches.length === 1) return prefixMatches[0];
2988
+ const nameMatches = folders.filter((folder) => folder.name.toLowerCase().includes(lower)).sort(compareFolders);
2989
+ if (nameMatches.length === 1) return nameMatches[0];
2990
+ if (exactNameMatches.length > 1 || prefixMatches.length > 1 || nameMatches.length > 1) throw new Error(`ambiguous folder query: ${trimmed}`);
2991
+ throw new Error(`folder not found: ${trimmed}`);
2992
+ }
2993
+ function renderFolderList(folders, format = "text") {
2994
+ switch (format) {
2995
+ case "json": return toJson(folders);
2996
+ case "yaml": return toYaml(folders);
2997
+ case "text": break;
2998
+ }
2999
+ if (folders.length === 0) return "No folders found\n";
3000
+ const lines = [`${"ID".padEnd(10)} ${"COUNT".padEnd(7)} ${"UPDATED".padEnd(10)} NAME`, `${"-".repeat(10)} ${"-".repeat(7)} ${"-".repeat(10)} ${"-".repeat(42)}`];
3001
+ for (const folder of folders) {
3002
+ const name = `${folder.isFavourite ? "★ " : ""}${folder.name || folder.id}`;
3003
+ lines.push([
3004
+ folder.id.slice(0, 8).padEnd(10),
3005
+ String(folder.documentCount).padEnd(7),
3006
+ formatFolderDate(folder.updatedAt || folder.createdAt).padEnd(10),
3007
+ truncate(name, 42)
3008
+ ].join(" "));
3009
+ }
3010
+ return `${lines.join("\n").trimEnd()}\n`;
3011
+ }
3012
+ function renderFolderView(folder, format = "text") {
3013
+ switch (format) {
3014
+ case "json": return toJson(folder);
3015
+ case "yaml": return toYaml(folder);
3016
+ case "text": break;
3017
+ }
3018
+ const lines = [
3019
+ `# ${folder.name || folder.id}`,
3020
+ "",
3021
+ `ID: ${folder.id}`,
3022
+ `Created: ${folder.createdAt || "-"}`,
3023
+ `Updated: ${folder.updatedAt || "-"}`,
3024
+ `Documents: ${folder.documentCount}`,
3025
+ `Favourite: ${folder.isFavourite ? "yes" : "no"}`,
3026
+ `Workspace: ${folder.workspaceId || "-"}`
3027
+ ];
3028
+ if (folder.description) lines.push(`Description: ${folder.description}`);
3029
+ lines.push("", "## Meetings", "");
3030
+ lines.push(renderMeetingList(folder.meetings, "text").trimEnd());
3031
+ lines.push("");
3032
+ return `${lines.join("\n").trimEnd()}\n`;
3033
+ }
3034
+ //#endregion
2829
3035
  //#region src/meeting-index.ts
2830
- const MEETING_INDEX_VERSION = 1;
3036
+ const MEETING_INDEX_VERSION = 2;
2831
3037
  var FileMeetingIndexStore = class {
2832
3038
  constructor(filePath = defaultMeetingIndexFilePath()) {
2833
3039
  this.filePath = filePath;
@@ -2838,6 +3044,7 @@ var FileMeetingIndexStore = class {
2838
3044
  if (!parsed || parsed.version !== MEETING_INDEX_VERSION || !Array.isArray(parsed.meetings)) return [];
2839
3045
  return parsed.meetings.map((meeting) => ({
2840
3046
  ...meeting,
3047
+ folders: Array.isArray(meeting.folders) ? meeting.folders.map((folder) => ({ ...folder })) : [],
2841
3048
  tags: [...meeting.tags]
2842
3049
  }));
2843
3050
  } catch {
@@ -2849,6 +3056,7 @@ var FileMeetingIndexStore = class {
2849
3056
  const payload = {
2850
3057
  meetings: meetings.map((meeting) => ({
2851
3058
  ...meeting,
3059
+ folders: Array.isArray(meeting.folders) ? meeting.folders.map((folder) => ({ ...folder })) : [],
2852
3060
  tags: [...meeting.tags]
2853
3061
  })),
2854
3062
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2877,6 +3085,16 @@ function cloneExportState(state) {
2877
3085
  function cloneExportJob(job) {
2878
3086
  return { ...job };
2879
3087
  }
3088
+ function cloneFolderSummary(folder) {
3089
+ return { ...folder };
3090
+ }
3091
+ function cloneMeetingSummary(meeting) {
3092
+ return {
3093
+ ...meeting,
3094
+ folders: Array.isArray(meeting.folders) ? meeting.folders.map((folder) => cloneFolderSummary(folder)) : [],
3095
+ tags: [...meeting.tags]
3096
+ };
3097
+ }
2880
3098
  function cloneState(state) {
2881
3099
  return {
2882
3100
  auth: { ...state.auth },
@@ -2887,6 +3105,7 @@ function cloneState(state) {
2887
3105
  transcripts: { ...state.config.transcripts }
2888
3106
  },
2889
3107
  documents: { ...state.documents },
3108
+ folders: { ...state.folders },
2890
3109
  exports: {
2891
3110
  jobs: state.exports.jobs.map((job) => cloneExportJob(job)),
2892
3111
  notes: cloneExportState(state.exports.notes),
@@ -2915,6 +3134,10 @@ function defaultState(config, auth, surface) {
2915
3134
  count: 0,
2916
3135
  loaded: false
2917
3136
  },
3137
+ folders: {
3138
+ count: 0,
3139
+ loaded: false
3140
+ },
2918
3141
  exports: { jobs: [] },
2919
3142
  index: {
2920
3143
  available: false,
@@ -2931,6 +3154,7 @@ function defaultState(config, auth, surface) {
2931
3154
  var GranolaApp = class {
2932
3155
  #cacheData;
2933
3156
  #cacheResolved = false;
3157
+ #folders;
2934
3158
  #granolaClient;
2935
3159
  #documents;
2936
3160
  #meetingIndex;
@@ -2942,10 +3166,7 @@ var GranolaApp = class {
2942
3166
  this.deps = deps;
2943
3167
  this.#state = defaultState(config, deps.auth, options.surface ?? "cli");
2944
3168
  this.#state.exports.jobs = (deps.exportJobs ?? []).map((job) => cloneExportJob(job));
2945
- this.#meetingIndex = (deps.meetingIndex ?? []).map((meeting) => ({
2946
- ...meeting,
2947
- tags: [...meeting.tags]
2948
- }));
3169
+ this.#meetingIndex = (deps.meetingIndex ?? []).map((meeting) => cloneMeetingSummary(meeting));
2949
3170
  this.#state.index = {
2950
3171
  available: this.#meetingIndex.length > 0,
2951
3172
  filePath: defaultMeetingIndexFilePath(),
@@ -2971,16 +3192,21 @@ var GranolaApp = class {
2971
3192
  this.emitStateUpdate();
2972
3193
  return this.getState();
2973
3194
  }
2974
- resetDocumentsState() {
3195
+ resetRemoteState() {
2975
3196
  this.#granolaClient = void 0;
3197
+ this.#folders = void 0;
2976
3198
  this.#documents = void 0;
2977
3199
  this.#state.documents = {
2978
3200
  count: 0,
2979
3201
  loaded: false
2980
3202
  };
3203
+ this.#state.folders = {
3204
+ count: 0,
3205
+ loaded: false
3206
+ };
2981
3207
  }
2982
3208
  applyAuthState(auth, options = {}) {
2983
- if (options.resetDocuments) this.resetDocumentsState();
3209
+ if (options.resetDocuments) this.resetRemoteState();
2984
3210
  this.#state.auth = { ...auth };
2985
3211
  if (options.view) this.#state.ui = {
2986
3212
  ...this.#state.ui,
@@ -2990,10 +3216,7 @@ var GranolaApp = class {
2990
3216
  return { ...auth };
2991
3217
  }
2992
3218
  async persistMeetingIndex(meetings) {
2993
- this.#meetingIndex = meetings.map((meeting) => ({
2994
- ...meeting,
2995
- tags: [...meeting.tags]
2996
- }));
3219
+ this.#meetingIndex = meetings.map((meeting) => cloneMeetingSummary(meeting));
2997
3220
  this.#state.index = {
2998
3221
  available: this.#meetingIndex.length > 0,
2999
3222
  filePath: this.#state.index.filePath,
@@ -3007,8 +3230,10 @@ var GranolaApp = class {
3007
3230
  async refreshMeetingIndexFromLiveData() {
3008
3231
  const cacheData = await this.loadCache();
3009
3232
  const documents = await this.listDocuments();
3233
+ const folders = await this.loadFolders();
3010
3234
  const meetings = listMeetings(documents, {
3011
3235
  cacheData,
3236
+ foldersByDocumentId: this.buildFoldersByDocumentId(folders),
3012
3237
  limit: documents.length || 1,
3013
3238
  sort: "updated-desc"
3014
3239
  });
@@ -3047,6 +3272,54 @@ var GranolaApp = class {
3047
3272
  this.applyAuthState(runtime.auth);
3048
3273
  return this.#granolaClient;
3049
3274
  }
3275
+ buildFoldersByDocumentId(folders) {
3276
+ if (!folders || folders.length === 0) return;
3277
+ const byDocumentId = /* @__PURE__ */ new Map();
3278
+ for (const folder of folders) {
3279
+ const summary = buildFolderSummary(folder);
3280
+ for (const documentId of folder.documentIds) {
3281
+ const existing = byDocumentId.get(documentId) ?? [];
3282
+ existing.push(summary);
3283
+ byDocumentId.set(documentId, existing);
3284
+ }
3285
+ }
3286
+ for (const [documentId, summaries] of byDocumentId.entries()) byDocumentId.set(documentId, summaries.slice().sort((left, right) => left.name.localeCompare(right.name)).map((folder) => cloneFolderSummary(folder)));
3287
+ return byDocumentId;
3288
+ }
3289
+ async loadFolders(options = {}) {
3290
+ if (options.forceRefresh) {
3291
+ this.resetRemoteState();
3292
+ this.emitStateUpdate();
3293
+ }
3294
+ if (this.#folders) return this.#folders.map((folder) => ({
3295
+ ...folder,
3296
+ documentIds: [...folder.documentIds]
3297
+ }));
3298
+ const client = await this.getGranolaClient();
3299
+ if (!client.listFolders) {
3300
+ if (options.required) throw new Error("Granola folder API is not configured");
3301
+ return;
3302
+ }
3303
+ try {
3304
+ this.#folders = (await client.listFolders({ timeoutMs: this.config.notes.timeoutMs })).map((folder) => ({
3305
+ ...folder,
3306
+ documentIds: [...folder.documentIds]
3307
+ }));
3308
+ this.#state.folders = {
3309
+ count: this.#folders.length,
3310
+ loaded: true,
3311
+ loadedAt: this.nowIso()
3312
+ };
3313
+ this.emitStateUpdate();
3314
+ return this.#folders.map((folder) => ({
3315
+ ...folder,
3316
+ documentIds: [...folder.documentIds]
3317
+ }));
3318
+ } catch (error) {
3319
+ if (options.required) throw error;
3320
+ return;
3321
+ }
3322
+ }
3050
3323
  missingCacheError() {
3051
3324
  return /* @__PURE__ */ new Error(`Granola cache file not found. Pass --cache or create .granola.toml. Expected locations include: ${granolaCacheCandidates().join(", ")}`);
3052
3325
  }
@@ -3159,12 +3432,7 @@ var GranolaApp = class {
3159
3432
  }
3160
3433
  async listDocuments(options = {}) {
3161
3434
  if (options.forceRefresh) {
3162
- this.#granolaClient = void 0;
3163
- this.#documents = void 0;
3164
- this.#state.documents = {
3165
- count: 0,
3166
- loaded: false
3167
- };
3435
+ this.resetRemoteState();
3168
3436
  this.emitStateUpdate();
3169
3437
  }
3170
3438
  if (this.#documents) return this.#documents;
@@ -3205,16 +3473,57 @@ var GranolaApp = class {
3205
3473
  if (options.required && !cacheData) throw this.missingCacheError();
3206
3474
  return cacheData;
3207
3475
  }
3476
+ async listFolders(options = {}) {
3477
+ const summaries = filterFolders((await this.loadFolders({
3478
+ forceRefresh: options.forceRefresh,
3479
+ required: true
3480
+ }) ?? []).map((folder) => buildFolderSummary(folder)), {
3481
+ limit: options.limit,
3482
+ search: options.search
3483
+ });
3484
+ this.setUiState({
3485
+ folderSearch: options.search,
3486
+ selectedFolderId: void 0,
3487
+ view: "folder-list"
3488
+ });
3489
+ return { folders: summaries };
3490
+ }
3491
+ async getFolder(id) {
3492
+ const folders = await this.loadFolders({ required: true });
3493
+ const cacheData = await this.loadCache();
3494
+ const documents = await this.listDocuments();
3495
+ const folder = resolveFolder((folders ?? []).map((folder) => buildFolderSummary(folder)), id);
3496
+ const rawFolder = (folders ?? []).find((candidate) => candidate.id === folder.id);
3497
+ if (!rawFolder) throw new Error(`folder not found: ${id}`);
3498
+ const record = buildFolderRecord(rawFolder, listMeetings(documents, {
3499
+ cacheData,
3500
+ folderId: folder.id,
3501
+ foldersByDocumentId: this.buildFoldersByDocumentId(folders),
3502
+ limit: Math.max(rawFolder.documentIds.length, 1),
3503
+ sort: "updated-desc"
3504
+ }));
3505
+ this.setUiState({
3506
+ selectedFolderId: folder.id,
3507
+ view: "folder-detail"
3508
+ });
3509
+ return record;
3510
+ }
3511
+ async findFolder(query) {
3512
+ const summary = resolveFolderQuery((await this.loadFolders({ required: true }) ?? []).map((folder) => buildFolderSummary(folder)), query);
3513
+ return await this.getFolder(summary.id);
3514
+ }
3208
3515
  async listMeetings(options = {}) {
3209
3516
  const preferIndex = options.preferIndex ?? (this.#state.ui.surface === "web" || this.#state.ui.surface === "server");
3210
3517
  if (!options.forceRefresh && preferIndex && !this.#documents && this.#meetingIndex.length > 0) {
3211
3518
  const meetings = filterMeetingSummaries(this.#meetingIndex, options);
3212
3519
  this.setUiState({
3520
+ folderSearch: void 0,
3213
3521
  meetingListSource: "index",
3214
3522
  meetingSearch: options.search,
3215
3523
  meetingSort: options.sort,
3216
3524
  meetingUpdatedFrom: options.updatedFrom,
3217
3525
  meetingUpdatedTo: options.updatedTo,
3526
+ selectedFolderId: options.folderId,
3218
3527
  selectedMeetingId: void 0,
3219
3528
  view: "meeting-list"
3220
3529
  });
@@ -3226,8 +3535,14 @@ var GranolaApp = class {
3226
3535
  }
3227
3536
  const cacheData = await this.loadCache();
3228
3537
  const documents = await this.listDocuments({ forceRefresh: options.forceRefresh });
3538
+ const folders = await this.loadFolders({
3539
+ forceRefresh: options.forceRefresh,
3540
+ required: Boolean(options.folderId)
3541
+ });
3229
3542
  const meetings = listMeetings(documents, {
3230
3543
  cacheData,
3544
+ folderId: options.folderId,
3545
+ foldersByDocumentId: this.buildFoldersByDocumentId(folders),
3231
3546
  limit: options.limit,
3232
3547
  search: options.search,
3233
3548
  sort: options.sort,
@@ -3236,15 +3551,18 @@ var GranolaApp = class {
3236
3551
  });
3237
3552
  await this.persistMeetingIndex(listMeetings(documents, {
3238
3553
  cacheData,
3554
+ foldersByDocumentId: this.buildFoldersByDocumentId(folders),
3239
3555
  limit: Math.max(documents.length, 1),
3240
3556
  sort: "updated-desc"
3241
3557
  }));
3242
3558
  this.setUiState({
3559
+ folderSearch: void 0,
3243
3560
  meetingListSource: "live",
3244
3561
  meetingSearch: options.search,
3245
3562
  meetingSort: options.sort,
3246
3563
  meetingUpdatedFrom: options.updatedFrom,
3247
3564
  meetingUpdatedTo: options.updatedTo,
3565
+ selectedFolderId: options.folderId,
3248
3566
  selectedMeetingId: void 0,
3249
3567
  view: "meeting-list"
3250
3568
  });
@@ -3256,9 +3574,11 @@ var GranolaApp = class {
3256
3574
  async getMeeting(id, options = {}) {
3257
3575
  const documents = await this.listDocuments();
3258
3576
  const cacheData = await this.loadCache({ required: options.requireCache });
3577
+ const folders = await this.loadFolders();
3259
3578
  const document = resolveMeeting(documents, id);
3260
- const meeting = buildMeetingRecord(document, cacheData);
3579
+ const meeting = buildMeetingRecord(document, cacheData, this.buildFoldersByDocumentId(folders)?.get(document.id));
3261
3580
  this.setUiState({
3581
+ selectedFolderId: meeting.meeting.folders[0]?.id,
3262
3582
  selectedMeetingId: document.id,
3263
3583
  view: "meeting-detail"
3264
3584
  });
@@ -3271,9 +3591,11 @@ var GranolaApp = class {
3271
3591
  async findMeeting(query, options = {}) {
3272
3592
  const documents = await this.listDocuments();
3273
3593
  const cacheData = await this.loadCache({ required: options.requireCache });
3594
+ const folders = await this.loadFolders();
3274
3595
  const document = resolveMeetingQuery(documents, query);
3275
- const meeting = buildMeetingRecord(document, cacheData);
3596
+ const meeting = buildMeetingRecord(document, cacheData, this.buildFoldersByDocumentId(folders)?.get(document.id));
3276
3597
  this.setUiState({
3598
+ selectedFolderId: meeting.meeting.folders[0]?.id,
3277
3599
  selectedMeetingId: document.id,
3278
3600
  view: "meeting-detail"
3279
3601
  });
@@ -3670,7 +3992,7 @@ function resolveListFormat$1(value) {
3670
3992
  default: throw new Error("invalid exports format: expected text, json, or yaml");
3671
3993
  }
3672
3994
  }
3673
- function parseLimit$1(value) {
3995
+ function parseLimit$2(value) {
3674
3996
  if (value === void 0) return 20;
3675
3997
  if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid exports limit: expected a positive integer");
3676
3998
  const limit = Number(value);
@@ -3699,7 +4021,7 @@ const exportsCommand = {
3699
4021
  async run({ commandArgs, commandFlags, globalFlags }) {
3700
4022
  const [action, id] = commandArgs;
3701
4023
  switch (action) {
3702
- case "list": return await list$1(commandFlags, globalFlags);
4024
+ case "list": return await list$2(commandFlags, globalFlags);
3703
4025
  case "rerun":
3704
4026
  if (!id) throw new Error("exports rerun requires a job id");
3705
4027
  return await rerun(id, commandFlags, globalFlags);
@@ -3710,9 +4032,9 @@ const exportsCommand = {
3710
4032
  }
3711
4033
  }
3712
4034
  };
3713
- async function list$1(commandFlags, globalFlags) {
4035
+ async function list$2(commandFlags, globalFlags) {
3714
4036
  const format = resolveListFormat$1(commandFlags.format);
3715
- const limit = parseLimit$1(commandFlags.limit);
4037
+ const limit = parseLimit$2(commandFlags.limit);
3716
4038
  const config = await loadConfig({
3717
4039
  globalFlags,
3718
4040
  subcommandFlags: commandFlags
@@ -3739,6 +4061,110 @@ async function rerun(id, commandFlags, globalFlags) {
3739
4061
  return 0;
3740
4062
  }
3741
4063
  //#endregion
4064
+ //#region src/commands/folder.ts
4065
+ function folderHelp() {
4066
+ return `Granola folder
4067
+
4068
+ Usage:
4069
+ granola folder <list|view> [options]
4070
+
4071
+ Subcommands:
4072
+ list List folders from the Granola API
4073
+ view <id|name> Show one folder and its meetings
4074
+
4075
+ Options:
4076
+ --format <value> text, json, yaml
4077
+ --limit <n> Number of folders for list (default: 20)
4078
+ --search <query> Filter folders by id, name, or description
4079
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
4080
+ --supabase <path> Path to supabase.json
4081
+ --debug Enable debug logging
4082
+ --config <path> Path to .granola.toml
4083
+ -h, --help Show help
4084
+ `;
4085
+ }
4086
+ function resolveFolderListFormat(value) {
4087
+ switch (value) {
4088
+ case void 0: return "text";
4089
+ case "json":
4090
+ case "text":
4091
+ case "yaml": return value;
4092
+ default: throw new Error("invalid folder format: expected text, json, or yaml");
4093
+ }
4094
+ }
4095
+ function resolveFolderDetailFormat(value) {
4096
+ return resolveFolderListFormat(value);
4097
+ }
4098
+ function parseLimit$1(value) {
4099
+ if (value === void 0) return 20;
4100
+ if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid folder limit: expected a positive integer");
4101
+ const limit = Number(value);
4102
+ if (!Number.isInteger(limit) || limit < 1) throw new Error("invalid folder limit: expected a positive integer");
4103
+ return limit;
4104
+ }
4105
+ const folderCommand = {
4106
+ description: "Inspect Granola folders and their meetings",
4107
+ flags: {
4108
+ format: { type: "string" },
4109
+ help: { type: "boolean" },
4110
+ limit: { type: "string" },
4111
+ search: { type: "string" },
4112
+ timeout: { type: "string" }
4113
+ },
4114
+ help: folderHelp,
4115
+ name: "folder",
4116
+ async run({ commandArgs, commandFlags, globalFlags }) {
4117
+ const [action, query] = commandArgs;
4118
+ switch (action) {
4119
+ case "list": return await list$1(commandFlags, globalFlags);
4120
+ case "view":
4121
+ if (!query) throw new Error("folder view requires an id or name");
4122
+ return await view$1(query, commandFlags, globalFlags);
4123
+ case void 0:
4124
+ console.log(folderHelp());
4125
+ return 1;
4126
+ default: throw new Error("invalid folder command: expected list or view");
4127
+ }
4128
+ }
4129
+ };
4130
+ async function list$1(commandFlags, globalFlags) {
4131
+ const format = resolveFolderListFormat(commandFlags.format);
4132
+ const limit = parseLimit$1(commandFlags.limit);
4133
+ const search = typeof commandFlags.search === "string" ? commandFlags.search : void 0;
4134
+ const config = await loadConfig({
4135
+ globalFlags,
4136
+ subcommandFlags: commandFlags
4137
+ });
4138
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
4139
+ debug(config.debug, "supabase", config.supabase);
4140
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
4141
+ const app = await createGranolaApp(config);
4142
+ debug(config.debug, "authMode", app.getState().auth.mode);
4143
+ console.log("Loading folders...");
4144
+ const result = await app.listFolders({
4145
+ limit,
4146
+ search
4147
+ });
4148
+ console.log(renderFolderList(result.folders, format).trimEnd());
4149
+ return 0;
4150
+ }
4151
+ async function view$1(query, commandFlags, globalFlags) {
4152
+ const format = resolveFolderDetailFormat(commandFlags.format);
4153
+ const config = await loadConfig({
4154
+ globalFlags,
4155
+ subcommandFlags: commandFlags
4156
+ });
4157
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
4158
+ debug(config.debug, "supabase", config.supabase);
4159
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
4160
+ const app = await createGranolaApp(config);
4161
+ debug(config.debug, "authMode", app.getState().auth.mode);
4162
+ console.log("Fetching folder from Granola API...");
4163
+ const result = await app.findFolder(query);
4164
+ console.log(renderFolderView(result, format).trimEnd());
4165
+ return 0;
4166
+ }
4167
+ //#endregion
3742
4168
  //#region src/browser.ts
3743
4169
  const execFileAsync = promisify(execFile);
3744
4170
  function getBrowserOpenCommand(url, platform = process.platform) {
@@ -3778,10 +4204,13 @@ const workspaceTabs = ["notes", "transcript", "metadata", "raw"];
3778
4204
  const state = {
3779
4205
  appState: null,
3780
4206
  detailError: "",
4207
+ folderError: "",
4208
+ folders: [],
3781
4209
  listError: "",
3782
4210
  meetings: [],
3783
4211
  quickOpen: "",
3784
4212
  search: "",
4213
+ selectedFolderId: null,
3785
4214
  selectedMeeting: null,
3786
4215
  selectedMeetingBundle: null,
3787
4216
  selectedMeetingId: null,
@@ -3799,6 +4228,7 @@ const els = {
3799
4228
  detailBody: document.querySelector("[data-detail-body]"),
3800
4229
  detailMeta: document.querySelector("[data-detail-meta]"),
3801
4230
  empty: document.querySelector("[data-empty]"),
4231
+ folderList: document.querySelector("[data-folder-list]"),
3802
4232
  jobsList: document.querySelector("[data-jobs-list]"),
3803
4233
  list: document.querySelector("[data-meeting-list]"),
3804
4234
  noteButton: document.querySelector("[data-export-notes]"),
@@ -3825,6 +4255,7 @@ function parseWorkspaceTab(value) {
3825
4255
  function startupSelection() {
3826
4256
  const params = new URLSearchParams(window.location.search);
3827
4257
  return {
4258
+ folderId: params.get("folder")?.trim() || "",
3828
4259
  meetingId: params.get("meeting")?.trim() || "",
3829
4260
  workspaceTab: parseWorkspaceTab(params.get("tab")),
3830
4261
  };
@@ -3833,6 +4264,12 @@ function startupSelection() {
3833
4264
  function syncBrowserUrl() {
3834
4265
  const url = new URL(window.location.href);
3835
4266
 
4267
+ if (state.selectedFolderId) {
4268
+ url.searchParams.set("folder", state.selectedFolderId);
4269
+ } else {
4270
+ url.searchParams.delete("folder");
4271
+ }
4272
+
3836
4273
  if (state.selectedMeetingId) {
3837
4274
  url.searchParams.set("meeting", state.selectedMeetingId);
3838
4275
  } else {
@@ -3876,6 +4313,11 @@ function syncFilterInputs() {
3876
4313
  function currentFilterSummary() {
3877
4314
  const parts = [];
3878
4315
 
4316
+ if (state.selectedFolderId) {
4317
+ const folder = state.folders.find((candidate) => candidate.id === state.selectedFolderId);
4318
+ parts.push("folder " + (folder ? '"' + folder.name + '"' : '"' + state.selectedFolderId + '"'));
4319
+ }
4320
+
3879
4321
  if (state.search) {
3880
4322
  parts.push('search "' + state.search + '"');
3881
4323
  }
@@ -3918,6 +4360,9 @@ function renderAppState() {
3918
4360
  : appState.index.available
3919
4361
  ? "available"
3920
4362
  : "not built";
4363
+ const folderStatus = appState.folders.loaded
4364
+ ? appState.folders.count + " folders"
4365
+ : "not loaded";
3921
4366
 
3922
4367
  els.appState.innerHTML = [
3923
4368
  '<div class="status-grid">',
@@ -3925,6 +4370,7 @@ function renderAppState() {
3925
4370
  '<div><span class="status-label">View</span><strong>' + escapeHtml(appState.ui.view) + "</strong></div>",
3926
4371
  '<div><span class="status-label">Auth</span><strong>' + escapeHtml(authMode) + "</strong></div>",
3927
4372
  '<div><span class="status-label">Documents</span><strong>' + escapeHtml(docs) + "</strong></div>",
4373
+ '<div><span class="status-label">Folders</span><strong>' + escapeHtml(folderStatus) + "</strong></div>",
3928
4374
  '<div><span class="status-label">Cache</span><strong>' + escapeHtml(cache) + "</strong></div>",
3929
4375
  '<div><span class="status-label">Index</span><strong>' + escapeHtml(indexStatus) + "</strong></div>",
3930
4376
  "</div>",
@@ -3935,6 +4381,50 @@ function renderAppState() {
3935
4381
  renderExportJobs();
3936
4382
  }
3937
4383
 
4384
+ function renderFolderList() {
4385
+ if (state.folderError) {
4386
+ els.folderList.innerHTML =
4387
+ '<div class="folder-empty folder-empty--error">' + escapeHtml(state.folderError) + "</div>";
4388
+ return;
4389
+ }
4390
+
4391
+ const buttons = [
4392
+ [
4393
+ '<button class="folder-row"' +
4394
+ (state.selectedFolderId ? "" : ' data-selected="true"') +
4395
+ ' data-folder-id="">',
4396
+ '<span class="folder-row__title">All meetings</span>',
4397
+ '<span class="folder-row__meta">Browse the full meeting list.</span>',
4398
+ "</button>",
4399
+ ].join(""),
4400
+ ];
4401
+
4402
+ for (const folder of state.folders) {
4403
+ buttons.push(
4404
+ [
4405
+ '<button class="folder-row"' +
4406
+ (folder.id === state.selectedFolderId ? ' data-selected="true"' : "") +
4407
+ ' data-folder-id="' +
4408
+ escapeHtml(folder.id) +
4409
+ '">',
4410
+ '<span class="folder-row__title">' +
4411
+ escapeHtml((folder.isFavourite ? "★ " : "") + (folder.name || folder.id)) +
4412
+ "</span>",
4413
+ '<span class="folder-row__meta">' +
4414
+ escapeHtml(String(folder.documentCount) + " meetings") +
4415
+ "</span>",
4416
+ "</button>",
4417
+ ].join(""),
4418
+ );
4419
+ }
4420
+
4421
+ if (buttons.length === 1) {
4422
+ buttons.push('<div class="folder-empty">No folders found.</div>');
4423
+ }
4424
+
4425
+ els.folderList.innerHTML = buttons.join("");
4426
+ }
4427
+
3938
4428
  function renderSecurityPanel() {
3939
4429
  els.securityPanel.hidden = !state.serverLocked;
3940
4430
  }
@@ -4078,6 +4568,7 @@ function renderMeetingDetail() {
4078
4568
  "Title: " + (record.meeting.title || record.meeting.id),
4079
4569
  "Created: " + record.meeting.createdAt,
4080
4570
  "Updated: " + record.meeting.updatedAt,
4571
+ "Folders: " + (record.meeting.folders.length ? record.meeting.folders.map((folder) => folder.name).join(", ") : "none"),
4081
4572
  "Tags: " + (record.meeting.tags.length ? record.meeting.tags.join(", ") : "none"),
4082
4573
  "Transcript loaded: " + (record.meeting.transcriptLoaded ? "yes" : "no"),
4083
4574
  ].join("\n");
@@ -4187,6 +4678,10 @@ function buildMeetingsQuery(limit = 100, refresh = false) {
4187
4678
  params.set("updatedTo", state.updatedTo);
4188
4679
  }
4189
4680
 
4681
+ if (state.selectedFolderId) {
4682
+ params.set("folderId", state.selectedFolderId);
4683
+ }
4684
+
4190
4685
  if (refresh) {
4191
4686
  params.set("refresh", "true");
4192
4687
  }
@@ -4194,6 +4689,39 @@ function buildMeetingsQuery(limit = 100, refresh = false) {
4194
4689
  return "?" + params.toString();
4195
4690
  }
4196
4691
 
4692
+ async function loadFolders(options = {}) {
4693
+ const refresh = options.refresh === true;
4694
+
4695
+ try {
4696
+ state.folderError = "";
4697
+ const params = new URLSearchParams();
4698
+ params.set("limit", "100");
4699
+ if (refresh) {
4700
+ params.set("refresh", "true");
4701
+ }
4702
+
4703
+ const payload = await fetchJson("/folders?" + params.toString());
4704
+ state.folders = payload.folders || [];
4705
+ if (
4706
+ state.selectedFolderId &&
4707
+ !state.folders.some((folder) => folder.id === state.selectedFolderId)
4708
+ ) {
4709
+ state.selectedFolderId = null;
4710
+ }
4711
+ } catch (error) {
4712
+ if (error.authRequired) {
4713
+ throw error;
4714
+ }
4715
+
4716
+ state.folderError = error instanceof Error ? error.message : String(error);
4717
+ state.folders = [];
4718
+ state.selectedFolderId = null;
4719
+ }
4720
+
4721
+ renderFolderList();
4722
+ syncBrowserUrl();
4723
+ }
4724
+
4197
4725
  async function loadMeetings(options = {}) {
4198
4726
  const preferredMeetingId = options.preferredMeetingId || state.selectedMeetingId;
4199
4727
  const refresh = options.refresh === true;
@@ -4257,10 +4785,12 @@ async function quickOpenMeeting() {
4257
4785
  try {
4258
4786
  state.quickOpen = query;
4259
4787
  const payload = await fetchJson("/meetings/resolve?q=" + encodeURIComponent(query));
4788
+ state.selectedFolderId = payload.meeting?.meeting?.folders?.[0]?.id || null;
4260
4789
  state.search = "";
4261
4790
  state.updatedFrom = "";
4262
4791
  state.updatedTo = "";
4263
4792
  syncFilterInputs();
4793
+ renderFolderList();
4264
4794
  await loadMeetings({
4265
4795
  preferredMeetingId: payload.document.id,
4266
4796
  });
@@ -4275,11 +4805,9 @@ async function quickOpenMeeting() {
4275
4805
  async function refreshAll(forceLiveMeetings = false) {
4276
4806
  setStatus("Refreshing…", "busy");
4277
4807
  try {
4278
- const [appState, authState] = await Promise.all([
4279
- fetchJson("/state"),
4280
- fetchJson("/auth/status"),
4281
- loadMeetings({ refresh: forceLiveMeetings }),
4282
- ]);
4808
+ await loadFolders({ refresh: forceLiveMeetings });
4809
+ const [appState, authState] = await Promise.all([fetchJson("/state"), fetchJson("/auth/status")]);
4810
+ await loadMeetings({ refresh: forceLiveMeetings });
4283
4811
  state.serverLocked = false;
4284
4812
  state.appState = {
4285
4813
  ...appState,
@@ -4430,17 +4958,40 @@ async function lockServer() {
4430
4958
 
4431
4959
  state.serverLocked = true;
4432
4960
  state.appState = null;
4961
+ state.folders = [];
4433
4962
  state.meetings = [];
4963
+ state.selectedFolderId = null;
4434
4964
  state.selectedMeeting = null;
4435
4965
  state.selectedMeetingBundle = null;
4436
4966
  state.detailError = "";
4967
+ state.folderError = "";
4437
4968
  els.serverPassword.value = "";
4438
4969
  renderSecurityPanel();
4970
+ renderFolderList();
4439
4971
  renderMeetingList();
4440
4972
  renderMeetingDetail();
4441
4973
  setStatus("Server locked", "error");
4442
4974
  }
4443
4975
 
4976
+ els.folderList.addEventListener("click", (event) => {
4977
+ if (!(event.target instanceof Element)) {
4978
+ return;
4979
+ }
4980
+
4981
+ const button = event.target.closest("[data-folder-id]");
4982
+ if (!button) {
4983
+ return;
4984
+ }
4985
+
4986
+ const nextFolderId = button.dataset.folderId || null;
4987
+ state.selectedFolderId = nextFolderId;
4988
+ state.selectedMeetingId = null;
4989
+ state.selectedMeeting = null;
4990
+ state.selectedMeetingBundle = null;
4991
+ renderFolderList();
4992
+ void loadMeetings();
4993
+ });
4994
+
4444
4995
  els.list.addEventListener("click", (event) => {
4445
4996
  if (!(event.target instanceof Element)) {
4446
4997
  return;
@@ -4645,6 +5196,7 @@ document.addEventListener("keydown", (event) => {
4645
5196
  });
4646
5197
 
4647
5198
  const initialSelection = startupSelection();
5199
+ state.selectedFolderId = initialSelection.folderId || null;
4648
5200
  state.selectedMeetingId = initialSelection.meetingId || null;
4649
5201
  state.workspaceTab = initialSelection.workspaceTab;
4650
5202
 
@@ -4660,9 +5212,12 @@ events.addEventListener("state.updated", (event) => {
4660
5212
  payload.state.documents?.loadedAt &&
4661
5213
  payload.state.documents.loadedAt !== previousLoadedAt
4662
5214
  ) {
4663
- void loadMeetings({
4664
- preferredMeetingId: state.selectedMeetingId,
4665
- });
5215
+ void (async () => {
5216
+ await loadFolders();
5217
+ await loadMeetings({
5218
+ preferredMeetingId: state.selectedMeetingId,
5219
+ });
5220
+ })();
4666
5221
  }
4667
5222
  });
4668
5223
  events.addEventListener("error", () => {
@@ -4671,6 +5226,7 @@ events.addEventListener("error", () => {
4671
5226
 
4672
5227
  syncFilterInputs();
4673
5228
  renderSecurityPanel();
5229
+ renderFolderList();
4674
5230
 
4675
5231
  void refreshAll().catch((error) => {
4676
5232
  setStatus("Error", "error");
@@ -4685,7 +5241,7 @@ const granolaWebMarkup = String.raw`
4685
5241
  <aside class="pane sidebar">
4686
5242
  <section class="hero">
4687
5243
  <h1>Granola Toolkit</h1>
4688
- <p>Browser workspace for meetings, notes, transcripts, and export flows on top of one local server instance.</p>
5244
+ <p>Browser workspace for folders, meetings, notes, transcripts, and export flows on top of one local server instance.</p>
4689
5245
  <input class="search" data-search placeholder="Search meetings, ids, or tags" />
4690
5246
  <div class="field-row field-row--inline">
4691
5247
  <label>
@@ -4707,6 +5263,13 @@ const granolaWebMarkup = String.raw`
4707
5263
  <input class="field-input" data-updated-to type="date" />
4708
5264
  </label>
4709
5265
  </section>
5266
+ <section class="folder-panel">
5267
+ <div class="folder-panel__head">
5268
+ <h2>Folders</h2>
5269
+ <p>Pick a folder to scope the meeting browser, or stay on All meetings.</p>
5270
+ </div>
5271
+ <div class="folder-list" data-folder-list></div>
5272
+ </section>
4710
5273
  <section class="toolbar">
4711
5274
  <div>
4712
5275
  <p>Meetings are loaded from the shared server state so this view can later coexist with the terminal UI.</p>
@@ -4828,11 +5391,11 @@ body {
4828
5391
 
4829
5392
  .sidebar {
4830
5393
  display: grid;
4831
- grid-template-rows: auto auto 1fr;
5394
+ grid-template-rows: auto auto auto 1fr;
4832
5395
  overflow: hidden;
4833
5396
  }
4834
5397
 
4835
- .hero, .toolbar, .detail-head {
5398
+ .hero, .toolbar, .detail-head, .folder-panel {
4836
5399
  padding: 22px 24px;
4837
5400
  border-bottom: 1px solid var(--line);
4838
5401
  }
@@ -4889,6 +5452,68 @@ body {
4889
5452
  overflow: auto;
4890
5453
  }
4891
5454
 
5455
+ .folder-panel {
5456
+ display: grid;
5457
+ gap: 14px;
5458
+ }
5459
+
5460
+ .folder-panel__head h2 {
5461
+ margin: 0;
5462
+ font-size: 0.92rem;
5463
+ letter-spacing: 0.08em;
5464
+ text-transform: uppercase;
5465
+ }
5466
+
5467
+ .folder-panel__head p {
5468
+ margin: 6px 0 0;
5469
+ color: var(--muted);
5470
+ font-size: 0.9rem;
5471
+ }
5472
+
5473
+ .folder-list {
5474
+ display: grid;
5475
+ gap: 10px;
5476
+ }
5477
+
5478
+ .folder-row {
5479
+ width: 100%;
5480
+ display: grid;
5481
+ gap: 4px;
5482
+ text-align: left;
5483
+ padding: 12px 14px;
5484
+ border: 1px solid transparent;
5485
+ border-radius: 16px;
5486
+ background: rgba(255, 255, 255, 0.72);
5487
+ color: inherit;
5488
+ cursor: pointer;
5489
+ transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
5490
+ }
5491
+
5492
+ .folder-row:hover,
5493
+ .folder-row[data-selected="true"] {
5494
+ transform: translateY(-1px);
5495
+ border-color: rgba(163, 79, 47, 0.26);
5496
+ background: var(--panel-strong);
5497
+ }
5498
+
5499
+ .folder-row__title {
5500
+ font-weight: 700;
5501
+ }
5502
+
5503
+ .folder-row__meta {
5504
+ color: var(--muted);
5505
+ font-size: 0.88rem;
5506
+ }
5507
+
5508
+ .folder-empty {
5509
+ color: var(--muted);
5510
+ font-size: 0.92rem;
5511
+ }
5512
+
5513
+ .folder-empty--error {
5514
+ color: var(--error);
5515
+ }
5516
+
4892
5517
  .meeting-row {
4893
5518
  width: 100%;
4894
5519
  display: grid;
@@ -5430,6 +6055,7 @@ async function startGranolaServer(app, options = {}) {
5430
6055
  auth: true,
5431
6056
  events: true,
5432
6057
  exports: true,
6058
+ folders: true,
5433
6059
  meetingOpen: true,
5434
6060
  webClient: enableWebClient
5435
6061
  },
@@ -5439,7 +6065,7 @@ async function startGranolaServer(app, options = {}) {
5439
6065
  sessionStore: defaultGranolaToolkitPersistenceLayout().sessionStoreKind
5440
6066
  },
5441
6067
  product: "granola-toolkit",
5442
- protocolVersion: 1,
6068
+ protocolVersion: 2,
5443
6069
  transport: "local-http"
5444
6070
  };
5445
6071
  const server = createServer(async (request, response) => {
@@ -5561,6 +6187,7 @@ async function startGranolaServer(app, options = {}) {
5561
6187
  return;
5562
6188
  }
5563
6189
  if (method === "GET" && path === granolaTransportPaths.meetings) {
6190
+ const folderId = url.searchParams.get("folderId")?.trim() || void 0;
5564
6191
  const limit = parseInteger(url.searchParams.get("limit"));
5565
6192
  const refresh = url.searchParams.get("refresh") === "true";
5566
6193
  const search = url.searchParams.get("search")?.trim() || void 0;
@@ -5568,6 +6195,7 @@ async function startGranolaServer(app, options = {}) {
5568
6195
  const updatedFrom = url.searchParams.get("updatedFrom")?.trim() || void 0;
5569
6196
  const updatedTo = url.searchParams.get("updatedTo")?.trim() || void 0;
5570
6197
  const result = await app.listMeetings({
6198
+ folderId,
5571
6199
  forceRefresh: refresh,
5572
6200
  limit,
5573
6201
  search,
@@ -5576,6 +6204,7 @@ async function startGranolaServer(app, options = {}) {
5576
6204
  updatedTo
5577
6205
  });
5578
6206
  sendJson(response, {
6207
+ folderId,
5579
6208
  meetings: result.meetings,
5580
6209
  refresh,
5581
6210
  search,
@@ -5586,12 +6215,39 @@ async function startGranolaServer(app, options = {}) {
5586
6215
  }, { headers: originHeaders });
5587
6216
  return;
5588
6217
  }
6218
+ if (method === "GET" && path === granolaTransportPaths.folders) {
6219
+ const limit = parseInteger(url.searchParams.get("limit"));
6220
+ const refresh = url.searchParams.get("refresh") === "true";
6221
+ const search = url.searchParams.get("search")?.trim() || void 0;
6222
+ sendJson(response, {
6223
+ folders: (await app.listFolders({
6224
+ forceRefresh: refresh,
6225
+ limit,
6226
+ search
6227
+ })).folders,
6228
+ refresh,
6229
+ search
6230
+ }, { headers: originHeaders });
6231
+ return;
6232
+ }
6233
+ if (method === "GET" && path === granolaTransportPaths.folderResolve) {
6234
+ const query = url.searchParams.get("q")?.trim();
6235
+ if (!query) throw new Error("folder query is required");
6236
+ sendJson(response, await app.findFolder(query), { headers: originHeaders });
6237
+ return;
6238
+ }
5589
6239
  if (method === "GET" && path === granolaTransportPaths.meetingResolve) {
5590
6240
  const query = url.searchParams.get("q")?.trim();
5591
6241
  if (!query) throw new Error("meeting query is required");
5592
6242
  sendJson(response, await app.findMeeting(query, { requireCache: url.searchParams.get("includeTranscript") === "true" }), { headers: originHeaders });
5593
6243
  return;
5594
6244
  }
6245
+ if (method === "GET" && path.startsWith(`${granolaTransportPaths.folders}/`) && path !== granolaTransportPaths.folderResolve) {
6246
+ const id = decodeURIComponent(path.slice(`${granolaTransportPaths.folders}/`.length));
6247
+ if (!id) throw new Error("folder id is required");
6248
+ sendJson(response, await app.getFolder(id), { headers: originHeaders });
6249
+ return;
6250
+ }
5595
6251
  if (method === "GET" && path.startsWith(`${granolaTransportPaths.meetings}/`) && path !== granolaTransportPaths.meetingResolve) {
5596
6252
  const id = decodeURIComponent(path.slice(`${granolaTransportPaths.meetings}/`.length));
5597
6253
  if (!id) throw new Error("meeting id is required");
@@ -5717,7 +6373,11 @@ function printWebRoutes() {
5717
6373
  console.log(" GET /auth/status");
5718
6374
  console.log(" GET /state");
5719
6375
  console.log(" GET /events");
6376
+ console.log(" GET /folders");
6377
+ console.log(" GET /folders/resolve?q=<query>");
6378
+ console.log(" GET /folders/:id");
5720
6379
  console.log(" GET /meetings");
6380
+ console.log(" GET /meetings?folderId=<id>");
5721
6381
  console.log(" GET /meetings/:id");
5722
6382
  console.log(" GET /exports/jobs");
5723
6383
  console.log(" POST /auth/login");
@@ -5776,6 +6436,7 @@ Subcommands:
5776
6436
 
5777
6437
  Options:
5778
6438
  --cache <path> Path to Granola cache JSON for transcript data
6439
+ --folder <query> Filter list to one folder id or name
5779
6440
  --format <value> list/view: text, json, yaml; export: json, yaml; notes: markdown, json, yaml, raw; transcript: text, json, yaml, raw
5780
6441
  --network <mode> open: local or lan (default: local)
5781
6442
  --hostname <value> open: hostname to bind (overrides network default)
@@ -5849,6 +6510,7 @@ const meetingCommand = {
5849
6510
  description: "Inspect and export individual Granola meetings",
5850
6511
  flags: {
5851
6512
  cache: { type: "string" },
6513
+ folder: { type: "string" },
5852
6514
  format: { type: "string" },
5853
6515
  help: { type: "boolean" },
5854
6516
  hostname: { type: "string" },
@@ -5892,6 +6554,7 @@ const meetingCommand = {
5892
6554
  async function list(commandFlags, globalFlags) {
5893
6555
  const format = resolveListFormat(commandFlags.format);
5894
6556
  const limit = parseLimit(commandFlags.limit);
6557
+ const folderQuery = typeof commandFlags.folder === "string" ? commandFlags.folder : void 0;
5895
6558
  const search = typeof commandFlags.search === "string" ? commandFlags.search : void 0;
5896
6559
  const config = await loadConfig({
5897
6560
  globalFlags,
@@ -5904,11 +6567,15 @@ async function list(commandFlags, globalFlags) {
5904
6567
  const app = await createGranolaApp(config);
5905
6568
  debug(config.debug, "authMode", app.getState().auth.mode);
5906
6569
  console.log("Loading meetings...");
6570
+ const folder = folderQuery ? await app.findFolder(folderQuery) : void 0;
6571
+ const folderId = folder?.id;
5907
6572
  const result = await app.listMeetings({
6573
+ folderId,
5908
6574
  limit,
5909
6575
  search
5910
6576
  });
5911
6577
  console.log(result.source === "index" ? "Loaded meetings from the local index" : "Fetched meetings from Granola API");
6578
+ if (folder) console.log(`Folder: ${folder.name} (${folder.id})`);
5912
6579
  console.log(renderMeetingList(result.meetings, format).trimEnd());
5913
6580
  return 0;
5914
6581
  }
@@ -6271,6 +6938,7 @@ const commands = [
6271
6938
  attachCommand,
6272
6939
  authCommand,
6273
6940
  exportsCommand,
6941
+ folderCommand,
6274
6942
  meetingCommand,
6275
6943
  notesCommand,
6276
6944
  serveCommand,
@@ -6399,6 +7067,7 @@ Global options:
6399
7067
 
6400
7068
  Examples:
6401
7069
  granola attach http://127.0.0.1:4123
7070
+ granola folder list
6402
7071
  granola notes --supabase "${granolaSupabaseCandidates()[0] ?? "/path/to/supabase.json"}"
6403
7072
  granola transcripts --cache "${granolaCacheCandidates()[0] ?? "/path/to/cache-v3.json"}"
6404
7073
  `;