granola-toolkit 0.8.0 → 0.10.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 +52 -1
- package/dist/cli.js +717 -135
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,6 +31,8 @@ Installed CLI:
|
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
33
|
granola --help
|
|
34
|
+
granola auth login
|
|
35
|
+
granola meeting --help
|
|
34
36
|
granola notes --help
|
|
35
37
|
granola transcripts --help
|
|
36
38
|
```
|
|
@@ -40,6 +42,7 @@ Local build:
|
|
|
40
42
|
```bash
|
|
41
43
|
vp pack
|
|
42
44
|
node dist/cli.js --help
|
|
45
|
+
node dist/cli.js meeting --help
|
|
43
46
|
node dist/cli.js notes --help
|
|
44
47
|
node dist/cli.js transcripts --help
|
|
45
48
|
```
|
|
@@ -48,6 +51,7 @@ You can also use the package scripts:
|
|
|
48
51
|
|
|
49
52
|
```bash
|
|
50
53
|
npm run build
|
|
54
|
+
npm run start -- meeting --help
|
|
51
55
|
npm run notes -- --help
|
|
52
56
|
npm run transcripts -- --help
|
|
53
57
|
```
|
|
@@ -57,6 +61,9 @@ npm run transcripts -- --help
|
|
|
57
61
|
Export notes:
|
|
58
62
|
|
|
59
63
|
```bash
|
|
64
|
+
granola auth login
|
|
65
|
+
granola notes
|
|
66
|
+
|
|
60
67
|
node dist/cli.js notes --supabase "$HOME/Library/Application Support/Granola/supabase.json"
|
|
61
68
|
node dist/cli.js notes --format json --output ./notes-json
|
|
62
69
|
```
|
|
@@ -68,6 +75,15 @@ node dist/cli.js transcripts --cache "$HOME/Library/Application Support/Granola/
|
|
|
68
75
|
node dist/cli.js transcripts --format yaml --output ./transcripts-yaml
|
|
69
76
|
```
|
|
70
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
|
+
|
|
71
87
|
## How It Works
|
|
72
88
|
|
|
73
89
|
### Notes
|
|
@@ -76,7 +92,7 @@ node dist/cli.js transcripts --format yaml --output ./transcripts-yaml
|
|
|
76
92
|
|
|
77
93
|
The flow is:
|
|
78
94
|
|
|
79
|
-
1. read your local `supabase.json`
|
|
95
|
+
1. read a stored Granola session, or fall back to your local `supabase.json`
|
|
80
96
|
2. extract the WorkOS access token from it
|
|
81
97
|
3. call Granola's paginated documents API
|
|
82
98
|
4. normalise each document into a structured note export
|
|
@@ -117,6 +133,41 @@ Speaker labels are currently normalised to:
|
|
|
117
133
|
|
|
118
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.
|
|
119
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
|
+
|
|
160
|
+
## Auth
|
|
161
|
+
|
|
162
|
+
If you do not want to keep passing `--supabase`, import the desktop app session once:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
granola auth login
|
|
166
|
+
granola auth status
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
That stores a reusable Granola session locally and lets `granola notes` use it directly. `granola auth logout` deletes the stored session.
|
|
170
|
+
|
|
120
171
|
### Incremental Writes
|
|
121
172
|
|
|
122
173
|
Both commands keep a small hidden state file in the output directory to track:
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { mkdir, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir, platform } from "node:os";
|
|
5
6
|
import { dirname, join } from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
6
8
|
import { createHash } from "node:crypto";
|
|
7
9
|
//#region src/utils.ts
|
|
8
10
|
const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g;
|
|
@@ -162,16 +164,46 @@ function transcriptSpeakerLabel(segment) {
|
|
|
162
164
|
}
|
|
163
165
|
//#endregion
|
|
164
166
|
//#region src/client/auth.ts
|
|
165
|
-
|
|
167
|
+
const execFileAsync = promisify(execFile);
|
|
168
|
+
const DEFAULT_CLIENT_ID = "client_GranolaMac";
|
|
169
|
+
const KEYCHAIN_SERVICE_NAME = "com.granola.toolkit";
|
|
170
|
+
const KEYCHAIN_ACCOUNT_NAME = "session";
|
|
171
|
+
const WORKOS_AUTH_URL = "https://api.workos.com/user_management/authenticate";
|
|
172
|
+
function numberValue(value) {
|
|
173
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
174
|
+
}
|
|
175
|
+
function parseSessionRecord(record) {
|
|
176
|
+
const accessToken = stringValue(record.access_token);
|
|
177
|
+
if (!accessToken.trim()) return;
|
|
178
|
+
return {
|
|
179
|
+
accessToken,
|
|
180
|
+
clientId: stringValue(record.client_id) || DEFAULT_CLIENT_ID,
|
|
181
|
+
expiresIn: numberValue(record.expires_in),
|
|
182
|
+
externalId: stringValue(record.external_id) || void 0,
|
|
183
|
+
obtainedAt: stringValue(record.obtained_at) || void 0,
|
|
184
|
+
refreshToken: stringValue(record.refresh_token) || void 0,
|
|
185
|
+
sessionId: stringValue(record.session_id) || void 0,
|
|
186
|
+
signInMethod: stringValue(record.sign_in_method) || void 0,
|
|
187
|
+
tokenType: stringValue(record.token_type) || void 0
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function parseNestedRecord(value) {
|
|
191
|
+
if (typeof value === "string") return parseJsonString(value);
|
|
192
|
+
return asRecord(value);
|
|
193
|
+
}
|
|
194
|
+
function getSessionFromSupabaseContents(supabaseContents) {
|
|
166
195
|
const wrapper = parseJsonString(supabaseContents);
|
|
167
196
|
if (!wrapper) throw new Error("failed to parse supabase.json");
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
if (
|
|
174
|
-
|
|
197
|
+
const workOsSession = parseSessionRecord(parseNestedRecord(wrapper.workos_tokens) ?? {});
|
|
198
|
+
if (workOsSession) return workOsSession;
|
|
199
|
+
const cognitoSession = parseSessionRecord(parseNestedRecord(wrapper.cognito_tokens) ?? {});
|
|
200
|
+
if (cognitoSession) return cognitoSession;
|
|
201
|
+
const legacySession = parseSessionRecord(wrapper);
|
|
202
|
+
if (legacySession) return legacySession;
|
|
203
|
+
throw new Error("access token not found in supabase.json");
|
|
204
|
+
}
|
|
205
|
+
function getAccessTokenFromSupabaseContents(supabaseContents) {
|
|
206
|
+
return getSessionFromSupabaseContents(supabaseContents).accessToken;
|
|
175
207
|
}
|
|
176
208
|
var SupabaseFileTokenSource = class {
|
|
177
209
|
constructor(filePath) {
|
|
@@ -181,11 +213,85 @@ var SupabaseFileTokenSource = class {
|
|
|
181
213
|
return getAccessTokenFromSupabaseContents(await readFile(this.filePath, "utf8"));
|
|
182
214
|
}
|
|
183
215
|
};
|
|
216
|
+
var SupabaseFileSessionSource = class {
|
|
217
|
+
constructor(filePath) {
|
|
218
|
+
this.filePath = filePath;
|
|
219
|
+
}
|
|
220
|
+
async loadSession() {
|
|
221
|
+
return getSessionFromSupabaseContents(await readFile(this.filePath, "utf8"));
|
|
222
|
+
}
|
|
223
|
+
};
|
|
184
224
|
var NoopTokenStore = class {
|
|
185
225
|
async clearToken() {}
|
|
186
226
|
async readToken() {}
|
|
187
227
|
async writeToken(_token) {}
|
|
188
228
|
};
|
|
229
|
+
var FileSessionStore = class {
|
|
230
|
+
constructor(filePath = defaultSessionFilePath()) {
|
|
231
|
+
this.filePath = filePath;
|
|
232
|
+
}
|
|
233
|
+
async clearSession() {
|
|
234
|
+
try {
|
|
235
|
+
await unlink(this.filePath);
|
|
236
|
+
} catch {}
|
|
237
|
+
}
|
|
238
|
+
async readSession() {
|
|
239
|
+
try {
|
|
240
|
+
const parsed = parseJsonString(await readFile(this.filePath, "utf8"));
|
|
241
|
+
return parsed?.accessToken ? parsed : void 0;
|
|
242
|
+
} catch {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
async writeSession(session) {
|
|
247
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
248
|
+
await writeFile(this.filePath, `${JSON.stringify(session, null, 2)}\n`, {
|
|
249
|
+
encoding: "utf8",
|
|
250
|
+
mode: 384
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
var KeychainSessionStore = class {
|
|
255
|
+
async clearSession() {
|
|
256
|
+
try {
|
|
257
|
+
await execFileAsync("security", [
|
|
258
|
+
"delete-generic-password",
|
|
259
|
+
"-s",
|
|
260
|
+
KEYCHAIN_SERVICE_NAME,
|
|
261
|
+
"-a",
|
|
262
|
+
KEYCHAIN_ACCOUNT_NAME
|
|
263
|
+
]);
|
|
264
|
+
} catch {}
|
|
265
|
+
}
|
|
266
|
+
async readSession() {
|
|
267
|
+
try {
|
|
268
|
+
const { stdout } = await execFileAsync("security", [
|
|
269
|
+
"find-generic-password",
|
|
270
|
+
"-s",
|
|
271
|
+
KEYCHAIN_SERVICE_NAME,
|
|
272
|
+
"-a",
|
|
273
|
+
KEYCHAIN_ACCOUNT_NAME,
|
|
274
|
+
"-w"
|
|
275
|
+
]);
|
|
276
|
+
const parsed = parseJsonString(stdout.trim());
|
|
277
|
+
return parsed?.accessToken ? parsed : void 0;
|
|
278
|
+
} catch {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async writeSession(session) {
|
|
283
|
+
await execFileAsync("security", [
|
|
284
|
+
"add-generic-password",
|
|
285
|
+
"-U",
|
|
286
|
+
"-s",
|
|
287
|
+
KEYCHAIN_SERVICE_NAME,
|
|
288
|
+
"-a",
|
|
289
|
+
KEYCHAIN_ACCOUNT_NAME,
|
|
290
|
+
"-w",
|
|
291
|
+
JSON.stringify(session)
|
|
292
|
+
]);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
189
295
|
var CachedTokenProvider = class {
|
|
190
296
|
#token;
|
|
191
297
|
constructor(source, store = new NoopTokenStore()) {
|
|
@@ -209,6 +315,189 @@ var CachedTokenProvider = class {
|
|
|
209
315
|
await this.store.clearToken();
|
|
210
316
|
}
|
|
211
317
|
};
|
|
318
|
+
var StoredSessionTokenProvider = class {
|
|
319
|
+
#session;
|
|
320
|
+
constructor(store, options = {}) {
|
|
321
|
+
this.store = store;
|
|
322
|
+
this.options = options;
|
|
323
|
+
}
|
|
324
|
+
async loadSession() {
|
|
325
|
+
if (this.#session) return this.#session;
|
|
326
|
+
const storedSession = await this.store.readSession();
|
|
327
|
+
if (storedSession?.accessToken.trim()) {
|
|
328
|
+
this.#session = storedSession;
|
|
329
|
+
return storedSession;
|
|
330
|
+
}
|
|
331
|
+
if (!this.options.source) throw new Error("no stored Granola session found");
|
|
332
|
+
const sourcedSession = await this.options.source.loadSession();
|
|
333
|
+
this.#session = sourcedSession;
|
|
334
|
+
return sourcedSession;
|
|
335
|
+
}
|
|
336
|
+
async getAccessToken() {
|
|
337
|
+
return (await this.loadSession()).accessToken;
|
|
338
|
+
}
|
|
339
|
+
async invalidate() {
|
|
340
|
+
const session = await this.loadSession().catch(() => void 0);
|
|
341
|
+
if (session?.refreshToken && session.clientId) {
|
|
342
|
+
const refreshedSession = await refreshGranolaSession(session, this.options.fetchImpl);
|
|
343
|
+
this.#session = refreshedSession;
|
|
344
|
+
await this.store.writeSession(refreshedSession);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (this.options.source) {
|
|
348
|
+
this.#session = await this.options.source.loadSession();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
this.#session = void 0;
|
|
352
|
+
await this.store.clearSession();
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
async function refreshGranolaSession(session, fetchImpl = fetch) {
|
|
356
|
+
if (!session.refreshToken?.trim()) throw new Error("refresh token not available");
|
|
357
|
+
const response = await fetchImpl(WORKOS_AUTH_URL, {
|
|
358
|
+
body: JSON.stringify({
|
|
359
|
+
client_id: session.clientId,
|
|
360
|
+
grant_type: "refresh_token",
|
|
361
|
+
refresh_token: session.refreshToken
|
|
362
|
+
}),
|
|
363
|
+
headers: { "Content-Type": "application/json" },
|
|
364
|
+
method: "POST"
|
|
365
|
+
});
|
|
366
|
+
if (!response.ok) throw new Error(`failed to refresh session: ${response.status} ${response.statusText}`);
|
|
367
|
+
const refreshed = parseSessionRecord(await response.json());
|
|
368
|
+
if (!refreshed) throw new Error("failed to parse refreshed session");
|
|
369
|
+
return {
|
|
370
|
+
...session,
|
|
371
|
+
...refreshed,
|
|
372
|
+
clientId: refreshed.clientId || session.clientId,
|
|
373
|
+
obtainedAt: refreshed.obtainedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
374
|
+
refreshToken: refreshed.refreshToken ?? session.refreshToken
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
function defaultSessionFilePath() {
|
|
378
|
+
const home = homedir();
|
|
379
|
+
return platform() === "darwin" ? join(home, "Library", "Application Support", "granola-toolkit", "session.json") : join(home, ".config", "granola-toolkit", "session.json");
|
|
380
|
+
}
|
|
381
|
+
function createDefaultSessionStore() {
|
|
382
|
+
return platform() === "darwin" ? new KeychainSessionStore() : new FileSessionStore();
|
|
383
|
+
}
|
|
384
|
+
//#endregion
|
|
385
|
+
//#region src/commands/auth.ts
|
|
386
|
+
function authHelp() {
|
|
387
|
+
return `Granola auth
|
|
388
|
+
|
|
389
|
+
Usage:
|
|
390
|
+
granola auth <login|status|logout>
|
|
391
|
+
|
|
392
|
+
Subcommands:
|
|
393
|
+
login Import credentials from the Granola desktop app
|
|
394
|
+
status Show whether a stored Granola session is available
|
|
395
|
+
logout Delete the stored Granola session
|
|
396
|
+
|
|
397
|
+
Options:
|
|
398
|
+
--supabase <path> Path to supabase.json for auth login
|
|
399
|
+
-h, --help Show help
|
|
400
|
+
`;
|
|
401
|
+
}
|
|
402
|
+
const authCommand = {
|
|
403
|
+
description: "Manage stored Granola sessions",
|
|
404
|
+
flags: { help: { type: "boolean" } },
|
|
405
|
+
help: authHelp,
|
|
406
|
+
name: "auth",
|
|
407
|
+
async run({ commandArgs, globalFlags }) {
|
|
408
|
+
const [action] = commandArgs;
|
|
409
|
+
switch (action) {
|
|
410
|
+
case "login": return await login(globalFlags.supabase);
|
|
411
|
+
case "logout": return await logout();
|
|
412
|
+
case "status": return await status();
|
|
413
|
+
case void 0:
|
|
414
|
+
console.log(authHelp());
|
|
415
|
+
return 1;
|
|
416
|
+
default: throw new Error("invalid auth command: expected login, status, or logout");
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
async function login(supabaseFlag) {
|
|
421
|
+
const supabasePath = typeof supabaseFlag === "string" && supabaseFlag.trim() || firstExistingPath(granolaSupabaseCandidates());
|
|
422
|
+
if (!supabasePath) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
|
|
423
|
+
if (!existsSync(supabasePath)) throw new Error(`supabase.json not found: ${supabasePath}`);
|
|
424
|
+
const sessionStore = createDefaultSessionStore();
|
|
425
|
+
const session = await new SupabaseFileSessionSource(supabasePath).loadSession();
|
|
426
|
+
await sessionStore.writeSession(session);
|
|
427
|
+
console.log(`Imported Granola session from ${supabasePath}`);
|
|
428
|
+
return 0;
|
|
429
|
+
}
|
|
430
|
+
async function status() {
|
|
431
|
+
const session = await createDefaultSessionStore().readSession();
|
|
432
|
+
if (!session) {
|
|
433
|
+
console.log("No stored Granola session");
|
|
434
|
+
return 1;
|
|
435
|
+
}
|
|
436
|
+
console.log("Stored Granola session");
|
|
437
|
+
console.log(`Client ID: ${session.clientId}`);
|
|
438
|
+
console.log(`Refresh token: ${session.refreshToken ? "available" : "missing"}`);
|
|
439
|
+
console.log(`Sign-in method: ${session.signInMethod ?? "unknown"}`);
|
|
440
|
+
return 0;
|
|
441
|
+
}
|
|
442
|
+
async function logout() {
|
|
443
|
+
await createDefaultSessionStore().clearSession();
|
|
444
|
+
console.log("Stored Granola session deleted");
|
|
445
|
+
return 0;
|
|
446
|
+
}
|
|
447
|
+
//#endregion
|
|
448
|
+
//#region src/cache.ts
|
|
449
|
+
function parseCacheDocument(id, value) {
|
|
450
|
+
const record = asRecord(value);
|
|
451
|
+
if (!record) return;
|
|
452
|
+
return {
|
|
453
|
+
createdAt: stringValue(record.created_at),
|
|
454
|
+
id,
|
|
455
|
+
title: stringValue(record.title),
|
|
456
|
+
updatedAt: stringValue(record.updated_at)
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
function parseTranscriptSegments(value) {
|
|
460
|
+
if (!Array.isArray(value)) return;
|
|
461
|
+
return value.flatMap((segment) => {
|
|
462
|
+
const record = asRecord(segment);
|
|
463
|
+
if (!record) return [];
|
|
464
|
+
return [{
|
|
465
|
+
documentId: stringValue(record.document_id),
|
|
466
|
+
endTimestamp: stringValue(record.end_timestamp),
|
|
467
|
+
id: stringValue(record.id),
|
|
468
|
+
isFinal: Boolean(record.is_final),
|
|
469
|
+
source: stringValue(record.source),
|
|
470
|
+
startTimestamp: stringValue(record.start_timestamp),
|
|
471
|
+
text: stringValue(record.text)
|
|
472
|
+
}];
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
function parseCacheContents(contents) {
|
|
476
|
+
const outer = parseJsonString(contents);
|
|
477
|
+
if (!outer) throw new Error("failed to parse cache JSON");
|
|
478
|
+
const rawCache = outer.cache;
|
|
479
|
+
let cachePayload;
|
|
480
|
+
if (typeof rawCache === "string") cachePayload = parseJsonString(rawCache);
|
|
481
|
+
else cachePayload = asRecord(rawCache);
|
|
482
|
+
const state = cachePayload ? asRecord(cachePayload.state) : void 0;
|
|
483
|
+
if (!state) throw new Error("failed to parse cache state");
|
|
484
|
+
const rawDocuments = asRecord(state.documents) ?? {};
|
|
485
|
+
const rawTranscripts = asRecord(state.transcripts) ?? {};
|
|
486
|
+
const documents = {};
|
|
487
|
+
for (const [id, rawDocument] of Object.entries(rawDocuments)) {
|
|
488
|
+
const document = parseCacheDocument(id, rawDocument);
|
|
489
|
+
if (document) documents[id] = document;
|
|
490
|
+
}
|
|
491
|
+
const transcripts = {};
|
|
492
|
+
for (const [id, rawTranscript] of Object.entries(rawTranscripts)) {
|
|
493
|
+
const segments = parseTranscriptSegments(rawTranscript);
|
|
494
|
+
if (segments) transcripts[id] = segments;
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
documents,
|
|
498
|
+
transcripts
|
|
499
|
+
};
|
|
500
|
+
}
|
|
212
501
|
//#endregion
|
|
213
502
|
//#region src/client/parsers.ts
|
|
214
503
|
function parseProseMirrorDoc(value, options = {}) {
|
|
@@ -348,6 +637,22 @@ var AuthenticatedHttpClient = class {
|
|
|
348
637
|
}
|
|
349
638
|
};
|
|
350
639
|
//#endregion
|
|
640
|
+
//#region src/client/default.ts
|
|
641
|
+
async function createDefaultGranolaApiClient(config, logger = console) {
|
|
642
|
+
const sessionStore = createDefaultSessionStore();
|
|
643
|
+
const storedSession = await sessionStore.readSession();
|
|
644
|
+
if (!storedSession && !config.supabase) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
|
|
645
|
+
if (!storedSession && config.supabase && !existsSync(config.supabase)) throw new Error(`supabase.json not found: ${config.supabase}`);
|
|
646
|
+
return new GranolaApiClient(new AuthenticatedHttpClient({
|
|
647
|
+
logger,
|
|
648
|
+
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())
|
|
649
|
+
}));
|
|
650
|
+
}
|
|
651
|
+
async function loadOptionalGranolaCache(cacheFile) {
|
|
652
|
+
if (!cacheFile || !existsSync(cacheFile)) return;
|
|
653
|
+
return parseCacheContents(await readFile(cacheFile, "utf8"));
|
|
654
|
+
}
|
|
655
|
+
//#endregion
|
|
351
656
|
//#region src/config.ts
|
|
352
657
|
function pickString(value) {
|
|
353
658
|
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
@@ -782,129 +1087,6 @@ async function writeNotes(documents, outputDir, format = "markdown") {
|
|
|
782
1087
|
});
|
|
783
1088
|
}
|
|
784
1089
|
//#endregion
|
|
785
|
-
//#region src/commands/shared.ts
|
|
786
|
-
function debug(enabled, ...values) {
|
|
787
|
-
if (enabled) console.error("[debug]", ...values);
|
|
788
|
-
}
|
|
789
|
-
//#endregion
|
|
790
|
-
//#region src/commands/notes.ts
|
|
791
|
-
function notesHelp() {
|
|
792
|
-
return `Granola notes
|
|
793
|
-
|
|
794
|
-
Usage:
|
|
795
|
-
granola notes [options]
|
|
796
|
-
|
|
797
|
-
Options:
|
|
798
|
-
--format <value> Output format: markdown, json, yaml, raw (default: markdown)
|
|
799
|
-
--output <path> Output directory for note files (default: ./notes)
|
|
800
|
-
--timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
|
|
801
|
-
--supabase <path> Path to supabase.json
|
|
802
|
-
--debug Enable debug logging
|
|
803
|
-
--config <path> Path to .granola.toml
|
|
804
|
-
-h, --help Show help
|
|
805
|
-
`;
|
|
806
|
-
}
|
|
807
|
-
const notesCommand = {
|
|
808
|
-
description: "Export Granola notes",
|
|
809
|
-
flags: {
|
|
810
|
-
format: { type: "string" },
|
|
811
|
-
help: { type: "boolean" },
|
|
812
|
-
output: { type: "string" },
|
|
813
|
-
timeout: { type: "string" }
|
|
814
|
-
},
|
|
815
|
-
help: notesHelp,
|
|
816
|
-
name: "notes",
|
|
817
|
-
async run({ commandFlags, globalFlags }) {
|
|
818
|
-
const config = await loadConfig({
|
|
819
|
-
globalFlags,
|
|
820
|
-
subcommandFlags: commandFlags
|
|
821
|
-
});
|
|
822
|
-
if (!config.supabase) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
|
|
823
|
-
if (!existsSync(config.supabase)) throw new Error(`supabase.json not found: ${config.supabase}`);
|
|
824
|
-
debug(config.debug, "using config", config.configFileUsed ?? "(none)");
|
|
825
|
-
debug(config.debug, "supabase", config.supabase);
|
|
826
|
-
debug(config.debug, "timeoutMs", config.notes.timeoutMs);
|
|
827
|
-
debug(config.debug, "output", config.notes.output);
|
|
828
|
-
const format = resolveNoteFormat(commandFlags.format);
|
|
829
|
-
debug(config.debug, "format", format);
|
|
830
|
-
console.log("Fetching documents from Granola API...");
|
|
831
|
-
const tokenProvider = new CachedTokenProvider(new SupabaseFileTokenSource(config.supabase), new NoopTokenStore());
|
|
832
|
-
const documents = await new GranolaApiClient(new AuthenticatedHttpClient({
|
|
833
|
-
logger: console,
|
|
834
|
-
tokenProvider
|
|
835
|
-
})).listDocuments({ timeoutMs: config.notes.timeoutMs });
|
|
836
|
-
console.log(`Exporting ${documents.length} notes to ${config.notes.output}...`);
|
|
837
|
-
const written = await writeNotes(documents, config.notes.output, format);
|
|
838
|
-
console.log("✓ Export completed successfully");
|
|
839
|
-
debug(config.debug, "notes written", written);
|
|
840
|
-
return 0;
|
|
841
|
-
}
|
|
842
|
-
};
|
|
843
|
-
function resolveNoteFormat(value) {
|
|
844
|
-
switch (value) {
|
|
845
|
-
case void 0: return "markdown";
|
|
846
|
-
case "json":
|
|
847
|
-
case "markdown":
|
|
848
|
-
case "raw":
|
|
849
|
-
case "yaml": return value;
|
|
850
|
-
default: throw new Error("invalid notes format: expected markdown, json, yaml, or raw");
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
//#endregion
|
|
854
|
-
//#region src/cache.ts
|
|
855
|
-
function parseCacheDocument(id, value) {
|
|
856
|
-
const record = asRecord(value);
|
|
857
|
-
if (!record) return;
|
|
858
|
-
return {
|
|
859
|
-
createdAt: stringValue(record.created_at),
|
|
860
|
-
id,
|
|
861
|
-
title: stringValue(record.title),
|
|
862
|
-
updatedAt: stringValue(record.updated_at)
|
|
863
|
-
};
|
|
864
|
-
}
|
|
865
|
-
function parseTranscriptSegments(value) {
|
|
866
|
-
if (!Array.isArray(value)) return;
|
|
867
|
-
return value.flatMap((segment) => {
|
|
868
|
-
const record = asRecord(segment);
|
|
869
|
-
if (!record) return [];
|
|
870
|
-
return [{
|
|
871
|
-
documentId: stringValue(record.document_id),
|
|
872
|
-
endTimestamp: stringValue(record.end_timestamp),
|
|
873
|
-
id: stringValue(record.id),
|
|
874
|
-
isFinal: Boolean(record.is_final),
|
|
875
|
-
source: stringValue(record.source),
|
|
876
|
-
startTimestamp: stringValue(record.start_timestamp),
|
|
877
|
-
text: stringValue(record.text)
|
|
878
|
-
}];
|
|
879
|
-
});
|
|
880
|
-
}
|
|
881
|
-
function parseCacheContents(contents) {
|
|
882
|
-
const outer = parseJsonString(contents);
|
|
883
|
-
if (!outer) throw new Error("failed to parse cache JSON");
|
|
884
|
-
const rawCache = outer.cache;
|
|
885
|
-
let cachePayload;
|
|
886
|
-
if (typeof rawCache === "string") cachePayload = parseJsonString(rawCache);
|
|
887
|
-
else cachePayload = asRecord(rawCache);
|
|
888
|
-
const state = cachePayload ? asRecord(cachePayload.state) : void 0;
|
|
889
|
-
if (!state) throw new Error("failed to parse cache state");
|
|
890
|
-
const rawDocuments = asRecord(state.documents) ?? {};
|
|
891
|
-
const rawTranscripts = asRecord(state.transcripts) ?? {};
|
|
892
|
-
const documents = {};
|
|
893
|
-
for (const [id, rawDocument] of Object.entries(rawDocuments)) {
|
|
894
|
-
const document = parseCacheDocument(id, rawDocument);
|
|
895
|
-
if (document) documents[id] = document;
|
|
896
|
-
}
|
|
897
|
-
const transcripts = {};
|
|
898
|
-
for (const [id, rawTranscript] of Object.entries(rawTranscripts)) {
|
|
899
|
-
const segments = parseTranscriptSegments(rawTranscript);
|
|
900
|
-
if (segments) transcripts[id] = segments;
|
|
901
|
-
}
|
|
902
|
-
return {
|
|
903
|
-
documents,
|
|
904
|
-
transcripts
|
|
905
|
-
};
|
|
906
|
-
}
|
|
907
|
-
//#endregion
|
|
908
1090
|
//#region src/transcripts.ts
|
|
909
1091
|
function transcriptSegmentKey(segment) {
|
|
910
1092
|
if (segment.id) return `id:${segment.id}`;
|
|
@@ -937,7 +1119,9 @@ function normaliseTranscriptSegments(segments) {
|
|
|
937
1119
|
const current = selected.get(key);
|
|
938
1120
|
selected.set(key, preferredTranscriptSegment(current, segment));
|
|
939
1121
|
}
|
|
940
|
-
|
|
1122
|
+
const resolved = [...selected.values()].sort(compareTranscriptSegments);
|
|
1123
|
+
if (resolved.some((segment) => segment.isFinal)) return resolved.filter((segment) => segment.isFinal);
|
|
1124
|
+
return resolved;
|
|
941
1125
|
}
|
|
942
1126
|
function buildTranscriptExport(document, segments, rawSegments = segments) {
|
|
943
1127
|
const renderedSegments = segments.map((segment) => ({
|
|
@@ -1038,6 +1222,398 @@ async function writeTranscripts(cacheData, outputDir, format = "text") {
|
|
|
1038
1222
|
});
|
|
1039
1223
|
}
|
|
1040
1224
|
//#endregion
|
|
1225
|
+
//#region src/meetings.ts
|
|
1226
|
+
function parseTimestamp(value) {
|
|
1227
|
+
if (!value.trim()) return;
|
|
1228
|
+
const timestamp = Date.parse(value);
|
|
1229
|
+
return Number.isNaN(timestamp) ? void 0 : timestamp;
|
|
1230
|
+
}
|
|
1231
|
+
function compareTimestampsDescending(left, right) {
|
|
1232
|
+
const leftTimestamp = parseTimestamp(left);
|
|
1233
|
+
const rightTimestamp = parseTimestamp(right);
|
|
1234
|
+
if (leftTimestamp != null && rightTimestamp != null) return rightTimestamp - leftTimestamp;
|
|
1235
|
+
if (leftTimestamp != null) return -1;
|
|
1236
|
+
if (rightTimestamp != null) return 1;
|
|
1237
|
+
return compareStrings(right, left);
|
|
1238
|
+
}
|
|
1239
|
+
function compareMeetingDocuments(left, right) {
|
|
1240
|
+
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);
|
|
1241
|
+
}
|
|
1242
|
+
function serialiseNote(note) {
|
|
1243
|
+
return {
|
|
1244
|
+
content: note.content,
|
|
1245
|
+
contentSource: note.contentSource,
|
|
1246
|
+
createdAt: note.createdAt,
|
|
1247
|
+
id: note.id,
|
|
1248
|
+
tags: [...note.tags],
|
|
1249
|
+
title: note.title,
|
|
1250
|
+
updatedAt: note.updatedAt
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
function serialiseTranscript(transcript) {
|
|
1254
|
+
return {
|
|
1255
|
+
createdAt: transcript.createdAt,
|
|
1256
|
+
id: transcript.id,
|
|
1257
|
+
segments: transcript.segments.map((segment) => ({ ...segment })),
|
|
1258
|
+
title: transcript.title,
|
|
1259
|
+
updatedAt: transcript.updatedAt
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
function cacheDocumentForMeeting(document, cacheData) {
|
|
1263
|
+
return cacheData?.documents[document.id] ?? {
|
|
1264
|
+
createdAt: document.createdAt,
|
|
1265
|
+
id: document.id,
|
|
1266
|
+
title: document.title,
|
|
1267
|
+
updatedAt: latestDocumentTimestamp(document)
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
function buildMeetingTranscript(document, cacheData) {
|
|
1271
|
+
if (!cacheData) return {
|
|
1272
|
+
loaded: false,
|
|
1273
|
+
segmentCount: 0,
|
|
1274
|
+
transcript: null,
|
|
1275
|
+
transcriptText: null
|
|
1276
|
+
};
|
|
1277
|
+
const rawSegments = cacheData.transcripts[document.id] ?? [];
|
|
1278
|
+
const normalisedSegments = normaliseTranscriptSegments(rawSegments);
|
|
1279
|
+
if (normalisedSegments.length === 0) return {
|
|
1280
|
+
loaded: true,
|
|
1281
|
+
segmentCount: 0,
|
|
1282
|
+
transcript: null,
|
|
1283
|
+
transcriptText: null
|
|
1284
|
+
};
|
|
1285
|
+
const transcript = buildTranscriptExport(cacheDocumentForMeeting(document, cacheData), normalisedSegments, rawSegments);
|
|
1286
|
+
return {
|
|
1287
|
+
loaded: true,
|
|
1288
|
+
segmentCount: transcript.segments.length,
|
|
1289
|
+
transcript: serialiseTranscript(transcript),
|
|
1290
|
+
transcriptText: renderTranscriptExport(transcript, "text")
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
function matchesMeetingSearch(document, search) {
|
|
1294
|
+
const query = search.trim().toLowerCase();
|
|
1295
|
+
if (!query) return true;
|
|
1296
|
+
return [
|
|
1297
|
+
document.id,
|
|
1298
|
+
document.title,
|
|
1299
|
+
...document.tags
|
|
1300
|
+
].some((value) => value.toLowerCase().includes(query));
|
|
1301
|
+
}
|
|
1302
|
+
function truncate(value, width) {
|
|
1303
|
+
if (value.length <= width) return value.padEnd(width);
|
|
1304
|
+
return `${value.slice(0, Math.max(0, width - 1))}…`;
|
|
1305
|
+
}
|
|
1306
|
+
function formatMeetingDate(value) {
|
|
1307
|
+
return value.trim().slice(0, 10) || "-";
|
|
1308
|
+
}
|
|
1309
|
+
function formatTranscriptStatus(meeting) {
|
|
1310
|
+
if (!meeting.transcriptLoaded) return "n/a";
|
|
1311
|
+
if (meeting.transcriptSegmentCount === 0) return "none";
|
|
1312
|
+
return String(meeting.transcriptSegmentCount);
|
|
1313
|
+
}
|
|
1314
|
+
function formatTranscriptLines(transcript) {
|
|
1315
|
+
if (!transcript || transcript.segments.length === 0) return "";
|
|
1316
|
+
return transcript.segments.map((segment) => `[${formatTimestampForTranscript(segment.startTimestamp)}] ${segment.speaker}: ${segment.text}`).join("\n");
|
|
1317
|
+
}
|
|
1318
|
+
function buildMeetingSummary(document, cacheData) {
|
|
1319
|
+
const note = buildNoteExport(document);
|
|
1320
|
+
const transcript = buildMeetingTranscript(document, cacheData);
|
|
1321
|
+
return {
|
|
1322
|
+
createdAt: document.createdAt,
|
|
1323
|
+
id: document.id,
|
|
1324
|
+
noteContentSource: note.contentSource,
|
|
1325
|
+
tags: [...document.tags],
|
|
1326
|
+
title: document.title,
|
|
1327
|
+
transcriptLoaded: transcript.loaded,
|
|
1328
|
+
transcriptSegmentCount: transcript.segmentCount,
|
|
1329
|
+
updatedAt: latestDocumentTimestamp(document)
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
function buildMeetingRecord(document, cacheData) {
|
|
1333
|
+
const note = buildNoteExport(document);
|
|
1334
|
+
const transcript = buildMeetingTranscript(document, cacheData);
|
|
1335
|
+
return {
|
|
1336
|
+
meeting: {
|
|
1337
|
+
createdAt: document.createdAt,
|
|
1338
|
+
id: document.id,
|
|
1339
|
+
noteContentSource: note.contentSource,
|
|
1340
|
+
tags: [...document.tags],
|
|
1341
|
+
title: document.title,
|
|
1342
|
+
transcriptLoaded: transcript.loaded,
|
|
1343
|
+
transcriptSegmentCount: transcript.segmentCount,
|
|
1344
|
+
updatedAt: latestDocumentTimestamp(document)
|
|
1345
|
+
},
|
|
1346
|
+
note: serialiseNote(note),
|
|
1347
|
+
noteMarkdown: renderNoteExport(note, "markdown"),
|
|
1348
|
+
transcript: transcript.transcript,
|
|
1349
|
+
transcriptText: transcript.transcriptText
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
function listMeetings(documents, options = {}) {
|
|
1353
|
+
const limit = options.limit ?? 20;
|
|
1354
|
+
return documents.filter((document) => options.search ? matchesMeetingSearch(document, options.search) : true).sort(compareMeetingDocuments).slice(0, limit).map((document) => buildMeetingSummary(document, options.cacheData));
|
|
1355
|
+
}
|
|
1356
|
+
function resolveMeeting(documents, id) {
|
|
1357
|
+
const exactMatch = documents.find((document) => document.id === id);
|
|
1358
|
+
if (exactMatch) return exactMatch;
|
|
1359
|
+
const matches = documents.filter((document) => document.id.startsWith(id));
|
|
1360
|
+
if (matches.length === 1) return matches[0];
|
|
1361
|
+
if (matches.length > 1) {
|
|
1362
|
+
const sample = matches.slice(0, 5).map((document) => document.id.slice(0, 8)).join(", ");
|
|
1363
|
+
throw new Error(`ambiguous meeting id: ${id} matches ${matches.length} meetings (${sample})`);
|
|
1364
|
+
}
|
|
1365
|
+
throw new Error(`meeting not found: ${id}`);
|
|
1366
|
+
}
|
|
1367
|
+
function renderMeetingList(meetings, format = "text") {
|
|
1368
|
+
switch (format) {
|
|
1369
|
+
case "json": return toJson(meetings);
|
|
1370
|
+
case "yaml": return toYaml(meetings);
|
|
1371
|
+
case "text": break;
|
|
1372
|
+
}
|
|
1373
|
+
if (meetings.length === 0) return "No meetings found\n";
|
|
1374
|
+
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)}`];
|
|
1375
|
+
for (const meeting of meetings) lines.push([
|
|
1376
|
+
meeting.id.slice(0, 8).padEnd(10),
|
|
1377
|
+
formatMeetingDate(meeting.updatedAt || meeting.createdAt).padEnd(10),
|
|
1378
|
+
truncate(meeting.title || meeting.id, 42),
|
|
1379
|
+
truncate(meeting.noteContentSource, 18),
|
|
1380
|
+
formatTranscriptStatus(meeting)
|
|
1381
|
+
].join(" "));
|
|
1382
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
1383
|
+
}
|
|
1384
|
+
function renderMeetingView(record, format = "text") {
|
|
1385
|
+
switch (format) {
|
|
1386
|
+
case "json": return toJson(record);
|
|
1387
|
+
case "yaml": return toYaml(record);
|
|
1388
|
+
case "text": break;
|
|
1389
|
+
}
|
|
1390
|
+
const tags = record.meeting.tags.length > 0 ? record.meeting.tags.join(", ") : "(none)";
|
|
1391
|
+
const transcriptStatus = !record.meeting.transcriptLoaded ? "cache not loaded" : record.meeting.transcriptSegmentCount === 0 ? "no transcript segments" : `${record.meeting.transcriptSegmentCount} segment(s)`;
|
|
1392
|
+
return `${[
|
|
1393
|
+
`# ${record.meeting.title || record.meeting.id}`,
|
|
1394
|
+
"",
|
|
1395
|
+
`ID: ${record.meeting.id}`,
|
|
1396
|
+
`Created: ${record.meeting.createdAt || "-"}`,
|
|
1397
|
+
`Updated: ${record.meeting.updatedAt || "-"}`,
|
|
1398
|
+
`Tags: ${tags}`,
|
|
1399
|
+
`Note source: ${record.meeting.noteContentSource}`,
|
|
1400
|
+
`Transcript: ${transcriptStatus}`,
|
|
1401
|
+
"",
|
|
1402
|
+
"## Notes",
|
|
1403
|
+
"",
|
|
1404
|
+
record.note.content.trim() || "(no notes)",
|
|
1405
|
+
"",
|
|
1406
|
+
"## Transcript",
|
|
1407
|
+
"",
|
|
1408
|
+
formatTranscriptLines(record.transcript) || (record.meeting.transcriptLoaded ? "(no transcript segments)" : "(Granola cache not loaded)"),
|
|
1409
|
+
""
|
|
1410
|
+
].join("\n").trimEnd()}\n`;
|
|
1411
|
+
}
|
|
1412
|
+
function renderMeetingExport(record, format = "json") {
|
|
1413
|
+
switch (format) {
|
|
1414
|
+
case "json": return toJson(record);
|
|
1415
|
+
case "yaml": return toYaml(record);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
//#endregion
|
|
1419
|
+
//#region src/commands/shared.ts
|
|
1420
|
+
function debug(enabled, ...values) {
|
|
1421
|
+
if (enabled) console.error("[debug]", ...values);
|
|
1422
|
+
}
|
|
1423
|
+
//#endregion
|
|
1424
|
+
//#region src/commands/meeting.ts
|
|
1425
|
+
function meetingHelp() {
|
|
1426
|
+
return `Granola meeting
|
|
1427
|
+
|
|
1428
|
+
Usage:
|
|
1429
|
+
granola meeting <list|view|export> [options]
|
|
1430
|
+
|
|
1431
|
+
Subcommands:
|
|
1432
|
+
list List meetings from the Granola API
|
|
1433
|
+
view <id> Show a single meeting with notes and transcript text
|
|
1434
|
+
export <id> Export a single meeting as JSON or YAML
|
|
1435
|
+
|
|
1436
|
+
Options:
|
|
1437
|
+
--cache <path> Path to Granola cache JSON for transcript data
|
|
1438
|
+
--format <value> list/view: text, json, yaml; export: json, yaml
|
|
1439
|
+
--limit <n> Number of meetings for list (default: 20)
|
|
1440
|
+
--search <query> Filter list by title, id, or tag
|
|
1441
|
+
--timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
|
|
1442
|
+
--supabase <path> Path to supabase.json
|
|
1443
|
+
--debug Enable debug logging
|
|
1444
|
+
--config <path> Path to .granola.toml
|
|
1445
|
+
-h, --help Show help
|
|
1446
|
+
`;
|
|
1447
|
+
}
|
|
1448
|
+
function resolveListFormat(value) {
|
|
1449
|
+
switch (value) {
|
|
1450
|
+
case void 0: return "text";
|
|
1451
|
+
case "json":
|
|
1452
|
+
case "text":
|
|
1453
|
+
case "yaml": return value;
|
|
1454
|
+
default: throw new Error("invalid meeting format: expected text, json, or yaml");
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
function resolveViewFormat(value) {
|
|
1458
|
+
switch (value) {
|
|
1459
|
+
case void 0: return "text";
|
|
1460
|
+
case "json":
|
|
1461
|
+
case "text":
|
|
1462
|
+
case "yaml": return value;
|
|
1463
|
+
default: throw new Error("invalid meeting format: expected text, json, or yaml");
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
function resolveExportFormat(value) {
|
|
1467
|
+
switch (value) {
|
|
1468
|
+
case void 0: return "json";
|
|
1469
|
+
case "json":
|
|
1470
|
+
case "yaml": return value;
|
|
1471
|
+
default: throw new Error("invalid meeting export format: expected json or yaml");
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
function parseLimit(value) {
|
|
1475
|
+
if (value === void 0) return 20;
|
|
1476
|
+
if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid meeting limit: expected a positive integer");
|
|
1477
|
+
const limit = Number(value);
|
|
1478
|
+
if (!Number.isInteger(limit) || limit < 1) throw new Error("invalid meeting limit: expected a positive integer");
|
|
1479
|
+
return limit;
|
|
1480
|
+
}
|
|
1481
|
+
const meetingCommand = {
|
|
1482
|
+
description: "Inspect and export individual Granola meetings",
|
|
1483
|
+
flags: {
|
|
1484
|
+
cache: { type: "string" },
|
|
1485
|
+
format: { type: "string" },
|
|
1486
|
+
help: { type: "boolean" },
|
|
1487
|
+
limit: { type: "string" },
|
|
1488
|
+
search: { type: "string" },
|
|
1489
|
+
timeout: { type: "string" }
|
|
1490
|
+
},
|
|
1491
|
+
help: meetingHelp,
|
|
1492
|
+
name: "meeting",
|
|
1493
|
+
async run({ commandArgs, commandFlags, globalFlags }) {
|
|
1494
|
+
const [action, id] = commandArgs;
|
|
1495
|
+
switch (action) {
|
|
1496
|
+
case "list": return await list(commandFlags, globalFlags);
|
|
1497
|
+
case "view":
|
|
1498
|
+
if (!id) throw new Error("meeting view requires an id");
|
|
1499
|
+
return await view(id, commandFlags, globalFlags);
|
|
1500
|
+
case "export":
|
|
1501
|
+
if (!id) throw new Error("meeting export requires an id");
|
|
1502
|
+
return await exportMeeting(id, commandFlags, globalFlags);
|
|
1503
|
+
case void 0:
|
|
1504
|
+
console.log(meetingHelp());
|
|
1505
|
+
return 1;
|
|
1506
|
+
default: throw new Error("invalid meeting command: expected list, view, or export");
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
};
|
|
1510
|
+
async function loadMeetingData(commandFlags, globalFlags) {
|
|
1511
|
+
const config = await loadConfig({
|
|
1512
|
+
globalFlags,
|
|
1513
|
+
subcommandFlags: commandFlags
|
|
1514
|
+
});
|
|
1515
|
+
if (config.transcripts.cacheFile && !existsSync(config.transcripts.cacheFile)) throw new Error(`Granola cache file not found: ${config.transcripts.cacheFile}`);
|
|
1516
|
+
debug(config.debug, "using config", config.configFileUsed ?? "(none)");
|
|
1517
|
+
debug(config.debug, "supabase", config.supabase);
|
|
1518
|
+
debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
|
|
1519
|
+
debug(config.debug, "timeoutMs", config.notes.timeoutMs);
|
|
1520
|
+
const granolaClient = await createDefaultGranolaApiClient(config);
|
|
1521
|
+
return {
|
|
1522
|
+
cacheData: await loadOptionalGranolaCache(config.transcripts.cacheFile),
|
|
1523
|
+
config,
|
|
1524
|
+
granolaClient
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
async function list(commandFlags, globalFlags) {
|
|
1528
|
+
const format = resolveListFormat(commandFlags.format);
|
|
1529
|
+
const limit = parseLimit(commandFlags.limit);
|
|
1530
|
+
const search = typeof commandFlags.search === "string" ? commandFlags.search : void 0;
|
|
1531
|
+
const { cacheData, config, granolaClient } = await loadMeetingData(commandFlags, globalFlags);
|
|
1532
|
+
console.log("Fetching meetings from Granola API...");
|
|
1533
|
+
const meetings = listMeetings(await granolaClient.listDocuments({ timeoutMs: config.notes.timeoutMs }), {
|
|
1534
|
+
cacheData,
|
|
1535
|
+
limit,
|
|
1536
|
+
search
|
|
1537
|
+
});
|
|
1538
|
+
console.log(renderMeetingList(meetings, format).trimEnd());
|
|
1539
|
+
return 0;
|
|
1540
|
+
}
|
|
1541
|
+
async function view(id, commandFlags, globalFlags) {
|
|
1542
|
+
const format = resolveViewFormat(commandFlags.format);
|
|
1543
|
+
const { cacheData, config, granolaClient } = await loadMeetingData(commandFlags, globalFlags);
|
|
1544
|
+
console.log("Fetching meeting from Granola API...");
|
|
1545
|
+
const meeting = buildMeetingRecord(resolveMeeting(await granolaClient.listDocuments({ timeoutMs: config.notes.timeoutMs }), id), cacheData);
|
|
1546
|
+
console.log(renderMeetingView(meeting, format).trimEnd());
|
|
1547
|
+
return 0;
|
|
1548
|
+
}
|
|
1549
|
+
async function exportMeeting(id, commandFlags, globalFlags) {
|
|
1550
|
+
const format = resolveExportFormat(commandFlags.format);
|
|
1551
|
+
const { cacheData, config, granolaClient } = await loadMeetingData(commandFlags, globalFlags);
|
|
1552
|
+
console.log("Fetching meeting from Granola API...");
|
|
1553
|
+
const meeting = buildMeetingRecord(resolveMeeting(await granolaClient.listDocuments({ timeoutMs: config.notes.timeoutMs }), id), cacheData);
|
|
1554
|
+
console.log(renderMeetingExport(meeting, format).trimEnd());
|
|
1555
|
+
return 0;
|
|
1556
|
+
}
|
|
1557
|
+
//#endregion
|
|
1558
|
+
//#region src/commands/notes.ts
|
|
1559
|
+
function notesHelp() {
|
|
1560
|
+
return `Granola notes
|
|
1561
|
+
|
|
1562
|
+
Usage:
|
|
1563
|
+
granola notes [options]
|
|
1564
|
+
|
|
1565
|
+
Options:
|
|
1566
|
+
--format <value> Output format: markdown, json, yaml, raw (default: markdown)
|
|
1567
|
+
--output <path> Output directory for note files (default: ./notes)
|
|
1568
|
+
--timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
|
|
1569
|
+
--supabase <path> Path to supabase.json
|
|
1570
|
+
--debug Enable debug logging
|
|
1571
|
+
--config <path> Path to .granola.toml
|
|
1572
|
+
-h, --help Show help
|
|
1573
|
+
`;
|
|
1574
|
+
}
|
|
1575
|
+
const notesCommand = {
|
|
1576
|
+
description: "Export Granola notes",
|
|
1577
|
+
flags: {
|
|
1578
|
+
format: { type: "string" },
|
|
1579
|
+
help: { type: "boolean" },
|
|
1580
|
+
output: { type: "string" },
|
|
1581
|
+
timeout: { type: "string" }
|
|
1582
|
+
},
|
|
1583
|
+
help: notesHelp,
|
|
1584
|
+
name: "notes",
|
|
1585
|
+
async run({ commandFlags, globalFlags }) {
|
|
1586
|
+
const config = await loadConfig({
|
|
1587
|
+
globalFlags,
|
|
1588
|
+
subcommandFlags: commandFlags
|
|
1589
|
+
});
|
|
1590
|
+
debug(config.debug, "using config", config.configFileUsed ?? "(none)");
|
|
1591
|
+
debug(config.debug, "supabase", config.supabase);
|
|
1592
|
+
debug(config.debug, "timeoutMs", config.notes.timeoutMs);
|
|
1593
|
+
debug(config.debug, "output", config.notes.output);
|
|
1594
|
+
const format = resolveNoteFormat(commandFlags.format);
|
|
1595
|
+
debug(config.debug, "format", format);
|
|
1596
|
+
const granolaClient = await createDefaultGranolaApiClient(config);
|
|
1597
|
+
console.log("Fetching documents from Granola API...");
|
|
1598
|
+
const documents = await granolaClient.listDocuments({ timeoutMs: config.notes.timeoutMs });
|
|
1599
|
+
console.log(`Exporting ${documents.length} notes to ${config.notes.output}...`);
|
|
1600
|
+
const written = await writeNotes(documents, config.notes.output, format);
|
|
1601
|
+
console.log("✓ Export completed successfully");
|
|
1602
|
+
debug(config.debug, "notes written", written);
|
|
1603
|
+
return 0;
|
|
1604
|
+
}
|
|
1605
|
+
};
|
|
1606
|
+
function resolveNoteFormat(value) {
|
|
1607
|
+
switch (value) {
|
|
1608
|
+
case void 0: return "markdown";
|
|
1609
|
+
case "json":
|
|
1610
|
+
case "markdown":
|
|
1611
|
+
case "raw":
|
|
1612
|
+
case "yaml": return value;
|
|
1613
|
+
default: throw new Error("invalid notes format: expected markdown, json, yaml, or raw");
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
//#endregion
|
|
1041
1617
|
//#region src/commands/transcripts.ts
|
|
1042
1618
|
function transcriptsHelp() {
|
|
1043
1619
|
return `Granola transcripts
|
|
@@ -1098,7 +1674,12 @@ function resolveTranscriptFormat(value) {
|
|
|
1098
1674
|
}
|
|
1099
1675
|
//#endregion
|
|
1100
1676
|
//#region src/commands/index.ts
|
|
1101
|
-
const commands = [
|
|
1677
|
+
const commands = [
|
|
1678
|
+
authCommand,
|
|
1679
|
+
meetingCommand,
|
|
1680
|
+
notesCommand,
|
|
1681
|
+
transcriptsCommand
|
|
1682
|
+
];
|
|
1102
1683
|
const commandMap = new Map(commands.map((command) => [command.name, command]));
|
|
1103
1684
|
//#endregion
|
|
1104
1685
|
//#region src/flags.ts
|
|
@@ -1213,6 +1794,7 @@ async function runCli(argv) {
|
|
|
1213
1794
|
return 0;
|
|
1214
1795
|
}
|
|
1215
1796
|
return await command.run({
|
|
1797
|
+
commandArgs: subcommand.rest,
|
|
1216
1798
|
commandFlags: subcommand.values,
|
|
1217
1799
|
globalFlags: global.values
|
|
1218
1800
|
});
|