granola-toolkit 0.3.0 → 0.5.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 +27 -9
  2. package/dist/cli.js +375 -72
  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,20 +105,35 @@ 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
- Both commands are incremental. They only rewrite an export file when the source document appears newer than the file already on disk.
122
+ Both commands keep a small hidden state file in the output directory to track:
123
+
124
+ - document id to filename
125
+ - content hash
126
+ - source timestamp
127
+ - last export time
128
+
129
+ That state is used to:
130
+
131
+ - keep filenames stable even if a meeting title changes later
132
+ - skip rewrites when the rendered content is unchanged
133
+ - migrate old files cleanly when the output format changes
134
+ - delete stale exports when a document disappears from the source data
117
135
 
118
- That means repeated runs are cheap, and you can safely point the CLI at the same output directory over time.
136
+ That makes repeated runs cheap and keeps long-lived export directories much cleaner.
119
137
 
120
138
  ## Config
121
139
 
package/dist/cli.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { existsSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
+ import { createHash } from "node:crypto";
6
7
  //#region src/utils.ts
7
8
  const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g;
8
9
  const CONTROL_CHARACTERS = /\p{Cc}/gu;
@@ -147,16 +148,6 @@ function latestDocumentTimestamp(document) {
147
148
  });
148
149
  return candidates[0] ?? document.updatedAt;
149
150
  }
150
- async function shouldWriteFile(filePath, updatedAt) {
151
- try {
152
- const existing = await stat(filePath);
153
- const updatedTime = new Date(updatedAt);
154
- if (Number.isNaN(updatedTime.getTime())) return true;
155
- return updatedTime.getTime() > existing.mtime.getTime();
156
- } catch {
157
- return true;
158
- }
159
- }
160
151
  async function writeTextFile(filePath, content) {
161
152
  await mkdir(dirname(filePath), { recursive: true });
162
153
  await writeFile(filePath, content, "utf8");
@@ -430,6 +421,170 @@ async function loadConfig(options) {
430
421
  };
431
422
  }
432
423
  //#endregion
