granola-toolkit 0.9.0 → 0.11.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 +36 -0
- package/dist/cli.js +475 -146
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -32,6 +32,7 @@ Installed CLI:
|
|
|
32
32
|
```bash
|
|
33
33
|
granola --help
|
|
34
34
|
granola auth login
|
|
35
|
+
granola meeting --help
|
|
35
36
|
granola notes --help
|
|
36
37
|
granola transcripts --help
|
|
37
38
|
```
|
|
@@ -41,6 +42,7 @@ Local build:
|
|
|
41
42
|
```bash
|
|
42
43
|
vp pack
|
|
43
44
|
node dist/cli.js --help
|
|
45
|
+
node dist/cli.js meeting --help
|
|
44
46
|
node dist/cli.js notes --help
|
|
45
47
|
node dist/cli.js transcripts --help
|
|
46
48
|
```
|
|
@@ -49,6 +51,7 @@ You can also use the package scripts:
|
|
|
49
51
|
|
|
50
52
|
```bash
|
|
51
53
|
npm run build
|
|
54
|
+
npm run start -- meeting --help
|
|
52
55
|
npm run notes -- --help
|
|
53
56
|
npm run transcripts -- --help
|
|
54
57
|
```
|
|
@@ -72,6 +75,15 @@ node dist/cli.js transcripts --cache "$HOME/Library/Application Support/Granola/
|
|
|
72
75
|
node dist/cli.js transcripts --format yaml --output ./transcripts-yaml
|
|
73
76
|
```
|
|
74
77
|
|
|
78
|
+
Inspect individual meetings:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
granola meeting list --limit 10
|
|
82
|
+
granola meeting list --search planning
|
|
83
|
+
granola meeting view 1234abcd
|
|
84
|
+
granola meeting export 1234abcd --format yaml
|
|
85
|
+
```
|
|
86
|
+
|
|
75
87
|
## How It Works
|
|
76
88
|
|
|
77
89
|
### Notes
|
|
@@ -121,6 +133,30 @@ Speaker labels are currently normalised to:
|
|
|
121
133
|
|
|
122
134
|
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.
|
|
123
135
|
|
|
136
|
+
### Meetings
|
|
137
|
+
|
|
138
|
+
`meeting` combines the API-backed notes path with the local transcript cache so you can inspect one meeting at a time.
|
|
139
|
+
|
|
140
|
+
The flow is:
|
|
141
|
+
|
|
142
|
+
1. read a stored Granola session, or fall back to `supabase.json`
|
|
143
|
+
2. fetch documents from Granola's API
|
|
144
|
+
3. optionally load the local cache for transcript data
|
|
145
|
+
4. resolve a meeting by full id or unique id prefix
|
|
146
|
+
5. render either a list, a human-readable meeting view, or a machine-readable export bundle
|
|
147
|
+
|
|
148
|
+
The human-readable `view` command shows:
|
|
149
|
+
|
|
150
|
+
- meeting metadata
|
|
151
|
+
- the selected notes content
|
|
152
|
+
- transcript lines when the local cache is available
|
|
153
|
+
|
|
154
|
+
The machine-readable `export` command includes:
|
|
155
|
+
|
|
156
|
+
- a meeting summary
|
|
157
|
+
- structured note data plus rendered Markdown
|
|
158
|
+
- structured transcript data plus rendered transcript text when available
|
|
159
|
+
|
|
124
160
|
## Auth
|
|
125
161
|
|
|
126
162
|
If you do not want to keep passing `--supabase`, import the desktop app session once:
|
package/dist/cli.js
CHANGED
|
@@ -5,12 +5,17 @@ import { mkdir, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
|
5
5
|
import { homedir, platform } from "node:os";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
|
+
import { NodeHtmlMarkdown } from "node-html-markdown";
|
|
8
9
|
import { createHash } from "node:crypto";
|
|
9
10
|
//#region src/utils.ts
|
|
10
11
|
const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g;
|
|
11
12
|
const CONTROL_CHARACTERS = /\p{Cc}/gu;
|
|
12
13
|
const MULTIPLE_UNDERSCORES = /_+/g;
|
|
13
14
|
const MULTIPLE_BLANK_LINES = /\n{3,}/g;
|
|
15
|
+
const htmlMarkdown = new NodeHtmlMarkdown({
|
|
16
|
+
bulletMarker: "-",
|
|
17
|
+
ignore: ["script", "style"]
|
|
18
|
+
});
|
|
14
19
|
function normaliseNewlines(value) {
|
|
15
20
|
return value.replace(/\r\n?/g, "\n");
|
|
16
21
|
}
|
|
@@ -117,25 +122,9 @@ function formatTimestampForTranscript(timestamp) {
|
|
|
117
122
|
if (Number.isNaN(parsed.getTime())) return timestamp;
|
|
118
123
|
return parsed.toISOString().slice(11, 19);
|
|
119
124
|
}
|
|
120
|
-
function
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
function htmlToMarkdownFallback(value) {
|
|
124
|
-
let output = normaliseNewlines(decodeHtmlEntities(value));
|
|
125
|
-
output = output.replace(/<br\s*\/?>/gi, "\n");
|
|
126
|
-
output = output.replace(/<li\b[^>]*>/gi, "- ");
|
|
127
|
-
output = output.replace(/<\/li>/gi, "\n");
|
|
128
|
-
output = output.replace(/<h1\b[^>]*>/gi, "# ");
|
|
129
|
-
output = output.replace(/<h2\b[^>]*>/gi, "## ");
|
|
130
|
-
output = output.replace(/<h3\b[^>]*>/gi, "### ");
|
|
131
|
-
output = output.replace(/<h4\b[^>]*>/gi, "#### ");
|
|
132
|
-
output = output.replace(/<h5\b[^>]*>/gi, "##### ");
|
|
133
|
-
output = output.replace(/<h6\b[^>]*>/gi, "###### ");
|
|
134
|
-
output = output.replace(/<\/(p|div|section|article|ul|ol|blockquote|h[1-6])>/gi, "\n\n");
|
|
135
|
-
output = output.replace(/<[^>]+>/g, "");
|
|
136
|
-
output = output.replace(/[ \t]+\n/g, "\n");
|
|
137
|
-
output = output.replace(MULTIPLE_BLANK_LINES, "\n\n");
|
|
138
|
-
return output.trim();
|
|
125
|
+
function htmlToMarkdown(value) {
|
|
126
|
+
if (!value.trim()) return "";
|
|
127
|
+
return normaliseNewlines(htmlMarkdown.translate(value)).replace(/[ \t]+\n/g, "\n").replace(MULTIPLE_BLANK_LINES, "\n\n").trim();
|
|
139
128
|
}
|
|
140
129
|
function latestDocumentTimestamp(document) {
|
|
141
130
|
const candidates = [
|
|
@@ -445,6 +434,60 @@ async function logout() {
|
|
|
445
434
|
return 0;
|
|
446
435
|
}
|
|
447
436
|
//#endregion
|
|
437
|
+
//#region src/cache.ts
|
|
438
|
+
function parseCacheDocument(id, value) {
|
|
439
|
+
const record = asRecord(value);
|
|
440
|
+
if (!record) return;
|
|
441
|
+
return {
|
|
442
|
+
createdAt: stringValue(record.created_at),
|
|
443
|
+
id,
|
|
444
|
+
title: stringValue(record.title),
|
|
445
|
+
updatedAt: stringValue(record.updated_at)
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
function parseTranscriptSegments(value) {
|
|
449
|
+
if (!Array.isArray(value)) return;
|
|
450
|
+
return value.flatMap((segment) => {
|
|
451
|
+
const record = asRecord(segment);
|
|
452
|
+
if (!record) return [];
|
|
453
|
+
return [{
|
|
454
|
+
documentId: stringValue(record.document_id),
|
|
455
|
+
endTimestamp: stringValue(record.end_timestamp),
|
|
456
|
+
id: stringValue(record.id),
|
|
457
|
+
isFinal: Boolean(record.is_final),
|
|
458
|
+
source: stringValue(record.source),
|
|
459
|
+
startTimestamp: stringValue(record.start_timestamp),
|
|
460
|
+
text: stringValue(record.text)
|
|
461
|
+
}];
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
function parseCacheContents(contents) {
|
|
465
|
+
const outer = parseJsonString(contents);
|
|
466
|
+
if (!outer) throw new Error("failed to parse cache JSON");
|
|
467
|
+
const rawCache = outer.cache;
|
|
468
|
+
let cachePayload;
|
|
469
|
+
if (typeof rawCache === "string") cachePayload = parseJsonString(rawCache);
|
|
470
|
+
else cachePayload = asRecord(rawCache);
|
|
471
|
+
const state = cachePayload ? asRecord(cachePayload.state) : void 0;
|
|
472
|
+
if (!state) throw new Error("failed to parse cache state");
|
|
473
|
+
const rawDocuments = asRecord(state.documents) ?? {};
|
|
474
|
+
const rawTranscripts = asRecord(state.transcripts) ?? {};
|
|
475
|
+
const documents = {};
|
|
476
|
+
for (const [id, rawDocument] of Object.entries(rawDocuments)) {
|
|
477
|
+
const document = parseCacheDocument(id, rawDocument);
|
|
478
|
+
if (document) documents[id] = document;
|
|
479
|
+
}
|
|
480
|
+
const transcripts = {};
|
|
481
|
+
for (const [id, rawTranscript] of Object.entries(rawTranscripts)) {
|
|
482
|
+
const segments = parseTranscriptSegments(rawTranscript);
|
|
483
|
+
if (segments) transcripts[id] = segments;
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
documents,
|
|
487
|
+
transcripts
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
//#endregion
|
|
448
491
|
//#region src/client/parsers.ts
|
|
449
492
|
function parseProseMirrorDoc(value, options = {}) {
|
|
450
493
|
if (value == null) return;
|
|
@@ -583,6 +626,22 @@ var AuthenticatedHttpClient = class {
|
|
|
583
626
|
}
|
|
584
627
|
};
|
|
585
628
|
//#endregion
|
|
629
|
+
//#region src/client/default.ts
|
|
630
|
+
async function createDefaultGranolaApiClient(config, logger = console) {
|
|
631
|
+
const sessionStore = createDefaultSessionStore();
|
|
632
|
+
const storedSession = await sessionStore.readSession();
|
|
633
|
+
if (!storedSession && !config.supabase) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
|
|
634
|
+
if (!storedSession && config.supabase && !existsSync(config.supabase)) throw new Error(`supabase.json not found: ${config.supabase}`);
|
|
635
|
+
return new GranolaApiClient(new AuthenticatedHttpClient({
|
|
636
|
+
logger,
|
|
637
|
+
tokenProvider: storedSession ? new StoredSessionTokenProvider(sessionStore, { source: config.supabase && existsSync(config.supabase) ? new SupabaseFileSessionSource(config.supabase) : void 0 }) : new CachedTokenProvider(new SupabaseFileTokenSource(config.supabase), new NoopTokenStore())
|
|
638
|
+
}));
|
|
639
|
+
}
|
|
640
|
+
async function loadOptionalGranolaCache(cacheFile) {
|
|
641
|
+
if (!cacheFile || !existsSync(cacheFile)) return;
|
|
642
|
+
return parseCacheContents(await readFile(cacheFile, "utf8"));
|
|
643
|
+
}
|
|
644
|
+
//#endregion
|
|
586
645
|
//#region src/config.ts
|
|
587
646
|
function pickString(value) {
|
|
588
647
|
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
@@ -928,7 +987,7 @@ function selectNoteContent(document) {
|
|
|
928
987
|
content: lastViewedPanel,
|
|
929
988
|
source: "lastViewedPanel.content"
|
|
930
989
|
};
|
|
931
|
-
const originalContent =
|
|
990
|
+
const originalContent = htmlToMarkdown(document.lastViewedPanel?.originalContent ?? "").trim();
|
|
932
991
|
if (originalContent) return {
|
|
933
992
|
content: originalContent,
|
|
934
993
|
source: "lastViewedPanel.originalContent"
|
|
@@ -1017,131 +1076,6 @@ async function writeNotes(documents, outputDir, format = "markdown") {
|
|
|
1017
1076
|
});
|
|
1018
1077
|
}
|
|
1019
1078
|
//#endregion
|
|
1020
|
-
//#region src/commands/shared.ts
|
|
1021
|
-
function debug(enabled, ...values) {
|
|
1022
|
-
if (enabled) console.error("[debug]", ...values);
|
|
1023
|
-
}
|
|
1024
|
-
//#endregion
|
|
1025
|
-
//#region src/commands/notes.ts
|
|
1026
|
-
function notesHelp() {
|
|
1027
|
-
return `Granola notes
|
|
1028
|
-
|
|
1029
|
-
Usage:
|
|
1030
|
-
granola notes [options]
|
|
1031
|
-
|
|
1032
|
-
Options:
|
|
1033
|
-
--format <value> Output format: markdown, json, yaml, raw (default: markdown)
|
|
1034
|
-
--output <path> Output directory for note files (default: ./notes)
|
|
1035
|
-
--timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
|
|
1036
|
-
--supabase <path> Path to supabase.json
|
|
1037
|
-
--debug Enable debug logging
|
|
1038
|
-
--config <path> Path to .granola.toml
|
|
1039
|
-
-h, --help Show help
|
|
1040
|
-
`;
|
|
1041
|
-
}
|
|
1042
|
-
const notesCommand = {
|
|
1043
|
-
description: "Export Granola notes",
|
|
1044
|
-
flags: {
|
|
1045
|
-
format: { type: "string" },
|
|
1046
|
-
help: { type: "boolean" },
|
|
1047
|
-
output: { type: "string" },
|
|
1048
|
-
timeout: { type: "string" }
|
|
1049
|
-
},
|
|
1050
|
-
help: notesHelp,
|
|
1051
|
-
name: "notes",
|
|
1052
|
-
async run({ commandFlags, globalFlags }) {
|
|
1053
|
-
const config = await loadConfig({
|
|
1054
|
-
globalFlags,
|
|
1055
|
-
subcommandFlags: commandFlags
|
|
1056
|
-
});
|
|
1057
|
-
const sessionStore = createDefaultSessionStore();
|
|
1058
|
-
const storedSession = await sessionStore.readSession();
|
|
1059
|
-
if (!storedSession && !config.supabase) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
|
|
1060
|
-
if (!storedSession && config.supabase && !existsSync(config.supabase)) throw new Error(`supabase.json not found: ${config.supabase}`);
|
|
1061
|
-
debug(config.debug, "using config", config.configFileUsed ?? "(none)");
|
|
1062
|
-
debug(config.debug, "supabase", config.supabase);
|
|
1063
|
-
debug(config.debug, "timeoutMs", config.notes.timeoutMs);
|
|
1064
|
-
debug(config.debug, "output", config.notes.output);
|
|
1065
|
-
const format = resolveNoteFormat(commandFlags.format);
|
|
1066
|
-
debug(config.debug, "format", format);
|
|
1067
|
-
console.log("Fetching documents from Granola API...");
|
|
1068
|
-
const tokenProvider = storedSession ? new StoredSessionTokenProvider(sessionStore, { source: config.supabase && existsSync(config.supabase) ? new SupabaseFileSessionSource(config.supabase) : void 0 }) : new CachedTokenProvider(new SupabaseFileTokenSource(config.supabase), new NoopTokenStore());
|
|
1069
|
-
const documents = await new GranolaApiClient(new AuthenticatedHttpClient({
|
|
1070
|
-
logger: console,
|
|
1071
|
-
tokenProvider
|
|
1072
|
-
})).listDocuments({ timeoutMs: config.notes.timeoutMs });
|
|
1073
|
-
console.log(`Exporting ${documents.length} notes to ${config.notes.output}...`);
|
|
1074
|
-
const written = await writeNotes(documents, config.notes.output, format);
|
|
1075
|
-
console.log("✓ Export completed successfully");
|
|
1076
|
-
debug(config.debug, "notes written", written);
|
|
1077
|
-
return 0;
|
|
1078
|
-
}
|
|
1079
|
-
};
|
|
1080
|
-
function resolveNoteFormat(value) {
|
|
1081
|
-
switch (value) {
|
|
1082
|
-
case void 0: return "markdown";
|
|
1083
|
-
case "json":
|
|
1084
|
-
case "markdown":
|
|
1085
|
-
case "raw":
|
|
1086
|
-
case "yaml": return value;
|
|
1087
|
-
default: throw new Error("invalid notes format: expected markdown, json, yaml, or raw");
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
//#endregion
|
|
1091
|
-
//#region src/cache.ts
|
|
1092
|
-
function parseCacheDocument(id, value) {
|
|
1093
|
-
const record = asRecord(value);
|
|
1094
|
-
if (!record) return;
|
|
1095
|
-
return {
|
|
1096
|
-
createdAt: stringValue(record.created_at),
|
|
1097
|
-
id,
|
|
1098
|
-
title: stringValue(record.title),
|
|
1099
|
-
updatedAt: stringValue(record.updated_at)
|
|
1100
|
-
};
|
|
1101
|
-
}
|
|
1102
|
-
function parseTranscriptSegments(value) {
|
|
1103
|
-
if (!Array.isArray(value)) return;
|
|
1104
|
-
return value.flatMap((segment) => {
|
|
1105
|
-
const record = asRecord(segment);
|
|
1106
|
-
if (!record) return [];
|
|
1107
|
-
return [{
|
|
1108
|
-
documentId: stringValue(record.document_id),
|
|
1109
|
-
endTimestamp: stringValue(record.end_timestamp),
|
|
1110
|
-
id: stringValue(record.id),
|
|
1111
|
-
isFinal: Boolean(record.is_final),
|
|
1112
|
-
source: stringValue(record.source),
|
|
1113
|
-
startTimestamp: stringValue(record.start_timestamp),
|
|
1114
|
-
text: stringValue(record.text)
|
|
1115
|
-
}];
|
|
1116
|
-
});
|
|
1117
|
-
}
|
|
1118
|
-
function parseCacheContents(contents) {
|
|
1119
|
-
const outer = parseJsonString(contents);
|
|
1120
|
-
if (!outer) throw new Error("failed to parse cache JSON");
|
|
1121
|
-
const rawCache = outer.cache;
|
|
1122
|
-
let cachePayload;
|
|
1123
|
-
if (typeof rawCache === "string") cachePayload = parseJsonString(rawCache);
|
|
1124
|
-
else cachePayload = asRecord(rawCache);
|
|
1125
|
-
const state = cachePayload ? asRecord(cachePayload.state) : void 0;
|
|
1126
|
-
if (!state) throw new Error("failed to parse cache state");
|
|
1127
|
-
const rawDocuments = asRecord(state.documents) ?? {};
|
|
1128
|
-
const rawTranscripts = asRecord(state.transcripts) ?? {};
|
|
1129
|
-
const documents = {};
|
|
1130
|
-
for (const [id, rawDocument] of Object.entries(rawDocuments)) {
|
|
1131
|
-
const document = parseCacheDocument(id, rawDocument);
|
|
1132
|
-
if (document) documents[id] = document;
|
|
1133
|
-
}
|
|
1134
|
-
const transcripts = {};
|
|
1135
|
-
for (const [id, rawTranscript] of Object.entries(rawTranscripts)) {
|
|
1136
|
-
const segments = parseTranscriptSegments(rawTranscript);
|
|
1137
|
-
if (segments) transcripts[id] = segments;
|
|
1138
|
-
}
|
|
1139
|
-
return {
|
|
1140
|
-
documents,
|
|
1141
|
-
transcripts
|
|
1142
|
-
};
|
|
1143
|
-
}
|
|
1144
|
-
//#endregion
|
|
1145
1079
|
//#region src/transcripts.ts
|
|
1146
1080
|
function transcriptSegmentKey(segment) {
|
|
1147
1081
|
if (segment.id) return `id:${segment.id}`;
|
|
@@ -1174,7 +1108,9 @@ function normaliseTranscriptSegments(segments) {
|
|
|
1174
1108
|
const current = selected.get(key);
|
|
1175
1109
|
selected.set(key, preferredTranscriptSegment(current, segment));
|
|
1176
1110
|
}
|
|
1177
|
-
|
|
1111
|
+
const resolved = [...selected.values()].sort(compareTranscriptSegments);
|
|
1112
|
+
if (resolved.some((segment) => segment.isFinal)) return resolved.filter((segment) => segment.isFinal);
|
|
1113
|
+
return resolved;
|
|
1178
1114
|
}
|
|
1179
1115
|
function buildTranscriptExport(document, segments, rawSegments = segments) {
|
|
1180
1116
|
const renderedSegments = segments.map((segment) => ({
|
|
@@ -1275,6 +1211,398 @@ async function writeTranscripts(cacheData, outputDir, format = "text") {
|
|
|
1275
1211
|
});
|
|
1276
1212
|
}
|
|
1277
1213
|
//#endregion
|
|
1214
|
+
//#region src/meetings.ts
|
|
1215
|
+
function parseTimestamp(value) {
|
|
1216
|
+
if (!value.trim()) return;
|
|
1217
|
+
const timestamp = Date.parse(value);
|
|
1218
|
+
return Number.isNaN(timestamp) ? void 0 : timestamp;
|
|
1219
|
+
}
|
|
1220
|
+
function compareTimestampsDescending(left, right) {
|
|
1221
|
+
const leftTimestamp = parseTimestamp(left);
|
|
1222
|
+
const rightTimestamp = parseTimestamp(right);
|
|
1223
|
+
if (leftTimestamp != null && rightTimestamp != null) return rightTimestamp - leftTimestamp;
|
|
1224
|
+
if (leftTimestamp != null) return -1;
|
|
1225
|
+
if (rightTimestamp != null) return 1;
|
|
1226
|
+
return compareStrings(right, left);
|
|
1227
|
+
}
|
|
1228
|
+
function compareMeetingDocuments(left, right) {
|
|
1229
|
+
return compareTimestampsDescending(latestDocumentTimestamp(left), latestDocumentTimestamp(right)) || compareTimestampsDescending(left.createdAt, right.createdAt) || compareStrings(left.title || left.id, right.title || right.id) || compareStrings(left.id, right.id);
|
|
1230
|
+
}
|
|
1231
|
+
function serialiseNote(note) {
|
|
1232
|
+
return {
|
|
1233
|
+
content: note.content,
|
|
1234
|
+
contentSource: note.contentSource,
|
|
1235
|
+
createdAt: note.createdAt,
|
|
1236
|
+
id: note.id,
|
|
1237
|
+
tags: [...note.tags],
|
|
1238
|
+
title: note.title,
|
|
1239
|
+
updatedAt: note.updatedAt
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
function serialiseTranscript(transcript) {
|
|
1243
|
+
return {
|
|
1244
|
+
createdAt: transcript.createdAt,
|
|
1245
|
+
id: transcript.id,
|
|
1246
|
+
segments: transcript.segments.map((segment) => ({ ...segment })),
|
|
1247
|
+
title: transcript.title,
|
|
1248
|
+
updatedAt: transcript.updatedAt
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
function cacheDocumentForMeeting(document, cacheData) {
|
|
1252
|
+
return cacheData?.documents[document.id] ?? {
|
|
1253
|
+
createdAt: document.createdAt,
|
|
1254
|
+
id: document.id,
|
|
1255
|
+
title: document.title,
|
|
1256
|
+
updatedAt: latestDocumentTimestamp(document)
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
function buildMeetingTranscript(document, cacheData) {
|
|
1260
|
+
if (!cacheData) return {
|
|
1261
|
+
loaded: false,
|
|
1262
|
+
segmentCount: 0,
|
|
1263
|
+
transcript: null,
|
|
1264
|
+
transcriptText: null
|
|
1265
|
+
};
|
|
1266
|
+
const rawSegments = cacheData.transcripts[document.id] ?? [];
|
|
1267
|
+
const normalisedSegments = normaliseTranscriptSegments(rawSegments);
|
|
1268
|
+
if (normalisedSegments.length === 0) return {
|
|
1269
|
+
loaded: true,
|
|
1270
|
+
segmentCount: 0,
|
|
1271
|
+
transcript: null,
|
|
1272
|
+
transcriptText: null
|
|
1273
|
+
};
|
|
1274
|
+
const transcript = buildTranscriptExport(cacheDocumentForMeeting(document, cacheData), normalisedSegments, rawSegments);
|
|
1275
|
+
return {
|
|
1276
|
+
loaded: true,
|
|
1277
|
+
segmentCount: transcript.segments.length,
|
|
1278
|
+
transcript: serialiseTranscript(transcript),
|
|
1279
|
+
transcriptText: renderTranscriptExport(transcript, "text")
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
function matchesMeetingSearch(document, search) {
|
|
1283
|
+
const query = search.trim().toLowerCase();
|
|
1284
|
+
if (!query) return true;
|
|
1285
|
+
return [
|
|
1286
|
+
document.id,
|
|
1287
|
+
document.title,
|
|
1288
|
+
...document.tags
|
|
1289
|
+
].some((value) => value.toLowerCase().includes(query));
|
|
1290
|
+
}
|
|
1291
|
+
function truncate(value, width) {
|
|
1292
|
+
if (value.length <= width) return value.padEnd(width);
|
|
1293
|
+
return `${value.slice(0, Math.max(0, width - 1))}…`;
|
|
1294
|
+
}
|
|
1295
|
+
function formatMeetingDate(value) {
|
|
1296
|
+
return value.trim().slice(0, 10) || "-";
|
|
1297
|
+
}
|
|
1298
|
+
function formatTranscriptStatus(meeting) {
|
|
1299
|
+
if (!meeting.transcriptLoaded) return "n/a";
|
|
1300
|
+
if (meeting.transcriptSegmentCount === 0) return "none";
|
|
1301
|
+
return String(meeting.transcriptSegmentCount);
|
|
1302
|
+
}
|
|
1303
|
+
function formatTranscriptLines(transcript) {
|
|
1304
|
+
if (!transcript || transcript.segments.length === 0) return "";
|
|
1305
|
+
return transcript.segments.map((segment) => `[${formatTimestampForTranscript(segment.startTimestamp)}] ${segment.speaker}: ${segment.text}`).join("\n");
|
|
1306
|
+
}
|
|
1307
|
+
function buildMeetingSummary(document, cacheData) {
|
|
1308
|
+
const note = buildNoteExport(document);
|
|
1309
|
+
const transcript = buildMeetingTranscript(document, cacheData);
|
|
1310
|
+
return {
|
|
1311
|
+
createdAt: document.createdAt,
|
|
1312
|
+
id: document.id,
|
|
1313
|
+
noteContentSource: note.contentSource,
|
|
1314
|
+
tags: [...document.tags],
|
|
1315
|
+
title: document.title,
|
|
1316
|
+
transcriptLoaded: transcript.loaded,
|
|
1317
|
+
transcriptSegmentCount: transcript.segmentCount,
|
|
1318
|
+
updatedAt: latestDocumentTimestamp(document)
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
function buildMeetingRecord(document, cacheData) {
|
|
1322
|
+
const note = buildNoteExport(document);
|
|
1323
|
+
const transcript = buildMeetingTranscript(document, cacheData);
|
|
1324
|
+
return {
|
|
1325
|
+
meeting: {
|
|
1326
|
+
createdAt: document.createdAt,
|
|
1327
|
+
id: document.id,
|
|
1328
|
+
noteContentSource: note.contentSource,
|
|
1329
|
+
tags: [...document.tags],
|
|
1330
|
+
title: document.title,
|
|
1331
|
+
transcriptLoaded: transcript.loaded,
|
|
1332
|
+
transcriptSegmentCount: transcript.segmentCount,
|
|
1333
|
+
updatedAt: latestDocumentTimestamp(document)
|
|
1334
|
+
},
|
|
1335
|
+
note: serialiseNote(note),
|
|
1336
|
+
noteMarkdown: renderNoteExport(note, "markdown"),
|
|
1337
|
+
transcript: transcript.transcript,
|
|
1338
|
+
transcriptText: transcript.transcriptText
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
function listMeetings(documents, options = {}) {
|
|
1342
|
+
const limit = options.limit ?? 20;
|
|
1343
|
+
return documents.filter((document) => options.search ? matchesMeetingSearch(document, options.search) : true).sort(compareMeetingDocuments).slice(0, limit).map((document) => buildMeetingSummary(document, options.cacheData));
|
|
1344
|
+
}
|
|
1345
|
+
function resolveMeeting(documents, id) {
|
|
1346
|
+
const exactMatch = documents.find((document) => document.id === id);
|
|
1347
|
+
if (exactMatch) return exactMatch;
|
|
1348
|
+
const matches = documents.filter((document) => document.id.startsWith(id));
|
|
1349
|
+
if (matches.length === 1) return matches[0];
|
|
1350
|
+
if (matches.length > 1) {
|
|
1351
|
+
const sample = matches.slice(0, 5).map((document) => document.id.slice(0, 8)).join(", ");
|
|
1352
|
+
throw new Error(`ambiguous meeting id: ${id} matches ${matches.length} meetings (${sample})`);
|
|
1353
|
+
}
|
|
1354
|
+
throw new Error(`meeting not found: ${id}`);
|
|
1355
|
+
}
|
|
1356
|
+
function renderMeetingList(meetings, format = "text") {
|
|
1357
|
+
switch (format) {
|
|
1358
|
+
case "json": return toJson(meetings);
|
|
1359
|
+
case "yaml": return toYaml(meetings);
|
|
1360
|
+
case "text": break;
|
|
1361
|
+
}
|
|
1362
|
+
if (meetings.length === 0) return "No meetings found\n";
|
|
1363
|
+
const lines = [`${"ID".padEnd(10)} ${"DATE".padEnd(10)} ${"TITLE".padEnd(42)} ${"NOTE".padEnd(18)} TRANSCRIPT`, `${"-".repeat(10)} ${"-".repeat(10)} ${"-".repeat(42)} ${"-".repeat(18)} ${"-".repeat(10)}`];
|
|
1364
|
+
for (const meeting of meetings) lines.push([
|
|
1365
|
+
meeting.id.slice(0, 8).padEnd(10),
|
|
1366
|
+
formatMeetingDate(meeting.updatedAt || meeting.createdAt).padEnd(10),
|
|
1367
|
+
truncate(meeting.title || meeting.id, 42),
|
|
1368
|
+
truncate(meeting.noteContentSource, 18),
|
|
1369
|
+
formatTranscriptStatus(meeting)
|
|
1370
|
+
].join(" "));
|
|
1371
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
1372
|
+
}
|
|
1373
|
+
function renderMeetingView(record, format = "text") {
|
|
1374
|
+
switch (format) {
|
|
1375
|
+
case "json": return toJson(record);
|
|
1376
|
+
case "yaml": return toYaml(record);
|
|
1377
|
+
case "text": break;
|
|
1378
|
+
}
|
|
1379
|
+
const tags = record.meeting.tags.length > 0 ? record.meeting.tags.join(", ") : "(none)";
|
|
1380
|
+
const transcriptStatus = !record.meeting.transcriptLoaded ? "cache not loaded" : record.meeting.transcriptSegmentCount === 0 ? "no transcript segments" : `${record.meeting.transcriptSegmentCount} segment(s)`;
|
|
1381
|
+
return `${[
|
|
1382
|
+
`# ${record.meeting.title || record.meeting.id}`,
|
|
1383
|
+
"",
|
|
1384
|
+
`ID: ${record.meeting.id}`,
|
|
1385
|
+
`Created: ${record.meeting.createdAt || "-"}`,
|
|
1386
|
+
`Updated: ${record.meeting.updatedAt || "-"}`,
|
|
1387
|
+
`Tags: ${tags}`,
|
|
1388
|
+
`Note source: ${record.meeting.noteContentSource}`,
|
|
1389
|
+
`Transcript: ${transcriptStatus}`,
|
|
1390
|
+
"",
|
|
1391
|
+
"## Notes",
|
|
1392
|
+
"",
|
|
1393
|
+
record.note.content.trim() || "(no notes)",
|
|
1394
|
+
"",
|
|
1395
|
+
"## Transcript",
|
|
1396
|
+
"",
|
|
1397
|
+
formatTranscriptLines(record.transcript) || (record.meeting.transcriptLoaded ? "(no transcript segments)" : "(Granola cache not loaded)"),
|
|
1398
|
+
""
|
|
1399
|
+
].join("\n").trimEnd()}\n`;
|
|
1400
|
+
}
|
|
1401
|
+
function renderMeetingExport(record, format = "json") {
|
|
1402
|
+
switch (format) {
|
|
1403
|
+
case "json": return toJson(record);
|
|
1404
|
+
case "yaml": return toYaml(record);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
//#endregion
|
|
1408
|
+
//#region src/commands/shared.ts
|
|
1409
|
+
function debug(enabled, ...values) {
|
|
1410
|
+
if (enabled) console.error("[debug]", ...values);
|
|
1411
|
+
}
|
|
1412
|
+
//#endregion
|
|
1413
|
+
//#region src/commands/meeting.ts
|
|
1414
|
+
function meetingHelp() {
|
|
1415
|
+
return `Granola meeting
|
|
1416
|
+
|
|
1417
|
+
Usage:
|
|
1418
|
+
granola meeting <list|view|export> [options]
|
|
1419
|
+
|
|
1420
|
+
Subcommands:
|
|
1421
|
+
list List meetings from the Granola API
|
|
1422
|
+
view <id> Show a single meeting with notes and transcript text
|
|
1423
|
+
export <id> Export a single meeting as JSON or YAML
|
|
1424
|
+
|
|
1425
|
+
Options:
|
|
1426
|
+
--cache <path> Path to Granola cache JSON for transcript data
|
|
1427
|
+
--format <value> list/view: text, json, yaml; export: json, yaml
|
|
1428
|
+
--limit <n> Number of meetings for list (default: 20)
|
|
1429
|
+
--search <query> Filter list by title, id, or tag
|
|
1430
|
+
--timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
|
|
1431
|
+
--supabase <path> Path to supabase.json
|
|
1432
|
+
--debug Enable debug logging
|
|
1433
|
+
--config <path> Path to .granola.toml
|
|
1434
|
+
-h, --help Show help
|
|
1435
|
+
`;
|
|
1436
|
+
}
|
|
1437
|
+
function resolveListFormat(value) {
|
|
1438
|
+
switch (value) {
|
|
1439
|
+
case void 0: return "text";
|
|
1440
|
+
case "json":
|
|
1441
|
+
case "text":
|
|
1442
|
+
case "yaml": return value;
|
|
1443
|
+
default: throw new Error("invalid meeting format: expected text, json, or yaml");
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
function resolveViewFormat(value) {
|
|
1447
|
+
switch (value) {
|
|
1448
|
+
case void 0: return "text";
|
|
1449
|
+
case "json":
|
|
1450
|
+
case "text":
|
|
1451
|
+
case "yaml": return value;
|
|
1452
|
+
default: throw new Error("invalid meeting format: expected text, json, or yaml");
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
function resolveExportFormat(value) {
|
|
1456
|
+
switch (value) {
|
|
1457
|
+
case void 0: return "json";
|
|
1458
|
+
case "json":
|
|
1459
|
+
case "yaml": return value;
|
|
1460
|
+
default: throw new Error("invalid meeting export format: expected json or yaml");
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
function parseLimit(value) {
|
|
1464
|
+
if (value === void 0) return 20;
|
|
1465
|
+
if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid meeting limit: expected a positive integer");
|
|
1466
|
+
const limit = Number(value);
|
|
1467
|
+
if (!Number.isInteger(limit) || limit < 1) throw new Error("invalid meeting limit: expected a positive integer");
|
|
1468
|
+
return limit;
|
|
1469
|
+
}
|
|
1470
|
+
const meetingCommand = {
|
|
1471
|
+
description: "Inspect and export individual Granola meetings",
|
|
1472
|
+
flags: {
|
|
1473
|
+
cache: { type: "string" },
|
|
1474
|
+
format: { type: "string" },
|
|
1475
|
+
help: { type: "boolean" },
|
|
1476
|
+
limit: { type: "string" },
|
|
1477
|
+
search: { type: "string" },
|
|
1478
|
+
timeout: { type: "string" }
|
|
1479
|
+
},
|
|
1480
|
+
help: meetingHelp,
|
|
1481
|
+
name: "meeting",
|
|
1482
|
+
async run({ commandArgs, commandFlags, globalFlags }) {
|
|
1483
|
+
const [action, id] = commandArgs;
|
|
1484
|
+
switch (action) {
|
|
1485
|
+
case "list": return await list(commandFlags, globalFlags);
|
|
1486
|
+
case "view":
|
|
1487
|
+
if (!id) throw new Error("meeting view requires an id");
|
|
1488
|
+
return await view(id, commandFlags, globalFlags);
|
|
1489
|
+
case "export":
|
|
1490
|
+
if (!id) throw new Error("meeting export requires an id");
|
|
1491
|
+
return await exportMeeting(id, commandFlags, globalFlags);
|
|
1492
|
+
case void 0:
|
|
1493
|
+
console.log(meetingHelp());
|
|
1494
|
+
return 1;
|
|
1495
|
+
default: throw new Error("invalid meeting command: expected list, view, or export");
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
};
|
|
1499
|
+
async function loadMeetingData(commandFlags, globalFlags) {
|
|
1500
|
+
const config = await loadConfig({
|
|
1501
|
+
globalFlags,
|
|
1502
|
+
subcommandFlags: commandFlags
|
|
1503
|
+
});
|
|
1504
|
+
if (config.transcripts.cacheFile && !existsSync(config.transcripts.cacheFile)) throw new Error(`Granola cache file not found: ${config.transcripts.cacheFile}`);
|
|
1505
|
+
debug(config.debug, "using config", config.configFileUsed ?? "(none)");
|
|
1506
|
+
debug(config.debug, "supabase", config.supabase);
|
|
1507
|
+
debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
|
|
1508
|
+
debug(config.debug, "timeoutMs", config.notes.timeoutMs);
|
|
1509
|
+
const granolaClient = await createDefaultGranolaApiClient(config);
|
|
1510
|
+
return {
|
|
1511
|
+
cacheData: await loadOptionalGranolaCache(config.transcripts.cacheFile),
|
|
1512
|
+
config,
|
|
1513
|
+
granolaClient
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
async function list(commandFlags, globalFlags) {
|
|
1517
|
+
const format = resolveListFormat(commandFlags.format);
|
|
1518
|
+
const limit = parseLimit(commandFlags.limit);
|
|
1519
|
+
const search = typeof commandFlags.search === "string" ? commandFlags.search : void 0;
|
|
1520
|
+
const { cacheData, config, granolaClient } = await loadMeetingData(commandFlags, globalFlags);
|
|
1521
|
+
console.log("Fetching meetings from Granola API...");
|
|
1522
|
+
const meetings = listMeetings(await granolaClient.listDocuments({ timeoutMs: config.notes.timeoutMs }), {
|
|
1523
|
+
cacheData,
|
|
1524
|
+
limit,
|
|
1525
|
+
search
|
|
1526
|
+
});
|
|
1527
|
+
console.log(renderMeetingList(meetings, format).trimEnd());
|
|
1528
|
+
return 0;
|
|
1529
|
+
}
|
|
1530
|
+
async function view(id, commandFlags, globalFlags) {
|
|
1531
|
+
const format = resolveViewFormat(commandFlags.format);
|
|
1532
|
+
const { cacheData, config, granolaClient } = await loadMeetingData(commandFlags, globalFlags);
|
|
1533
|
+
console.log("Fetching meeting from Granola API...");
|
|
1534
|
+
const meeting = buildMeetingRecord(resolveMeeting(await granolaClient.listDocuments({ timeoutMs: config.notes.timeoutMs }), id), cacheData);
|
|
1535
|
+
console.log(renderMeetingView(meeting, format).trimEnd());
|
|
1536
|
+
return 0;
|
|
1537
|
+
}
|
|
1538
|
+
async function exportMeeting(id, commandFlags, globalFlags) {
|
|
1539
|
+
const format = resolveExportFormat(commandFlags.format);
|
|
1540
|
+
const { cacheData, config, granolaClient } = await loadMeetingData(commandFlags, globalFlags);
|
|
1541
|
+
console.log("Fetching meeting from Granola API...");
|
|
1542
|
+
const meeting = buildMeetingRecord(resolveMeeting(await granolaClient.listDocuments({ timeoutMs: config.notes.timeoutMs }), id), cacheData);
|
|
1543
|
+
console.log(renderMeetingExport(meeting, format).trimEnd());
|
|
1544
|
+
return 0;
|
|
1545
|
+
}
|
|
1546
|
+
//#endregion
|
|
1547
|
+
//#region src/commands/notes.ts
|
|
1548
|
+
function notesHelp() {
|
|
1549
|
+
return `Granola notes
|
|
1550
|
+
|
|
1551
|
+
Usage:
|
|
1552
|
+
granola notes [options]
|
|
1553
|
+
|
|
1554
|
+
Options:
|
|
1555
|
+
--format <value> Output format: markdown, json, yaml, raw (default: markdown)
|
|
1556
|
+
--output <path> Output directory for note files (default: ./notes)
|
|
1557
|
+
--timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
|
|
1558
|
+
--supabase <path> Path to supabase.json
|
|
1559
|
+
--debug Enable debug logging
|
|
1560
|
+
--config <path> Path to .granola.toml
|
|
1561
|
+
-h, --help Show help
|
|
1562
|
+
`;
|
|
1563
|
+
}
|
|
1564
|
+
const notesCommand = {
|
|
1565
|
+
description: "Export Granola notes",
|
|
1566
|
+
flags: {
|
|
1567
|
+
format: { type: "string" },
|
|
1568
|
+
help: { type: "boolean" },
|
|
1569
|
+
output: { type: "string" },
|
|
1570
|
+
timeout: { type: "string" }
|
|
1571
|
+
},
|
|
1572
|
+
help: notesHelp,
|
|
1573
|
+
name: "notes",
|
|
1574
|
+
async run({ commandFlags, globalFlags }) {
|
|
1575
|
+
const config = await loadConfig({
|
|
1576
|
+
globalFlags,
|
|
1577
|
+
subcommandFlags: commandFlags
|
|
1578
|
+
});
|
|
1579
|
+
debug(config.debug, "using config", config.configFileUsed ?? "(none)");
|
|
1580
|
+
debug(config.debug, "supabase", config.supabase);
|
|
1581
|
+
debug(config.debug, "timeoutMs", config.notes.timeoutMs);
|
|
1582
|
+
debug(config.debug, "output", config.notes.output);
|
|
1583
|
+
const format = resolveNoteFormat(commandFlags.format);
|
|
1584
|
+
debug(config.debug, "format", format);
|
|
1585
|
+
const granolaClient = await createDefaultGranolaApiClient(config);
|
|
1586
|
+
console.log("Fetching documents from Granola API...");
|
|
1587
|
+
const documents = await granolaClient.listDocuments({ timeoutMs: config.notes.timeoutMs });
|
|
1588
|
+
console.log(`Exporting ${documents.length} notes to ${config.notes.output}...`);
|
|
1589
|
+
const written = await writeNotes(documents, config.notes.output, format);
|
|
1590
|
+
console.log("✓ Export completed successfully");
|
|
1591
|
+
debug(config.debug, "notes written", written);
|
|
1592
|
+
return 0;
|
|
1593
|
+
}
|
|
1594
|
+
};
|
|
1595
|
+
function resolveNoteFormat(value) {
|
|
1596
|
+
switch (value) {
|
|
1597
|
+
case void 0: return "markdown";
|
|
1598
|
+
case "json":
|
|
1599
|
+
case "markdown":
|
|
1600
|
+
case "raw":
|
|
1601
|
+
case "yaml": return value;
|
|
1602
|
+
default: throw new Error("invalid notes format: expected markdown, json, yaml, or raw");
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
//#endregion
|
|
1278
1606
|
//#region src/commands/transcripts.ts
|
|
1279
1607
|
function transcriptsHelp() {
|
|
1280
1608
|
return `Granola transcripts
|
|
@@ -1337,6 +1665,7 @@ function resolveTranscriptFormat(value) {
|
|
|
1337
1665
|
//#region src/commands/index.ts
|
|
1338
1666
|
const commands = [
|
|
1339
1667
|
authCommand,
|
|
1668
|
+
meetingCommand,
|
|
1340
1669
|
notesCommand,
|
|
1341
1670
|
transcriptsCommand
|
|
1342
1671
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "granola-toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "CLI toolkit for exporting and working with Granola notes and transcripts",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -49,6 +49,9 @@
|
|
|
49
49
|
"typecheck": "vp exec tsc --noEmit",
|
|
50
50
|
"prepare": "vp config"
|
|
51
51
|
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"node-html-markdown": "^2.0.0"
|
|
54
|
+
},
|
|
52
55
|
"devDependencies": {
|
|
53
56
|
"@types/node": "^25.5.2",
|
|
54
57
|
"typescript": "^5.9.3",
|