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.
- package/README.md +13 -7
- package/dist/cli.js +212 -33
- 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.
|
|
81
|
-
5.
|
|
82
|
-
6.
|
|
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
|
-
|
|
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.
|
|
106
|
-
4.
|
|
107
|
-
5.
|
|
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
|
|
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(
|
|
532
|
-
`created: ${quoteYamlString(
|
|
533
|
-
`updated: ${quoteYamlString(
|
|
623
|
+
`id: ${quoteYamlString(note.id)}`,
|
|
624
|
+
`created: ${quoteYamlString(note.createdAt)}`,
|
|
625
|
+
`updated: ${quoteYamlString(note.updatedAt)}`
|
|
534
626
|
];
|
|
535
|
-
if (
|
|
627
|
+
if (note.tags.length > 0) {
|
|
536
628
|
lines.push("tags:");
|
|
537
|
-
for (const tag of
|
|
629
|
+
for (const tag of note.tags) lines.push(` - ${quoteYamlString(tag)}`);
|
|
538
630
|
}
|
|
539
631
|
lines.push("---", "");
|
|
540
|
-
if (
|
|
541
|
-
|
|
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
|
-
|
|
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)}
|
|
653
|
+
const filePath = join(outputDir, `${makeUniqueFilename(documentFilename(document), used)}${noteFileExtension(format)}`);
|
|
555
654
|
if (!await shouldWriteFile(filePath, latestDocumentTimestamp(document))) continue;
|
|
556
|
-
await writeTextFile(filePath,
|
|
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
|
-
--
|
|
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
|
|
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
|
|
672
|
-
|
|
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
|
-
|
|
676
|
-
`ID: ${
|
|
677
|
-
|
|
678
|
-
|
|
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)}] ${
|
|
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
|
-
|
|
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)}
|
|
871
|
+
const filePath = join(outputDir, `${makeUniqueFilename(transcriptFilename(document), used)}${transcriptFileExtension(format)}`);
|
|
708
872
|
if (!await shouldWriteFile(filePath, document.updatedAt)) continue;
|
|
709
|
-
const content =
|
|
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
|
-
|
|
733
|
-
|
|
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
|