granola-toolkit 0.12.0 → 0.14.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 +8 -1
  2. package/dist/cli.js +133 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -81,6 +81,8 @@ Inspect individual meetings:
81
81
  granola meeting list --limit 10
82
82
  granola meeting list --search planning
83
83
  granola meeting view 1234abcd
84
+ granola meeting notes 1234abcd
85
+ granola meeting transcript 1234abcd --format json
84
86
  granola meeting export 1234abcd --format yaml
85
87
  ```
86
88
 
@@ -143,7 +145,7 @@ The flow is:
143
145
  2. fetch documents from Granola's API
144
146
  3. optionally load the local cache for transcript data
145
147
  4. resolve a meeting by full id or unique id prefix
146
- 5. render either a list, a human-readable meeting view, or a machine-readable export bundle
148
+ 5. render either a list, a combined meeting view, focused notes/transcript output, or a machine-readable export bundle
147
149
 
148
150
  The human-readable `view` command shows:
149
151
 
@@ -151,6 +153,11 @@ The human-readable `view` command shows:
151
153
  - the selected notes content
152
154
  - transcript lines when the local cache is available
153
155
 
156
+ The focused meeting subcommands are:
157
+
158
+ - `meeting notes` for just the selected note output
159
+ - `meeting transcript` for just the selected transcript output
160
+
154
161
  The machine-readable `export` command includes:
155
162
 
156
163
  - a meeting summary
package/dist/cli.js CHANGED
@@ -327,14 +327,22 @@ var StoredSessionTokenProvider = class {
327
327
  }
328
328
  async invalidate() {
329
329
  const session = await this.loadSession().catch(() => void 0);
330
- if (session?.refreshToken && session.clientId) {
330
+ if (session?.refreshToken && session.clientId) try {
331
331
  const refreshedSession = await refreshGranolaSession(session, this.options.fetchImpl);
332
332
  this.#session = refreshedSession;
333
333
  await this.store.writeSession(refreshedSession);
334
334
  return;
335
+ } catch {
336
+ if (!this.options.source) {
337
+ this.#session = void 0;
338
+ await this.store.clearSession();
339
+ throw new Error("failed to refresh stored Granola session");
340
+ }
335
341
  }
336
342
  if (this.options.source) {
337
- this.#session = await this.options.source.loadSession();
343
+ const sourcedSession = await this.options.source.loadSession();
344
+ this.#session = sourcedSession;
345
+ await this.store.writeSession(sourcedSession);
338
346
  return;
339
347
  }
340
348
  this.#session = void 0;
@@ -939,6 +947,9 @@ function toJson(value) {
939
947
  function repeatIndent(level) {
940
948
  return " ".repeat(level);
941
949
  }
950
+ function escapeMarkdownText(text) {
951
+ return text.replace(/\\/g, "\\\\").replace(/([*_`[\]])/g, "\\$1");
952
+ }
942
953
  function renderInline(nodes = []) {
943
954
  return nodes.map((node) => renderInlineNode(node)).join("");
944
955
  }
