granola-toolkit 0.32.0 → 0.34.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 +15 -3
  2. package/dist/cli.js +307 -59
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -79,6 +79,7 @@ Export notes:
79
79
  ```bash
80
80
  granola auth login
81
81
  granola notes
82
+ granola notes --folder Team
82
83
 
83
84
  node dist/cli.js notes --supabase "$HOME/Library/Application Support/Granola/supabase.json"
84
85
  node dist/cli.js notes --format json --output ./notes-json
@@ -91,6 +92,7 @@ Export transcripts:
91
92
  ```bash
92
93
  node dist/cli.js transcripts --cache "$HOME/Library/Application Support/Granola/cache-v3.json"
93
94
  node dist/cli.js transcripts --format yaml --output ./transcripts-yaml
95
+ granola transcripts --folder Team
94
96
  ```
95
97
 
96
98
  Inspect individual meetings:
@@ -143,6 +145,8 @@ The flow is:
143
145
  6. render that export as Markdown, JSON, YAML, or raw JSON
144
146
  7. write one file per document into the output directory
145
147
 
148
+ When you pass `--folder <id|name>`, the export is filtered to that folder and, by default, written into a stable per-folder subdirectory under the notes output root.
149
+
146
150
  Content is chosen in this order:
147
151
 
148
152
  1. `notes`
@@ -169,6 +173,8 @@ The flow is:
169
173
  5. render each export as text, JSON, YAML, or raw JSON
170
174
  6. write one file per document into the output directory
171
175
 
176
+ When you pass `--folder <id|name>`, the export is filtered to that folder and, by default, written into a stable per-folder subdirectory under the transcripts output root.
177
+
172
178
  Speaker labels are currently normalised to:
173
179
 
174
180
  - `You` for `microphone`
@@ -223,6 +229,8 @@ The current CLI surface includes:
223
229
  - `folder list`
224
230
  - `folder view <id|name>`
225
231
  - `meeting list --folder <id|name>`
232
+ - `notes --folder <id|name>`
233
+ - `transcripts --folder <id|name>`
226
234
 
227
235
  ### Server
228
236
 
@@ -250,9 +258,9 @@ The initial server API includes:
250
258
  - `POST /auth/logout`
251
259
  - `POST /auth/mode`
252
260
  - `POST /auth/refresh`
253
- - `POST /exports/notes`
261
+ - `POST /exports/notes` with optional `folderId`
254
262
  - `POST /exports/jobs/:id/rerun`
255
- - `POST /exports/transcripts`
263
+ - `POST /exports/transcripts` with optional `folderId`
256
264
 
257
265
  This is the shared runtime for `granola web` and `granola attach`.
258
266
 
@@ -287,6 +295,7 @@ The initial browser client includes:
287
295
  - app-state status from the shared core
288
296
  - an auth session panel for login, refresh, source switching, and sign-out
289
297
  - note and transcript export actions backed by the same local API
298
+ - folder-scoped export actions that follow the currently selected folder
290
299
  - a recent export-jobs panel with rerun actions
291
300
  - stronger empty and error states for list/detail failures
292
301
  - a server-access panel that can unlock or lock a password-protected local server
@@ -319,6 +328,7 @@ That keeps the current single-package repo simple, while making a future split i
319
328
 
320
329
  The initial terminal workspace includes:
321
330
 
331
+ - a folder scope inside the navigation pane, including an explicit All meetings view
322
332
  - a meeting list pane with keyboard navigation
323
333
  - a detail pane with notes, transcript, metadata, and raw views
324
334
  - an auth session overlay for import, refresh, source switching, and sign-out
@@ -327,7 +337,8 @@ The initial terminal workspace includes:
327
337
 
328
338
  The main keyboard controls are:
329
339
 
330
- - `j` / `k` or arrow keys to move between meetings
340
+ - `h` / `l`, left / right, or `Tab` to switch between folders and meetings
341
+ - `j` / `k` or arrow keys to move within the active folder or meeting list
331
342
  - `/` or `Ctrl+P` to open quick open
332
343
  - `a` to open auth session actions
333
344
  - `1`-`4` to switch detail tabs
@@ -352,6 +363,7 @@ The web client uses the index as a fast path and upgrades to live data automatic
352
363
  Exports are now tracked as jobs with:
353
364
 
354
365
  - persistent local history across CLI and web runs
366
+ - explicit scope metadata for all-meetings and folder-scoped runs
355
367
  - running, completed, and failed status
356
368
  - per-export progress counters
357
369
  - rerun support from `granola exports rerun <job-id>` or the web client
package/dist/cli.js CHANGED
@@ -218,16 +218,22 @@ var GranolaServerClient = class GranolaServerClient {
218
218
  async listExportJobs(options = {}) {
219
219
  return await this.requestJson(granolaExportJobsPath(options));
220
220
  }
221
- async exportNotes(format = "markdown") {
221
+ async exportNotes(format = "markdown", options = {}) {
222
222
  return await this.requestJson(granolaTransportPaths.exportNotes, {
223
- body: JSON.stringify({ format }),
223
+ body: JSON.stringify({
224
+ folderId: options.folderId,
225
+ format
226
+ }),
224
227
  headers: { "content-type": "application/json" },
225
228
  method: "POST"
226
229
  });
227
230
  }
228
- async exportTranscripts(format = "text") {
231
+ async exportTranscripts(format = "text", options = {}) {
229
232
  return await this.requestJson(granolaTransportPaths.exportTranscripts, {
230
- body: JSON.stringify({ format }),
233
+ body: JSON.stringify({
234
+ folderId: options.folderId,
235
+ format
236
+ }),
231
237
  headers: { "content-type": "application/json" },
232
238
  method: "POST"
233
239
  });
@@ -1345,6 +1351,7 @@ function renderGranolaTuiMeetingTab(bundle, tab) {
1345
1351
  `ID: ${summary.id}`,
1346
1352
  `Created: ${summary.createdAt}`,
1347
1353
  `Updated: ${summary.updatedAt}`,
1354
+ `Folders: ${summary.folders.length > 0 ? summary.folders.map((folder) => folder.name).join(", ") : "none"}`,
1348
1355
  `Tags: ${summary.tags.length > 0 ? summary.tags.join(", ") : "none"}`,
1349
1356
  `Notes source: ${summary.noteContentSource}`,
1350
1357
  `Transcript loaded: ${summary.transcriptLoaded ? "yes" : "no"}`,
@@ -1360,7 +1367,7 @@ function renderGranolaTuiMeetingTab(bundle, tab) {
1360
1367
  }
1361
1368
  }
1362
1369
  function buildGranolaTuiSummary(state, meetingSource) {
1363
- return `auth ${state.auth.mode === "stored-session" ? "stored" : "supabase"} | ${state.documents.loaded ? `${state.documents.count} docs` : "docs pending"} | ${state.cache.loaded ? `${state.cache.transcriptCount} transcript sets` : state.cache.configured ? "cache configured" : "cache missing"} | ${state.index.loaded ? `${state.index.meetingCount} indexed` : "index pending"} | list ${meetingSource}`;
1370
+ return `auth ${state.auth.mode === "stored-session" ? "stored" : "supabase"} | ${state.documents.loaded ? `${state.documents.count} docs` : "docs pending"} | ${state.folders.loaded ? `${state.folders.count} folders` : "folders pending"} | ${state.cache.loaded ? `${state.cache.transcriptCount} transcript sets` : state.cache.configured ? "cache configured" : "cache missing"} | ${state.index.loaded ? `${state.index.meetingCount} indexed` : "index pending"} | list ${meetingSource}`;
1364
1371
  }
1365
1372
  //#endregion
1366
1373
  //#region src/tui/theme.ts
@@ -1657,9 +1664,13 @@ var GranolaTuiWorkspace = class {
1657
1664
  focused = false;
1658
1665
  #maxMeetings;
1659
1666
  #appState;
1667
+ #activePane = "meetings";
1660
1668
  #detailError = "";
1661
1669
  #detailScroll = 0;
1662
1670
  #detailToken = 0;
1671
+ #folderError = "";
1672
+ #folderToken = 0;
1673
+ #folders = [];
1663
1674
  #listError = "";
1664
1675
  #listToken = 0;
1665
1676
  #loadingDetail = false;
@@ -1667,6 +1678,7 @@ var GranolaTuiWorkspace = class {
1667
1678
  #meetingSource = "live";
1668
1679
  #meetings = [];
1669
1680
  #overlay;
1681
+ #selectedFolderId;
1670
1682
  #selectedMeeting;
1671
1683
  #selectedMeetingId;
1672
1684
  #statusMessage = "Loading meetings…";
@@ -1679,11 +1691,13 @@ var GranolaTuiWorkspace = class {
1679
1691
  this.options = options;
1680
1692
  this.#appState = app.getState();
1681
1693
  this.#maxMeetings = options.maxMeetings ?? 200;
1694
+ this.#selectedFolderId = this.#appState.ui.selectedFolderId;
1682
1695
  }
1683
1696
  async initialise() {
1684
1697
  this.#unsubscribe = this.app.subscribe((event) => {
1685
1698
  this.handleAppUpdate(event);
1686
1699
  });
1700
+ await this.loadFolders({ setStatus: false });
1687
1701
  await this.loadMeetings({
1688
1702
  preferredMeetingId: this.options.initialMeetingId,
1689
1703
  setStatus: true
@@ -1699,7 +1713,12 @@ var GranolaTuiWorkspace = class {
1699
1713
  handleAppUpdate(event) {
1700
1714
  const previousDocumentsLoadedAt = this.#appState.documents.loadedAt;
1701
1715
  this.#appState = event.state;
1702
- if (this.#meetingSource === "index" && event.state.documents.loadedAt && event.state.documents.loadedAt !== previousDocumentsLoadedAt && !this.#loadingMeetings) this.loadMeetings({ preferredMeetingId: this.#selectedMeetingId });
1716
+ this.#selectedFolderId = event.state.ui.selectedFolderId;
1717
+ this.#selectedMeetingId = event.state.ui.selectedMeetingId ?? this.#selectedMeetingId;
1718
+ if (this.#meetingSource === "index" && event.state.documents.loadedAt && event.state.documents.loadedAt !== previousDocumentsLoadedAt && !this.#loadingMeetings) (async () => {
1719
+ await this.loadFolders({ setStatus: false });
1720
+ await this.loadMeetings({ preferredMeetingId: this.#selectedMeetingId });
1721
+ })();
1703
1722
  this.tui.requestRender();
1704
1723
  }
1705
1724
  setStatus(message, tone = "info") {
@@ -1712,6 +1731,11 @@ var GranolaTuiWorkspace = class {
1712
1731
  const selectedIndex = this.#selectedMeetingId ? this.#meetings.findIndex((meeting) => meeting.id === this.#selectedMeetingId) : -1;
1713
1732
  return selectedIndex >= 0 ? selectedIndex : 0;
1714
1733
  }
1734
+ normaliseSelectedFolderIndex() {
1735
+ if (!this.#selectedFolderId) return 0;
1736
+ const selectedIndex = this.#folders.findIndex((folder) => folder.id === this.#selectedFolderId);
1737
+ return selectedIndex >= 0 ? selectedIndex + 1 : 0;
1738
+ }
1715
1739
  ensureMeetingVisible(meeting) {
1716
1740
  const existingIndex = this.#meetings.findIndex((item) => item.id === meeting.id);
1717
1741
  if (existingIndex >= 0) this.#meetings[existingIndex] = meeting;
@@ -1721,6 +1745,30 @@ var GranolaTuiWorkspace = class {
1721
1745
  return left.title.localeCompare(right.title);
1722
1746
  });
1723
1747
  }
1748
+ async loadFolders(options = {}) {
1749
+ const token = ++this.#folderToken;
1750
+ this.#folderError = "";
1751
+ if (options.setStatus) this.setStatus(options.forceRefresh ? "Refreshing folders…" : "Loading folders…");
1752
+ try {
1753
+ const result = await this.app.listFolders({
1754
+ forceRefresh: options.forceRefresh,
1755
+ limit: 100
1756
+ });
1757
+ if (token !== this.#folderToken) return;
1758
+ this.#folders = result.folders;
1759
+ if (this.#selectedFolderId && !this.#folders.some((folder) => folder.id === this.#selectedFolderId)) this.#selectedFolderId = void 0;
1760
+ this.#folderError = "";
1761
+ } catch (error) {
1762
+ if (token !== this.#folderToken) return;
1763
+ const message = error instanceof Error ? error.message : String(error);
1764
+ this.#folderError = message;
1765
+ this.#folders = [];
1766
+ this.#selectedFolderId = void 0;
1767
+ this.setStatus(message, "error");
1768
+ } finally {
1769
+ if (token === this.#folderToken) this.tui.requestRender();
1770
+ }
1771
+ }
1724
1772
  async loadMeetings(options = {}) {
1725
1773
  const token = ++this.#listToken;
1726
1774
  this.#loadingMeetings = true;
@@ -1728,6 +1776,7 @@ var GranolaTuiWorkspace = class {
1728
1776
  if (options.setStatus !== false) this.setStatus(options.forceRefresh ? "Refreshing meetings…" : "Loading meetings…");
1729
1777
  try {
1730
1778
  const result = await this.app.listMeetings({
1779
+ folderId: this.#selectedFolderId,
1731
1780
  forceRefresh: options.forceRefresh,
1732
1781
  limit: this.#maxMeetings,
1733
1782
  preferIndex: true
@@ -1736,8 +1785,13 @@ var GranolaTuiWorkspace = class {
1736
1785
  this.#meetings = result.meetings;
1737
1786
  this.#meetingSource = result.source;
1738
1787
  this.#selectedMeetingId = options.preferredMeetingId && this.#meetings.some((meeting) => meeting.id === options.preferredMeetingId) ? options.preferredMeetingId : this.#selectedMeetingId && this.#meetings.some((meeting) => meeting.id === this.#selectedMeetingId) ? this.#selectedMeetingId : this.#meetings[0]?.id;
1788
+ if (!this.#selectedMeetingId) {
1789
+ this.#selectedMeeting = void 0;
1790
+ this.#detailError = "";
1791
+ this.#detailScroll = 0;
1792
+ }
1739
1793
  this.#listError = "";
1740
- this.setStatus(result.source === "index" ? "Loaded meetings from the local index" : "Connected to Granola");
1794
+ this.setStatus(result.source === "index" ? "Loaded meetings from the local index" : this.#selectedFolderId ? "Connected to Granola (folder scope)" : "Connected to Granola");
1741
1795
  } catch (error) {
1742
1796
  if (token !== this.#listToken) return;
1743
1797
  const message = error instanceof Error ? error.message : String(error);
@@ -1780,6 +1834,10 @@ var GranolaTuiWorkspace = class {
1780
1834
  }
1781
1835
  async refresh(forceRefresh) {
1782
1836
  try {
1837
+ await this.loadFolders({
1838
+ forceRefresh,
1839
+ setStatus: false
1840
+ });
1783
1841
  await this.loadMeetings({
1784
1842
  forceRefresh,
1785
1843
  preferredMeetingId: this.#selectedMeetingId
@@ -1787,7 +1845,7 @@ var GranolaTuiWorkspace = class {
1787
1845
  if (this.#selectedMeetingId) await this.loadMeeting(this.#selectedMeetingId, { ensureMeetingVisible: true });
1788
1846
  } catch {}
1789
1847
  }
1790
- async moveSelection(delta) {
1848
+ async moveMeetingSelection(delta) {
1791
1849
  if (this.#meetings.length === 0) return;
1792
1850
  const currentIndex = this.normaliseSelectedIndex();
1793
1851
  const nextIndex = Math.max(0, Math.min(this.#meetings.length - 1, currentIndex + delta));
@@ -1795,6 +1853,33 @@ var GranolaTuiWorkspace = class {
1795
1853
  if (!nextMeeting || nextMeeting.id === this.#selectedMeetingId) return;
1796
1854
  await this.loadMeeting(nextMeeting.id);
1797
1855
  }
1856
+ async moveFolderSelection(delta) {
1857
+ const total = this.#folders.length + 1;
1858
+ const currentIndex = this.normaliseSelectedFolderIndex();
1859
+ const nextIndex = Math.max(0, Math.min(total - 1, currentIndex + delta));
1860
+ const nextFolderId = nextIndex === 0 ? void 0 : this.#folders[nextIndex - 1]?.id;
1861
+ if (nextFolderId === this.#selectedFolderId) return;
1862
+ this.#selectedFolderId = nextFolderId;
1863
+ this.#selectedMeeting = void 0;
1864
+ this.#detailError = "";
1865
+ this.#detailScroll = 0;
1866
+ await this.loadMeetings({
1867
+ preferredMeetingId: this.#selectedMeetingId,
1868
+ setStatus: false
1869
+ });
1870
+ if (this.#selectedMeetingId) {
1871
+ await this.loadMeeting(this.#selectedMeetingId, { ensureMeetingVisible: true });
1872
+ return;
1873
+ }
1874
+ this.tui.requestRender();
1875
+ }
1876
+ async moveSelection(delta) {
1877
+ if (this.#activePane === "folders") {
1878
+ await this.moveFolderSelection(delta);
1879
+ return;
1880
+ }
1881
+ await this.moveMeetingSelection(delta);
1882
+ }
1798
1883
  currentDetailBody(width) {
1799
1884
  if (this.#detailError) return wrapBlock(this.#detailError, width);
1800
1885
  if (this.#loadingDetail && !this.#selectedMeeting) return wrapBlock("Loading meeting details…", width);
@@ -1832,6 +1917,10 @@ var GranolaTuiWorkspace = class {
1832
1917
  async reloadAfterAuthChange() {
1833
1918
  const preferredMeetingId = this.#selectedMeeting?.document.id ?? this.#selectedMeetingId;
1834
1919
  try {
1920
+ await this.loadFolders({
1921
+ forceRefresh: true,
1922
+ setStatus: false
1923
+ });
1835
1924
  await this.loadMeetings({
1836
1925
  forceRefresh: true,
1837
1926
  preferredMeetingId,
@@ -1956,6 +2045,21 @@ var GranolaTuiWorkspace = class {
1956
2045
  this.openAuthPanel();
1957
2046
  return;
1958
2047
  }
2048
+ if (matchesKey(data, "tab")) {
2049
+ this.#activePane = this.#activePane === "folders" ? "meetings" : "folders";
2050
+ this.tui.requestRender();
2051
+ return;
2052
+ }
2053
+ if (matchesKey(data, "left") || matchesKey(data, "h")) {
2054
+ this.#activePane = "folders";
2055
+ this.tui.requestRender();
2056
+ return;
2057
+ }
2058
+ if (matchesKey(data, "right") || matchesKey(data, "l")) {
2059
+ this.#activePane = "meetings";
2060
+ this.tui.requestRender();
2061
+ return;
2062
+ }
1959
2063
  if (matchesKey(data, "up") || matchesKey(data, "k")) {
1960
2064
  this.moveSelection(-1);
1961
2065
  return;
@@ -2021,22 +2125,53 @@ var GranolaTuiWorkspace = class {
2021
2125
  renderListPane(width, height) {
2022
2126
  const lines = [];
2023
2127
  const innerWidth = Math.max(1, width - 2);
2024
- const header = `${granolaTuiTheme.strong("Meetings")} ${granolaTuiTheme.dim(`(${this.#meetings.length})`)}`;
2025
- lines.push(padLine(header, innerWidth));
2128
+ const folderEntries = [{
2129
+ id: void 0,
2130
+ label: "All meetings",
2131
+ meta: this.#folders.length > 0 ? `${this.#folders.length} folders` : "global scope"
2132
+ }, ...this.#folders.map((folder) => ({
2133
+ id: folder.id,
2134
+ label: `${folder.isFavourite ? "★ " : ""}${folder.name || folder.id}`,
2135
+ meta: `${folder.documentCount} meetings`
2136
+ }))];
2137
+ const availableRows = Math.max(2, height - 3);
2138
+ const folderWindowSize = Math.min(Math.max(3, Math.min(8, Math.floor(availableRows * .35))), Math.max(1, availableRows - 1));
2139
+ const meetingWindowSize = Math.max(1, availableRows - folderWindowSize);
2140
+ const folderHeader = `${this.#activePane === "folders" ? granolaTuiTheme.accent("Folders") : granolaTuiTheme.strong("Folders")} ${granolaTuiTheme.dim(`(${this.#folders.length})`)}`;
2141
+ lines.push(padLine(folderHeader, innerWidth));
2142
+ if (this.#folderError) {
2143
+ lines.push(...wrapBlock(granolaTuiTheme.error(this.#folderError), innerWidth).slice(0, folderWindowSize));
2144
+ while (lines.length < 1 + folderWindowSize) lines.push(" ".repeat(innerWidth));
2145
+ } else {
2146
+ const selectedFolderIndex = this.normaliseSelectedFolderIndex();
2147
+ const folderStartIndex = Math.max(0, Math.min(selectedFolderIndex - Math.floor(folderWindowSize / 2), folderEntries.length - folderWindowSize));
2148
+ const visibleFolders = folderEntries.slice(folderStartIndex, folderStartIndex + folderWindowSize);
2149
+ for (const [offset, folder] of visibleFolders.entries()) {
2150
+ const selected = folderStartIndex + offset === selectedFolderIndex;
2151
+ const prefix = selected ? "> " : " ";
2152
+ const maxLabelWidth = Math.max(6, innerWidth - visibleWidth(prefix) - visibleWidth(folder.meta) - 1);
2153
+ const labelBlock = `${prefix}${truncateToWidth(folder.label, maxLabelWidth, "")}`;
2154
+ const line = `${labelBlock}${" ".repeat(Math.max(1, innerWidth - visibleWidth(labelBlock) - visibleWidth(folder.meta)))}${granolaTuiTheme.dim(folder.meta)}`;
2155
+ lines.push(selected ? padLine(granolaTuiTheme.selected(line), innerWidth) : padLine(line, innerWidth));
2156
+ }
2157
+ while (lines.length < 1 + folderWindowSize) lines.push(" ".repeat(innerWidth));
2158
+ }
2159
+ lines.push(padLine(granolaTuiTheme.dim(""), innerWidth));
2160
+ const meetingsHeader = `${this.#activePane === "meetings" ? granolaTuiTheme.accent("Meetings") : granolaTuiTheme.strong("Meetings")} ${granolaTuiTheme.dim(`(${this.#meetings.length})`)}`;
2161
+ lines.push(padLine(meetingsHeader, innerWidth));
2026
2162
  if (this.#listError) {
2027
- lines.push(...wrapBlock(granolaTuiTheme.error(this.#listError), innerWidth).slice(0, height - 1));
2163
+ lines.push(...wrapBlock(granolaTuiTheme.error(this.#listError), innerWidth).slice(0, meetingWindowSize));
2028
2164
  while (lines.length < height) lines.push(" ".repeat(innerWidth));
2029
2165
  return lines;
2030
2166
  }
2031
2167
  if (this.#meetings.length === 0) {
2032
- lines.push(...wrapBlock("No meetings available yet.", innerWidth).slice(0, height - 1));
2168
+ lines.push(...wrapBlock("No meetings available yet.", innerWidth).slice(0, meetingWindowSize));
2033
2169
  while (lines.length < height) lines.push(" ".repeat(innerWidth));
2034
2170
  return lines;
2035
2171
  }
2036
2172
  const selectedIndex = this.normaliseSelectedIndex();
2037
- const windowSize = Math.max(1, height - 1);
2038
- const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(windowSize / 2), this.#meetings.length - windowSize));
2039
- const visibleMeetings = this.#meetings.slice(startIndex, startIndex + windowSize);
2173
+ const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(meetingWindowSize / 2), this.#meetings.length - meetingWindowSize));
2174
+ const visibleMeetings = this.#meetings.slice(startIndex, startIndex + meetingWindowSize);
2040
2175
  for (const [offset, meeting] of visibleMeetings.entries()) {
2041
2176
  const selected = startIndex + offset === selectedIndex;
2042
2177
  const dateLabel = meeting.updatedAt.slice(0, 10);
@@ -2094,7 +2229,7 @@ var GranolaTuiWorkspace = class {
2094
2229
  const bodyLines = [];
2095
2230
  for (let index = 0; index < bodyHeight; index += 1) bodyLines.push(`${padLine(listLines[index] ?? "", listWidth)} | ${padLine(detailLines[index] ?? "", detailWidth)}`);
2096
2231
  const footerStatus = padLine(toneText(this.#statusTone, this.#statusMessage), width);
2097
- const footerHints = padLine(granolaTuiTheme.dim("/ quick open a auth r refresh 1-4 tabs PgUp/PgDn scroll q quit"), width);
2232
+ const footerHints = padLine(granolaTuiTheme.dim("h/l or Tab pane j/k move / quick open a auth r refresh 1-4 tabs PgUp/PgDn scroll q quit"), width);
2098
2233
  return [
2099
2234
  headerTitle,
2100
2235
  headerSummary,
@@ -2845,6 +2980,42 @@ async function loadOptionalGranolaCache(cacheFile) {
2845
2980
  return parseCacheContents(await readFile(cacheFile, "utf8"));
2846
2981
  }
2847
2982
  //#endregion
2983
+ //#region src/export-scope.ts
2984
+ const FOLDER_EXPORT_DIRECTORY = "_folders";
2985
+ function allExportScope() {
2986
+ return { mode: "all" };
2987
+ }
2988
+ function folderExportScope(folder) {
2989
+ return {
2990
+ folderId: folder.id,
2991
+ folderName: folder.name || folder.id,
2992
+ mode: "folder"
2993
+ };
2994
+ }
2995
+ function cloneExportScope(scope) {
2996
+ return scope.mode === "folder" ? { ...scope } : { mode: "all" };
2997
+ }
2998
+ function normaliseExportScope(value) {
2999
+ const record = asRecord(value);
3000
+ if (!record) return allExportScope();
3001
+ if (record.mode !== "folder") return allExportScope();
3002
+ const folderId = stringValue(record.folderId);
3003
+ const folderName = stringValue(record.folderName) || folderId;
3004
+ if (!folderId) return allExportScope();
3005
+ return {
3006
+ folderId,
3007
+ folderName,
3008
+ mode: "folder"
3009
+ };
3010
+ }
3011
+ function renderExportScopeLabel(scope) {
3012
+ return scope.mode === "folder" ? `folder ${scope.folderName}` : "all meetings";
3013
+ }
3014
+ function resolveExportOutputDir(outputDir, scope, options = {}) {
3015
+ if (scope.mode !== "folder" || options.scopedDirectory === false) return outputDir;
3016
+ return join(outputDir, FOLDER_EXPORT_DIRECTORY, sanitiseFilename(scope.folderId, "folder"));
3017
+ }
3018
+ //#endregion
2848
3019
  //#region src/export-jobs.ts
2849
3020
  const EXPORT_JOBS_VERSION = 1;
2850
3021
  const MAX_EXPORT_JOBS = 100;
@@ -2870,6 +3041,7 @@ function normaliseJob(value) {
2870
3041
  itemCount,
2871
3042
  kind,
2872
3043
  outputDir,
3044
+ scope: normaliseExportScope(record.scope),
2873
3045
  startedAt,
2874
3046
  status,
2875
3047
  written
@@ -3080,10 +3252,16 @@ function transcriptCount(cacheData) {
3080
3252
  return Object.values(cacheData.transcripts).filter((segments) => segments.length > 0).length;
3081
3253
  }
3082
3254
  function cloneExportState(state) {
3083
- return state ? { ...state } : void 0;
3255
+ return state ? {
3256
+ ...state,
3257
+ scope: cloneExportScope(state.scope)
3258
+ } : void 0;
3084
3259
  }
3085
3260
  function cloneExportJob(job) {
3086
- return { ...job };
3261
+ return {
3262
+ ...job,
3263
+ scope: cloneExportScope(job.scope)
3264
+ };
3087
3265
  }
3088
3266
  function cloneFolderSummary(folder) {
3089
3267
  return { ...folder };
@@ -3334,7 +3512,7 @@ var GranolaApp = class {
3334
3512
  this.emitStateUpdate();
3335
3513
  return cloneExportJob(job);
3336
3514
  }
3337
- async startExportJob(kind, format, itemCount, outputDir) {
3515
+ async startExportJob(kind, format, itemCount, outputDir, scope) {
3338
3516
  return await this.updateExportJob({
3339
3517
  completedCount: 0,
3340
3518
  format,
@@ -3342,6 +3520,7 @@ var GranolaApp = class {
3342
3520
  itemCount,
3343
3521
  kind,
3344
3522
  outputDir,
3523
+ scope: cloneExportScope(scope),
3345
3524
  startedAt: this.nowIso(),
3346
3525
  status: "running",
3347
3526
  written: 0
@@ -3611,25 +3790,29 @@ var GranolaApp = class {
3611
3790
  this.setUiState({ view: "exports-history" });
3612
3791
  return { jobs };
3613
3792
  }
3614
- async exportNotes(format = "markdown") {
3793
+ async exportNotes(format = "markdown", options = {}) {
3794
+ const documents = await this.listDocuments();
3795
+ const exportContext = await this.resolveExportContext(options.folderId);
3796
+ const filteredDocuments = exportContext.documentIds ? documents.filter((document) => exportContext.documentIds.has(document.id)) : documents;
3615
3797
  return await this.runNotesExport({
3798
+ documents: filteredDocuments,
3616
3799
  format,
3617
- outputDir: this.config.notes.output
3800
+ outputDir: resolveExportOutputDir(options.outputDir ?? this.config.notes.output, exportContext.scope, { scopedDirectory: options.scopedOutput }),
3801
+ scope: exportContext.scope
3618
3802
  });
3619
3803
  }
3620
3804
  async runNotesExport(options) {
3621
- const documents = await this.listDocuments();
3622
- let job = await this.startExportJob("notes", options.format, documents.length, options.outputDir);
3805
+ let job = await this.startExportJob("notes", options.format, options.documents.length, options.outputDir, options.scope);
3623
3806
  let written = 0;
3624
3807
  try {
3625
- written = await writeNotes(documents, options.outputDir, options.format, { onProgress: async (progress) => {
3808
+ written = await writeNotes(options.documents, options.outputDir, options.format, { onProgress: async (progress) => {
3626
3809
  job = await this.setExportJobProgress(job, {
3627
3810
  completedCount: progress.completed,
3628
3811
  written: progress.written
3629
3812
  });
3630
3813
  } });
3631
3814
  job = await this.completeExportJob(job, {
3632
- completedCount: documents.length,
3815
+ completedCount: options.documents.length,
3633
3816
  written
3634
3817
  });
3635
3818
  } catch (error) {
@@ -3638,37 +3821,49 @@ var GranolaApp = class {
3638
3821
  }
3639
3822
  this.#state.exports.notes = {
3640
3823
  format: options.format,
3641
- itemCount: documents.length,
3824
+ itemCount: options.documents.length,
3642
3825
  jobId: job.id,
3643
3826
  outputDir: options.outputDir,
3644
3827
  ranAt: this.nowIso(),
3828
+ scope: cloneExportScope(options.scope),
3645
3829
  written
3646
3830
  };
3647
3831
  this.emitStateUpdate();
3648
- this.setUiState({ view: "notes-export" });
3832
+ this.setUiState({
3833
+ selectedFolderId: options.scope.mode === "folder" ? options.scope.folderId : void 0,
3834
+ view: "notes-export"
3835
+ });
3649
3836
  return {
3650
- documentCount: documents.length,
3651
- documents,
3837
+ documentCount: options.documents.length,
3838
+ documents: options.documents,
3652
3839
  format: options.format,
3653
3840
  job,
3654
3841
  outputDir: options.outputDir,
3842
+ scope: cloneExportScope(options.scope),
3655
3843
  written
3656
3844
  };
3657
3845
  }
3658
- async exportTranscripts(format = "text") {
3846
+ async exportTranscripts(format = "text", options = {}) {
3847
+ const cacheData = await this.loadCache({ required: true });
3848
+ if (!cacheData) throw this.missingCacheError();
3849
+ const exportContext = await this.resolveExportContext(options.folderId);
3850
+ const scopedCacheData = exportContext.documentIds ? {
3851
+ documents: Object.fromEntries(Object.entries(cacheData.documents).filter(([id]) => exportContext.documentIds.has(id))),
3852
+ transcripts: Object.fromEntries(Object.entries(cacheData.transcripts).filter(([id]) => exportContext.documentIds.has(id)))
3853
+ } : cacheData;
3659
3854
  return await this.runTranscriptsExport({
3855
+ cacheData: scopedCacheData,
3660
3856
  format,
3661
- outputDir: this.config.transcripts.output
3857
+ outputDir: resolveExportOutputDir(options.outputDir ?? this.config.transcripts.output, exportContext.scope, { scopedDirectory: options.scopedOutput }),
3858
+ scope: exportContext.scope
3662
3859
  });
3663
3860
  }
3664
3861
  async runTranscriptsExport(options) {
3665
- const cacheData = await this.loadCache({ required: true });
3666
- if (!cacheData) throw this.missingCacheError();
3667
- const count = transcriptCount(cacheData);
3668
- let job = await this.startExportJob("transcripts", options.format, count, options.outputDir);
3862
+ const count = transcriptCount(options.cacheData);
3863
+ let job = await this.startExportJob("transcripts", options.format, count, options.outputDir, options.scope);
3669
3864
  let written = 0;
3670
3865
  try {
3671
- written = await writeTranscripts(cacheData, options.outputDir, options.format, { onProgress: async (progress) => {
3866
+ written = await writeTranscripts(options.cacheData, options.outputDir, options.format, { onProgress: async (progress) => {
3672
3867
  job = await this.setExportJobProgress(job, {
3673
3868
  completedCount: progress.completed,
3674
3869
  written: progress.written
@@ -3688,15 +3883,20 @@ var GranolaApp = class {
3688
3883
  jobId: job.id,
3689
3884
  outputDir: options.outputDir,
3690
3885
  ranAt: this.nowIso(),
3886
+ scope: cloneExportScope(options.scope),
3691
3887
  written
3692
3888
  };
3693
3889
  this.emitStateUpdate();
3694
- this.setUiState({ view: "transcripts-export" });
3890
+ this.setUiState({
3891
+ selectedFolderId: options.scope.mode === "folder" ? options.scope.folderId : void 0,
3892
+ view: "transcripts-export"
3893
+ });
3695
3894
  return {
3696
- cacheData,
3895
+ cacheData: options.cacheData,
3697
3896
  format: options.format,
3698
3897
  job,
3699
3898
  outputDir: options.outputDir,
3899
+ scope: cloneExportScope(options.scope),
3700
3900
  transcriptCount: count,
3701
3901
  written
3702
3902
  };
@@ -3704,15 +3904,28 @@ var GranolaApp = class {
3704
3904
  async rerunExportJob(id) {
3705
3905
  const job = this.#state.exports.jobs.find((candidate) => candidate.id === id);
3706
3906
  if (!job) throw new Error(`export job not found: ${id}`);
3707
- if (job.kind === "notes") return await this.runNotesExport({
3708
- format: job.format,
3709
- outputDir: job.outputDir
3907
+ if (job.kind === "notes") return await this.exportNotes(job.format, {
3908
+ folderId: job.scope.mode === "folder" ? job.scope.folderId : void 0,
3909
+ outputDir: job.outputDir,
3910
+ scopedOutput: false
3710
3911
  });
3711
- return await this.runTranscriptsExport({
3712
- format: job.format,
3713
- outputDir: job.outputDir
3912
+ return await this.exportTranscripts(job.format, {
3913
+ folderId: job.scope.mode === "folder" ? job.scope.folderId : void 0,
3914
+ outputDir: job.outputDir,
3915
+ scopedOutput: false
3714
3916
  });
3715
3917
  }
3918
+ async resolveExportContext(folderId) {
3919
+ if (!folderId) return { scope: allExportScope() };
3920
+ const folders = await this.loadFolders({ required: true });
3921
+ const summary = resolveFolder((folders ?? []).map((folder) => buildFolderSummary(folder)), folderId);
3922
+ const rawFolder = (folders ?? []).find((candidate) => candidate.id === summary.id);
3923
+ if (!rawFolder) throw new Error(`folder not found: ${folderId}`);
3924
+ return {
3925
+ documentIds: new Set(rawFolder.documentIds),
3926
+ scope: folderExportScope(summary)
3927
+ };
3928
+ }
3716
3929
  };
3717
3930
  async function createGranolaApp(config, options = {}) {
3718
3931
  const auth = await inspectDefaultGranolaAuth(config);
@@ -4003,8 +4216,8 @@ function renderExportJobs(jobs, format) {
4003
4216
  if (format === "json") return toJson({ jobs });
4004
4217
  if (format === "yaml") return toYaml({ jobs });
4005
4218
  if (jobs.length === 0) return "No export jobs\n";
4006
- return `${["ID KIND STATUS FORMAT ITEMS WRITTEN STARTED", ...jobs.map((job) => {
4007
- return `${job.id.padEnd(28).slice(0, 28)} ${job.kind.padEnd(12)} ${job.status.padEnd(11)} ${job.format.padEnd(11)} ${String(job.itemCount).padEnd(7)} ${String(job.written).padEnd(8)} ${job.startedAt.slice(0, 19)}`;
4219
+ return `${["ID KIND STATUS FORMAT SCOPE ITEMS WRITTEN STARTED", ...jobs.map((job) => {
4220
+ return `${job.id.padEnd(28).slice(0, 28)} ${job.kind.padEnd(12)} ${job.status.padEnd(11)} ${job.format.padEnd(11)} ${renderExportScopeLabel(job.scope).padEnd(20).slice(0, 20)} ${String(job.itemCount).padEnd(7)} ${String(job.written).padEnd(8)} ${job.startedAt.slice(0, 19)}`;
4008
4221
  })].join("\n")}\n`;
4009
4222
  }
4010
4223
  const exportsCommand = {
@@ -4054,10 +4267,10 @@ async function rerun(id, commandFlags, globalFlags) {
4054
4267
  debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
4055
4268
  const result = await (await createGranolaApp(config)).rerunExportJob(id);
4056
4269
  if ("documentCount" in result) {
4057
- console.log(`✓ Reran notes export job ${result.job.id} to ${result.outputDir} (${result.written}/${result.documentCount} written)`);
4270
+ console.log(`✓ Reran notes export job ${result.job.id} from ${renderExportScopeLabel(result.scope)} to ${result.outputDir} (${result.written}/${result.documentCount} written)`);
4058
4271
  return 0;
4059
4272
  }
4060
- console.log(`✓ Reran transcripts export job ${result.job.id} to ${result.outputDir} (${result.written}/${result.transcriptCount} written)`);
4273
+ console.log(`✓ Reran transcripts export job ${result.job.id} from ${renderExportScopeLabel(result.scope)} to ${result.outputDir} (${result.written}/${result.transcriptCount} written)`);
4061
4274
  return 0;
4062
4275
  }
4063
4276
  //#endregion
@@ -4297,6 +4510,12 @@ function escapeHtml(value) {
4297
4510
  .replaceAll('"', "&quot;");
4298
4511
  }
4299
4512
 
4513
+ function exportScopeLabel(scope) {
4514
+ return scope && scope.mode === "folder"
4515
+ ? "Folder: " + (scope.folderName || scope.folderId)
4516
+ : "Scope: All meetings";
4517
+ }
4518
+
4300
4519
  function setStatus(label, tone = "idle") {
4301
4520
  els.stateBadge.textContent = label;
4302
4521
  els.stateBadge.dataset.tone = tone;
@@ -4635,8 +4854,9 @@ function renderExportJobs() {
4635
4854
  "</div>",
4636
4855
  '<div class="job-card__status" data-status="' + escapeHtml(job.status) + '">' + escapeHtml(job.status) + "</div>",
4637
4856
  "</div>",
4638
- '<div class="job-card__meta">Format: ' + escapeHtml(job.format) + " • " + escapeHtml(progress) + " • Written: " + escapeHtml(String(job.written)) + "</div>",
4857
+ '<div class="job-card__meta">Format: ' + escapeHtml(job.format) + " • " + escapeHtml(exportScopeLabel(job.scope)) + " • " + escapeHtml(progress) + " • Written: " + escapeHtml(String(job.written)) + "</div>",
4639
4858
  '<div class="job-card__meta">Started: ' + escapeHtml(job.startedAt.slice(0, 19)) + "</div>",
4859
+ '<div class="job-card__meta">Output: ' + escapeHtml(job.outputDir) + "</div>",
4640
4860
  error,
4641
4861
  '<div class="job-card__actions">' + rerunButton + "</div>",
4642
4862
  "</article>",
@@ -4836,9 +5056,12 @@ async function syncAuthState() {
4836
5056
  }
4837
5057
 
4838
5058
  async function exportNotes() {
4839
- setStatus("Exporting notes…", "busy");
5059
+ setStatus(state.selectedFolderId ? "Exporting folder notes…" : "Exporting notes…", "busy");
4840
5060
  await fetchJson("/exports/notes", {
4841
- body: JSON.stringify({ format: "markdown" }),
5061
+ body: JSON.stringify({
5062
+ folderId: state.selectedFolderId || undefined,
5063
+ format: "markdown",
5064
+ }),
4842
5065
  headers: { "content-type": "application/json" },
4843
5066
  method: "POST",
4844
5067
  });
@@ -4846,9 +5069,15 @@ async function exportNotes() {
4846
5069
  }
4847
5070
 
4848
5071
  async function exportTranscripts() {
4849
- setStatus("Exporting transcripts…", "busy");
5072
+ setStatus(
5073
+ state.selectedFolderId ? "Exporting folder transcripts…" : "Exporting transcripts…",
5074
+ "busy",
5075
+ );
4850
5076
  await fetchJson("/exports/transcripts", {
4851
- body: JSON.stringify({ format: "text" }),
5077
+ body: JSON.stringify({
5078
+ folderId: state.selectedFolderId || undefined,
5079
+ format: "text",
5080
+ }),
4852
5081
  headers: { "content-type": "application/json" },
4853
5082
  method: "POST",
4854
5083
  });
@@ -5930,6 +6159,9 @@ function parseAuthMode(value) {
5930
6159
  default: throw new Error("invalid auth mode: expected stored-session or supabase-file");
5931
6160
  }
5932
6161
  }
6162
+ function folderIdFromBody(value) {
6163
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
6164
+ }
5933
6165
  function sendJson(response, body, init = {}) {
5934
6166
  const payload = `${JSON.stringify(body, null, 2)}\n`;
5935
6167
  response.writeHead(init.status ?? 200, {
@@ -6275,7 +6507,7 @@ async function startGranolaServer(app, options = {}) {
6275
6507
  }
6276
6508
  if (method === "POST" && path === granolaTransportPaths.exportNotes) {
6277
6509
  const body = await readJsonBody(request);
6278
- sendJson(response, await app.exportNotes(noteFormatFromBody(body.format)), {
6510
+ sendJson(response, await app.exportNotes(noteFormatFromBody(body.format), { folderId: folderIdFromBody(body.folderId) }), {
6279
6511
  headers: originHeaders,
6280
6512
  status: 202
6281
6513
  });
@@ -6297,7 +6529,7 @@ async function startGranolaServer(app, options = {}) {
6297
6529
  }
6298
6530
  if (method === "POST" && path === granolaTransportPaths.exportTranscripts) {
6299
6531
  const body = await readJsonBody(request);
6300
- sendJson(response, await app.exportTranscripts(transcriptFormatFromBody(body.format)), {
6532
+ sendJson(response, await app.exportTranscripts(transcriptFormatFromBody(body.format), { folderId: folderIdFromBody(body.folderId) }), {
6301
6533
  headers: originHeaders,
6302
6534
  status: 202
6303
6535
  });
@@ -6677,6 +6909,7 @@ Usage:
6677
6909
  granola notes [options]
6678
6910
 
6679
6911
  Options:
6912
+ --folder <query> Export only meetings inside one folder id or name
6680
6913
  --format <value> Output format: markdown, json, yaml, raw (default: markdown)
6681
6914
  --output <path> Output directory for note files (default: ./notes)
6682
6915
  --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
@@ -6689,6 +6922,7 @@ Options:
6689
6922
  const notesCommand = {
6690
6923
  description: "Export Granola notes",
6691
6924
  flags: {
6925
+ folder: { type: "string" },
6692
6926
  format: { type: "string" },
6693
6927
  help: { type: "boolean" },
6694
6928
  output: { type: "string" },
@@ -6709,8 +6943,14 @@ const notesCommand = {
6709
6943
  debug(config.debug, "format", format);
6710
6944
  const app = await createGranolaApp(config);
6711
6945
  debug(config.debug, "authMode", app.getState().auth.mode);
6712
- const result = await app.exportNotes(format);
6713
- console.log(`✓ Exported ${result.documentCount} notes to ${result.outputDir} (job ${result.job.id})`);
6946
+ const folderQuery = typeof commandFlags.folder === "string" ? commandFlags.folder : void 0;
6947
+ const folder = folderQuery ? await app.findFolder(folderQuery) : void 0;
6948
+ debug(config.debug, "folder", folder?.id ?? "(all)");
6949
+ const result = await app.exportNotes(format, {
6950
+ folderId: folder?.id,
6951
+ scopedOutput: typeof commandFlags.output !== "string"
6952
+ });
6953
+ console.log(`✓ Exported ${result.documentCount} notes from ${renderExportScopeLabel(result.scope)} to ${result.outputDir} (job ${result.job.id})`);
6714
6954
  debug(config.debug, "notes written", result.written);
6715
6955
  return 0;
6716
6956
  }
@@ -6863,6 +7103,7 @@ Usage:
6863
7103
 
6864
7104
  Options:
6865
7105
  --cache <path> Path to Granola cache JSON
7106
+ --folder <query> Export only meetings inside one folder id or name
6866
7107
  --format <value> Output format: text, json, yaml, raw (default: text)
6867
7108
  --output <path> Output directory for transcript files (default: ./transcripts)
6868
7109
  --debug Enable debug logging
@@ -6874,6 +7115,7 @@ const transcriptsCommand = {
6874
7115
  description: "Export Granola transcripts",
6875
7116
  flags: {
6876
7117
  cache: { type: "string" },
7118
+ folder: { type: "string" },
6877
7119
  format: { type: "string" },
6878
7120
  help: { type: "boolean" },
6879
7121
  output: { type: "string" }
@@ -6892,8 +7134,14 @@ const transcriptsCommand = {
6892
7134
  debug(config.debug, "format", format);
6893
7135
  const app = await createGranolaApp(config);
6894
7136
  debug(config.debug, "authMode", app.getState().auth.mode);
6895
- const result = await app.exportTranscripts(format);
6896
- console.log(`✓ Exported ${result.transcriptCount} transcripts to ${result.outputDir} (job ${result.job.id})`);
7137
+ const folderQuery = typeof commandFlags.folder === "string" ? commandFlags.folder : void 0;
7138
+ const folder = folderQuery ? await app.findFolder(folderQuery) : void 0;
7139
+ debug(config.debug, "folder", folder?.id ?? "(all)");
7140
+ const result = await app.exportTranscripts(format, {
7141
+ folderId: folder?.id,
7142
+ scopedOutput: typeof commandFlags.output !== "string"
7143
+ });
7144
+ console.log(`✓ Exported ${result.transcriptCount} transcripts from ${renderExportScopeLabel(result.scope)} to ${result.outputDir} (job ${result.job.id})`);
6897
7145
  debug(config.debug, "transcripts written", result.written);
6898
7146
  return 0;
6899
7147
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.32.0",
3
+ "version": "0.34.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",