424
+ //#region src/export-state.ts
425
+ const EXPORT_STATE_VERSION = 1;
426
+ function exportStatePath(outputDir, kind) {
427
+ return join(outputDir, `.granola-toolkit-${kind}-state.json`);
428
+ }
429
+ function emptyExportState(kind) {
430
+ return {
431
+ entries: {},
432
+ kind,
433
+ version: EXPORT_STATE_VERSION
434
+ };
435
+ }
436
+ function normaliseExportState(parsed, kind) {
437
+ const record = asRecord(parsed);
438
+ if (!record || record.version !== EXPORT_STATE_VERSION || record.kind !== kind) return emptyExportState(kind);
439
+ const rawEntries = asRecord(record.entries) ?? {};
440
+ return {
441
+ entries: Object.fromEntries(Object.entries(rawEntries).map(([id, entry]) => {
442
+ const value = asRecord(entry);
443
+ if (!value) return;
444
+ const fileName = stringValue(value.fileName);
445
+ const fileStem = stringValue(value.fileStem);
446
+ if (!fileName || !fileStem) return;
447
+ return [id, {
448
+ contentHash: stringValue(value.contentHash),
449
+ exportedAt: stringValue(value.exportedAt),
450
+ fileName,
451
+ fileStem,
452
+ sourceUpdatedAt: stringValue(value.sourceUpdatedAt)
453
+ }];
454
+ }).filter((entry) => Boolean(entry))),
455
+ kind,
456
+ version: EXPORT_STATE_VERSION
457
+ };
458
+ }
459
+ async function loadExportState(outputDir, kind) {
460
+ const statePath = exportStatePath(outputDir, kind);
461
+ try {
462
+ return normaliseExportState(parseJsonString(await readUtf8(statePath)), kind);
463
+ } catch {
464
+ return emptyExportState(kind);
465
+ }
466
+ }
467
+ function hashContent(content) {
468
+ return createHash("sha256").update(content).digest("hex");
469
+ }
470
+ function reserveStem(used, preferredStem, existingStem) {
471
+ if (existingStem && (used.get(existingStem) ?? 0) === 0) {
472
+ used.set(existingStem, 1);
473
+ return existingStem;
474
+ }
475
+ return makeUniqueFilename(preferredStem, used);
476
+ }
477
+ async function fileExists(pathname) {
478
+ try {
479
+ await stat(pathname);
480
+ return true;
481
+ } catch {
482
+ return false;
483
+ }
484
+ }
485
+ function entryChanged(left, right) {
486
+ if (!left) return true;
487
+ return left.contentHash !== right.contentHash || left.exportedAt !== right.exportedAt || left.fileName !== right.fileName || left.fileStem !== right.fileStem || left.sourceUpdatedAt !== right.sourceUpdatedAt;
488
+ }
489
+ async function syncManagedExports({ items, kind, outputDir }) {
490
+ await ensureDirectory(outputDir);
491
+ const previousEntries = (await loadExportState(outputDir, kind)).entries;
492
+ const used = /* @__PURE__ */ new Map();
493
+ const plans = items.map((item) => {
494
+ const existing = previousEntries[item.id];
495
+ const fileStem = reserveStem(used, item.preferredStem, existing?.fileStem);
496
+ return {
497
+ content: item.content,
498
+ contentHash: hashContent(item.content),
499
+ existing,
500
+ fileName: `${fileStem}${item.extension}`,
501
+ fileStem,
502
+ id: item.id,
503
+ sourceUpdatedAt: item.sourceUpdatedAt
504
+ };
505
+ });
506
+ const activeIds = new Set(plans.map((plan) => plan.id));
507
+ const activeFileNames = new Set(plans.map((plan) => plan.fileName));
508
+ const exportedAt = (/* @__PURE__ */ new Date()).toISOString();
509
+ const nextEntries = {};
510
+ let written = 0;
511
+ let stateChanged = false;
512
+ for (const plan of plans) {
513
+ const filePath = join(outputDir, plan.fileName);
514
+ const shouldWrite = !plan.existing || plan.existing.contentHash !== plan.contentHash || plan.existing.fileName !== plan.fileName || !await fileExists(filePath);
515
+ if (shouldWrite) {
516
+ await writeTextFile(filePath, plan.content);
517
+ written += 1;
518
+ }
519
+ const nextEntry = {
520
+ contentHash: plan.contentHash,
521
+ exportedAt: shouldWrite ? exportedAt : plan.existing?.exportedAt ?? exportedAt,
522
+ fileName: plan.fileName,
523
+ fileStem: plan.fileStem,
524
+ sourceUpdatedAt: plan.sourceUpdatedAt
525
+ };
526
+ nextEntries[plan.id] = nextEntry;
527
+ stateChanged = stateChanged || entryChanged(plan.existing, nextEntry);
528
+ }
529
+ for (const plan of plans) {
530
+ const previousFileName = plan.existing?.fileName;
531
+ if (previousFileName && previousFileName !== plan.fileName && !activeFileNames.has(previousFileName)) {
532
+ await rm(join(outputDir, previousFileName), { force: true });
533
+ stateChanged = true;
534
+ }
535
+ }
536
+ for (const [id, entry] of Object.entries(previousEntries)) {
537
+ if (activeIds.has(id)) continue;
538
+ if (!activeFileNames.has(entry.fileName)) await rm(join(outputDir, entry.fileName), { force: true });
539
+ stateChanged = true;
540
+ }
541
+ const serialisedState = `${JSON.stringify({
542
+ entries: nextEntries,
543
+ kind,
544
+ version: EXPORT_STATE_VERSION
545
+ }, null, 2)}\n`;
546
+ const statePath = exportStatePath(outputDir, kind);
547
+ const existingState = await fileExists(statePath) ? await readUtf8(statePath) : void 0;
548
+ if (stateChanged || existingState !== serialisedState) await writeTextFile(statePath, serialisedState);
549
+ return written;
550
+ }
551
+ //#endregion
552
+ //#region src/render.ts
553
+ function formatScalar(value) {
554
+ if (value == null) return "null";
555
+ if (typeof value === "string") return JSON.stringify(value);
556
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
557
+ return JSON.stringify(value);
558
+ }
559
+ function renderYaml(value, depth = 0) {
560
+ const indent = " ".repeat(depth);
561
+ if (Array.isArray(value)) {
562
+ if (value.length === 0) return [`${indent}[]`];
563
+ return value.flatMap((item) => {
564
+ if (item && typeof item === "object" && !Array.isArray(item)) {
565
+ const nested = renderYaml(item, depth + 1);
566
+ return [`${indent}- ${(nested[0] ?? `${" ".repeat(depth + 1)}{}`).trimStart()}`, ...nested.slice(1)];
567
+ }
568
+ return [`${indent}- ${formatScalar(item)}`];
569
+ });
570
+ }
571
+ if (value && typeof value === "object") {
572
+ const entries = Object.entries(value);
573
+ if (entries.length === 0) return [`${indent}{}`];
574
+ return entries.flatMap(([key, entryValue]) => {
575
+ if (Array.isArray(entryValue) || entryValue && typeof entryValue === "object") return [`${indent}${key}:`, ...renderYaml(entryValue, depth + 1)];
576
+ return [`${indent}${key}: ${formatScalar(entryValue)}`];
577
+ });
578
+ }
579
+ return [`${indent}${formatScalar(value)}`];
580
+ }
581
+ function toYaml(value) {
582
+ return `${renderYaml(value).join("\n").trimEnd()}\n`;
583
+ }
584
+ function toJson(value) {
585
+ return `${JSON.stringify(value, null, 2)}\n`;
586
+ }
587
+ //#endregion
433
588
  //#region src/prosemirror.ts
