granola-toolkit 0.3.0 → 0.4.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 +13 -7
  2. package/dist/cli.js +212 -33
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -58,12 +58,14 @@ Export notes:
58
58
 
59
59
  ```bash
60
60
  node dist/cli.js notes --supabase "$HOME/Library/Application Support/Granola/supabase.json"
61
+ node dist/cli.js notes --format json --output ./notes-json
61
62
  ```
62
63
 
63
64
  Export transcripts:
64
65
 
65
66
  ```bash
66
67
  node dist/cli.js transcripts --cache "$HOME/Library/Application Support/Granola/cache-v3.json"
68
+ node dist/cli.js transcripts --format yaml --output ./transcripts-yaml
67
69
  ```
68
70
 
69
71
  ## How It Works
@@ -77,9 +79,10 @@ The flow is:
77
79
  1. read your local `supabase.json`
78
80
  2. extract the WorkOS access token from it
79
81
  3. call Granola's paginated documents API
80
- 4. choose the best available note content for each document
81
- 5. convert ProseMirror content into Markdown
82
- 6. write one Markdown file per document into the output directory
82
+ 4. normalise each document into a structured note export
83
+ 5. choose the best available note content for each document
84
+ 6. render that export as Markdown, JSON, YAML, or raw JSON
85
+ 7. write one file per document into the output directory
83
86
 
84
87
  Content is chosen in this order:
85
88
 
@@ -88,7 +91,7 @@ Content is chosen in this order:
88
91
  3. `last_viewed_panel.original_content`
89
92
  4. raw `content`
90
93
 
91
- Each note file includes:
94
+ Markdown note files include:
92
95
 
93
96
  - YAML frontmatter with the document id, created timestamp, updated timestamp, and tags
94
97
  - a top-level heading from the note title
@@ -102,15 +105,18 @@ The flow is:
102
105
 
103
106
  1. read Granola's cache JSON from disk
104
107
  2. parse the cache payload, whether it is double-encoded or already an object
105
- 3. match transcript segments to documents by document id
106
- 4. format segments as `[HH:MM:SS] Speaker: Text`
107
- 5. write one `.txt` file per document into the output directory
108
+ 3. normalise transcript data into a structured export per document
109
+ 4. match transcript segments to documents by document id
110
+ 5. render each export as text, JSON, YAML, or raw JSON
111
+ 6. write one file per document into the output directory
108
112
 
109
113
  Speaker labels are currently normalised to:
110
114
 
111
115
  - `You` for `microphone`
112
116
  - `System` for everything else
113
117
 
118
+ Structured output formats are useful when you want to post-process exports in scripts instead of reading the default human-oriented Markdown or text files.
119
+
114
120
  ### Incremental Writes
115
121
 
116
122
  Both commands are incremental. They only rewrite an export file when the source document appears newer than the file already on disk.
package/dist/cli.js CHANGED
@@ -430,6 +430,42 @@ async function loadConfig(options) {
430
430
  };
431
431
  }
432
432
  //#endregion
433
+ //#region src/render.ts
434
+ function formatScalar(value) {
435
+ if (value == null) return "null";
436
+ if (typeof value === "string") return JSON.stringify(value);
437
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
438
+ return JSON.stringify(value);
439
+ }
440
+ function renderYaml(value, depth = 0) {
441
+ const indent = " ".repeat(depth);
442
+ if (Array.isArray(value)) {
443
+ if (value.length === 0) return [`${indent}[]`];
444
+ return value.flatMap((item) => {
445
+ if (item && typeof item === "object" && !Array.isArray(item)) {
446
+ const nested = renderYaml(item, depth + 1);
447
+ return [`${indent}- ${(nested[0] ?? `${" ".repeat(depth + 1)}{}`).trimStart()}`, ...nested.slice(1)];
448
+ }
449
+ return [`${indent}- ${formatScalar(item)}`];
450
+ });
451
+ }
452
+ if (value && typeof value === "object") {
453
+ const entries = Object.entries(value);
454
+ if (entries.length === 0) return [`${indent}{}`];
455
+ return entries.flatMap(([key, entryValue]) => {
456
+ if (Array.isArray(entryValue) || entryValue && typeof entryValue === "object") return [`${indent}${key}:`, ...renderYaml(entryValue, depth + 1)];
457
+ return [`${indent}${key}: ${formatScalar(entryValue)}`];
458
+ });
459
+ }
460
+ return [`${indent}${formatScalar(value)}`];
461
+ }
462
+ function toYaml(value) {
463
+ return `${renderYaml(value).join("\n").trimEnd()}\n`;
464
+ }
465
+ function toJson(value) {
466
+ return `${JSON.stringify(value, null, 2)}\n`;
467
+ }
468
+ //#endregion
433
469
  //#region src/prosemirror.ts