@@ -949,6 +960,9 @@ function applyMarks(text, marks = []) {
949
960
  case "em": return `*${current}*`;
950
961
  case "code": return `\`${current}\``;
951
962
  case "strike": return `~~${current}~~`;
963
+ case "underline": return `<u>${current}</u>`;
964
+ case "subscript": return `<sub>${current}</sub>`;
965
+ case "superscript": return `<sup>${current}</sup>`;
952
966
  case "link": {
953
967
  const href = typeof mark.attrs?.href === "string" ? mark.attrs.href : void 0;
954
968
  return href ? `[${current}](${href})` : current;
@@ -959,8 +973,9 @@ function applyMarks(text, marks = []) {
959
973
  }
960
974
  function renderInlineNode(node) {
961
975
  switch (node.type) {
962
- case "text": return applyMarks(node.text ?? "", node.marks);
976
+ case "text": return applyMarks(escapeMarkdownText(node.text ?? ""), node.marks);
963
977
  case "hardBreak": return " \n";
978
+ case "mention": return applyMarks(escapeMarkdownText(typeof node.attrs?.label === "string" ? node.attrs.label : typeof node.attrs?.text === "string" ? node.attrs.text : typeof node.attrs?.name === "string" ? node.attrs.name : renderInline(node.content)), node.marks);
964
979
  default: return applyMarks(renderInline(node.content), node.marks);
965
980
  }
966
981
  }
@@ -968,21 +983,45 @@ function indentLines(value, level) {
968
983
  const indent = repeatIndent(level);
969
984
  return value.split("\n").map((line) => line.length === 0 ? line : `${indent}${line}`).join("\n");
970
985
  }
971
- function renderList(items, ordered, indentLevel) {
972
- return items.map((item, index) => renderListItem(item, ordered ? `${index + 1}.` : "-", indentLevel)).join("\n");
986
+ function renderList(items, ordered, indentLevel, start = 1) {
987
+ return items.map((item, index) => renderListItem(item, ordered ? `${start + index}.` : "-", indentLevel)).join("\n");
973
988
  }
974
989
  function renderListItem(node, marker, indentLevel) {
975
990
  const children = node.content ?? [];
976
991
  const blockChildren = children.filter((child) => child.type !== "bulletList" && child.type !== "orderedList");
977
992
  const nestedLists = children.filter((child) => child.type === "bulletList" || child.type === "orderedList");
978
993
  const mainText = blockChildren.map((child) => renderBlock(child, indentLevel + 1)).filter(Boolean).join("\n").trim();
979
- let output = `${`${repeatIndent(indentLevel)}${marker} `}${mainText || ""}`.trimEnd();
994
+ const prefix = `${repeatIndent(indentLevel)}${marker} `;
995
+ const continuationIndent = `${repeatIndent(indentLevel)}${" ".repeat(marker.length + 1)}`;
996
+ let output = `${prefix}${mainText.split("\n").map((line, index) => index === 0 ? line : `${continuationIndent}${line}`).join("\n") || ""}`.trimEnd();
980
997
  if (nestedLists.length > 0) {
981
998
  const nestedText = nestedLists.map((child) => renderBlock(child, indentLevel + 1)).filter(Boolean).map((value) => indentLines(value, 0)).join("\n");
982
999
  output = `${output}\n${nestedText}`;
983
1000
  }
984
1001
  return output;
985
1002
  }
1003
+ function renderTaskList(items, indentLevel) {
1004
+ return items.map((item) => renderTaskItem(item, indentLevel)).join("\n");
1005
+ }
1006
+ function renderTaskItem(node, indentLevel) {
1007
+ return renderListItem(node, node.attrs?.checked === true ? "[x]" : "[ ]", indentLevel);
1008
+ }
1009
+ function renderTableCell(node) {
1010
+ return renderBlocks(node.content ?? [], 0).replace(/\n+/g, " <br> ").replace(/\|/g, "\\|").trim();
1011
+ }
1012
+ function renderTable(node) {
1013
+ const rows = (node.content ?? []).map((row) => (row.content ?? []).map((cell) => renderTableCell(cell))).filter((row) => row.length > 0);
1014
+ if (rows.length === 0) return "";
1015
+ const header = rows[0];
1016
+ const body = rows.slice(1);
1017
+ const separator = header.map(() => "---");
1018
+ const lines = [`| ${header.map((cell) => cell || " ").join(" | ")} |`, `| ${separator.join(" | ")} |`];
1019
+ for (const row of body) {
1020
+ const padded = header.map((_, index) => row[index] ?? " ");
1021
+ lines.push(`| ${padded.join(" | ")} |`);
1022
+ }
1023
+ return lines.join("\n");
1024
+ }
986
1025
  function renderBlock(node, indentLevel) {
987
1026
  switch (node.type) {
988
1027
  case "heading": {
@@ -991,10 +1030,25 @@ function renderBlock(node, indentLevel) {
991
1030
  }
992
1031
  case "paragraph": return renderInline(node.content).trim();
993
1032
  case "bulletList": return renderList(node.content ?? [], false, indentLevel);
994
- case "orderedList": return renderList(node.content ?? [], true, indentLevel);
1033
+ case "orderedList": {
1034
+ const start = typeof node.attrs?.start === "number" ? node.attrs.start : 1;
1035
+ return renderList(node.content ?? [], true, indentLevel, start);
1036
+ }
995
1037
  case "listItem": return renderListItem(node, "-", indentLevel);
1038
+ case "taskList": return renderTaskList(node.content ?? [], indentLevel);
1039
+ case "taskItem": return renderTaskItem(node, indentLevel);
1040
+ case "table": return renderTable(node);
1041
+ case "tableRow": return (node.content ?? []).map((cell) => renderTableCell(cell)).join(" | ");
1042
+ case "tableCell":
1043
+ case "tableHeader": return renderTableCell(node);
996
1044
  case "blockquote": return renderBlocks(node.content ?? [], indentLevel).split("\n").map((line) => line ? `> ${line}` : ">").join("\n").trim();
997
- case "codeBlock": return `\`\`\`\n${extractPlainText(node).trimEnd()}\n\`\`\``;
1045
+ case "codeBlock": {
1046
+ const text = extractPlainText({
1047
+ type: "doc",
1048
+ content: node.content
1049
+ }).trimEnd();
1050
+ return `\`\`\`${typeof node.attrs?.language === "string" ? node.attrs.language.trim() : typeof node.attrs?.params === "string" ? node.attrs.params.trim() : ""}\n${text}\n\`\`\``;
1051
+ }
998
1052
  case "horizontalRule": return "---";
999
1053
  case "hardBreak": return "";
1000
1054
  case "text": return renderInlineNode(node);
@@ -1315,6 +1369,7 @@ function buildMeetingTranscript(document, cacheData) {
1315
1369
  loaded: false,
1316
1370
  segmentCount: 0,
1317
1371
  transcript: null,
1372
+ transcriptRecord: null,
1318
1373
  transcriptText: null
1319
1374
  };
1320
1375
  const rawSegments = cacheData.transcripts[document.id] ?? [];
@@ -1323,6 +1378,7 @@ function buildMeetingTranscript(document, cacheData) {
1323
1378
  loaded: true,
1324
1379
  segmentCount: 0,
1325
1380
  transcript: null,
1381
+ transcriptRecord: null,
1326
1382
  transcriptText: null
1327
1383
  };
1328
1384
  const transcript = buildTranscriptExport(cacheDocumentForMeeting(document, cacheData), normalisedSegments, rawSegments);
@@ -1330,6 +1386,7 @@ function buildMeetingTranscript(document, cacheData) {
1330
1386
  loaded: true,
1331
1387
  segmentCount: transcript.segments.length,
1332
1388
  transcript: serialiseTranscript(transcript),
1389
+ transcriptRecord: transcript,
1333
1390
  transcriptText: renderTranscriptExport(transcript, "text")
1334
1391
  };
1335
1392
  }
@@ -1458,6 +1515,14 @@ function renderMeetingExport(record, format = "json") {
1458
1515
  case "yaml": return toYaml(record);
1459
1516
  }
1460
1517
  }
1518
+ function renderMeetingNotes(document, format = "markdown") {
1519
+ return renderNoteExport(buildNoteExport(document), format);
1520
+ }
1521
+ function renderMeetingTranscript(document, cacheData, format = "text") {
1522
+ const transcript = buildMeetingTranscript(document, cacheData).transcriptRecord;
1523
+ if (!transcript) return "";
1524
+ return renderTranscriptExport(transcript, format);
1525
+ }
1461
1526
  //#endregion
1462
1527
  //#region src/commands/shared.ts
1463
1528
  function debug(enabled, ...values) {
@@ -1469,16 +1534,18 @@ function meetingHelp() {
1469
1534
  return `Granola meeting
1470
1535
 
1471
1536
  Usage:
1472
- granola meeting <list|view|export> [options]
1537
+ granola meeting <list|view|export|notes|transcript> [options]
1473
1538
 
1474
1539
  Subcommands:
1475
1540
  list List meetings from the Granola API
1476
1541
  view <id> Show a single meeting with notes and transcript text
1477
1542
  export <id> Export a single meeting as JSON or YAML
1543
+ notes <id> Show a single meeting's notes
1544
+ transcript <id> Show a single meeting's transcript
1478
1545
 
1479
1546
  Options:
1480
1547
  --cache <path> Path to Granola cache JSON for transcript data
1481
- --format <value> list/view: text, json, yaml; export: json, yaml
1548
+ --format <value> list/view: text, json, yaml; export: json, yaml; notes: markdown, json, yaml, raw; transcript: text, json, yaml, raw
1482
1549
  --limit <n> Number of meetings for list (default: 20)
1483
1550
  --search <query> Filter list by title, id, or tag
1484
1551
  --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
@@ -1514,6 +1581,26 @@ function resolveExportFormat(value) {
1514
1581
  default: throw new Error("invalid meeting export format: expected json or yaml");
1515
1582
  }
1516
1583
  }
1584
+ function resolveNotesFormat(value) {
1585
+ switch (value) {
1586
+ case void 0: return "markdown";
1587
+ case "json":
1588
+ case "markdown":
1589
+ case "raw":
1590
+ case "yaml": return value;
1591
+ default: throw new Error("invalid meeting notes format: expected markdown, json, yaml, or raw");
1592
+ }
1593
+ }
1594
+ function resolveTranscriptFormat$1(value) {
1595
+ switch (value) {
1596
+ case void 0: return "text";
1597
+ case "json":
1598
+ case "raw":
1599
+ case "text":
1600
+ case "yaml": return value;
1601
+ default: throw new Error("invalid meeting transcript format: expected text, json, yaml, or raw");
1602
+ }
1603
+ }
1517
1604
  function parseLimit(value) {
1518
1605
  if (value === void 0) return 20;
1519
1606
  if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid meeting limit: expected a positive integer");
@@ -1543,18 +1630,25 @@ const meetingCommand = {
1543
1630
  case "export":
1544
1631
  if (!id) throw new Error("meeting export requires an id");
1545
1632
  return await exportMeeting(id, commandFlags, globalFlags);
1633
+ case "notes":
1634
+ if (!id) throw new Error("meeting notes requires an id");
1635
+ return await notes(id, commandFlags, globalFlags);
1636
+ case "transcript":
1637
+ if (!id) throw new Error("meeting transcript requires an id");
1638
+ return await transcript(id, commandFlags, globalFlags);
1546
1639
  case void 0:
1547
1640
  console.log(meetingHelp());
1548
1641
  return 1;
1549
- default: throw new Error("invalid meeting command: expected list, view, or export");
1642
+ default: throw new Error("invalid meeting command: expected list, view, export, notes, or transcript");
1550
1643
  }
1551
1644
  }
1552
1645
  };
1553
- async function loadMeetingData(commandFlags, globalFlags) {
1646
+ async function loadMeetingData(commandFlags, globalFlags, options = {}) {
1554
1647
  const config = await loadConfig({
1555
1648
  globalFlags,
1556
1649
  subcommandFlags: commandFlags
1557
1650
  });
1651
+ if (options.requireCache && !config.transcripts.cacheFile) throw new Error(`Granola cache file not found. Pass --cache or create .granola.toml. Expected locations include: ${granolaCacheCandidates().join(", ")}`);
1558
1652
  if (config.transcripts.cacheFile && !existsSync(config.transcripts.cacheFile)) throw new Error(`Granola cache file not found: ${config.transcripts.cacheFile}`);
1559
1653
  debug(config.debug, "using config", config.configFileUsed ?? "(none)");
1560
1654
  debug(config.debug, "supabase", config.supabase);
@@ -1567,6 +1661,15 @@ async function loadMeetingData(commandFlags, globalFlags) {
1567
1661
  granolaClient
1568
1662
  };
1569
1663
  }
1664
+ async function loadResolvedMeeting(id, commandFlags, globalFlags, options = {}) {
1665
+ const { cacheData, config, granolaClient } = await loadMeetingData(commandFlags, globalFlags, options);
1666
+ console.log("Fetching meeting from Granola API...");
1667
+ return {
1668
+ cacheData,
1669
+ config,
1670
+ document: resolveMeeting(await granolaClient.listDocuments({ timeoutMs: config.notes.timeoutMs }), id)
1671
+ };
1672
+ }
1570
1673
  async function list(commandFlags, globalFlags) {
1571
1674
  const format = resolveListFormat(commandFlags.format);
1572
1675
  const limit = parseLimit(commandFlags.limit);
@@ -1583,20 +1686,32 @@ async function list(commandFlags, globalFlags) {
1583
1686
  }
1584
1687
  async function view(id, commandFlags, globalFlags) {
1585
1688
  const format = resolveViewFormat(commandFlags.format);
1586
- const { cacheData, config, granolaClient } = await loadMeetingData(commandFlags, globalFlags);
1587
- console.log("Fetching meeting from Granola API...");
1588
- const meeting = buildMeetingRecord(resolveMeeting(await granolaClient.listDocuments({ timeoutMs: config.notes.timeoutMs }), id), cacheData);
1689
+ const { cacheData, document } = await loadResolvedMeeting(id, commandFlags, globalFlags);
1690
+ const meeting = buildMeetingRecord(document, cacheData);
1589
1691
  console.log(renderMeetingView(meeting, format).trimEnd());
1590
1692
  return 0;
1591
1693
  }
1592
1694
  async function exportMeeting(id, commandFlags, globalFlags) {
1593
1695
  const format = resolveExportFormat(commandFlags.format);
1594
- const { cacheData, config, granolaClient } = await loadMeetingData(commandFlags, globalFlags);
1595
- console.log("Fetching meeting from Granola API...");
1596
- const meeting = buildMeetingRecord(resolveMeeting(await granolaClient.listDocuments({ timeoutMs: config.notes.timeoutMs }), id), cacheData);
1696
+ const { cacheData, document } = await loadResolvedMeeting(id, commandFlags, globalFlags);
1697
+ const meeting = buildMeetingRecord(document, cacheData);
1597
1698
  console.log(renderMeetingExport(meeting, format).trimEnd());
1598
1699
  return 0;
1599
1700
  }
1701
+ async function notes(id, commandFlags, globalFlags) {
1702
+ const format = resolveNotesFormat(commandFlags.format);
1703
+ const { document } = await loadResolvedMeeting(id, commandFlags, globalFlags);
1704
+ console.log(renderMeetingNotes(document, format).trimEnd());
1705
+ return 0;
1706
+ }
1707
+ async function transcript(id, commandFlags, globalFlags) {
1708
+ const format = resolveTranscriptFormat$1(commandFlags.format);
1709
+ const { cacheData, document } = await loadResolvedMeeting(id, commandFlags, globalFlags, { requireCache: true });
1710
+ const output = renderMeetingTranscript(document, cacheData, format);
1711
+ if (!output.trim()) throw new Error(`no transcript found for meeting: ${document.id}`);
1712
+ console.log(output.trimEnd());
1713
+ return 0;
1714
+ }
1600
1715
  //#endregion
1601
1716
  //#region src/commands/notes.ts
1602
1717
  function notesHelp() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "CLI toolkit for exporting and working with Granola notes and transcripts",
5
5
  "keywords": [
6
6
  "cli",