434
589
  function repeatIndent(level) {
435
590
  return " ".repeat(level);
@@ -525,38 +680,104 @@ function extractPlainText(doc) {
525
680
  }
526
681
  //#endregion
527
682
  //#region src/notes.ts
528
- function documentToMarkdown(document) {
683
+ function selectNoteContent(document) {
684
+ const notes = convertProseMirrorToMarkdown(document.notes).trim();
685
+ if (notes) return {
686
+ content: notes,
687
+ source: "notes"
688
+ };
689
+ const lastViewedPanel = convertProseMirrorToMarkdown(document.lastViewedPanel?.content).trim();
690
+ if (lastViewedPanel) return {
691
+ content: lastViewedPanel,
692
+ source: "lastViewedPanel.content"
693
+ };
694
+ const originalContent = htmlToMarkdownFallback(document.lastViewedPanel?.originalContent ?? "").trim();
695
+ if (originalContent) return {
696
+ content: originalContent,
697
+ source: "lastViewedPanel.originalContent"
698
+ };
699
+ return {
700
+ content: document.content.trim(),
701
+ source: "content"
702
+ };
703
+ }
704
+ function buildNoteExport(document) {
705
+ const { content, source } = selectNoteContent(document);
706
+ return {
707
+ content,
708
+ contentSource: source,
709
+ createdAt: document.createdAt,
710
+ id: document.id,
711
+ raw: document,
712
+ tags: document.tags,
713
+ title: document.title,
714
+ updatedAt: document.updatedAt
715
+ };
716
+ }
717
+ function renderNoteExport(note, format = "markdown") {
718
+ switch (format) {
719
+ case "json": return toJson({
720
+ content: note.content,
721
+ contentSource: note.contentSource,
722
+ createdAt: note.createdAt,
723
+ id: note.id,
724
+ tags: note.tags,
725
+ title: note.title,
726
+ updatedAt: note.updatedAt
727
+ });
728
+ case "raw": return toJson(note.raw);
729
+ case "yaml": return toYaml({
730
+ content: note.content,
731
+ contentSource: note.contentSource,
732
+ createdAt: note.createdAt,
733
+ id: note.id,
734
+ tags: note.tags,
735
+ title: note.title,
736
+ updatedAt: note.updatedAt
737
+ });
738
+ case "markdown": break;
739
+ }
529
740
  const lines = [
530
741
  "---",
531
- `id: ${quoteYamlString(document.id)}`,
532
- `created: ${quoteYamlString(document.createdAt)}`,
533
- `updated: ${quoteYamlString(document.updatedAt)}`
742
+ `id: ${quoteYamlString(note.id)}`,
743
+ `created: ${quoteYamlString(note.createdAt)}`,
744
+ `updated: ${quoteYamlString(note.updatedAt)}`
534
745
  ];
535
- if (document.tags.length > 0) {
746
+ if (note.tags.length > 0) {
536
747
  lines.push("tags:");
537
- for (const tag of document.tags) lines.push(` - ${quoteYamlString(tag)}`);
748
+ for (const tag of note.tags) lines.push(` - ${quoteYamlString(tag)}`);
538
749
  }
539
750
  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);
751
+ if (note.title.trim()) lines.push(`# ${note.title.trim()}`, "");
752
+ if (note.content) lines.push(note.content);
543
753
  return `${lines.join("\n").trimEnd()}\n`;
544
754
  }
545
755
  function documentFilename(document) {
546
756
  return sanitiseFilename(document.title || document.id, "untitled");
547
757
  }
548
- async function writeNotes(documents, outputDir) {
549
- await ensureDirectory(outputDir);
550
- const sorted = [...documents].sort((left, right) => compareStrings(left.title || left.id, right.title || right.id) || compareStrings(left.id, right.id));
551
- const used = /* @__PURE__ */ new Map();
552
- let written = 0;
553
- for (const document of sorted) {
554
- const filePath = join(outputDir, `${makeUniqueFilename(documentFilename(document), used)}.md`);
555
- if (!await shouldWriteFile(filePath, latestDocumentTimestamp(document))) continue;
556
- await writeTextFile(filePath, documentToMarkdown(document));
557
- written += 1;
758
+ function noteFileExtension(format) {
759
+ switch (format) {
760
+ case "json": return ".json";
761
+ case "raw": return ".raw.json";
762
+ case "yaml": return ".yaml";
763
+ case "markdown": return ".md";
558
764
  }
559
- return written;
765
+ }
766
+ async function writeNotes(documents, outputDir, format = "markdown") {
767
+ return await syncManagedExports({
768
+ items: [...documents].sort((left, right) => compareStrings(left.title || left.id, right.title || right.id) || compareStrings(left.id, right.id)).map((document) => {
769
+ const note = buildNoteExport(document);
770
+ return {
771
+ content: renderNoteExport(note, format),
772
+ extension: noteFileExtension(format),
773
+ id: note.id,
774
+ preferredStem: documentFilename(document),
775
+ sourceUpdatedAt: latestDocumentTimestamp(document)
776
+ };
777
+ }),
778
+ kind: "notes",
779
+ outputDir
780
+ });
560
781
  }
561
782
  //#endregion
562
783
  //#region src/commands/shared.ts
@@ -572,7 +793,8 @@ Usage:
572
793
  granola notes [options]
573
794
 
574
795
  Options:
575
- --output <path> Output directory for Markdown files (default: ./notes)
796
+ --format <value> Output format: markdown, json, yaml, raw (default: markdown)
797
+ --output <path> Output directory for note files (default: ./notes)
576
798
  --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
577
799
  --supabase <path> Path to supabase.json
578
800
  --debug Enable debug logging
@@ -581,8 +803,9 @@ Options:
581
803
  `;
582
804
  }
583
805
  const notesCommand = {
584
- description: "Export Granola notes to Markdown",
806
+ description: "Export Granola notes",
585
807
  flags: {
808
+ format: { type: "string" },
586
809
  help: { type: "boolean" },
587
810
  output: { type: "string" },
588
811
  timeout: { type: "string" }
@@ -599,6 +822,8 @@ const notesCommand = {
599
822
  debug(config.debug, "supabase", config.supabase);
600
823
  debug(config.debug, "timeoutMs", config.notes.timeoutMs);
601
824
  debug(config.debug, "output", config.notes.output);
825
+ const format = resolveNoteFormat(commandFlags.format);
826
+ debug(config.debug, "format", format);
602
827
  console.log("Fetching documents from Granola API...");
603
828
  const tokenProvider = new CachedTokenProvider(new SupabaseFileTokenSource(config.supabase), new NoopTokenStore());
604
829
  const documents = await new GranolaApiClient(new AuthenticatedHttpClient({
@@ -606,12 +831,22 @@ const notesCommand = {
606
831
  tokenProvider
607
832
  })).listDocuments({ timeoutMs: config.notes.timeoutMs });
608
833
  console.log(`Exporting ${documents.length} notes to ${config.notes.output}...`);
609
- const written = await writeNotes(documents, config.notes.output);
834
+ const written = await writeNotes(documents, config.notes.output, format);
610
835
  console.log("✓ Export completed successfully");
611
836
  debug(config.debug, "notes written", written);
612
837
  return 0;
613
838
  }
614
839
  };
840
+ function resolveNoteFormat(value) {
841
+ switch (value) {
842
+ case void 0: return "markdown";
843
+ case "json":
844
+ case "markdown":
845
+ case "raw":
846
+ case "yaml": return value;
847
+ default: throw new Error("invalid notes format: expected markdown, json, yaml, or raw");
848
+ }
849
+ }
615
850
  //#endregion
616
851
  //#region src/cache.ts
617
852
  function parseCacheDocument(id, value) {
@@ -668,50 +903,103 @@ function parseCacheContents(contents) {
668
903
  }
669
904
  //#endregion
670
905
  //#region src/transcripts.ts
671
- function formatTranscript(document, segments) {
672
- if (segments.length === 0) return "";
906
+ function buildTranscriptExport(document, segments) {
907
+ const renderedSegments = segments.map((segment) => ({
908
+ endTimestamp: segment.endTimestamp,
909
+ id: segment.id,
910
+ isFinal: segment.isFinal,
911
+ source: segment.source,
912
+ speaker: transcriptSpeakerLabel(segment),
913
+ startTimestamp: segment.startTimestamp,
914
+ text: segment.text
915
+ }));
916
+ return {
917
+ createdAt: document.createdAt,
918
+ id: document.id,
919
+ raw: {
920
+ document,
921
+ segments
922
+ },
923
+ segments: renderedSegments,
924
+ title: document.title,
925
+ updatedAt: document.updatedAt
926
+ };
927
+ }
928
+ function renderTranscriptExport(transcript, format = "text") {
929
+ switch (format) {
930
+ case "json": return toJson({
931
+ createdAt: transcript.createdAt,
932
+ id: transcript.id,
933
+ segments: transcript.segments,
934
+ title: transcript.title,
935
+ updatedAt: transcript.updatedAt
936
+ });
937
+ case "raw": return toJson(transcript.raw);
938
+ case "yaml": return toYaml({
939
+ createdAt: transcript.createdAt,
940
+ id: transcript.id,
941
+ segments: transcript.segments,
942
+ title: transcript.title,
943
+ updatedAt: transcript.updatedAt
944
+ });
945
+ case "text": break;
946
+ }
947
+ return formatTranscriptText(transcript);
948
+ }
949
+ function formatTranscriptText(transcript) {
950
+ if (transcript.segments.length === 0) return "";
673
951
  const header = [
674
952
  "=".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}`,
953
+ transcript.title || transcript.id,
954
+ `ID: ${transcript.id}`,
955
+ transcript.createdAt ? `Created: ${transcript.createdAt}` : "",
956
+ transcript.updatedAt ? `Updated: ${transcript.updatedAt}` : "",
957
+ `Segments: ${transcript.segments.length}`,
680
958
  "=".repeat(80),
681
959
  ""
682
960
  ].filter(Boolean);
683
- const body = segments.map((segment) => {
684
- return `[${formatTimestampForTranscript(segment.startTimestamp)}] ${transcriptSpeakerLabel(segment)}: ${segment.text}`;
961
+ const body = transcript.segments.map((segment) => {
962
+ return `[${formatTimestampForTranscript(segment.startTimestamp)}] ${segment.speaker}: ${segment.text}`;
685
963
  });
686
964
  return `${[...header, ...body].join("\n").trimEnd()}\n`;
687
965
  }
688
966
  function transcriptFilename(document) {
689
967
  return sanitiseFilename(document.title || document.id, "untitled");
690
968
  }
691
- async function writeTranscripts(cacheData, outputDir) {
692
- await ensureDirectory(outputDir);
693
- const entries = Object.entries(cacheData.transcripts).filter(([, segments]) => segments.length > 0).sort(([leftId], [rightId]) => {
694
- const leftDocument = cacheData.documents[leftId];
695
- const rightDocument = cacheData.documents[rightId];
696
- return compareStrings(leftDocument?.title || leftId, rightDocument?.title || rightId) || compareStrings(leftId, rightId);
697
- });
698
- const used = /* @__PURE__ */ new Map();
699
- let written = 0;
700
- for (const [documentId, segments] of entries) {
701
- const document = cacheData.documents[documentId] ?? {
702
- createdAt: "",
703
- id: documentId,
704
- title: documentId,
705
- updatedAt: ""
706
- };
707
- const filePath = join(outputDir, `${makeUniqueFilename(transcriptFilename(document), used)}.txt`);
708
- if (!await shouldWriteFile(filePath, document.updatedAt)) continue;
709
- const content = formatTranscript(document, segments);
710
- if (!content) continue;
711
- await writeTextFile(filePath, content);
712
- written += 1;
969
+ function transcriptFileExtension(format) {
970
+ switch (format) {
971
+ case "json": return ".json";
972
+ case "raw": return ".raw.json";
973
+ case "text": return ".txt";
974
+ case "yaml": return ".yaml";
713
975
  }
714
- return written;
976
+ }
977
+ async function writeTranscripts(cacheData, outputDir, format = "text") {
978
+ return await syncManagedExports({
979
+ items: Object.entries(cacheData.transcripts).filter(([, segments]) => segments.length > 0).sort(([leftId], [rightId]) => {
980
+ const leftDocument = cacheData.documents[leftId];
981
+ const rightDocument = cacheData.documents[rightId];
982
+ return compareStrings(leftDocument?.title || leftId, rightDocument?.title || rightId) || compareStrings(leftId, rightId);
983
+ }).flatMap(([documentId, segments]) => {
984
+ const document = cacheData.documents[documentId] ?? {
985
+ createdAt: "",
986
+ id: documentId,
987
+ title: documentId,
988
+ updatedAt: ""
989
+ };
990
+ const content = renderTranscriptExport(buildTranscriptExport(document, segments), format);
991
+ if (!content) return [];
992
+ return [{
993
+ content,
994
+ extension: transcriptFileExtension(format),
995
+ id: document.id,
996
+ preferredStem: transcriptFilename(document),
997
+ sourceUpdatedAt: document.updatedAt
998
+ }];
999
+ }),
1000
+ kind: "transcripts",
1001
+ outputDir
1002
+ });
715
1003
  }
716
1004
  //#endregion
717
1005
  //#region src/commands/transcripts.ts
@@ -723,18 +1011,18 @@ Usage:
723
1011
 
724
1012
  Options:
725
1013
  --cache <path> Path to Granola cache JSON
1014
+ --format <value> Output format: text, json, yaml, raw (default: text)
726
1015
  --output <path> Output directory for transcript files (default: ./transcripts)
727
1016
  --debug Enable debug logging
728
1017
  --config <path> Path to .granola.toml
729
1018
  -h, --help Show help
730
1019
  `;
731
1020
  }
732
- //#endregion
733
- //#region src/commands/index.ts
734
- const commands = [notesCommand, {
735
- description: "Export Granola transcripts to text files",
1021
+ const transcriptsCommand = {
1022
+ description: "Export Granola transcripts",
736
1023
  flags: {
737
1024
  cache: { type: "string" },
1025
+ format: { type: "string" },
738
1026
  help: { type: "boolean" },
739
1027
  output: { type: "string" }
740
1028
  },
@@ -749,16 +1037,31 @@ const commands = [notesCommand, {
749
1037
  debug(config.debug, "using config", config.configFileUsed ?? "(none)");
750
1038
  debug(config.debug, "cacheFile", config.transcripts.cacheFile);
751
1039
  debug(config.debug, "output", config.transcripts.output);
1040
+ const format = resolveTranscriptFormat(commandFlags.format);
1041
+ debug(config.debug, "format", format);
752
1042
  console.log("Reading Granola cache file...");
753
1043
  const cacheData = parseCacheContents(await readFile(config.transcripts.cacheFile, "utf8"));
754
1044
  const transcriptCount = Object.values(cacheData.transcripts).filter((segments) => segments.length > 0).length;
755
1045
  console.log(`Exporting ${transcriptCount} transcripts to ${config.transcripts.output}...`);
756
- const written = await writeTranscripts(cacheData, config.transcripts.output);
1046
+ const written = await writeTranscripts(cacheData, config.transcripts.output, format);
757
1047
  console.log("✓ Export completed successfully");
758
1048
  debug(config.debug, "transcripts written", written);
759
1049
  return 0;
760
1050
  }
761
- }];
1051
+ };
1052
+ function resolveTranscriptFormat(value) {
1053
+ switch (value) {
1054
+ case void 0: return "text";
1055
+ case "json":
1056
+ case "raw":
1057
+ case "text":
1058
+ case "yaml": return value;
1059
+ default: throw new Error("invalid transcripts format: expected text, json, yaml, or raw");
1060
+ }
1061
+ }
1062
+ //#endregion
1063
+ //#region src/commands/index.ts
1064
+ const commands = [notesCommand, transcriptsCommand];
762
1065
  const commandMap = new Map(commands.map((command) => [command.name, command]));
763
1066
  //#endregion
764
1067
  //#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.5.0",
4
4
  "description": "CLI toolkit for exporting and working with Granola notes and transcripts",
5
5
  "keywords": [
6
6
  "cli",