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.
- package/README.md +27 -9
- package/dist/cli.js +375 -72
- 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,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.
|
|
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
|
-
Both commands
|
|
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
|
|
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
|
|
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(
|
|
532
|
-
`created: ${quoteYamlString(
|
|
533
|
-
`updated: ${quoteYamlString(
|
|
742
|
+
`id: ${quoteYamlString(note.id)}`,
|
|
743
|
+
`created: ${quoteYamlString(note.createdAt)}`,
|
|
744
|
+
`updated: ${quoteYamlString(note.updatedAt)}`
|
|
534
745
|
];
|
|
535
|
-
if (
|
|
746
|
+
if (note.tags.length > 0) {
|
|
536
747
|
lines.push("tags:");
|
|
537
|
-
for (const tag of
|
|
748
|
+
for (const tag of note.tags) lines.push(` - ${quoteYamlString(tag)}`);
|
|
538
749
|
}
|
|
539
750
|
lines.push("---", "");
|
|
540
|
-
if (
|
|
541
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
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
|
-
--
|
|
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
|
|
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
|
|
672
|
-
|
|
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
|
-
|
|
676
|
-
`ID: ${
|
|
677
|
-
|
|
678
|
-
|
|
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)}] ${
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
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
|
-
|
|
733
|
-
|
|
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
|