434
470
  function repeatIndent(level) {
435
471
  return " ".repeat(level);
@@ -525,35 +561,98 @@ function extractPlainText(doc) {
525
561
  }
526
562
  //#endregion
527
563
  //#region src/notes.ts
528
- function documentToMarkdown(document) {
564
+ function selectNoteContent(document) {
565
+ const notes = convertProseMirrorToMarkdown(document.notes).trim();
566
+ if (notes) return {
567
+ content: notes,
568
+ source: "notes"
569
+ };
570
+ const lastViewedPanel = convertProseMirrorToMarkdown(document.lastViewedPanel?.content).trim();
571
+ if (lastViewedPanel) return {
572
+ content: lastViewedPanel,
573
+ source: "lastViewedPanel.content"
574
+ };
575
+ const originalContent = htmlToMarkdownFallback(document.lastViewedPanel?.originalContent ?? "").trim();
576
+ if (originalContent) return {
577
+ content: originalContent,
578
+ source: "lastViewedPanel.originalContent"
579
+ };
580
+ return {
581
+ content: document.content.trim(),
582
+ source: "content"
583
+ };
584
+ }
585
+ function buildNoteExport(document) {
586
+ const { content, source } = selectNoteContent(document);
587
+ return {
588
+ content,
589
+ contentSource: source,
590
+ createdAt: document.createdAt,
591
+ id: document.id,
592
+ raw: document,
593
+ tags: document.tags,
594
+ title: document.title,
595
+ updatedAt: document.updatedAt
596
+ };
597
+ }
598
+ function renderNoteExport(note, format = "markdown") {
599
+ switch (format) {
600
+ case "json": return toJson({
601
+ content: note.content,
602
+ contentSource: note.contentSource,
603
+ createdAt: note.createdAt,
604
+ id: note.id,
605
+ tags: note.tags,
606
+ title: note.title,
607
+ updatedAt: note.updatedAt
608
+ });
609
+ case "raw": return toJson(note.raw);
610
+ case "yaml": return toYaml({
611
+ content: note.content,
612
+ contentSource: note.contentSource,
613
+ createdAt: note.createdAt,
614
+ id: note.id,
615
+ tags: note.tags,
616
+ title: note.title,
617
+ updatedAt: note.updatedAt
618
+ });
619
+ case "markdown": break;
620
+ }
529
621
  const lines = [
530
622
  "---",
531
- `id: ${quoteYamlString(document.id)}`,
532
- `created: ${quoteYamlString(document.createdAt)}`,
533
- `updated: ${quoteYamlString(document.updatedAt)}`
623
+ `id: ${quoteYamlString(note.id)}`,
624
+ `created: ${quoteYamlString(note.createdAt)}`,
625
+ `updated: ${quoteYamlString(note.updatedAt)}`
534
626
  ];
535
- if (document.tags.length > 0) {
627
+ if (note.tags.length > 0) {
536
628
  lines.push("tags:");
537
- for (const tag of document.tags) lines.push(` - ${quoteYamlString(tag)}`);
629
+ for (const tag of note.tags) lines.push(` - ${quoteYamlString(tag)}`);
538
630
  }
539
631
  lines.push("---", "");
540
- if (document.title.trim()) lines.push(`# ${document.title.trim()}`, "");
541
- const content = convertProseMirrorToMarkdown(document.notes).trim() || convertProseMirrorToMarkdown(document.lastViewedPanel?.content).trim() || htmlToMarkdownFallback(document.lastViewedPanel?.originalContent ?? "").trim() || document.content.trim();
542
- if (content) lines.push(content);
632
+ if (note.title.trim()) lines.push(`# ${note.title.trim()}`, "");
633
+ if (note.content) lines.push(note.content);
543
634
  return `${lines.join("\n").trimEnd()}\n`;
544
635
  }
545
636
  function documentFilename(document) {
546
637
  return sanitiseFilename(document.title || document.id, "untitled");
547
638
  }
548
- async function writeNotes(documents, outputDir) {
639
+ function noteFileExtension(format) {
640
+ switch (format) {
641
+ case "json": return ".json";
642
+ case "raw": return ".raw.json";
643
+ case "yaml": return ".yaml";
644
+ case "markdown": return ".md";
645
+ }
646
+ }
647
+ async function writeNotes(documents, outputDir, format = "markdown") {
549
648
  await ensureDirectory(outputDir);
550
649
  const sorted = [...documents].sort((left, right) => compareStrings(left.title || left.id, right.title || right.id) || compareStrings(left.id, right.id));
551
650
  const used = /* @__PURE__ */ new Map();
552
651
  let written = 0;
553
652
  for (const document of sorted) {
554
- const filePath = join(outputDir, `${makeUniqueFilename(documentFilename(document), used)}.md`);
653
+ const filePath = join(outputDir, `${makeUniqueFilename(documentFilename(document), used)}${noteFileExtension(format)}`);
555
654
  if (!await shouldWriteFile(filePath, latestDocumentTimestamp(document))) continue;
556
- await writeTextFile(filePath, documentToMarkdown(document));
655
+ await writeTextFile(filePath, renderNoteExport(buildNoteExport(document), format));
557
656
  written += 1;
558
657
  }
559
658
  return written;
@@ -572,7 +671,8 @@ Usage:
572
671
  granola notes [options]
573
672
 
574
673
  Options:
575
- --output <path> Output directory for Markdown files (default: ./notes)
674
+ --format <value> Output format: markdown, json, yaml, raw (default: markdown)
675
+ --output <path> Output directory for note files (default: ./notes)
576
676
  --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
577
677
  --supabase <path> Path to supabase.json
578
678
  --debug Enable debug logging
@@ -581,8 +681,9 @@ Options:
581
681
  `;
582
682
  }
583
683
  const notesCommand = {
584
- description: "Export Granola notes to Markdown",
684
+ description: "Export Granola notes",
585
685
  flags: {
686
+ format: { type: "string" },
586
687
  help: { type: "boolean" },
587
688
  output: { type: "string" },
588
689
  timeout: { type: "string" }
@@ -599,6 +700,8 @@ const notesCommand = {
599
700
  debug(config.debug, "supabase", config.supabase);
600
701
  debug(config.debug, "timeoutMs", config.notes.timeoutMs);
601
702
  debug(config.debug, "output", config.notes.output);
703
+ const format = resolveNoteFormat(commandFlags.format);
704
+ debug(config.debug, "format", format);
602
705
  console.log("Fetching documents from Granola API...");
603
706
  const tokenProvider = new CachedTokenProvider(new SupabaseFileTokenSource(config.supabase), new NoopTokenStore());
604
707
  const documents = await new GranolaApiClient(new AuthenticatedHttpClient({
@@ -606,12 +709,22 @@ const notesCommand = {
606
709
  tokenProvider
607
710
  })).listDocuments({ timeoutMs: config.notes.timeoutMs });
608
711
  console.log(`Exporting ${documents.length} notes to ${config.notes.output}...`);
609
- const written = await writeNotes(documents, config.notes.output);
712
+ const written = await writeNotes(documents, config.notes.output, format);
610
713
  console.log("✓ Export completed successfully");
611
714
  debug(config.debug, "notes written", written);
612
715
  return 0;
613
716
  }
614
717
  };
718
+ function resolveNoteFormat(value) {
719
+ switch (value) {
720
+ case void 0: return "markdown";
721
+ case "json":
722
+ case "markdown":
723
+ case "raw":
724
+ case "yaml": return value;
725
+ default: throw new Error("invalid notes format: expected markdown, json, yaml, or raw");
726
+ }
727
+ }
615
728
  //#endregion
616
729
  //#region src/cache.ts
617
730
  function parseCacheDocument(id, value) {
@@ -668,27 +781,78 @@ function parseCacheContents(contents) {
668
781
  }
669
782
  //#endregion
670
783
  //#region src/transcripts.ts
671
- function formatTranscript(document, segments) {
672
- if (segments.length === 0) return "";
784
+ function buildTranscriptExport(document, segments) {
785
+ const renderedSegments = segments.map((segment) => ({
786
+ endTimestamp: segment.endTimestamp,
787
+ id: segment.id,
788
+ isFinal: segment.isFinal,
789
+ source: segment.source,
790
+ speaker: transcriptSpeakerLabel(segment),
791
+ startTimestamp: segment.startTimestamp,
792
+ text: segment.text
793
+ }));
794
+ return {
795
+ createdAt: document.createdAt,
796
+ id: document.id,
797
+ raw: {
798
+ document,
799
+ segments
800
+ },
801
+ segments: renderedSegments,
802
+ title: document.title,
803
+ updatedAt: document.updatedAt
804
+ };
805
+ }
806
+ function renderTranscriptExport(transcript, format = "text") {
807
+ switch (format) {
808
+ case "json": return toJson({
809
+ createdAt: transcript.createdAt,
810
+ id: transcript.id,
811
+ segments: transcript.segments,
812
+ title: transcript.title,
813
+ updatedAt: transcript.updatedAt
814
+ });
815
+ case "raw": return toJson(transcript.raw);
816
+ case "yaml": return toYaml({
817
+ createdAt: transcript.createdAt,
818
+ id: transcript.id,
819
+ segments: transcript.segments,
820
+ title: transcript.title,
821
+ updatedAt: transcript.updatedAt
822
+ });
823
+ case "text": break;
824
+ }
825
+ return formatTranscriptText(transcript);
826
+ }
827
+ function formatTranscriptText(transcript) {
828
+ if (transcript.segments.length === 0) return "";
673
829
  const header = [
674
830
  "=".repeat(80),
675
- document.title || document.id,
676
- `ID: ${document.id}`,
677
- document.createdAt ? `Created: ${document.createdAt}` : "",
678
- document.updatedAt ? `Updated: ${document.updatedAt}` : "",
679
- `Segments: ${segments.length}`,
831
+ transcript.title || transcript.id,
832
+ `ID: ${transcript.id}`,
833
+ transcript.createdAt ? `Created: ${transcript.createdAt}` : "",
834
+ transcript.updatedAt ? `Updated: ${transcript.updatedAt}` : "",
835
+ `Segments: ${transcript.segments.length}`,
680
836
  "=".repeat(80),
681
837
  ""
682
838
  ].filter(Boolean);
683
- const body = segments.map((segment) => {
684
- return `[${formatTimestampForTranscript(segment.startTimestamp)}] ${transcriptSpeakerLabel(segment)}: ${segment.text}`;
839
+ const body = transcript.segments.map((segment) => {
840
+ return `[${formatTimestampForTranscript(segment.startTimestamp)}] ${segment.speaker}: ${segment.text}`;
685
841
  });
686
842
  return `${[...header, ...body].join("\n").trimEnd()}\n`;
687
843
  }
688
844
  function transcriptFilename(document) {
689
845
  return sanitiseFilename(document.title || document.id, "untitled");
690
846
  }
691
- async function writeTranscripts(cacheData, outputDir) {
847
+ function transcriptFileExtension(format) {
848
+ switch (format) {
849
+ case "json": return ".json";
850
+ case "raw": return ".raw.json";
851
+ case "text": return ".txt";
852
+ case "yaml": return ".yaml";
853
+ }
854
+ }
855
+ async function writeTranscripts(cacheData, outputDir, format = "text") {
692
856
  await ensureDirectory(outputDir);
693
857
  const entries = Object.entries(cacheData.transcripts).filter(([, segments]) => segments.length > 0).sort(([leftId], [rightId]) => {
694
858
  const leftDocument = cacheData.documents[leftId];
@@ -704,9 +868,9 @@ async function writeTranscripts(cacheData, outputDir) {
704
868
  title: documentId,
705
869
  updatedAt: ""
706
870
  };
707
- const filePath = join(outputDir, `${makeUniqueFilename(transcriptFilename(document), used)}.txt`);
871
+ const filePath = join(outputDir, `${makeUniqueFilename(transcriptFilename(document), used)}${transcriptFileExtension(format)}`);
708
872
  if (!await shouldWriteFile(filePath, document.updatedAt)) continue;
709
- const content = formatTranscript(document, segments);
873
+ const content = renderTranscriptExport(buildTranscriptExport(document, segments), format);
710
874
  if (!content) continue;
711
875
  await writeTextFile(filePath, content);
712
876
  written += 1;
@@ -723,18 +887,18 @@ Usage:
723
887
 
724
888
  Options:
725
889
  --cache <path> Path to Granola cache JSON
890
+ --format <value> Output format: text, json, yaml, raw (default: text)
726
891
  --output <path> Output directory for transcript files (default: ./transcripts)
727
892
  --debug Enable debug logging
728
893
  --config <path> Path to .granola.toml
729
894
  -h, --help Show help
730
895
  `;
731
896
  }
732
- //#endregion
733
- //#region src/commands/index.ts
734
- const commands = [notesCommand, {
735
- description: "Export Granola transcripts to text files",
897
+ const transcriptsCommand = {
898
+ description: "Export Granola transcripts",
736
899
  flags: {
737
900
  cache: { type: "string" },
901
+ format: { type: "string" },
738
902
  help: { type: "boolean" },
739
903
  output: { type: "string" }
740
904
  },
@@ -749,16 +913,31 @@ const commands = [notesCommand, {
749
913
  debug(config.debug, "using config", config.configFileUsed ?? "(none)");
750
914
  debug(config.debug, "cacheFile", config.transcripts.cacheFile);
751
915
  debug(config.debug, "output", config.transcripts.output);
916
+ const format = resolveTranscriptFormat(commandFlags.format);
917
+ debug(config.debug, "format", format);
752
918
  console.log("Reading Granola cache file...");
753
919
  const cacheData = parseCacheContents(await readFile(config.transcripts.cacheFile, "utf8"));
754
920
  const transcriptCount = Object.values(cacheData.transcripts).filter((segments) => segments.length > 0).length;
755
921
  console.log(`Exporting ${transcriptCount} transcripts to ${config.transcripts.output}...`);
756
- const written = await writeTranscripts(cacheData, config.transcripts.output);
922
+ const written = await writeTranscripts(cacheData, config.transcripts.output, format);
757
923
  console.log("✓ Export completed successfully");
758
924
  debug(config.debug, "transcripts written", written);
759
925
  return 0;
760
926
  }
761
- }];
927
+ };
928
+ function resolveTranscriptFormat(value) {
929
+ switch (value) {
930
+ case void 0: return "text";
931
+ case "json":
932
+ case "raw":
933
+ case "text":
934
+ case "yaml": return value;
935
+ default: throw new Error("invalid transcripts format: expected text, json, yaml, or raw");
936
+ }
937
+ }
938
+ //#endregion
939
+ //#region src/commands/index.ts
940
+ const commands = [notesCommand, transcriptsCommand];
762
941
  const commandMap = new Map(commands.map((command) => [command.name, command]));
763
942
  //#endregion
764
943
  //#region src/flags.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "CLI toolkit for exporting and working with Granola notes and transcripts",
5
5
  "keywords": [
6
6
  "cli",