granola-toolkit 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +43 -1
  2. package/dist/cli.js +2283 -1378
  3. package/package.json +3 -1
package/dist/cli.js CHANGED
@@ -1,13 +1,235 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync } from "node:fs";
2
+ import { Input, ProcessTerminal, TUI, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
3
+ import { createHash, randomUUID } from "node:crypto";
3
4
  import { mkdir, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
4
- import { homedir, platform } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
+ import { existsSync } from "node:fs";
7
+ import { homedir, platform } from "node:os";
6
8
  import { NodeHtmlMarkdown } from "node-html-markdown";
7
9
  import { execFile } from "node:child_process";
8
10
  import { promisify } from "node:util";
9
- import { createHash, randomUUID } from "node:crypto";
10
11
  import { createServer } from "node:http";
12
+ //#region src/server/client.ts
13
+ function cloneValue(value) {
14
+ return structuredClone(value);
15
+ }
16
+ function normaliseServerUrl(serverUrl) {
17
+ const raw = serverUrl instanceof URL ? serverUrl.href : serverUrl.trim();
18
+ if (!raw) throw new Error("server URL is required");
19
+ const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`;
20
+ const parsed = new URL(withProtocol);
21
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("server URL must use http or https");
22
+ parsed.pathname = "/";
23
+ parsed.search = "";
24
+ parsed.hash = "";
25
+ return parsed;
26
+ }
27
+ function appendSearchParams(path, params) {
28
+ const url = new URL(path, "http://localhost");
29
+ for (const [key, value] of Object.entries(params)) {
30
+ if (value === void 0 || value === false || value === "") continue;
31
+ url.searchParams.set(key, String(value));
32
+ }
33
+ return `${url.pathname}${url.search}`;
34
+ }
35
+ function mergeHeaders(...values) {
36
+ const headers = new Headers();
37
+ for (const value of values) {
38
+ if (!value) continue;
39
+ new Headers(value).forEach((headerValue, headerName) => {
40
+ headers.set(headerName, headerValue);
41
+ });
42
+ }
43
+ return headers;
44
+ }
45
+ async function responseError(response) {
46
+ let message = `${response.status} ${response.statusText}`.trim();
47
+ try {
48
+ const payload = await response.json();
49
+ if (typeof payload.error === "string" && payload.error.trim()) message = payload.error;
50
+ else if (typeof payload.message === "string" && payload.message.trim()) message = payload.message;
51
+ } catch {
52
+ const text = (await response.text()).trim();
53
+ if (text) message = text;
54
+ }
55
+ return new Error(message);
56
+ }
57
+ function parseSseEvent(payload) {
58
+ const data = payload.replaceAll("\r\n", "\n").split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
59
+ if (!data) return;
60
+ return JSON.parse(data);
61
+ }
62
+ var GranolaServerClient = class GranolaServerClient {
63
+ #closed = false;
64
+ #eventLoop;
65
+ #listeners = /* @__PURE__ */ new Set();
66
+ #fetchImpl;
67
+ #password;
68
+ #reconnectDelayMs;
69
+ #streamAbortController;
70
+ constructor(url, initialState, options = {}) {
71
+ this.url = url;
72
+ this.#fetchImpl = options.fetchImpl ?? fetch;
73
+ this.#password = options.password?.trim() || void 0;
74
+ this.#reconnectDelayMs = options.reconnectDelayMs ?? 1e3;
75
+ this.#state = cloneValue(initialState);
76
+ }
77
+ static async connect(serverUrl, options = {}) {
78
+ const url = normaliseServerUrl(serverUrl);
79
+ const response = await (options.fetchImpl ?? fetch)(new URL("/state", url), { headers: mergeHeaders({
80
+ ...options.password?.trim() ? { "x-granola-password": options.password.trim() } : {},
81
+ accept: "application/json"
82
+ }) });
83
+ if (!response.ok) throw await responseError(response);
84
+ const client = new GranolaServerClient(url, await response.json(), options);
85
+ client.startEvents();
86
+ return client;
87
+ }
88
+ #state;
89
+ async close() {
90
+ this.#closed = true;
91
+ this.#streamAbortController?.abort();
92
+ try {
93
+ await this.#eventLoop;
94
+ } catch {}
95
+ }
96
+ getState() {
97
+ return cloneValue(this.#state);
98
+ }
99
+ subscribe(listener) {
100
+ this.#listeners.add(listener);
101
+ return () => {
102
+ this.#listeners.delete(listener);
103
+ };
104
+ }
105
+ async inspectAuth() {
106
+ return await this.requestJson("/auth/status");
107
+ }
108
+ async loginAuth(options = {}) {
109
+ return await this.requestJson("/auth/login", {
110
+ body: JSON.stringify(options),
111
+ headers: { "content-type": "application/json" },
112
+ method: "POST"
113
+ });
114
+ }
115
+ async logoutAuth() {
116
+ return await this.requestJson("/auth/logout", { method: "POST" });
117
+ }
118
+ async refreshAuth() {
119
+ return await this.requestJson("/auth/refresh", { method: "POST" });
120
+ }
121
+ async switchAuthMode(mode) {
122
+ return await this.requestJson("/auth/mode", {
123
+ body: JSON.stringify({ mode }),
124
+ headers: { "content-type": "application/json" },
125
+ method: "POST"
126
+ });
127
+ }
128
+ async listMeetings(options = {}) {
129
+ return await this.requestJson(appendSearchParams("/meetings", {
130
+ limit: options.limit,
131
+ refresh: options.forceRefresh ? "true" : void 0,
132
+ search: options.search,
133
+ sort: options.sort,
134
+ updatedFrom: options.updatedFrom,
135
+ updatedTo: options.updatedTo
136
+ }));
137
+ }
138
+ async getMeeting(id, options = {}) {
139
+ return await this.requestJson(appendSearchParams(`/meetings/${encodeURIComponent(id)}`, { includeTranscript: options.requireCache ? "true" : void 0 }));
140
+ }
141
+ async findMeeting(query, options = {}) {
142
+ return await this.requestJson(appendSearchParams("/meetings/resolve", {
143
+ includeTranscript: options.requireCache ? "true" : void 0,
144
+ q: query
145
+ }));
146
+ }
147
+ async listExportJobs(options = {}) {
148
+ return await this.requestJson(appendSearchParams("/exports/jobs", { limit: options.limit }));
149
+ }
150
+ async exportNotes(format = "markdown") {
151
+ return await this.requestJson("/exports/notes", {
152
+ body: JSON.stringify({ format }),
153
+ headers: { "content-type": "application/json" },
154
+ method: "POST"
155
+ });
156
+ }
157
+ async exportTranscripts(format = "text") {
158
+ return await this.requestJson("/exports/transcripts", {
159
+ body: JSON.stringify({ format }),
160
+ headers: { "content-type": "application/json" },
161
+ method: "POST"
162
+ });
163
+ }
164
+ async rerunExportJob(id) {
165
+ return await this.requestJson(`/exports/jobs/${encodeURIComponent(id)}/rerun`, { method: "POST" });
166
+ }
167
+ async request(path, init = {}) {
168
+ const response = await this.#fetchImpl(new URL(path, this.url), {
169
+ ...init,
170
+ headers: mergeHeaders({
171
+ ...this.#password ? { "x-granola-password": this.#password } : {},
172
+ accept: "application/json"
173
+ }, init.headers)
174
+ });
175
+ if (!response.ok) throw await responseError(response);
176
+ return response;
177
+ }
178
+ async requestJson(path, init = {}) {
179
+ return cloneValue(await (await this.request(path, init)).json());
180
+ }
181
+ emit(event) {
182
+ this.#state = cloneValue(event.state);
183
+ const nextEvent = cloneValue(event);
184
+ for (const listener of this.#listeners) listener(nextEvent);
185
+ }
186
+ startEvents() {
187
+ if (this.#eventLoop) return;
188
+ this.#eventLoop = this.runEventsLoop();
189
+ }
190
+ async runEventsLoop() {
191
+ while (!this.#closed) {
192
+ const controller = new AbortController();
193
+ this.#streamAbortController = controller;
194
+ try {
195
+ const response = await this.request("/events", {
196
+ headers: { accept: "text/event-stream" },
197
+ signal: controller.signal
198
+ });
199
+ await this.consumeEventStream(response);
200
+ } catch {
201
+ if (this.#closed || controller.signal.aborted) break;
202
+ await new Promise((resolve) => {
203
+ setTimeout(resolve, this.#reconnectDelayMs);
204
+ });
205
+ }
206
+ }
207
+ }
208
+ async consumeEventStream(response) {
209
+ const reader = response.body?.getReader();
210
+ if (!reader) throw new Error("server did not provide an event stream");
211
+ const decoder = new TextDecoder();
212
+ let buffer = "";
213
+ while (!this.#closed) {
214
+ const { done, value } = await reader.read();
215
+ if (done) return;
216
+ buffer += decoder.decode(value, { stream: true });
217
+ buffer = buffer.replaceAll("\r\n", "\n");
218
+ while (true) {
219
+ const boundary = buffer.indexOf("\n\n");
220
+ if (boundary < 0) break;
221
+ const chunk = buffer.slice(0, boundary);
222
+ buffer = buffer.slice(boundary + 2);
223
+ const event = parseSseEvent(chunk);
224
+ if (event) this.emit(event);
225
+ }
226
+ }
227
+ }
228
+ };
229
+ async function createGranolaServerClient(serverUrl, options = {}) {
230
+ return await GranolaServerClient.connect(serverUrl, options);
231
+ }
232
+ //#endregion
11
233
  //#region src/utils.ts
12
234
  const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g;
13
235
  const CONTROL_CHARACTERS = /\p{Cc}/gu;
@@ -153,1531 +375,2167 @@ function transcriptSpeakerLabel(segment) {
153
375
  return segment.source === "microphone" ? "You" : "System";
154
376
  }
155
377
  //#endregion
156
- //#region src/cache.ts
157
- function parseCacheDocument(id, value) {
158
- const record = asRecord(value);
159
- if (!record) return;
160
- return {
161
- createdAt: stringValue(record.created_at),
162
- id,
163
- title: stringValue(record.title),
164
- updatedAt: stringValue(record.updated_at)
165
- };
166
- }
167
- function parseTranscriptSegments(value) {
168
- if (!Array.isArray(value)) return;
169
- return value.flatMap((segment) => {
170
- const record = asRecord(segment);
171
- if (!record) return [];
172
- return [{
173
- documentId: stringValue(record.document_id),
174
- endTimestamp: stringValue(record.end_timestamp),
175
- id: stringValue(record.id),
176
- isFinal: Boolean(record.is_final),
177
- source: stringValue(record.source),
178
- startTimestamp: stringValue(record.start_timestamp),
179
- text: stringValue(record.text)
180
- }];
181
- });
378
+ //#region src/export-state.ts
379
+ const EXPORT_STATE_VERSION = 1;
380
+ function exportStatePath(outputDir, kind) {
381
+ return join(outputDir, `.granola-toolkit-${kind}-state.json`);
182
382
  }
183
- function parseCacheContents(contents) {
184
- const outer = parseJsonString(contents);
185
- if (!outer) throw new Error("failed to parse cache JSON");
186
- const rawCache = outer.cache;
187
- let cachePayload;
188
- if (typeof rawCache === "string") cachePayload = parseJsonString(rawCache);
189
- else cachePayload = asRecord(rawCache);
190
- const state = cachePayload ? asRecord(cachePayload.state) : void 0;
191
- if (!state) throw new Error("failed to parse cache state");
192
- const rawDocuments = asRecord(state.documents) ?? {};
193
- const rawTranscripts = asRecord(state.transcripts) ?? {};
194
- const documents = {};
195
- for (const [id, rawDocument] of Object.entries(rawDocuments)) {
196
- const document = parseCacheDocument(id, rawDocument);
197
- if (document) documents[id] = document;
198
- }
199
- const transcripts = {};
200
- for (const [id, rawTranscript] of Object.entries(rawTranscripts)) {
201
- const segments = parseTranscriptSegments(rawTranscript);
202
- if (segments) transcripts[id] = segments;
203
- }
383
+ function emptyExportState(kind) {
204
384
  return {
205
- documents,
206
- transcripts
385
+ entries: {},
386
+ kind,
387
+ version: EXPORT_STATE_VERSION
207
388
  };
208
389
  }
209
- //#endregion
210
- //#region src/client/auth.ts
211
- const execFileAsync$1 = promisify(execFile);
212
- const DEFAULT_CLIENT_ID = "client_GranolaMac";
213
- const KEYCHAIN_SERVICE_NAME = "com.granola.toolkit";
214
- const KEYCHAIN_ACCOUNT_NAME = "session";
215
- const WORKOS_AUTH_URL = "https://api.workos.com/user_management/authenticate";
216
- function numberValue(value) {
217
- return typeof value === "number" && Number.isFinite(value) ? value : void 0;
218
- }
219
- function parseSessionRecord(record) {
220
- const accessToken = stringValue(record.access_token);
221
- if (!accessToken.trim()) return;
390
+ function normaliseExportState(parsed, kind) {
391
+ const record = asRecord(parsed);
392
+ if (!record || record.version !== EXPORT_STATE_VERSION || record.kind !== kind) return emptyExportState(kind);
393
+ const rawEntries = asRecord(record.entries) ?? {};
222
394
  return {
223
- accessToken,
224
- clientId: stringValue(record.client_id) || DEFAULT_CLIENT_ID,
225
- expiresIn: numberValue(record.expires_in),
226
- externalId: stringValue(record.external_id) || void 0,
227
- obtainedAt: stringValue(record.obtained_at) || void 0,
228
- refreshToken: stringValue(record.refresh_token) || void 0,
229
- sessionId: stringValue(record.session_id) || void 0,
230
- signInMethod: stringValue(record.sign_in_method) || void 0,
231
- tokenType: stringValue(record.token_type) || void 0
395
+ entries: Object.fromEntries(Object.entries(rawEntries).map(([id, entry]) => {
396
+ const value = asRecord(entry);
397
+ if (!value) return;
398
+ const fileName = stringValue(value.fileName);
399
+ const fileStem = stringValue(value.fileStem);
400
+ if (!fileName || !fileStem) return;
401
+ return [id, {
402
+ contentHash: stringValue(value.contentHash),
403
+ exportedAt: stringValue(value.exportedAt),
404
+ fileName,
405
+ fileStem,
406
+ sourceUpdatedAt: stringValue(value.sourceUpdatedAt)
407
+ }];
408
+ }).filter((entry) => Boolean(entry))),
409
+ kind,
410
+ version: EXPORT_STATE_VERSION
232
411
  };
233
412
  }
234
- function parseNestedRecord(value) {
235
- if (typeof value === "string") return parseJsonString(value);
236
- return asRecord(value);
237
- }
238
- function getSessionFromSupabaseContents(supabaseContents) {
239
- const wrapper = parseJsonString(supabaseContents);
240
- if (!wrapper) throw new Error("failed to parse supabase.json");
241
- const workOsSession = parseSessionRecord(parseNestedRecord(wrapper.workos_tokens) ?? {});
242
- if (workOsSession) return workOsSession;
243
- const cognitoSession = parseSessionRecord(parseNestedRecord(wrapper.cognito_tokens) ?? {});
244
- if (cognitoSession) return cognitoSession;
245
- const legacySession = parseSessionRecord(wrapper);
246
- if (legacySession) return legacySession;
247
- throw new Error("access token not found in supabase.json");
413
+ async function loadExportState(outputDir, kind) {
414
+ const statePath = exportStatePath(outputDir, kind);
415
+ try {
416
+ return normaliseExportState(parseJsonString(await readUtf8(statePath)), kind);
417
+ } catch {
418
+ return emptyExportState(kind);
419
+ }
248
420
  }
249
- function getAccessTokenFromSupabaseContents(supabaseContents) {
250
- return getSessionFromSupabaseContents(supabaseContents).accessToken;
421
+ function hashContent(content) {
422
+ return createHash("sha256").update(content).digest("hex");
251
423
  }
252
- var SupabaseFileTokenSource = class {
253
- constructor(filePath) {
254
- this.filePath = filePath;
255
- }
256
- async loadAccessToken() {
257
- return getAccessTokenFromSupabaseContents(await readFile(this.filePath, "utf8"));
258
- }
259
- };
260
- var SupabaseFileSessionSource = class {
261
- constructor(filePath) {
262
- this.filePath = filePath;
263
- }
264
- async loadSession() {
265
- return getSessionFromSupabaseContents(await readFile(this.filePath, "utf8"));
266
- }
267
- };
268
- var NoopTokenStore = class {
269
- async clearToken() {}
270
- async readToken() {}
271
- async writeToken(_token) {}
272
- };
273
- var FileSessionStore = class {
274
- constructor(filePath = defaultSessionFilePath()) {
275
- this.filePath = filePath;
276
- }
277
- async clearSession() {
278
- try {
279
- await unlink(this.filePath);
280
- } catch {}
424
+ function reserveStem(used, preferredStem, existingStem) {
425
+ if (existingStem && (used.get(existingStem) ?? 0) === 0) {
426
+ used.set(existingStem, 1);
427
+ return existingStem;
281
428
  }
282
- async readSession() {
283
- try {
284
- const parsed = parseJsonString(await readFile(this.filePath, "utf8"));
285
- return parsed?.accessToken ? parsed : void 0;
286
- } catch {
287
- return;
288
- }
429
+ return makeUniqueFilename(preferredStem, used);
430
+ }
431
+ async function fileExists(pathname) {
432
+ try {
433
+ await stat(pathname);
434
+ return true;
435
+ } catch {
436
+ return false;
289
437
  }
290
- async writeSession(session) {
291
- await mkdir(dirname(this.filePath), { recursive: true });
292
- await writeFile(this.filePath, `${JSON.stringify(session, null, 2)}\n`, {
293
- encoding: "utf8",
294
- mode: 384
295
- });
296
- }
297
- };
298
- var KeychainSessionStore = class {
299
- async clearSession() {
300
- try {
301
- await execFileAsync$1("security", [
302
- "delete-generic-password",
303
- "-s",
304
- KEYCHAIN_SERVICE_NAME,
305
- "-a",
306
- KEYCHAIN_ACCOUNT_NAME
307
- ]);
308
- } catch {}
309
- }
310
- async readSession() {
311
- try {
312
- const { stdout } = await execFileAsync$1("security", [
313
- "find-generic-password",
314
- "-s",
315
- KEYCHAIN_SERVICE_NAME,
316
- "-a",
317
- KEYCHAIN_ACCOUNT_NAME,
318
- "-w"
319
- ]);
320
- const parsed = parseJsonString(stdout.trim());
321
- return parsed?.accessToken ? parsed : void 0;
322
- } catch {
323
- return;
324
- }
325
- }
326
- async writeSession(session) {
327
- await execFileAsync$1("security", [
328
- "add-generic-password",
329
- "-U",
330
- "-s",
331
- KEYCHAIN_SERVICE_NAME,
332
- "-a",
333
- KEYCHAIN_ACCOUNT_NAME,
334
- "-w",
335
- JSON.stringify(session)
336
- ]);
337
- }
338
- };
339
- var CachedTokenProvider = class {
340
- #token;
341
- constructor(source, store = new NoopTokenStore()) {
342
- this.source = source;
343
- this.store = store;
344
- }
345
- async getAccessToken() {
346
- if (this.#token) return this.#token;
347
- const storedToken = await this.store.readToken();
348
- if (storedToken?.trim()) {
349
- this.#token = storedToken;
350
- return storedToken;
438
+ }
439
+ function entryChanged(left, right) {
440
+ if (!left) return true;
441
+ return left.contentHash !== right.contentHash || left.exportedAt !== right.exportedAt || left.fileName !== right.fileName || left.fileStem !== right.fileStem || left.sourceUpdatedAt !== right.sourceUpdatedAt;
442
+ }
443
+ async function syncManagedExports({ items, kind, onProgress, outputDir }) {
444
+ await ensureDirectory(outputDir);
445
+ const previousEntries = (await loadExportState(outputDir, kind)).entries;
446
+ const used = /* @__PURE__ */ new Map();
447
+ const plans = items.map((item) => {
448
+ const existing = previousEntries[item.id];
449
+ const fileStem = reserveStem(used, item.preferredStem, existing?.fileStem);
450
+ return {
451
+ content: item.content,
452
+ contentHash: hashContent(item.content),
453
+ existing,
454
+ fileName: `${fileStem}${item.extension}`,
455
+ fileStem,
456
+ id: item.id,
457
+ sourceUpdatedAt: item.sourceUpdatedAt
458
+ };
459
+ });
460
+ const activeIds = new Set(plans.map((plan) => plan.id));
461
+ const activeFileNames = new Set(plans.map((plan) => plan.fileName));
462
+ const exportedAt = (/* @__PURE__ */ new Date()).toISOString();
463
+ const nextEntries = {};
464
+ let completed = 0;
465
+ let written = 0;
466
+ let stateChanged = false;
467
+ for (const plan of plans) {
468
+ const filePath = join(outputDir, plan.fileName);
469
+ const shouldWrite = !plan.existing || plan.existing.contentHash !== plan.contentHash || plan.existing.fileName !== plan.fileName || !await fileExists(filePath);
470
+ if (shouldWrite) {
471
+ await writeTextFile(filePath, plan.content);
472
+ written += 1;
351
473
  }
352
- const token = await this.source.loadAccessToken();
353
- this.#token = token;
354
- await this.store.writeToken(token);
355
- return token;
356
- }
357
- async invalidate() {
358
- this.#token = void 0;
359
- await this.store.clearToken();
360
- }
361
- };
362
- var StoredSessionTokenProvider = class {
363
- #session;
364
- constructor(store, options = {}) {
365
- this.store = store;
366
- this.options = options;
474
+ const nextEntry = {
475
+ contentHash: plan.contentHash,
476
+ exportedAt: shouldWrite ? exportedAt : plan.existing?.exportedAt ?? exportedAt,
477
+ fileName: plan.fileName,
478
+ fileStem: plan.fileStem,
479
+ sourceUpdatedAt: plan.sourceUpdatedAt
480
+ };
481
+ nextEntries[plan.id] = nextEntry;
482
+ stateChanged = stateChanged || entryChanged(plan.existing, nextEntry);
483
+ completed += 1;
484
+ if (onProgress) await onProgress({
485
+ completed,
486
+ total: plans.length,
487
+ written
488
+ });
367
489
  }
368
- async loadSession() {
369
- if (this.#session) return this.#session;
370
- const storedSession = await this.store.readSession();
371
- if (storedSession?.accessToken.trim()) {
372
- this.#session = storedSession;
373
- return storedSession;
490
+ for (const plan of plans) {
491
+ const previousFileName = plan.existing?.fileName;
492
+ if (previousFileName && previousFileName !== plan.fileName && !activeFileNames.has(previousFileName)) {
493
+ await rm(join(outputDir, previousFileName), { force: true });
494
+ stateChanged = true;
374
495
  }
375
- if (!this.options.source) throw new Error("no stored Granola session found");
376
- const sourcedSession = await this.options.source.loadSession();
377
- this.#session = sourcedSession;
378
- return sourcedSession;
379
496
  }
380
- async getAccessToken() {
381
- return (await this.loadSession()).accessToken;
497
+ for (const [id, entry] of Object.entries(previousEntries)) {
498
+ if (activeIds.has(id)) continue;
499
+ if (!activeFileNames.has(entry.fileName)) await rm(join(outputDir, entry.fileName), { force: true });
500
+ stateChanged = true;
382
501
  }
383
- async invalidate() {
384
- const session = await this.loadSession().catch(() => void 0);
385
- if (session?.refreshToken && session.clientId) try {
386
- const refreshedSession = await refreshGranolaSession(session, this.options.fetchImpl);
387
- this.#session = refreshedSession;
388
- await this.store.writeSession(refreshedSession);
389
- return;
390
- } catch {
391
- if (!this.options.source) {
392
- this.#session = void 0;
393
- await this.store.clearSession();
394
- throw new Error("failed to refresh stored Granola session");
502
+ const serialisedState = `${JSON.stringify({
503
+ entries: nextEntries,
504
+ kind,
505
+ version: EXPORT_STATE_VERSION
506
+ }, null, 2)}\n`;
507
+ const statePath = exportStatePath(outputDir, kind);
508
+ const existingState = await fileExists(statePath) ? await readUtf8(statePath) : void 0;
509
+ if (stateChanged || existingState !== serialisedState) await writeTextFile(statePath, serialisedState);
510
+ return written;
511
+ }
512
+ //#endregion
513
+ //#region src/render.ts
514
+ function formatScalar(value) {
515
+ if (value == null) return "null";
516
+ if (typeof value === "string") return JSON.stringify(value);
517
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
518
+ return JSON.stringify(value);
519
+ }
520
+ function renderYaml(value, depth = 0) {
521
+ const indent = " ".repeat(depth);
522
+ if (Array.isArray(value)) {
523
+ if (value.length === 0) return [`${indent}[]`];
524
+ return value.flatMap((item) => {
525
+ if (item && typeof item === "object" && !Array.isArray(item)) {
526
+ const nested = renderYaml(item, depth + 1);
527
+ return [`${indent}- ${(nested[0] ?? `${" ".repeat(depth + 1)}{}`).trimStart()}`, ...nested.slice(1)];
395
528
  }
396
- }
397
- if (this.options.source) {
398
- const sourcedSession = await this.options.source.loadSession();
399
- this.#session = sourcedSession;
400
- await this.store.writeSession(sourcedSession);
401
- return;
402
- }
403
- this.#session = void 0;
404
- await this.store.clearSession();
529
+ return [`${indent}- ${formatScalar(item)}`];
530
+ });
405
531
  }
406
- };
407
- async function refreshGranolaSession(session, fetchImpl = fetch) {
408
- if (!session.refreshToken?.trim()) throw new Error("refresh token not available");
409
- const response = await fetchImpl(WORKOS_AUTH_URL, {
410
- body: JSON.stringify({
411
- client_id: session.clientId,
412
- grant_type: "refresh_token",
413
- refresh_token: session.refreshToken
414
- }),
415
- headers: { "Content-Type": "application/json" },
416
- method: "POST"
417
- });
418
- if (!response.ok) throw new Error(`failed to refresh session: ${response.status} ${response.statusText}`);
419
- const refreshed = parseSessionRecord(await response.json());
420
- if (!refreshed) throw new Error("failed to parse refreshed session");
421
- return {
422
- ...session,
423
- ...refreshed,
424
- clientId: refreshed.clientId || session.clientId,
425
- obtainedAt: refreshed.obtainedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
426
- refreshToken: refreshed.refreshToken ?? session.refreshToken
427
- };
532
+ if (value && typeof value === "object") {
533
+ const entries = Object.entries(value);
534
+ if (entries.length === 0) return [`${indent}{}`];
535
+ return entries.flatMap(([key, entryValue]) => {
536
+ if (Array.isArray(entryValue) || entryValue && typeof entryValue === "object") return [`${indent}${key}:`, ...renderYaml(entryValue, depth + 1)];
537
+ return [`${indent}${key}: ${formatScalar(entryValue)}`];
538
+ });
539
+ }
540
+ return [`${indent}${formatScalar(value)}`];
428
541
  }
429
- function defaultSessionFilePath() {
430
- const home = homedir();
431
- return platform() === "darwin" ? join(home, "Library", "Application Support", "granola-toolkit", "session.json") : join(home, ".config", "granola-toolkit", "session.json");
542
+ function toYaml(value) {
543
+ return `${renderYaml(value).join("\n").trimEnd()}\n`;
432
544
  }
433
- function createDefaultSessionStore() {
434
- return platform() === "darwin" ? new KeychainSessionStore() : new FileSessionStore();
545
+ function toJson(value) {
546
+ return `${JSON.stringify(value, null, 2)}\n`;
435
547
  }
436
548
  //#endregion
437
- //#region src/client/default-auth.ts
438
- function hasStoredSession(session) {
439
- return Boolean(session?.accessToken.trim());
549
+ //#region src/prosemirror.ts
550
+ function repeatIndent(level) {
551
+ return " ".repeat(level);
440
552
  }
441
- function resolveActiveMode(options) {
442
- if (options.preferredMode === "stored-session" && options.storedSessionAvailable) return "stored-session";
443
- if (options.preferredMode === "supabase-file" && options.supabaseAvailable) return "supabase-file";
444
- if (options.storedSessionAvailable) return "stored-session";
445
- return "supabase-file";
553
+ function escapeMarkdownText(text) {
554
+ return text.replace(/\\/g, "\\\\").replace(/([*_`[\]])/g, "\\$1");
446
555
  }
447
- function missingSupabaseError() {
448
- return /* @__PURE__ */ new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
556
+ function renderInline(nodes = []) {
557
+ return nodes.map((node) => renderInlineNode(node)).join("");
449
558
  }
450
- function buildDefaultGranolaAuthInfo(config, options = {}) {
451
- const existsSyncImpl = options.existsSyncImpl ?? existsSync;
452
- const session = options.session;
453
- const storedSessionAvailable = hasStoredSession(session);
454
- const supabasePath = config.supabase || void 0;
455
- const supabaseAvailable = Boolean(supabasePath && existsSyncImpl(supabasePath));
456
- return {
457
- clientId: session?.clientId,
458
- lastError: options.lastError,
459
- mode: resolveActiveMode({
460
- preferredMode: options.preferredMode,
461
- storedSessionAvailable,
462
- supabaseAvailable
463
- }),
464
- refreshAvailable: Boolean(session?.refreshToken?.trim()),
465
- signInMethod: session?.signInMethod,
466
- storedSessionAvailable,
467
- supabaseAvailable,
468
- supabasePath
469
- };
470
- }
471
- async function inspectDefaultGranolaAuth(config, options = {}) {
472
- const sessionStore = options.sessionStore ?? createDefaultSessionStore();
473
- const session = options.session ?? await sessionStore.readSession();
474
- return buildDefaultGranolaAuthInfo(config, {
475
- existsSyncImpl: options.existsSyncImpl,
476
- lastError: options.lastError,
477
- preferredMode: options.preferredMode,
478
- session
479
- });
559
+ function applyMarks(text, marks = []) {
560
+ return marks.reduce((current, mark) => {
561
+ switch (mark.type) {
562
+ case "strong": return `**${current}**`;
563
+ case "em": return `*${current}*`;
564
+ case "code": return `\`${current}\``;
565
+ case "strike": return `~~${current}~~`;
566
+ case "underline": return `<u>${current}</u>`;
567
+ case "subscript": return `<sub>${current}</sub>`;
568
+ case "superscript": return `<sup>${current}</sup>`;
569
+ case "link": {
570
+ const href = typeof mark.attrs?.href === "string" ? mark.attrs.href : void 0;
571
+ return href ? `[${current}](${href})` : current;
572
+ }
573
+ default: return current;
574
+ }
575
+ }, text);
480
576
  }
481
- var DefaultAuthController = class {
482
- #lastError;
483
- #preferredMode;
484
- constructor(config, options = {}) {
485
- this.config = config;
486
- this.options = options;
487
- }
488
- sessionStore() {
489
- return this.options.sessionStore ?? createDefaultSessionStore();
490
- }
491
- readSession() {
492
- return this.sessionStore().readSession();
493
- }
494
- resolveSupabasePath(overridePath) {
495
- const supabasePath = overridePath?.trim() || this.config.supabase || "";
496
- if (!supabasePath) throw missingSupabaseError();
497
- if (!(this.options.existsSyncImpl ?? existsSync)(supabasePath)) throw new Error(`supabase.json not found: ${supabasePath}`);
498
- return supabasePath;
499
- }
500
- sessionSource(supabasePath) {
501
- return this.options.sessionSourceFactory?.(supabasePath) ?? new SupabaseFileSessionSource(supabasePath);
502
- }
503
- async inspect() {
504
- const session = await this.readSession();
505
- return buildDefaultGranolaAuthInfo(this.config, {
506
- existsSyncImpl: this.options.existsSyncImpl,
507
- lastError: this.#lastError,
508
- preferredMode: this.#preferredMode,
509
- session
510
- });
577
+ function renderInlineNode(node) {
578
+ switch (node.type) {
579
+ case "text": return applyMarks(escapeMarkdownText(node.text ?? ""), node.marks);
580
+ case "hardBreak": return " \n";
581
+ case "mention": return applyMarks(escapeMarkdownText(typeof node.attrs?.label === "string" ? node.attrs.label : typeof node.attrs?.text === "string" ? node.attrs.text : typeof node.attrs?.name === "string" ? node.attrs.name : renderInline(node.content)), node.marks);
582
+ default: return applyMarks(renderInline(node.content), node.marks);
511
583
  }
512
- async login(options = {}) {
513
- const supabasePath = this.resolveSupabasePath(options.supabasePath);
514
- const session = await this.sessionSource(supabasePath).loadSession();
515
- await this.sessionStore().writeSession(session);
516
- this.#lastError = void 0;
517
- this.#preferredMode = "stored-session";
518
- return await this.inspect();
584
+ }
585
+ function indentLines(value, level) {
586
+ const indent = repeatIndent(level);
587
+ return value.split("\n").map((line) => line.length === 0 ? line : `${indent}${line}`).join("\n");
588
+ }
589
+ function renderList(items, ordered, indentLevel, start = 1) {
590
+ return items.map((item, index) => renderListItem(item, ordered ? `${start + index}.` : "-", indentLevel)).join("\n");
591
+ }
592
+ function renderListItem(node, marker, indentLevel) {
593
+ const children = node.content ?? [];
594
+ const blockChildren = children.filter((child) => child.type !== "bulletList" && child.type !== "orderedList");
595
+ const nestedLists = children.filter((child) => child.type === "bulletList" || child.type === "orderedList");
596
+ const mainText = blockChildren.map((child) => renderBlock(child, indentLevel + 1)).filter(Boolean).join("\n").trim();
597
+ const prefix = `${repeatIndent(indentLevel)}${marker} `;
598
+ const continuationIndent = `${repeatIndent(indentLevel)}${" ".repeat(marker.length + 1)}`;
599
+ let output = `${prefix}${mainText.split("\n").map((line, index) => index === 0 ? line : `${continuationIndent}${line}`).join("\n") || ""}`.trimEnd();
600
+ if (nestedLists.length > 0) {
601
+ const nestedText = nestedLists.map((child) => renderBlock(child, indentLevel + 1)).filter(Boolean).map((value) => indentLines(value, 0)).join("\n");
602
+ output = `${output}\n${nestedText}`;
519
603
  }
520
- async logout() {
521
- await this.sessionStore().clearSession();
522
- this.#lastError = void 0;
523
- this.#preferredMode = void 0;
524
- return await this.inspect();
604
+ return output;
605
+ }
606
+ function renderTaskList(items, indentLevel) {
607
+ return items.map((item) => renderTaskItem(item, indentLevel)).join("\n");
608
+ }
609
+ function renderTaskItem(node, indentLevel) {
610
+ return renderListItem(node, node.attrs?.checked === true ? "[x]" : "[ ]", indentLevel);
611
+ }
612
+ function renderTableCell(node) {
613
+ return renderBlocks(node.content ?? [], 0).replace(/\n+/g, " <br> ").replace(/\|/g, "\\|").trim();
614
+ }
615
+ function renderTable(node) {
616
+ const rows = (node.content ?? []).map((row) => (row.content ?? []).map((cell) => renderTableCell(cell))).filter((row) => row.length > 0);
617
+ if (rows.length === 0) return "";
618
+ const header = rows[0];
619
+ const body = rows.slice(1);
620
+ const separator = header.map(() => "---");
621
+ const lines = [`| ${header.map((cell) => cell || " ").join(" | ")} |`, `| ${separator.join(" | ")} |`];
622
+ for (const row of body) {
623
+ const padded = header.map((_, index) => row[index] ?? " ");
624
+ lines.push(`| ${padded.join(" | ")} |`);
525
625
  }
526
- async refresh() {
527
- const session = await this.readSession();
528
- if (!hasStoredSession(session)) {
529
- this.#lastError = "no stored Granola session found";
530
- throw new Error(this.#lastError);
626
+ return lines.join("\n");
627
+ }
628
+ function renderBlock(node, indentLevel) {
629
+ switch (node.type) {
630
+ case "heading": {
631
+ const level = typeof node.attrs?.level === "number" ? node.attrs.level : 1;
632
+ return `${"#".repeat(level)} ${renderInline(node.content).trim()}`.trim();
531
633
  }
532
- try {
533
- const refreshed = await refreshGranolaSession(session, this.options.fetchImpl);
534
- await this.sessionStore().writeSession(refreshed);
535
- this.#lastError = void 0;
536
- this.#preferredMode = "stored-session";
537
- return await this.inspect();
538
- } catch (error) {
539
- this.#lastError = error instanceof Error ? error.message : String(error);
540
- throw error;
634
+ case "paragraph": return renderInline(node.content).trim();
635
+ case "bulletList": return renderList(node.content ?? [], false, indentLevel);
636
+ case "orderedList": {
637
+ const start = typeof node.attrs?.start === "number" ? node.attrs.start : 1;
638
+ return renderList(node.content ?? [], true, indentLevel, start);
541
639
  }
542
- }
543
- async switchMode(mode) {
544
- const state = await this.inspect();
545
- if (mode === "stored-session" && !state.storedSessionAvailable) {
546
- this.#lastError = "no stored Granola session found";
547
- throw new Error(this.#lastError);
640
+ case "listItem": return renderListItem(node, "-", indentLevel);
641
+ case "taskList": return renderTaskList(node.content ?? [], indentLevel);
642
+ case "taskItem": return renderTaskItem(node, indentLevel);
643
+ case "table": return renderTable(node);
644
+ case "tableRow": return (node.content ?? []).map((cell) => renderTableCell(cell)).join(" | ");
645
+ case "tableCell":
646
+ case "tableHeader": return renderTableCell(node);
647
+ case "blockquote": return renderBlocks(node.content ?? [], indentLevel).split("\n").map((line) => line ? `> ${line}` : ">").join("\n").trim();
648
+ case "codeBlock": {
649
+ const text = extractPlainText({
650
+ type: "doc",
651
+ content: node.content
652
+ }).trimEnd();
653
+ return `\`\`\`${typeof node.attrs?.language === "string" ? node.attrs.language.trim() : typeof node.attrs?.params === "string" ? node.attrs.params.trim() : ""}\n${text}\n\`\`\``;
548
654
  }
549
- if (mode === "supabase-file") this.resolveSupabasePath();
550
- this.#lastError = void 0;
551
- this.#preferredMode = mode;
552
- return await this.inspect();
655
+ case "horizontalRule": return "---";
656
+ case "hardBreak": return "";
657
+ case "text": return renderInlineNode(node);
658
+ default:
659
+ if (node.content?.length) return renderBlocks(node.content, indentLevel);
660
+ return renderInlineNode(node).trim();
553
661
  }
554
- };
555
- function createDefaultGranolaAuthController(config, options = {}) {
556
- return new DefaultAuthController(config, options);
557
662
  }
558
- //#endregion
559
- //#region src/client/parsers.ts
560
- function parseProseMirrorDoc(value, options = {}) {
561
- if (value == null) return;
562
- if (typeof value === "string") {
563
- const trimmed = value.trim();
564
- if (!trimmed) return;
565
- if (options.skipHtmlStrings && trimmed.startsWith("<")) return;
566
- const parsed = parseJsonString(trimmed);
567
- if (!parsed) return;
568
- return parseProseMirrorDoc(parsed, options);
569
- }
570
- const record = asRecord(value);
571
- if (!record || record.type !== "doc") return;
572
- return record;
663
+ function renderBlocks(nodes, indentLevel = 0) {
664
+ return nodes.map((node) => renderBlock(node, indentLevel)).filter((value) => value.length > 0).join("\n\n").replace(/\n{3,}/g, "\n\n").trim();
573
665
  }
574
- function parseLastViewedPanel(value) {
575
- const panel = asRecord(value);
576
- if (!panel) return;
577
- return {
578
- affinityNoteId: stringValue(panel.affinity_note_id),
579
- content: parseProseMirrorDoc(panel.content, { skipHtmlStrings: true }),
580
- contentUpdatedAt: stringValue(panel.content_updated_at),
581
- createdAt: stringValue(panel.created_at),
582
- deletedAt: stringValue(panel.deleted_at),
583
- documentId: stringValue(panel.document_id),
584
- generatedLines: Array.isArray(panel.generated_lines) ? panel.generated_lines : [],
585
- id: stringValue(panel.id),
586
- lastViewedAt: stringValue(panel.last_viewed_at),
587
- originalContent: stringValue(panel.original_content),
588
- suggestedQuestions: panel.suggested_questions,
589
- templateSlug: stringValue(panel.template_slug),
590
- title: stringValue(panel.title),
591
- updatedAt: stringValue(panel.updated_at)
592
- };
666
+ function extractPlainTextNode(node) {
667
+ switch (node.type) {
668
+ case "hardBreak": return "\n";
669
+ case "text": return node.text ?? "";
670
+ default: return extractPlainText({
671
+ type: "doc",
672
+ content: node.content
673
+ });
674
+ }
593
675
  }
594
- function parseDocument(value) {
595
- const record = asRecord(value);
596
- if (!record) throw new Error("document payload is not an object");
597
- return {
598
- content: stringValue(record.content),
599
- createdAt: stringValue(record.created_at),
600
- id: stringValue(record.id),
601
- lastViewedPanel: parseLastViewedPanel(record.last_viewed_panel),
602
- notes: parseProseMirrorDoc(record.notes),
603
- notesPlain: stringValue(record.notes_plain),
604
- tags: stringArray(record.tags),
605
- title: stringValue(record.title),
606
- updatedAt: stringValue(record.updated_at)
607
- };
676
+ function convertProseMirrorToMarkdown(doc) {
677
+ if (!doc || doc.type !== "doc" || !doc.content?.length) return "";
678
+ const rendered = renderBlocks(doc.content);
679
+ return rendered ? `${rendered}\n` : "";
608
680
  }
609
- //#endregion
610
- //#region src/client/granola.ts
611
- const DEFAULT_CLIENT_VERSION = "5.354.0";
612
- const DOCUMENTS_URL = "https://api.granola.ai/v2/get-documents";
613
- function resolveClientVersion(value) {
614
- return value?.trim() || process.env.GRANOLA_CLIENT_VERSION?.trim() || DEFAULT_CLIENT_VERSION;
681
+ function extractPlainText(doc) {
682
+ if (!doc || doc.type !== "doc" || !doc.content?.length) return "";
683
+ return doc.content.map((node) => {
684
+ if (node.type === "bulletList" || node.type === "orderedList") return (node.content ?? []).map((child) => extractPlainTextNode(child)).filter(Boolean).join("\n");
685
+ return extractPlainTextNode(node);
686
+ }).filter(Boolean).join("\n\n").replace(/\n{3,}/g, "\n\n").trim();
615
687
  }
616
- var GranolaApiClient = class {
617
- clientVersion;
618
- documentsUrl;
619
- constructor(httpClient, options = DOCUMENTS_URL) {
620
- this.httpClient = httpClient;
621
- if (typeof options === "string") {
622
- this.documentsUrl = options;
623
- this.clientVersion = resolveClientVersion();
624
- return;
625
- }
626
- this.documentsUrl = options.documentsUrl ?? DOCUMENTS_URL;
627
- this.clientVersion = resolveClientVersion(options.clientVersion);
628
- }
629
- async listDocuments(options) {
630
- const documents = [];
631
- const limit = options.limit ?? 100;
632
- let offset = 0;
633
- for (;;) {
634
- const response = await this.httpClient.postJson(this.documentsUrl, {
635
- include_last_viewed_panel: true,
636
- limit,
637
- offset
638
- }, {
639
- headers: {
640
- "User-Agent": `Granola/${this.clientVersion}`,
641
- "X-Client-Version": this.clientVersion
642
- },
643
- timeoutMs: options.timeoutMs
644
- });
645
- if (!response.ok) {
646
- const body = (await response.text()).slice(0, 500);
647
- throw new Error(`failed to get documents: ${response.status} ${response.statusText}${body ? `: ${body}` : ""}`);
648
- }
649
- const payload = await response.json();
650
- if (!Array.isArray(payload.docs)) throw new Error("failed to parse documents response");
651
- const page = payload.docs.map(parseDocument);
652
- documents.push(...page);
653
- if (page.length < limit) break;
654
- offset += limit;
655
- }
656
- return documents;
657
- }
658
- };
659
688
  //#endregion
660
- //#region src/client/http.ts
661
- const RETRYABLE_STATUS_CODES = new Set([
662
- 429,
663
- 500,
664
- 502,
665
- 503,
666
- 504
667
- ]);
668
- function sleep(delayMs) {
669
- return new Promise((resolve) => {
670
- setTimeout(resolve, delayMs);
671
- });
689
+ //#region src/notes.ts
690
+ function selectNoteContent(document) {
691
+ const notes = convertProseMirrorToMarkdown(document.notes).trim();
692
+ if (notes) return {
693
+ content: notes,
694
+ source: "notes"
695
+ };
696
+ const lastViewedPanel = convertProseMirrorToMarkdown(document.lastViewedPanel?.content).trim();
697
+ if (lastViewedPanel) return {
698
+ content: lastViewedPanel,
699
+ source: "lastViewedPanel.content"
700
+ };
701
+ const originalContent = htmlToMarkdown(document.lastViewedPanel?.originalContent ?? "").trim();
702
+ if (originalContent) return {
703
+ content: originalContent,
704
+ source: "lastViewedPanel.originalContent"
705
+ };
706
+ return {
707
+ content: document.content.trim(),
708
+ source: "content"
709
+ };
672
710
  }
673
- function parseRetryAfter(headerValue) {
674
- if (!headerValue?.trim()) return;
675
- if (/^\d+$/.test(headerValue.trim())) return Number(headerValue.trim()) * 1e3;
676
- const retryAt = Date.parse(headerValue);
677
- if (Number.isNaN(retryAt)) return;
678
- return Math.max(0, retryAt - Date.now());
711
+ function buildNoteExport(document) {
712
+ const { content, source } = selectNoteContent(document);
713
+ return {
714
+ content,
715
+ contentSource: source,
716
+ createdAt: document.createdAt,
717
+ id: document.id,
718
+ raw: document,
719
+ tags: document.tags,
720
+ title: document.title,
721
+ updatedAt: document.updatedAt
722
+ };
679
723
  }
680
- var AuthenticatedHttpClient = class {
681
- fetchImpl;
682
- constructor(options) {
683
- this.fetchImpl = options.fetchImpl ?? fetch;
684
- this.logger = options.logger;
685
- this.maxRetries = options.maxRetries ?? 2;
686
- this.retryBaseDelayMs = options.retryBaseDelayMs ?? 500;
687
- this.retryMaxDelayMs = options.retryMaxDelayMs ?? 5e3;
688
- this.sleepImpl = options.sleepImpl ?? sleep;
689
- this.tokenProvider = options.tokenProvider;
690
- }
691
- logger;
692
- maxRetries;
693
- retryBaseDelayMs;
694
- retryMaxDelayMs;
695
- sleepImpl;
696
- tokenProvider;
697
- async retry(options, attempt, reason, response) {
698
- const retryAfterMs = parseRetryAfter(response?.headers.get("retry-after") ?? null);
699
- const delayMs = Math.min(retryAfterMs ?? this.retryBaseDelayMs * 2 ** attempt, this.retryMaxDelayMs);
700
- this.logger?.warn?.(`${reason}; retrying in ${delayMs}ms (${attempt + 1}/${this.maxRetries})`);
701
- await this.sleepImpl(delayMs);
702
- return this.request(options, attempt + 1);
724
+ function renderNoteExport(note, format = "markdown") {
725
+ switch (format) {
726
+ case "json": return toJson({
727
+ content: note.content,
728
+ contentSource: note.contentSource,
729
+ createdAt: note.createdAt,
730
+ id: note.id,
731
+ tags: note.tags,
732
+ title: note.title,
733
+ updatedAt: note.updatedAt
734
+ });
735
+ case "raw": return toJson(note.raw);
736
+ case "yaml": return toYaml({
737
+ content: note.content,
738
+ contentSource: note.contentSource,
739
+ createdAt: note.createdAt,
740
+ id: note.id,
741
+ tags: note.tags,
742
+ title: note.title,
743
+ updatedAt: note.updatedAt
744
+ });
745
+ case "markdown": break;
703
746
  }
704
- async request(options, attempt = 0) {
705
- const { retryOnUnauthorized = true, timeoutMs, url } = options;
706
- const accessToken = await this.tokenProvider.getAccessToken();
707
- let response;
708
- try {
709
- response = await this.fetchImpl(url, {
710
- body: options.body,
711
- headers: {
712
- ...options.headers,
713
- Authorization: `Bearer ${accessToken}`
714
- },
715
- method: options.method ?? "GET",
716
- signal: AbortSignal.timeout(timeoutMs)
717
- });
718
- } catch (error) {
719
- if (attempt < this.maxRetries) {
720
- const message = error instanceof Error ? error.message : String(error);
721
- return this.retry(options, attempt, `request failed: ${message}`);
722
- }
723
- throw error;
724
- }
725
- if (response.status === 401 && retryOnUnauthorized) {
726
- this.logger?.warn?.("request returned 401; invalidating token provider and retrying once");
727
- await this.tokenProvider.invalidate();
728
- return this.request({
729
- ...options,
730
- retryOnUnauthorized: false
731
- }, attempt);
732
- }
733
- if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < this.maxRetries) return this.retry(options, attempt, `request returned ${response.status} ${response.statusText || ""}`.trim(), response);
734
- return response;
747
+ const lines = [
748
+ "---",
749
+ `id: ${quoteYamlString(note.id)}`,
750
+ `created: ${quoteYamlString(note.createdAt)}`,
751
+ `updated: ${quoteYamlString(note.updatedAt)}`
752
+ ];
753
+ if (note.tags.length > 0) {
754
+ lines.push("tags:");
755
+ for (const tag of note.tags) lines.push(` - ${quoteYamlString(tag)}`);
735
756
  }
736
- async postJson(url, body, options = { timeoutMs: 3e4 }) {
737
- return this.request({
738
- ...options,
739
- body: JSON.stringify(body),
740
- headers: {
741
- Accept: "*/*",
742
- "Content-Type": "application/json",
743
- ...options.headers
744
- },
745
- method: "POST",
746
- url
747
- });
757
+ lines.push("---", "");
758
+ if (note.title.trim()) lines.push(`# ${note.title.trim()}`, "");
759
+ if (note.content) lines.push(note.content);
760
+ return `${lines.join("\n").trimEnd()}\n`;
761
+ }
762
+ function documentFilename(document) {
763
+ return sanitiseFilename(document.title || document.id, "untitled");
764
+ }
765
+ function noteFileExtension(format) {
766
+ switch (format) {
767
+ case "json": return ".json";
768
+ case "raw": return ".raw.json";
769
+ case "yaml": return ".yaml";
770
+ case "markdown": return ".md";
748
771
  }
749
- };
750
- //#endregion
751
- //#region src/client/default.ts
752
- async function createDefaultGranolaRuntime(config, logger = console, options = {}) {
753
- const auth = await inspectDefaultGranolaAuth(config, { preferredMode: options.preferredMode });
754
- if (!auth.storedSessionAvailable && !config.supabase) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
755
- if (!auth.storedSessionAvailable && config.supabase && !existsSync(config.supabase)) throw new Error(`supabase.json not found: ${config.supabase}`);
756
- const sessionStore = createDefaultSessionStore();
757
- return {
758
- auth,
759
- client: new GranolaApiClient(new AuthenticatedHttpClient({
760
- logger,
761
- tokenProvider: auth.mode === "stored-session" ? new StoredSessionTokenProvider(sessionStore, { source: config.supabase && existsSync(config.supabase) ? new SupabaseFileSessionSource(config.supabase) : void 0 }) : new CachedTokenProvider(new SupabaseFileTokenSource(config.supabase), new NoopTokenStore())
762
- }))
763
- };
764
772
  }
765
- async function loadOptionalGranolaCache(cacheFile) {
766
- if (!cacheFile || !existsSync(cacheFile)) return;
767
- return parseCacheContents(await readFile(cacheFile, "utf8"));
773
+ async function writeNotes(documents, outputDir, format = "markdown", options = {}) {
774
+ return await syncManagedExports({
775
+ items: [...documents].sort((left, right) => compareStrings(left.title || left.id, right.title || right.id) || compareStrings(left.id, right.id)).map((document) => {
776
+ const note = buildNoteExport(document);
777
+ return {
778
+ content: renderNoteExport(note, format),
779
+ extension: noteFileExtension(format),
780
+ id: note.id,
781
+ preferredStem: documentFilename(document),
782
+ sourceUpdatedAt: latestDocumentTimestamp(document)
783
+ };
784
+ }),
785
+ kind: "notes",
786
+ onProgress: options.onProgress,
787
+ outputDir
788
+ });
768
789
  }
769
790
  //#endregion
770
- //#region src/export-jobs.ts
771
- const EXPORT_JOBS_VERSION = 1;
772
- const MAX_EXPORT_JOBS = 100;
773
- function normaliseJob(value) {
774
- const record = asRecord(value);
775
- if (!record) return;
776
- const id = stringValue(record.id);
777
- const kind = stringValue(record.kind);
778
- const status = stringValue(record.status);
779
- const format = stringValue(record.format);
780
- const outputDir = stringValue(record.outputDir);
781
- const startedAt = stringValue(record.startedAt);
782
- const itemCount = typeof record.itemCount === "number" && Number.isFinite(record.itemCount) ? record.itemCount : 0;
783
- const written = typeof record.written === "number" && Number.isFinite(record.written) ? record.written : 0;
784
- const completedCount = typeof record.completedCount === "number" && Number.isFinite(record.completedCount) ? record.completedCount : written;
785
- if (!id || !format || !outputDir || !startedAt || kind !== "notes" && kind !== "transcripts" || status !== "running" && status !== "completed" && status !== "failed") return;
786
- return {
787
- completedCount,
788
- error: stringValue(record.error) || void 0,
789
- finishedAt: stringValue(record.finishedAt) || void 0,
790
- format,
791
- id,
792
- itemCount,
793
- kind,
794
- outputDir,
795
- startedAt,
796
- status,
797
- written
798
- };
791
+ //#region src/transcripts.ts
792
+ function transcriptSegmentKey(segment) {
793
+ if (segment.id) return `id:${segment.id}`;
794
+ return [
795
+ segment.documentId,
796
+ segment.source,
797
+ segment.startTimestamp,
798
+ segment.endTimestamp
799
+ ].join("|");
799
800
  }
800
- function normaliseJobsFile(parsed) {
801
- const record = asRecord(parsed);
802
- if (!record || record.version !== EXPORT_JOBS_VERSION || !Array.isArray(record.jobs)) return {
803
- jobs: [],
804
- version: EXPORT_JOBS_VERSION
805
- };
806
- return {
807
- jobs: record.jobs.map((job) => normaliseJob(job)).filter((job) => Boolean(job)).slice(0, MAX_EXPORT_JOBS),
808
- version: EXPORT_JOBS_VERSION
809
- };
801
+ function compareSegmentTimestamps(left, right) {
802
+ if (left === right) return 0;
803
+ const leftTime = Date.parse(left);
804
+ const rightTime = Date.parse(right);
805
+ if (!Number.isNaN(leftTime) && !Number.isNaN(rightTime)) return leftTime - rightTime;
806
+ return compareStrings(left, right);
810
807
  }
811
- function createExportJobId(kind) {
812
- return `${kind}-${randomUUID()}`;
808
+ function compareTranscriptSegments(left, right) {
809
+ return compareSegmentTimestamps(left.startTimestamp, right.startTimestamp) || compareSegmentTimestamps(left.endTimestamp, right.endTimestamp) || compareStrings(left.source, right.source) || compareStrings(left.id, right.id) || compareStrings(left.text, right.text);
813
810
  }
814
- function defaultExportJobsFilePath() {
815
- const home = homedir();
816
- return platform() === "darwin" ? join(home, "Library", "Application Support", "granola-toolkit", "export-jobs.json") : join(home, ".config", "granola-toolkit", "export-jobs.json");
811
+ function preferredTranscriptSegment(current, candidate) {
812
+ if (!current) return candidate;
813
+ if (candidate.isFinal !== current.isFinal) return candidate.isFinal ? candidate : current;
814
+ return compareSegmentTimestamps(candidate.endTimestamp, current.endTimestamp) > 0 || candidate.text.length > current.text.length ? candidate : current;
817
815
  }
818
- var FileExportJobStore = class {
819
- constructor(filePath = defaultExportJobsFilePath()) {
820
- this.filePath = filePath;
821
- }
822
- async readJobs() {
823
- try {
824
- return normaliseJobsFile(parseJsonString(await readFile(this.filePath, "utf8"))).jobs;
825
- } catch {
826
- return [];
827
- }
828
- }
829
- async writeJobs(jobs) {
830
- const payload = {
831
- jobs: jobs.slice(0, MAX_EXPORT_JOBS),
832
- version: EXPORT_JOBS_VERSION
833
- };
834
- await mkdir(dirname(this.filePath), { recursive: true });
835
- await writeFile(this.filePath, `${JSON.stringify(payload, null, 2)}\n`, {
836
- encoding: "utf8",
837
- mode: 384
838
- });
816
+ function normaliseTranscriptSegments(segments) {
817
+ const selected = /* @__PURE__ */ new Map();
818
+ for (const segment of [...segments].sort(compareTranscriptSegments)) {
819
+ const key = transcriptSegmentKey(segment);
820
+ const current = selected.get(key);
821
+ selected.set(key, preferredTranscriptSegment(current, segment));
839
822
  }
840
- };
841
- function createDefaultExportJobStore() {
842
- return new FileExportJobStore();
843
- }
844
- //#endregion
845
- //#region src/export-state.ts
846
- const EXPORT_STATE_VERSION = 1;
847
- function exportStatePath(outputDir, kind) {
848
- return join(outputDir, `.granola-toolkit-${kind}-state.json`);
849
- }
850
- function emptyExportState(kind) {
851
- return {
852
- entries: {},
853
- kind,
854
- version: EXPORT_STATE_VERSION
855
- };
823
+ const resolved = [...selected.values()].sort(compareTranscriptSegments);
824
+ if (resolved.some((segment) => segment.isFinal)) return resolved.filter((segment) => segment.isFinal);
825
+ return resolved;
856
826
  }
857
- function normaliseExportState(parsed, kind) {
858
- const record = asRecord(parsed);
859
- if (!record || record.version !== EXPORT_STATE_VERSION || record.kind !== kind) return emptyExportState(kind);
860
- const rawEntries = asRecord(record.entries) ?? {};
827
+ function buildTranscriptExport(document, segments, rawSegments = segments) {
828
+ const renderedSegments = segments.map((segment) => ({
829
+ endTimestamp: segment.endTimestamp,
830
+ id: segment.id,
831
+ isFinal: segment.isFinal,
832
+ source: segment.source,
833
+ speaker: transcriptSpeakerLabel(segment),
834
+ startTimestamp: segment.startTimestamp,
835
+ text: segment.text
836
+ }));
861
837
  return {
862
- entries: Object.fromEntries(Object.entries(rawEntries).map(([id, entry]) => {
863
- const value = asRecord(entry);
864
- if (!value) return;
865
- const fileName = stringValue(value.fileName);
866
- const fileStem = stringValue(value.fileStem);
867
- if (!fileName || !fileStem) return;
868
- return [id, {
869
- contentHash: stringValue(value.contentHash),
870
- exportedAt: stringValue(value.exportedAt),
871
- fileName,
872
- fileStem,
873
- sourceUpdatedAt: stringValue(value.sourceUpdatedAt)
874
- }];
875
- }).filter((entry) => Boolean(entry))),
876
- kind,
877
- version: EXPORT_STATE_VERSION
838
+ createdAt: document.createdAt,
839
+ id: document.id,
840
+ raw: {
841
+ document,
842
+ segments: rawSegments
843
+ },
844
+ segments: renderedSegments,
845
+ title: document.title,
846
+ updatedAt: document.updatedAt
878
847
  };
879
848
  }
880
- async function loadExportState(outputDir, kind) {
881
- const statePath = exportStatePath(outputDir, kind);
882
- try {
883
- return normaliseExportState(parseJsonString(await readUtf8(statePath)), kind);
884
- } catch {
885
- return emptyExportState(kind);
849
+ function renderTranscriptExport(transcript, format = "text") {
850
+ switch (format) {
851
+ case "json": return toJson({
852
+ createdAt: transcript.createdAt,
853
+ id: transcript.id,
854
+ segments: transcript.segments,
855
+ title: transcript.title,
856
+ updatedAt: transcript.updatedAt
857
+ });
858
+ case "raw": return toJson(transcript.raw);
859
+ case "yaml": return toYaml({
860
+ createdAt: transcript.createdAt,
861
+ id: transcript.id,
862
+ segments: transcript.segments,
863
+ title: transcript.title,
864
+ updatedAt: transcript.updatedAt
865
+ });
866
+ case "text": break;
886
867
  }
868
+ return formatTranscriptText(transcript);
887
869
  }
888
- function hashContent(content) {
889
- return createHash("sha256").update(content).digest("hex");
870
+ function formatTranscriptText(transcript) {
871
+ if (transcript.segments.length === 0) return "";
872
+ const header = [
873
+ "=".repeat(80),
874
+ transcript.title || transcript.id,
875
+ `ID: ${transcript.id}`,
876
+ transcript.createdAt ? `Created: ${transcript.createdAt}` : "",
877
+ transcript.updatedAt ? `Updated: ${transcript.updatedAt}` : "",
878
+ `Segments: ${transcript.segments.length}`,
879
+ "=".repeat(80),
880
+ ""
881
+ ].filter(Boolean);
882
+ const body = transcript.segments.map((segment) => {
883
+ return `[${formatTimestampForTranscript(segment.startTimestamp)}] ${segment.speaker}: ${segment.text}`;
884
+ });
885
+ return `${[...header, ...body].join("\n").trimEnd()}\n`;
890
886
  }
891
- function reserveStem(used, preferredStem, existingStem) {
892
- if (existingStem && (used.get(existingStem) ?? 0) === 0) {
893
- used.set(existingStem, 1);
894
- return existingStem;
895
- }
896
- return makeUniqueFilename(preferredStem, used);
887
+ function transcriptFilename(document) {
888
+ return sanitiseFilename(document.title || document.id, "untitled");
897
889
  }
898
- async function fileExists(pathname) {
899
- try {
900
- await stat(pathname);
901
- return true;
902
- } catch {
903
- return false;
890
+ function transcriptFileExtension(format) {
891
+ switch (format) {
892
+ case "json": return ".json";
893
+ case "raw": return ".raw.json";
894
+ case "text": return ".txt";
895
+ case "yaml": return ".yaml";
904
896
  }
905
897
  }
906
- function entryChanged(left, right) {
907
- if (!left) return true;
908
- return left.contentHash !== right.contentHash || left.exportedAt !== right.exportedAt || left.fileName !== right.fileName || left.fileStem !== right.fileStem || left.sourceUpdatedAt !== right.sourceUpdatedAt;
909
- }
910
- async function syncManagedExports({ items, kind, onProgress, outputDir }) {
911
- await ensureDirectory(outputDir);
912
- const previousEntries = (await loadExportState(outputDir, kind)).entries;
913
- const used = /* @__PURE__ */ new Map();
914
- const plans = items.map((item) => {
915
- const existing = previousEntries[item.id];
916
- const fileStem = reserveStem(used, item.preferredStem, existing?.fileStem);
917
- return {
918
- content: item.content,
919
- contentHash: hashContent(item.content),
920
- existing,
921
- fileName: `${fileStem}${item.extension}`,
922
- fileStem,
923
- id: item.id,
924
- sourceUpdatedAt: item.sourceUpdatedAt
925
- };
898
+ async function writeTranscripts(cacheData, outputDir, format = "text", options = {}) {
899
+ return await syncManagedExports({
900
+ items: Object.entries(cacheData.transcripts).filter(([, segments]) => segments.length > 0).sort(([leftId], [rightId]) => {
901
+ const leftDocument = cacheData.documents[leftId];
902
+ const rightDocument = cacheData.documents[rightId];
903
+ return compareStrings(leftDocument?.title || leftId, rightDocument?.title || rightId) || compareStrings(leftId, rightId);
904
+ }).flatMap(([documentId, segments]) => {
905
+ const document = cacheData.documents[documentId] ?? {
906
+ createdAt: "",
907
+ id: documentId,
908
+ title: documentId,
909
+ updatedAt: ""
910
+ };
911
+ const content = renderTranscriptExport(buildTranscriptExport(document, normaliseTranscriptSegments(segments), segments), format);
912
+ if (!content) return [];
913
+ return [{
914
+ content,
915
+ extension: transcriptFileExtension(format),
916
+ id: document.id,
917
+ preferredStem: transcriptFilename(document),
918
+ sourceUpdatedAt: document.updatedAt
919
+ }];
920
+ }),
921
+ kind: "transcripts",
922
+ onProgress: options.onProgress,
923
+ outputDir
926
924
  });
927
- const activeIds = new Set(plans.map((plan) => plan.id));
928
- const activeFileNames = new Set(plans.map((plan) => plan.fileName));
929
- const exportedAt = (/* @__PURE__ */ new Date()).toISOString();
930
- const nextEntries = {};
931
- let completed = 0;
932
- let written = 0;
933
- let stateChanged = false;
934
- for (const plan of plans) {
935
- const filePath = join(outputDir, plan.fileName);
936
- const shouldWrite = !plan.existing || plan.existing.contentHash !== plan.contentHash || plan.existing.fileName !== plan.fileName || !await fileExists(filePath);
937
- if (shouldWrite) {
938
- await writeTextFile(filePath, plan.content);
939
- written += 1;
940
- }
941
- const nextEntry = {
942
- contentHash: plan.contentHash,
943
- exportedAt: shouldWrite ? exportedAt : plan.existing?.exportedAt ?? exportedAt,
944
- fileName: plan.fileName,
945
- fileStem: plan.fileStem,
946
- sourceUpdatedAt: plan.sourceUpdatedAt
947
- };
948
- nextEntries[plan.id] = nextEntry;
949
- stateChanged = stateChanged || entryChanged(plan.existing, nextEntry);
950
- completed += 1;
951
- if (onProgress) await onProgress({
952
- completed,
953
- total: plans.length,
954
- written
955
- });
956
- }
957
- for (const plan of plans) {
958
- const previousFileName = plan.existing?.fileName;
959
- if (previousFileName && previousFileName !== plan.fileName && !activeFileNames.has(previousFileName)) {
960
- await rm(join(outputDir, previousFileName), { force: true });
961
- stateChanged = true;
962
- }
963
- }
964
- for (const [id, entry] of Object.entries(previousEntries)) {
965
- if (activeIds.has(id)) continue;
966
- if (!activeFileNames.has(entry.fileName)) await rm(join(outputDir, entry.fileName), { force: true });
967
- stateChanged = true;
968
- }
969
- const serialisedState = `${JSON.stringify({
970
- entries: nextEntries,
971
- kind,
972
- version: EXPORT_STATE_VERSION
973
- }, null, 2)}\n`;
974
- const statePath = exportStatePath(outputDir, kind);
975
- const existingState = await fileExists(statePath) ? await readUtf8(statePath) : void 0;
976
- if (stateChanged || existingState !== serialisedState) await writeTextFile(statePath, serialisedState);
977
- return written;
978
925
  }
979
926
  //#endregion
980
- //#region src/render.ts
981
- function formatScalar(value) {
982
- if (value == null) return "null";
983
- if (typeof value === "string") return JSON.stringify(value);
984
- if (typeof value === "number" || typeof value === "boolean") return String(value);
985
- return JSON.stringify(value);
986
- }
987
- function renderYaml(value, depth = 0) {
988
- const indent = " ".repeat(depth);
989
- if (Array.isArray(value)) {
990
- if (value.length === 0) return [`${indent}[]`];
991
- return value.flatMap((item) => {
992
- if (item && typeof item === "object" && !Array.isArray(item)) {
993
- const nested = renderYaml(item, depth + 1);
994
- return [`${indent}- ${(nested[0] ?? `${" ".repeat(depth + 1)}{}`).trimStart()}`, ...nested.slice(1)];
995
- }
996
- return [`${indent}- ${formatScalar(item)}`];
997
- });
998
- }
999
- if (value && typeof value === "object") {
1000
- const entries = Object.entries(value);
1001
- if (entries.length === 0) return [`${indent}{}`];
1002
- return entries.flatMap(([key, entryValue]) => {
1003
- if (Array.isArray(entryValue) || entryValue && typeof entryValue === "object") return [`${indent}${key}:`, ...renderYaml(entryValue, depth + 1)];
1004
- return [`${indent}${key}: ${formatScalar(entryValue)}`];
1005
- });
1006
- }
1007
- return [`${indent}${formatScalar(value)}`];
927
+ //#region src/meetings.ts
928
+ function parseTimestamp(value) {
929
+ if (!value.trim()) return;
930
+ const timestamp = Date.parse(value);
931
+ return Number.isNaN(timestamp) ? void 0 : timestamp;
1008
932
  }
1009
- function toYaml(value) {
1010
- return `${renderYaml(value).join("\n").trimEnd()}\n`;
933
+ function compareTimestampsDescending(left, right) {
934
+ const leftTimestamp = parseTimestamp(left);
935
+ const rightTimestamp = parseTimestamp(right);
936
+ if (leftTimestamp != null && rightTimestamp != null) return rightTimestamp - leftTimestamp;
937
+ if (leftTimestamp != null) return -1;
938
+ if (rightTimestamp != null) return 1;
939
+ return compareStrings(right, left);
1011
940
  }
1012
- function toJson(value) {
1013
- return `${JSON.stringify(value, null, 2)}\n`;
941
+ function compareMeetingDocuments(left, right) {
942
+ 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);
1014
943
  }
1015
- //#endregion
1016
- //#region src/prosemirror.ts
1017
- function repeatIndent(level) {
1018
- return " ".repeat(level);
944
+ function compareMeetingDocumentsByTitle(left, right) {
945
+ return compareStrings(left.title || left.id, right.title || right.id) || compareTimestampsDescending(latestDocumentTimestamp(left), latestDocumentTimestamp(right)) || compareStrings(left.id, right.id);
1019
946
  }
1020
- function escapeMarkdownText(text) {
1021
- return text.replace(/\\/g, "\\\\").replace(/([*_`[\]])/g, "\\$1");
947
+ function compareMeetingDocumentsBySort(left, right, sort) {
948
+ switch (sort) {
949
+ case "title-asc": return compareMeetingDocumentsByTitle(left, right);
950
+ case "title-desc": return -compareMeetingDocumentsByTitle(left, right);
951
+ case "updated-asc": return -compareMeetingDocuments(left, right);
952
+ default: return compareMeetingDocuments(left, right);
953
+ }
1022
954
  }
1023
- function renderInline(nodes = []) {
1024
- return nodes.map((node) => renderInlineNode(node)).join("");
955
+ function compareMeetingSummariesByUpdated(left, right) {
956
+ return compareTimestampsDescending(left.updatedAt, right.updatedAt) || compareTimestampsDescending(left.createdAt, right.createdAt) || compareStrings(left.title || left.id, right.title || right.id) || compareStrings(left.id, right.id);
1025
957
  }
1026
- function applyMarks(text, marks = []) {
1027
- return marks.reduce((current, mark) => {
1028
- switch (mark.type) {
1029
- case "strong": return `**${current}**`;
1030
- case "em": return `*${current}*`;
1031
- case "code": return `\`${current}\``;
1032
- case "strike": return `~~${current}~~`;
1033
- case "underline": return `<u>${current}</u>`;
1034
- case "subscript": return `<sub>${current}</sub>`;
1035
- case "superscript": return `<sup>${current}</sup>`;
1036
- case "link": {
1037
- const href = typeof mark.attrs?.href === "string" ? mark.attrs.href : void 0;
1038
- return href ? `[${current}](${href})` : current;
1039
- }
1040
- default: return current;
1041
- }
1042
- }, text);
958
+ function compareMeetingSummariesByTitle(left, right) {
959
+ return compareStrings(left.title || left.id, right.title || right.id) || compareTimestampsDescending(left.updatedAt, right.updatedAt) || compareStrings(left.id, right.id);
1043
960
  }
1044
- function renderInlineNode(node) {
1045
- switch (node.type) {
1046
- case "text": return applyMarks(escapeMarkdownText(node.text ?? ""), node.marks);
1047
- case "hardBreak": return " \n";
1048
- case "mention": return applyMarks(escapeMarkdownText(typeof node.attrs?.label === "string" ? node.attrs.label : typeof node.attrs?.text === "string" ? node.attrs.text : typeof node.attrs?.name === "string" ? node.attrs.name : renderInline(node.content)), node.marks);
1049
- default: return applyMarks(renderInline(node.content), node.marks);
961
+ function compareMeetingSummariesBySort(left, right, sort) {
962
+ switch (sort) {
963
+ case "title-asc": return compareMeetingSummariesByTitle(left, right);
964
+ case "title-desc": return -compareMeetingSummariesByTitle(left, right);
965
+ case "updated-asc": return -compareMeetingSummariesByUpdated(left, right);
966
+ default: return compareMeetingSummariesByUpdated(left, right);
1050
967
  }
1051
968
  }
1052
- function indentLines(value, level) {
1053
- const indent = repeatIndent(level);
1054
- return value.split("\n").map((line) => line.length === 0 ? line : `${indent}${line}`).join("\n");
969
+ function serialiseNote(note) {
970
+ return {
971
+ content: note.content,
972
+ contentSource: note.contentSource,
973
+ createdAt: note.createdAt,
974
+ id: note.id,
975
+ tags: [...note.tags],
976
+ title: note.title,
977
+ updatedAt: note.updatedAt
978
+ };
1055
979
  }
1056
- function renderList(items, ordered, indentLevel, start = 1) {
1057
- return items.map((item, index) => renderListItem(item, ordered ? `${start + index}.` : "-", indentLevel)).join("\n");
980
+ function serialiseTranscript(transcript) {
981
+ return {
982
+ createdAt: transcript.createdAt,
983
+ id: transcript.id,
984
+ segments: transcript.segments.map((segment) => ({ ...segment })),
985
+ title: transcript.title,
986
+ updatedAt: transcript.updatedAt
987
+ };
1058
988
  }
1059
- function renderListItem(node, marker, indentLevel) {
1060
- const children = node.content ?? [];
1061
- const blockChildren = children.filter((child) => child.type !== "bulletList" && child.type !== "orderedList");
1062
- const nestedLists = children.filter((child) => child.type === "bulletList" || child.type === "orderedList");
1063
- const mainText = blockChildren.map((child) => renderBlock(child, indentLevel + 1)).filter(Boolean).join("\n").trim();
1064
- const prefix = `${repeatIndent(indentLevel)}${marker} `;
1065
- const continuationIndent = `${repeatIndent(indentLevel)}${" ".repeat(marker.length + 1)}`;
1066
- let output = `${prefix}${mainText.split("\n").map((line, index) => index === 0 ? line : `${continuationIndent}${line}`).join("\n") || ""}`.trimEnd();
1067
- if (nestedLists.length > 0) {
1068
- const nestedText = nestedLists.map((child) => renderBlock(child, indentLevel + 1)).filter(Boolean).map((value) => indentLines(value, 0)).join("\n");
1069
- output = `${output}\n${nestedText}`;
1070
- }
1071
- return output;
989
+ function cacheDocumentForMeeting(document, cacheData) {
990
+ return cacheData?.documents[document.id] ?? {
991
+ createdAt: document.createdAt,
992
+ id: document.id,
993
+ title: document.title,
994
+ updatedAt: latestDocumentTimestamp(document)
995
+ };
1072
996
  }
1073
- function renderTaskList(items, indentLevel) {
1074
- return items.map((item) => renderTaskItem(item, indentLevel)).join("\n");
997
+ function buildMeetingTranscript(document, cacheData) {
998
+ if (!cacheData) return {
999
+ loaded: false,
1000
+ segmentCount: 0,
1001
+ transcript: null,
1002
+ transcriptRecord: null,
1003
+ transcriptText: null
1004
+ };
1005
+ const rawSegments = cacheData.transcripts[document.id] ?? [];
1006
+ const normalisedSegments = normaliseTranscriptSegments(rawSegments);
1007
+ if (normalisedSegments.length === 0) return {
1008
+ loaded: true,
1009
+ segmentCount: 0,
1010
+ transcript: null,
1011
+ transcriptRecord: null,
1012
+ transcriptText: null
1013
+ };
1014
+ const transcript = buildTranscriptExport(cacheDocumentForMeeting(document, cacheData), normalisedSegments, rawSegments);
1015
+ return {
1016
+ loaded: true,
1017
+ segmentCount: transcript.segments.length,
1018
+ transcript: serialiseTranscript(transcript),
1019
+ transcriptRecord: transcript,
1020
+ transcriptText: renderTranscriptExport(transcript, "text")
1021
+ };
1075
1022
  }
1076
- function renderTaskItem(node, indentLevel) {
1077
- return renderListItem(node, node.attrs?.checked === true ? "[x]" : "[ ]", indentLevel);
1023
+ function matchesMeetingSearch(document, search) {
1024
+ const query = search.trim().toLowerCase();
1025
+ if (!query) return true;
1026
+ return [
1027
+ document.id,
1028
+ document.title,
1029
+ ...document.tags
1030
+ ].some((value) => value.toLowerCase().includes(query));
1078
1031
  }
1079
- function renderTableCell(node) {
1080
- return renderBlocks(node.content ?? [], 0).replace(/\n+/g, " <br> ").replace(/\|/g, "\\|").trim();
1032
+ function matchesMeetingSummarySearch(meeting, search) {
1033
+ const query = search.trim().toLowerCase();
1034
+ if (!query) return true;
1035
+ return [
1036
+ meeting.id,
1037
+ meeting.title,
1038
+ ...meeting.tags
1039
+ ].some((value) => value.toLowerCase().includes(query));
1081
1040
  }
1082
- function renderTable(node) {
1083
- const rows = (node.content ?? []).map((row) => (row.content ?? []).map((cell) => renderTableCell(cell))).filter((row) => row.length > 0);
1084
- if (rows.length === 0) return "";
1085
- const header = rows[0];
1086
- const body = rows.slice(1);
1087
- const separator = header.map(() => "---");
1088
- const lines = [`| ${header.map((cell) => cell || " ").join(" | ")} |`, `| ${separator.join(" | ")} |`];
1089
- for (const row of body) {
1090
- const padded = header.map((_, index) => row[index] ?? " ");
1091
- lines.push(`| ${padded.join(" | ")} |`);
1092
- }
1093
- return lines.join("\n");
1041
+ function parseDateFilter(value, label) {
1042
+ const trimmed = value?.trim();
1043
+ if (!trimmed) return;
1044
+ const candidate = /^\d{4}-\d{2}-\d{2}$/.test(trimmed) ? `${trimmed}T${label === "updatedFrom" ? "00:00:00.000" : "23:59:59.999"}` : trimmed;
1045
+ const timestamp = Date.parse(candidate);
1046
+ if (Number.isNaN(timestamp)) throw new Error(`invalid ${label}: expected ISO timestamp or YYYY-MM-DD`);
1047
+ return timestamp;
1094
1048
  }
1095
- function renderBlock(node, indentLevel) {
1096
- switch (node.type) {
1097
- case "heading": {
1098
- const level = typeof node.attrs?.level === "number" ? node.attrs.level : 1;
1099
- return `${"#".repeat(level)} ${renderInline(node.content).trim()}`.trim();
1100
- }
1101
- case "paragraph": return renderInline(node.content).trim();
1102
- case "bulletList": return renderList(node.content ?? [], false, indentLevel);
1103
- case "orderedList": {
1104
- const start = typeof node.attrs?.start === "number" ? node.attrs.start : 1;
1105
- return renderList(node.content ?? [], true, indentLevel, start);
1106
- }
1107
- case "listItem": return renderListItem(node, "-", indentLevel);
1108
- case "taskList": return renderTaskList(node.content ?? [], indentLevel);
1109
- case "taskItem": return renderTaskItem(node, indentLevel);
1110
- case "table": return renderTable(node);
1111
- case "tableRow": return (node.content ?? []).map((cell) => renderTableCell(cell)).join(" | ");
1112
- case "tableCell":
1113
- case "tableHeader": return renderTableCell(node);
1114
- case "blockquote": return renderBlocks(node.content ?? [], indentLevel).split("\n").map((line) => line ? `> ${line}` : ">").join("\n").trim();
1115
- case "codeBlock": {
1116
- const text = extractPlainText({
1117
- type: "doc",
1118
- content: node.content
1119
- }).trimEnd();
1120
- return `\`\`\`${typeof node.attrs?.language === "string" ? node.attrs.language.trim() : typeof node.attrs?.params === "string" ? node.attrs.params.trim() : ""}\n${text}\n\`\`\``;
1121
- }
1122
- case "horizontalRule": return "---";
1123
- case "hardBreak": return "";
1124
- case "text": return renderInlineNode(node);
1125
- default:
1126
- if (node.content?.length) return renderBlocks(node.content, indentLevel);
1127
- return renderInlineNode(node).trim();
1128
- }
1049
+ function matchesUpdatedRange(document, updatedFrom, updatedTo) {
1050
+ const from = parseDateFilter(updatedFrom, "updatedFrom");
1051
+ const to = parseDateFilter(updatedTo, "updatedTo");
1052
+ const updatedAt = parseTimestamp(latestDocumentTimestamp(document));
1053
+ if (updatedAt == null) return from == null && to == null;
1054
+ if (from != null && updatedAt < from) return false;
1055
+ if (to != null && updatedAt > to) return false;
1056
+ return true;
1129
1057
  }
1130
- function renderBlocks(nodes, indentLevel = 0) {
1131
- return nodes.map((node) => renderBlock(node, indentLevel)).filter((value) => value.length > 0).join("\n\n").replace(/\n{3,}/g, "\n\n").trim();
1058
+ function matchesMeetingSummaryUpdatedRange(meeting, updatedFrom, updatedTo) {
1059
+ const from = parseDateFilter(updatedFrom, "updatedFrom");
1060
+ const to = parseDateFilter(updatedTo, "updatedTo");
1061
+ const updatedAt = parseTimestamp(meeting.updatedAt || meeting.createdAt);
1062
+ if (updatedAt == null) return from == null && to == null;
1063
+ if (from != null && updatedAt < from) return false;
1064
+ if (to != null && updatedAt > to) return false;
1065
+ return true;
1132
1066
  }
1133
- function extractPlainTextNode(node) {
1134
- switch (node.type) {
1135
- case "hardBreak": return "\n";
1136
- case "text": return node.text ?? "";
1137
- default: return extractPlainText({
1138
- type: "doc",
1139
- content: node.content
1140
- });
1141
- }
1067
+ function truncate(value, width) {
1068
+ if (value.length <= width) return value.padEnd(width);
1069
+ return `${value.slice(0, Math.max(0, width - 1))}…`;
1142
1070
  }
1143
- function convertProseMirrorToMarkdown(doc) {
1144
- if (!doc || doc.type !== "doc" || !doc.content?.length) return "";
1145
- const rendered = renderBlocks(doc.content);
1146
- return rendered ? `${rendered}\n` : "";
1071
+ function formatMeetingDate(value) {
1072
+ return value.trim().slice(0, 10) || "-";
1147
1073
  }
1148
- function extractPlainText(doc) {
1149
- if (!doc || doc.type !== "doc" || !doc.content?.length) return "";
1150
- return doc.content.map((node) => {
1151
- if (node.type === "bulletList" || node.type === "orderedList") return (node.content ?? []).map((child) => extractPlainTextNode(child)).filter(Boolean).join("\n");
1152
- return extractPlainTextNode(node);
1153
- }).filter(Boolean).join("\n\n").replace(/\n{3,}/g, "\n\n").trim();
1074
+ function formatTranscriptStatus(meeting) {
1075
+ if (!meeting.transcriptLoaded) return "n/a";
1076
+ if (meeting.transcriptSegmentCount === 0) return "none";
1077
+ return String(meeting.transcriptSegmentCount);
1154
1078
  }
1155
- //#endregion
1156
- //#region src/notes.ts
1157
- function selectNoteContent(document) {
1158
- const notes = convertProseMirrorToMarkdown(document.notes).trim();
1159
- if (notes) return {
1160
- content: notes,
1161
- source: "notes"
1162
- };
1163
- const lastViewedPanel = convertProseMirrorToMarkdown(document.lastViewedPanel?.content).trim();
1164
- if (lastViewedPanel) return {
1165
- content: lastViewedPanel,
1166
- source: "lastViewedPanel.content"
1167
- };
1168
- const originalContent = htmlToMarkdown(document.lastViewedPanel?.originalContent ?? "").trim();
1169
- if (originalContent) return {
1170
- content: originalContent,
1171
- source: "lastViewedPanel.originalContent"
1172
- };
1173
- return {
1174
- content: document.content.trim(),
1175
- source: "content"
1176
- };
1079
+ function formatTranscriptLines(transcript) {
1080
+ if (!transcript || transcript.segments.length === 0) return "";
1081
+ return transcript.segments.map((segment) => `[${formatTimestampForTranscript(segment.startTimestamp)}] ${segment.speaker}: ${segment.text}`).join("\n");
1177
1082
  }
1178
- function buildNoteExport(document) {
1179
- const { content, source } = selectNoteContent(document);
1083
+ function buildMeetingSummary(document, cacheData) {
1084
+ const note = buildNoteExport(document);
1085
+ const transcript = buildMeetingTranscript(document, cacheData);
1180
1086
  return {
1181
- content,
1182
- contentSource: source,
1183
1087
  createdAt: document.createdAt,
1184
1088
  id: document.id,
1185
- raw: document,
1186
- tags: document.tags,
1089
+ noteContentSource: note.contentSource,
1090
+ tags: [...document.tags],
1187
1091
  title: document.title,
1188
- updatedAt: document.updatedAt
1092
+ transcriptLoaded: transcript.loaded,
1093
+ transcriptSegmentCount: transcript.segmentCount,
1094
+ updatedAt: latestDocumentTimestamp(document)
1189
1095
  };
1190
1096
  }
1191
- function renderNoteExport(note, format = "markdown") {
1192
- switch (format) {
1193
- case "json": return toJson({
1194
- content: note.content,
1195
- contentSource: note.contentSource,
1196
- createdAt: note.createdAt,
1197
- id: note.id,
1198
- tags: note.tags,
1199
- title: note.title,
1200
- updatedAt: note.updatedAt
1201
- });
1202
- case "raw": return toJson(note.raw);
1203
- case "yaml": return toYaml({
1204
- content: note.content,
1205
- contentSource: note.contentSource,
1206
- createdAt: note.createdAt,
1207
- id: note.id,
1208
- tags: note.tags,
1209
- title: note.title,
1210
- updatedAt: note.updatedAt
1211
- });
1212
- case "markdown": break;
1097
+ function buildMeetingRecord(document, cacheData) {
1098
+ const note = buildNoteExport(document);
1099
+ const transcript = buildMeetingTranscript(document, cacheData);
1100
+ return {
1101
+ meeting: {
1102
+ createdAt: document.createdAt,
1103
+ id: document.id,
1104
+ noteContentSource: note.contentSource,
1105
+ tags: [...document.tags],
1106
+ title: document.title,
1107
+ transcriptLoaded: transcript.loaded,
1108
+ transcriptSegmentCount: transcript.segmentCount,
1109
+ updatedAt: latestDocumentTimestamp(document)
1110
+ },
1111
+ note: serialiseNote(note),
1112
+ noteMarkdown: renderNoteExport(note, "markdown"),
1113
+ transcript: transcript.transcript,
1114
+ transcriptText: transcript.transcriptText
1115
+ };
1116
+ }
1117
+ function listMeetings(documents, options = {}) {
1118
+ const limit = options.limit ?? 20;
1119
+ const sort = options.sort ?? "updated-desc";
1120
+ return documents.filter((document) => options.search ? matchesMeetingSearch(document, options.search) : true).filter((document) => matchesUpdatedRange(document, options.updatedFrom, options.updatedTo)).sort((left, right) => compareMeetingDocumentsBySort(left, right, sort)).slice(0, limit).map((document) => buildMeetingSummary(document, options.cacheData));
1121
+ }
1122
+ function filterMeetingSummaries(meetings, options = {}) {
1123
+ const limit = options.limit ?? 20;
1124
+ const sort = options.sort ?? "updated-desc";
1125
+ return meetings.filter((meeting) => options.search ? matchesMeetingSummarySearch(meeting, options.search) : true).filter((meeting) => matchesMeetingSummaryUpdatedRange(meeting, options.updatedFrom, options.updatedTo)).sort((left, right) => compareMeetingSummariesBySort(left, right, sort)).slice(0, limit).map((meeting) => ({
1126
+ ...meeting,
1127
+ tags: [...meeting.tags]
1128
+ }));
1129
+ }
1130
+ function resolveMeetingQuery(documents, query) {
1131
+ const trimmed = query.trim();
1132
+ if (!trimmed) throw new Error("meeting query is required");
1133
+ const lower = trimmed.toLowerCase();
1134
+ const exactId = documents.find((document) => document.id === trimmed);
1135
+ if (exactId) return exactId;
1136
+ const exactTitleMatches = documents.filter((document) => document.title.toLowerCase() === lower);
1137
+ if (exactTitleMatches.length === 1) return exactTitleMatches[0];
1138
+ const prefixMatches = documents.filter((document) => document.id.startsWith(trimmed));
1139
+ if (prefixMatches.length === 1) return prefixMatches[0];
1140
+ const titleMatches = documents.filter((document) => document.title.toLowerCase().includes(lower)).sort(compareMeetingDocuments);
1141
+ if (titleMatches.length === 1) return titleMatches[0];
1142
+ if (exactTitleMatches.length > 1 || prefixMatches.length > 1 || titleMatches.length > 1) throw new Error(`ambiguous meeting query: ${trimmed}`);
1143
+ throw new Error(`meeting not found: ${trimmed}`);
1144
+ }
1145
+ function resolveMeeting(documents, id) {
1146
+ const exactMatch = documents.find((document) => document.id === id);
1147
+ if (exactMatch) return exactMatch;
1148
+ const matches = documents.filter((document) => document.id.startsWith(id));
1149
+ if (matches.length === 1) return matches[0];
1150
+ if (matches.length > 1) {
1151
+ const sample = matches.slice(0, 5).map((document) => document.id.slice(0, 8)).join(", ");
1152
+ throw new Error(`ambiguous meeting id: ${id} matches ${matches.length} meetings (${sample})`);
1213
1153
  }
1214
- const lines = [
1215
- "---",
1216
- `id: ${quoteYamlString(note.id)}`,
1217
- `created: ${quoteYamlString(note.createdAt)}`,
1218
- `updated: ${quoteYamlString(note.updatedAt)}`
1219
- ];
1220
- if (note.tags.length > 0) {
1221
- lines.push("tags:");
1222
- for (const tag of note.tags) lines.push(` - ${quoteYamlString(tag)}`);
1154
+ throw new Error(`meeting not found: ${id}`);
1155
+ }
1156
+ function renderMeetingList(meetings, format = "text") {
1157
+ switch (format) {
1158
+ case "json": return toJson(meetings);
1159
+ case "yaml": return toYaml(meetings);
1160
+ case "text": break;
1223
1161
  }
1224
- lines.push("---", "");
1225
- if (note.title.trim()) lines.push(`# ${note.title.trim()}`, "");
1226
- if (note.content) lines.push(note.content);
1162
+ if (meetings.length === 0) return "No meetings found\n";
1163
+ 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)}`];
1164
+ for (const meeting of meetings) lines.push([
1165
+ meeting.id.slice(0, 8).padEnd(10),
1166
+ formatMeetingDate(meeting.updatedAt || meeting.createdAt).padEnd(10),
1167
+ truncate(meeting.title || meeting.id, 42),
1168
+ truncate(meeting.noteContentSource, 18),
1169
+ formatTranscriptStatus(meeting)
1170
+ ].join(" "));
1227
1171
  return `${lines.join("\n").trimEnd()}\n`;
1228
1172
  }
1229
- function documentFilename(document) {
1230
- return sanitiseFilename(document.title || document.id, "untitled");
1231
- }
1232
- function noteFileExtension(format) {
1173
+ function renderMeetingView(record, format = "text") {
1233
1174
  switch (format) {
1234
- case "json": return ".json";
1235
- case "raw": return ".raw.json";
1236
- case "yaml": return ".yaml";
1237
- case "markdown": return ".md";
1175
+ case "json": return toJson(record);
1176
+ case "yaml": return toYaml(record);
1177
+ case "text": break;
1238
1178
  }
1179
+ const tags = record.meeting.tags.length > 0 ? record.meeting.tags.join(", ") : "(none)";
1180
+ const transcriptStatus = !record.meeting.transcriptLoaded ? "cache not loaded" : record.meeting.transcriptSegmentCount === 0 ? "no transcript segments" : `${record.meeting.transcriptSegmentCount} segment(s)`;
1181
+ return `${[
1182
+ `# ${record.meeting.title || record.meeting.id}`,
1183
+ "",
1184
+ `ID: ${record.meeting.id}`,
1185
+ `Created: ${record.meeting.createdAt || "-"}`,
1186
+ `Updated: ${record.meeting.updatedAt || "-"}`,
1187
+ `Tags: ${tags}`,
1188
+ `Note source: ${record.meeting.noteContentSource}`,
1189
+ `Transcript: ${transcriptStatus}`,
1190
+ "",
1191
+ "## Notes",
1192
+ "",
1193
+ record.note.content.trim() || "(no notes)",
1194
+ "",
1195
+ "## Transcript",
1196
+ "",
1197
+ formatTranscriptLines(record.transcript) || (record.meeting.transcriptLoaded ? "(no transcript segments)" : "(Granola cache not loaded)"),
1198
+ ""
1199
+ ].join("\n").trimEnd()}\n`;
1239
1200
  }
1240
- async function writeNotes(documents, outputDir, format = "markdown", options = {}) {
1241
- return await syncManagedExports({
1242
- items: [...documents].sort((left, right) => compareStrings(left.title || left.id, right.title || right.id) || compareStrings(left.id, right.id)).map((document) => {
1243
- const note = buildNoteExport(document);
1244
- return {
1245
- content: renderNoteExport(note, format),
1246
- extension: noteFileExtension(format),
1247
- id: note.id,
1248
- preferredStem: documentFilename(document),
1249
- sourceUpdatedAt: latestDocumentTimestamp(document)
1250
- };
1251
- }),
1252
- kind: "notes",
1253
- onProgress: options.onProgress,
1254
- outputDir
1201
+ function renderMeetingExport(record, format = "json") {
1202
+ switch (format) {
1203
+ case "json": return toJson(record);
1204
+ case "yaml": return toYaml(record);
1205
+ }
1206
+ }
1207
+ function renderMeetingNotes(document, format = "markdown") {
1208
+ return renderNoteExport(buildNoteExport(document), format);
1209
+ }
1210
+ function renderMeetingTranscript(document, cacheData, format = "text") {
1211
+ const transcript = buildMeetingTranscript(document, cacheData).transcriptRecord;
1212
+ if (!transcript) return "";
1213
+ return renderTranscriptExport(transcript, format);
1214
+ }
1215
+ //#endregion
1216
+ //#region src/tui/helpers.ts
1217
+ function splitQuery(query) {
1218
+ return query.trim().toLowerCase().split(/\s+/).filter(Boolean);
1219
+ }
1220
+ function scoreMeetingTerm(meeting, term) {
1221
+ const title = meeting.title.toLowerCase();
1222
+ const id = meeting.id.toLowerCase();
1223
+ const tags = meeting.tags.map((tag) => tag.toLowerCase());
1224
+ if (title === term || id === term) return 0;
1225
+ if (title.startsWith(term)) return 1;
1226
+ if (id.startsWith(term)) return 2;
1227
+ if (title.includes(term)) return 3;
1228
+ if (id.includes(term)) return 4;
1229
+ if (tags.some((tag) => tag.includes(term))) return 5;
1230
+ }
1231
+ function buildGranolaTuiQuickOpenItems(meetings, query) {
1232
+ const terms = splitQuery(query);
1233
+ return meetings.map((meeting) => {
1234
+ const score = terms.reduce((current, term) => {
1235
+ const termScore = scoreMeetingTerm(meeting, term);
1236
+ if (termScore === void 0) return;
1237
+ return (current ?? 0) + termScore;
1238
+ }, 0);
1239
+ if (terms.length > 0 && score === void 0) return;
1240
+ const tags = meeting.tags.length > 0 ? meeting.tags.map((tag) => `#${tag}`).join(" ") : "untagged";
1241
+ return {
1242
+ description: `${meeting.updatedAt.slice(0, 10)} | ${tags} | ${meeting.id}`,
1243
+ id: meeting.id,
1244
+ label: meeting.title || meeting.id,
1245
+ score: score ?? 99
1246
+ };
1247
+ }).filter((item) => item !== void 0).sort((left, right) => {
1248
+ if (left.score !== right.score) return left.score - right.score;
1249
+ if (left.description !== right.description) return right.description.localeCompare(left.description);
1250
+ return left.label.localeCompare(right.label);
1255
1251
  });
1256
1252
  }
1253
+ function renderGranolaTuiMeetingTab(bundle, tab) {
1254
+ const summary = bundle.meeting.meeting;
1255
+ switch (tab) {
1256
+ case "metadata": return [
1257
+ `Title: ${summary.title || summary.id}`,
1258
+ `ID: ${summary.id}`,
1259
+ `Created: ${summary.createdAt}`,
1260
+ `Updated: ${summary.updatedAt}`,
1261
+ `Tags: ${summary.tags.length > 0 ? summary.tags.join(", ") : "none"}`,
1262
+ `Notes source: ${summary.noteContentSource}`,
1263
+ `Transcript loaded: ${summary.transcriptLoaded ? "yes" : "no"}`,
1264
+ `Transcript segments: ${summary.transcriptSegmentCount}`
1265
+ ].join("\n");
1266
+ case "raw": return JSON.stringify(bundle, null, 2);
1267
+ case "transcript": {
1268
+ const transcript = renderMeetingTranscript(bundle.document, bundle.cacheData, "text").trim();
1269
+ if (transcript) return transcript;
1270
+ return bundle.cacheData ? "(Transcript unavailable)" : "(Granola cache not loaded)";
1271
+ }
1272
+ default: return renderMeetingNotes(bundle.document, "markdown").trim();
1273
+ }
1274
+ }
1275
+ function buildGranolaTuiSummary(state, meetingSource) {
1276
+ return `auth ${state.auth.mode === "stored-session" ? "stored" : "supabase"} | ${state.documents.loaded ? `${state.documents.count} docs` : "docs pending"} | ${state.cache.loaded ? `${state.cache.transcriptCount} transcript sets` : state.cache.configured ? "cache configured" : "cache missing"} | ${state.index.loaded ? `${state.index.meetingCount} indexed` : "index pending"} | list ${meetingSource}`;
1277
+ }
1257
1278
  //#endregion
1258
- //#region src/transcripts.ts
1259
- function transcriptSegmentKey(segment) {
1260
- if (segment.id) return `id:${segment.id}`;
1261
- return [
1262
- segment.documentId,
1263
- segment.source,
1264
- segment.startTimestamp,
1265
- segment.endTimestamp
1266
- ].join("|");
1279
+ //#region src/tui/theme.ts
1280
+ const RESET = "\x1B[0m";
1281
+ function colour(code, text) {
1282
+ return `\x1b[${code}m${text}${RESET}`;
1283
+ }
1284
+ const granolaTuiTheme = {
1285
+ accent(text) {
1286
+ return colour("36", text);
1287
+ },
1288
+ dim(text) {
1289
+ return colour("2", text);
1290
+ },
1291
+ error(text) {
1292
+ return colour("31", text);
1293
+ },
1294
+ info(text) {
1295
+ return colour("32", text);
1296
+ },
1297
+ selected(text) {
1298
+ return colour("7", text);
1299
+ },
1300
+ strong(text) {
1301
+ return colour("1", text);
1302
+ },
1303
+ warning(text) {
1304
+ return colour("33", text);
1305
+ }
1306
+ };
1307
+ //#endregion
1308
+ //#region src/tui/palette.ts
1309
+ function padLine$1(text, width) {
1310
+ const clipped = truncateToWidth(text, width, "");
1311
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
1312
+ }
1313
+ function frameLine(text, width) {
1314
+ return `| ${padLine$1(text, Math.max(1, width - 4))} |`;
1315
+ }
1316
+ var GranolaTuiQuickOpenPalette = class {
1317
+ focused = false;
1318
+ #input = new Input();
1319
+ #matches;
1320
+ #selectedIndex = 0;
1321
+ constructor(options) {
1322
+ this.options = options;
1323
+ this.#matches = buildGranolaTuiQuickOpenItems(this.options.meetings, "");
1324
+ this.#input.onEscape = () => {
1325
+ this.options.onCancel();
1326
+ };
1327
+ this.#input.onSubmit = () => {
1328
+ this.chooseSelection();
1329
+ };
1330
+ }
1331
+ get query() {
1332
+ return this.#input.getValue();
1333
+ }
1334
+ updateMatches() {
1335
+ this.#matches = buildGranolaTuiQuickOpenItems(this.options.meetings, this.query);
1336
+ this.#selectedIndex = Math.max(0, Math.min(this.#selectedIndex, this.#matches.length - 1));
1337
+ }
1338
+ async chooseSelection() {
1339
+ const selected = this.#matches[this.#selectedIndex];
1340
+ if (selected) {
1341
+ await this.options.onPick(selected.id);
1342
+ return;
1343
+ }
1344
+ if (this.query.trim()) {
1345
+ await this.options.onResolveQuery(this.query.trim());
1346
+ return;
1347
+ }
1348
+ this.options.onCancel();
1349
+ }
1350
+ invalidate() {}
1351
+ handleInput(data) {
1352
+ if (matchesKey(data, "up")) {
1353
+ this.#selectedIndex = Math.max(0, this.#selectedIndex - 1);
1354
+ return;
1355
+ }
1356
+ if (matchesKey(data, "down")) {
1357
+ this.#selectedIndex = Math.min(this.#matches.length - 1, this.#selectedIndex + 1);
1358
+ return;
1359
+ }
1360
+ if (matchesKey(data, "pageUp")) {
1361
+ this.#selectedIndex = Math.max(0, this.#selectedIndex - 5);
1362
+ return;
1363
+ }
1364
+ if (matchesKey(data, "pageDown")) {
1365
+ this.#selectedIndex = Math.min(this.#matches.length - 1, this.#selectedIndex + 5);
1366
+ return;
1367
+ }
1368
+ const before = this.query;
1369
+ this.#input.focused = this.focused;
1370
+ this.#input.handleInput(data);
1371
+ if (before !== this.query) {
1372
+ this.#selectedIndex = 0;
1373
+ this.updateMatches();
1374
+ }
1375
+ }
1376
+ render(width) {
1377
+ const lines = [];
1378
+ const bodyWidth = Math.max(32, width);
1379
+ const visibleMatches = this.#matches.slice(0, 8);
1380
+ lines.push(`+${"-".repeat(bodyWidth - 2)}+`);
1381
+ lines.push(frameLine(granolaTuiTheme.strong("Quick Open") + granolaTuiTheme.dim(" title, id, or tag"), bodyWidth));
1382
+ lines.push(frameLine("", bodyWidth));
1383
+ for (const inputLine of this.#input.render(Math.max(1, bodyWidth - 4))) lines.push(frameLine(inputLine, bodyWidth));
1384
+ for (const hintLine of wrapTextWithAnsi(granolaTuiTheme.dim("Enter to open, Esc to cancel, arrows to move"), Math.max(1, bodyWidth - 4))) lines.push(frameLine(hintLine, bodyWidth));
1385
+ lines.push(frameLine("", bodyWidth));
1386
+ if (visibleMatches.length === 0) lines.push(frameLine(granolaTuiTheme.warning("No matching meetings"), bodyWidth));
1387
+ else for (const [index, item] of visibleMatches.entries()) {
1388
+ const selected = index === this.#selectedIndex;
1389
+ const title = `${selected ? "> " : " "}${item.label}`;
1390
+ const titleLine = selected ? granolaTuiTheme.selected(title) : title;
1391
+ const detailLine = granolaTuiTheme.dim(` ${item.description}`);
1392
+ lines.push(frameLine(titleLine, bodyWidth));
1393
+ lines.push(frameLine(detailLine, bodyWidth));
1394
+ }
1395
+ lines.push(`+${"-".repeat(bodyWidth - 2)}+`);
1396
+ return lines;
1397
+ }
1398
+ };
1399
+ //#endregion
1400
+ //#region src/tui/workspace.ts
1401
+ function padLine(text, width) {
1402
+ const clipped = truncateToWidth(text, width, "");
1403
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
1404
+ }
1405
+ function wrapBlock(text, width) {
1406
+ const lines = [];
1407
+ for (const line of text.split("\n")) {
1408
+ const wrapped = wrapTextWithAnsi(line, Math.max(1, width));
1409
+ if (wrapped.length === 0) {
1410
+ lines.push("");
1411
+ continue;
1412
+ }
1413
+ lines.push(...wrapped);
1414
+ }
1415
+ return lines;
1416
+ }
1417
+ function toneText(tone, text) {
1418
+ switch (tone) {
1419
+ case "error": return granolaTuiTheme.error(text);
1420
+ case "warning": return granolaTuiTheme.warning(text);
1421
+ default: return granolaTuiTheme.info(text);
1422
+ }
1423
+ }
1424
+ var GranolaTuiWorkspace = class {
1425
+ focused = false;
1426
+ #maxMeetings;
1427
+ #appState;
1428
+ #detailError = "";
1429
+ #detailScroll = 0;
1430
+ #detailToken = 0;
1431
+ #listError = "";
1432
+ #listToken = 0;
1433
+ #loadingDetail = false;
1434
+ #loadingMeetings = false;
1435
+ #meetingSource = "live";
1436
+ #meetings = [];
1437
+ #overlay;
1438
+ #selectedMeeting;
1439
+ #selectedMeetingId;
1440
+ #statusMessage = "Loading meetings…";
1441
+ #statusTone = "info";
1442
+ #tab = "notes";
1443
+ #unsubscribe;
1444
+ constructor(tui, app, options) {
1445
+ this.tui = tui;
1446
+ this.app = app;
1447
+ this.options = options;
1448
+ this.#appState = app.getState();
1449
+ this.#maxMeetings = options.maxMeetings ?? 200;
1450
+ }
1451
+ async initialise() {
1452
+ this.#unsubscribe = this.app.subscribe((event) => {
1453
+ this.handleAppUpdate(event);
1454
+ });
1455
+ await this.loadMeetings({
1456
+ preferredMeetingId: this.options.initialMeetingId,
1457
+ setStatus: true
1458
+ });
1459
+ if (this.options.initialMeetingId) await this.loadMeeting(this.options.initialMeetingId, { ensureMeetingVisible: true });
1460
+ else if (this.#selectedMeetingId) this.loadMeeting(this.#selectedMeetingId);
1461
+ }
1462
+ dispose() {
1463
+ this.#unsubscribe?.();
1464
+ this.#unsubscribe = void 0;
1465
+ }
1466
+ invalidate() {}
1467
+ handleAppUpdate(event) {
1468
+ const previousDocumentsLoadedAt = this.#appState.documents.loadedAt;
1469
+ this.#appState = event.state;
1470
+ if (this.#meetingSource === "index" && event.state.documents.loadedAt && event.state.documents.loadedAt !== previousDocumentsLoadedAt && !this.#loadingMeetings) this.loadMeetings({ preferredMeetingId: this.#selectedMeetingId });
1471
+ this.tui.requestRender();
1472
+ }
1473
+ setStatus(message, tone = "info") {
1474
+ this.#statusMessage = message;
1475
+ this.#statusTone = tone;
1476
+ this.tui.requestRender();
1477
+ }
1478
+ normaliseSelectedIndex() {
1479
+ if (this.#meetings.length === 0) return -1;
1480
+ const selectedIndex = this.#selectedMeetingId ? this.#meetings.findIndex((meeting) => meeting.id === this.#selectedMeetingId) : -1;
1481
+ return selectedIndex >= 0 ? selectedIndex : 0;
1482
+ }
1483
+ ensureMeetingVisible(meeting) {
1484
+ const existingIndex = this.#meetings.findIndex((item) => item.id === meeting.id);
1485
+ if (existingIndex >= 0) this.#meetings[existingIndex] = meeting;
1486
+ else this.#meetings.push(meeting);
1487
+ this.#meetings.sort((left, right) => {
1488
+ if (left.updatedAt !== right.updatedAt) return right.updatedAt.localeCompare(left.updatedAt);
1489
+ return left.title.localeCompare(right.title);
1490
+ });
1491
+ }
1492
+ async loadMeetings(options = {}) {
1493
+ const token = ++this.#listToken;
1494
+ this.#loadingMeetings = true;
1495
+ this.#listError = "";
1496
+ if (options.setStatus !== false) this.setStatus(options.forceRefresh ? "Refreshing meetings…" : "Loading meetings…");
1497
+ try {
1498
+ const result = await this.app.listMeetings({
1499
+ forceRefresh: options.forceRefresh,
1500
+ limit: this.#maxMeetings,
1501
+ preferIndex: true
1502
+ });
1503
+ if (token !== this.#listToken) return;
1504
+ this.#meetings = result.meetings;
1505
+ this.#meetingSource = result.source;
1506
+ this.#selectedMeetingId = options.preferredMeetingId && this.#meetings.some((meeting) => meeting.id === options.preferredMeetingId) ? options.preferredMeetingId : this.#selectedMeetingId && this.#meetings.some((meeting) => meeting.id === this.#selectedMeetingId) ? this.#selectedMeetingId : this.#meetings[0]?.id;
1507
+ this.#listError = "";
1508
+ this.setStatus(result.source === "index" ? "Loaded meetings from the local index" : "Connected to Granola");
1509
+ } catch (error) {
1510
+ if (token !== this.#listToken) return;
1511
+ const message = error instanceof Error ? error.message : String(error);
1512
+ this.#listError = message;
1513
+ this.setStatus(message, "error");
1514
+ throw error;
1515
+ } finally {
1516
+ if (token === this.#listToken) {
1517
+ this.#loadingMeetings = false;
1518
+ this.tui.requestRender();
1519
+ }
1520
+ }
1521
+ }
1522
+ async loadMeeting(meetingId, options = {}) {
1523
+ const token = ++this.#detailToken;
1524
+ this.#loadingDetail = true;
1525
+ this.#detailError = "";
1526
+ this.#selectedMeetingId = meetingId;
1527
+ this.#detailScroll = 0;
1528
+ this.setStatus(`Opening ${meetingId}…`);
1529
+ try {
1530
+ const bundle = options.resolveQuery ? await this.app.findMeeting(meetingId) : await this.app.getMeeting(meetingId);
1531
+ if (token !== this.#detailToken) return;
1532
+ this.#selectedMeeting = bundle;
1533
+ this.#selectedMeetingId = bundle.document.id;
1534
+ if (options.ensureMeetingVisible) this.ensureMeetingVisible(bundle.meeting.meeting);
1535
+ this.setStatus(`Opened ${bundle.meeting.meeting.title || bundle.meeting.meeting.id}`);
1536
+ } catch (error) {
1537
+ if (token !== this.#detailToken) return;
1538
+ const message = error instanceof Error ? error.message : String(error);
1539
+ this.#selectedMeeting = void 0;
1540
+ this.#detailError = message;
1541
+ this.setStatus(message, "error");
1542
+ } finally {
1543
+ if (token === this.#detailToken) {
1544
+ this.#loadingDetail = false;
1545
+ this.tui.requestRender();
1546
+ }
1547
+ }
1548
+ }
1549
+ async refresh(forceRefresh) {
1550
+ try {
1551
+ await this.loadMeetings({
1552
+ forceRefresh,
1553
+ preferredMeetingId: this.#selectedMeetingId
1554
+ });
1555
+ if (this.#selectedMeetingId) await this.loadMeeting(this.#selectedMeetingId, { ensureMeetingVisible: true });
1556
+ } catch {}
1557
+ }
1558
+ async moveSelection(delta) {
1559
+ if (this.#meetings.length === 0) return;
1560
+ const currentIndex = this.normaliseSelectedIndex();
1561
+ const nextIndex = Math.max(0, Math.min(this.#meetings.length - 1, currentIndex + delta));
1562
+ const nextMeeting = this.#meetings[nextIndex];
1563
+ if (!nextMeeting || nextMeeting.id === this.#selectedMeetingId) return;
1564
+ await this.loadMeeting(nextMeeting.id);
1565
+ }
1566
+ currentDetailBody(width) {
1567
+ if (this.#detailError) return wrapBlock(this.#detailError, width);
1568
+ if (this.#loadingDetail && !this.#selectedMeeting) return wrapBlock("Loading meeting details…", width);
1569
+ if (!this.#selectedMeeting) return wrapBlock("Select a meeting to inspect its notes, transcript, and metadata.", width);
1570
+ return wrapBlock(renderGranolaTuiMeetingTab(this.#selectedMeeting, this.#tab), width);
1571
+ }
1572
+ detailScrollStep(width, height) {
1573
+ const bodyHeight = Math.max(1, height - 2);
1574
+ const totalLines = this.currentDetailBody(width).length;
1575
+ if (totalLines <= bodyHeight) return 0;
1576
+ return Math.max(1, Math.min(bodyHeight - 1, totalLines - bodyHeight));
1577
+ }
1578
+ scrollDetail(delta) {
1579
+ const totalWidth = this.tui.terminal.columns;
1580
+ const totalHeight = this.tui.terminal.rows;
1581
+ const { detailWidth } = this.resolveLayout(totalWidth);
1582
+ const bodyHeight = Math.max(1, totalHeight - 6);
1583
+ const detailLines = this.currentDetailBody(Math.max(1, detailWidth - 2));
1584
+ const visibleBodyLines = Math.max(1, bodyHeight - 2);
1585
+ const maxScroll = Math.max(0, detailLines.length - visibleBodyLines);
1586
+ this.#detailScroll = Math.max(0, Math.min(maxScroll, this.#detailScroll + delta));
1587
+ this.tui.requestRender();
1588
+ }
1589
+ cycleTab(delta) {
1590
+ const tabs = [
1591
+ "notes",
1592
+ "transcript",
1593
+ "metadata",
1594
+ "raw"
1595
+ ];
1596
+ this.#tab = tabs[(tabs.indexOf(this.#tab) + delta + tabs.length) % tabs.length] ?? "notes";
1597
+ this.#detailScroll = 0;
1598
+ this.tui.requestRender();
1599
+ }
1600
+ openQuickOpen() {
1601
+ if (this.#overlay) return;
1602
+ const closeOverlay = () => {
1603
+ this.#overlay?.hide();
1604
+ this.#overlay = void 0;
1605
+ this.tui.setFocus(this);
1606
+ this.tui.requestRender();
1607
+ };
1608
+ const palette = new GranolaTuiQuickOpenPalette({
1609
+ meetings: this.#meetings,
1610
+ onCancel: closeOverlay,
1611
+ onPick: async (meetingId) => {
1612
+ closeOverlay();
1613
+ await this.loadMeeting(meetingId, { ensureMeetingVisible: true });
1614
+ },
1615
+ onResolveQuery: async (query) => {
1616
+ closeOverlay();
1617
+ await this.loadMeeting(query, {
1618
+ ensureMeetingVisible: true,
1619
+ resolveQuery: true
1620
+ });
1621
+ }
1622
+ });
1623
+ this.#overlay = this.tui.showOverlay(palette, {
1624
+ anchor: "center",
1625
+ maxHeight: "60%",
1626
+ minWidth: 48,
1627
+ width: "70%"
1628
+ });
1629
+ this.setStatus("Quick open");
1630
+ }
1631
+ handleInput(data) {
1632
+ if (matchesKey(data, "ctrl+c") || matchesKey(data, "q")) {
1633
+ this.options.onExit();
1634
+ return;
1635
+ }
1636
+ if (matchesKey(data, "r")) {
1637
+ this.refresh(true);
1638
+ return;
1639
+ }
1640
+ if (matchesKey(data, "/") || matchesKey(data, "ctrl+p")) {
1641
+ this.openQuickOpen();
1642
+ return;
1643
+ }
1644
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
1645
+ this.moveSelection(-1);
1646
+ return;
1647
+ }
1648
+ if (matchesKey(data, "down") || matchesKey(data, "j")) {
1649
+ this.moveSelection(1);
1650
+ return;
1651
+ }
1652
+ if (matchesKey(data, "pageUp")) {
1653
+ this.scrollDetail(-Math.max(1, this.detailScrollStep(this.tui.terminal.columns, this.tui.terminal.rows)));
1654
+ return;
1655
+ }
1656
+ if (matchesKey(data, "pageDown")) {
1657
+ this.scrollDetail(this.detailScrollStep(this.tui.terminal.columns, this.tui.terminal.rows));
1658
+ return;
1659
+ }
1660
+ if (matchesKey(data, "1")) {
1661
+ this.#tab = "notes";
1662
+ this.#detailScroll = 0;
1663
+ this.tui.requestRender();
1664
+ return;
1665
+ }
1666
+ if (matchesKey(data, "2")) {
1667
+ this.#tab = "transcript";
1668
+ this.#detailScroll = 0;
1669
+ this.tui.requestRender();
1670
+ return;
1671
+ }
1672
+ if (matchesKey(data, "3")) {
1673
+ this.#tab = "metadata";
1674
+ this.#detailScroll = 0;
1675
+ this.tui.requestRender();
1676
+ return;
1677
+ }
1678
+ if (matchesKey(data, "4")) {
1679
+ this.#tab = "raw";
1680
+ this.#detailScroll = 0;
1681
+ this.tui.requestRender();
1682
+ return;
1683
+ }
1684
+ if (matchesKey(data, "]")) {
1685
+ this.cycleTab(1);
1686
+ return;
1687
+ }
1688
+ if (matchesKey(data, "[")) this.cycleTab(-1);
1689
+ }
1690
+ resolveLayout(width) {
1691
+ const minimumDetailWidth = 24;
1692
+ const minimumListWidth = 24;
1693
+ const available = Math.max(1, width - 3);
1694
+ let listWidth = Math.max(minimumListWidth, Math.min(42, Math.floor(available * .34)));
1695
+ let detailWidth = available - listWidth;
1696
+ if (detailWidth < minimumDetailWidth) {
1697
+ detailWidth = minimumDetailWidth;
1698
+ listWidth = Math.max(minimumListWidth, available - detailWidth);
1699
+ }
1700
+ if (listWidth + detailWidth > available) detailWidth = Math.max(minimumDetailWidth, available - listWidth);
1701
+ return {
1702
+ detailWidth,
1703
+ listWidth
1704
+ };
1705
+ }
1706
+ renderListPane(width, height) {
1707
+ const lines = [];
1708
+ const innerWidth = Math.max(1, width - 2);
1709
+ const header = `${granolaTuiTheme.strong("Meetings")} ${granolaTuiTheme.dim(`(${this.#meetings.length})`)}`;
1710
+ lines.push(padLine(header, innerWidth));
1711
+ if (this.#listError) {
1712
+ lines.push(...wrapBlock(granolaTuiTheme.error(this.#listError), innerWidth).slice(0, height - 1));
1713
+ while (lines.length < height) lines.push(" ".repeat(innerWidth));
1714
+ return lines;
1715
+ }
1716
+ if (this.#meetings.length === 0) {
1717
+ lines.push(...wrapBlock("No meetings available yet.", innerWidth).slice(0, height - 1));
1718
+ while (lines.length < height) lines.push(" ".repeat(innerWidth));
1719
+ return lines;
1720
+ }
1721
+ const selectedIndex = this.normaliseSelectedIndex();
1722
+ const windowSize = Math.max(1, height - 1);
1723
+ const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(windowSize / 2), this.#meetings.length - windowSize));
1724
+ const visibleMeetings = this.#meetings.slice(startIndex, startIndex + windowSize);
1725
+ for (const [offset, meeting] of visibleMeetings.entries()) {
1726
+ const selected = startIndex + offset === selectedIndex;
1727
+ const dateLabel = meeting.updatedAt.slice(0, 10);
1728
+ const prefix = selected ? "> " : " ";
1729
+ const maxTitleWidth = Math.max(6, innerWidth - visibleWidth(prefix) - dateLabel.length - 1);
1730
+ const titleBlock = `${prefix}${truncateToWidth(meeting.title || meeting.id, maxTitleWidth, "")}`;
1731
+ const line = `${titleBlock}${" ".repeat(Math.max(1, innerWidth - visibleWidth(titleBlock) - visibleWidth(dateLabel)))}${granolaTuiTheme.dim(dateLabel)}`;
1732
+ lines.push(selected ? padLine(granolaTuiTheme.selected(line), innerWidth) : padLine(line, innerWidth));
1733
+ }
1734
+ while (lines.length < height) lines.push(" ".repeat(innerWidth));
1735
+ return lines;
1736
+ }
1737
+ renderDetailPane(width, height) {
1738
+ const lines = [];
1739
+ const innerWidth = Math.max(1, width - 2);
1740
+ const tabs = [
1741
+ {
1742
+ id: "notes",
1743
+ label: "1 Notes"
1744
+ },
1745
+ {
1746
+ id: "transcript",
1747
+ label: "2 Transcript"
1748
+ },
1749
+ {
1750
+ id: "metadata",
1751
+ label: "3 Metadata"
1752
+ },
1753
+ {
1754
+ id: "raw",
1755
+ label: "4 Raw"
1756
+ }
1757
+ ];
1758
+ const title = this.#selectedMeeting?.meeting.meeting.title || this.#selectedMeetingId || "Meeting";
1759
+ const titleLine = `${granolaTuiTheme.strong(title)} ${granolaTuiTheme.dim(this.#selectedMeeting ? this.#selectedMeeting.meeting.meeting.id : "")}`.trim();
1760
+ lines.push(padLine(titleLine, innerWidth));
1761
+ const tabLine = tabs.map((tab) => tab.id === this.#tab ? granolaTuiTheme.selected(` ${tab.label} `) : ` ${tab.label} `).join(" ");
1762
+ lines.push(padLine(tabLine, innerWidth));
1763
+ const bodyLines = this.currentDetailBody(innerWidth);
1764
+ const bodyHeight = Math.max(1, height - 2);
1765
+ const visibleBody = bodyLines.slice(this.#detailScroll, this.#detailScroll + bodyHeight);
1766
+ lines.push(...visibleBody.map((line) => padLine(line, innerWidth)));
1767
+ while (lines.length < height) lines.push(" ".repeat(innerWidth));
1768
+ return lines;
1769
+ }
1770
+ render(width) {
1771
+ const totalHeight = Math.max(12, this.tui.terminal.rows);
1772
+ const { detailWidth, listWidth } = this.resolveLayout(width);
1773
+ const bodyHeight = Math.max(6, totalHeight - 2 - 2);
1774
+ const selectedLabel = this.#selectedMeeting?.meeting.meeting.title || this.#selectedMeetingId || "none";
1775
+ const headerTitle = padLine(`${granolaTuiTheme.accent("Granola Toolkit TUI")} ${granolaTuiTheme.dim(this.#loadingMeetings ? "loading…" : selectedLabel)}`, width);
1776
+ const headerSummary = padLine(granolaTuiTheme.dim(buildGranolaTuiSummary(this.#appState, this.#meetingSource)), width);
1777
+ const listLines = this.renderListPane(listWidth, bodyHeight);
1778
+ const detailLines = this.renderDetailPane(detailWidth, bodyHeight);
1779
+ const bodyLines = [];
1780
+ for (let index = 0; index < bodyHeight; index += 1) bodyLines.push(`${padLine(listLines[index] ?? "", listWidth)} | ${padLine(detailLines[index] ?? "", detailWidth)}`);
1781
+ const footerStatus = padLine(toneText(this.#statusTone, this.#statusMessage), width);
1782
+ const footerHints = padLine(granolaTuiTheme.dim("/ quick open r refresh 1-4 tabs PgUp/PgDn scroll q quit"), width);
1783
+ return [
1784
+ headerTitle,
1785
+ headerSummary,
1786
+ ...bodyLines,
1787
+ footerStatus,
1788
+ footerHints
1789
+ ];
1790
+ }
1791
+ };
1792
+ async function runGranolaTui(app, options = {}) {
1793
+ const tui = new TUI(new ProcessTerminal());
1794
+ return await new Promise((resolve, reject) => {
1795
+ const workspace = new GranolaTuiWorkspace(tui, app, {
1796
+ initialMeetingId: options.initialMeetingId,
1797
+ onExit: () => {
1798
+ workspace.dispose();
1799
+ tui.stop();
1800
+ Promise.resolve(app.close?.()).catch(() => {}).finally(() => {
1801
+ resolve(0);
1802
+ });
1803
+ }
1804
+ });
1805
+ (async () => {
1806
+ try {
1807
+ await workspace.initialise();
1808
+ } catch (error) {
1809
+ workspace.dispose();
1810
+ await Promise.resolve(app.close?.()).catch(() => {});
1811
+ reject(error);
1812
+ return;
1813
+ }
1814
+ tui.addChild(workspace);
1815
+ tui.setFocus(workspace);
1816
+ tui.start();
1817
+ tui.requestRender(true);
1818
+ })();
1819
+ });
1267
1820
  }
1268
- function compareSegmentTimestamps(left, right) {
1269
- if (left === right) return 0;
1270
- const leftTime = Date.parse(left);
1271
- const rightTime = Date.parse(right);
1272
- if (!Number.isNaN(leftTime) && !Number.isNaN(rightTime)) return leftTime - rightTime;
1273
- return compareStrings(left, right);
1821
+ //#endregion
1822
+ //#region src/commands/attach.ts
1823
+ function attachHelp() {
1824
+ return `Granola attach
1825
+
1826
+ Usage:
1827
+ granola attach <url> [options]
1828
+
1829
+ Options:
1830
+ --meeting <id> Open the workspace focused on a specific meeting
1831
+ --password <value> Server password for protected local APIs
1832
+ -h, --help Show help
1833
+ `;
1274
1834
  }
1275
- function compareTranscriptSegments(left, right) {
1276
- return compareSegmentTimestamps(left.startTimestamp, right.startTimestamp) || compareSegmentTimestamps(left.endTimestamp, right.endTimestamp) || compareStrings(left.source, right.source) || compareStrings(left.id, right.id) || compareStrings(left.text, right.text);
1835
+ const attachCommand = {
1836
+ description: "Attach the terminal workspace to an existing Granola server",
1837
+ flags: {
1838
+ help: { type: "boolean" },
1839
+ meeting: { type: "string" },
1840
+ password: { type: "string" }
1841
+ },
1842
+ help: attachHelp,
1843
+ name: "attach",
1844
+ async run({ commandArgs, commandFlags }) {
1845
+ const serverUrl = commandArgs[0];
1846
+ if (!serverUrl?.trim()) throw new Error("attach requires a server URL, for example http://127.0.0.1:4123");
1847
+ const initialMeetingId = typeof commandFlags.meeting === "string" && commandFlags.meeting.trim() ? commandFlags.meeting.trim() : void 0;
1848
+ return await runGranolaTui(await createGranolaServerClient(serverUrl, { password: typeof commandFlags.password === "string" && commandFlags.password.trim() ? commandFlags.password.trim() : void 0 }), { initialMeetingId });
1849
+ }
1850
+ };
1851
+ //#endregion
1852
+ //#region src/cache.ts
1853
+ function parseCacheDocument(id, value) {
1854
+ const record = asRecord(value);
1855
+ if (!record) return;
1856
+ return {
1857
+ createdAt: stringValue(record.created_at),
1858
+ id,
1859
+ title: stringValue(record.title),
1860
+ updatedAt: stringValue(record.updated_at)
1861
+ };
1277
1862
  }
1278
- function preferredTranscriptSegment(current, candidate) {
1279
- if (!current) return candidate;
1280
- if (candidate.isFinal !== current.isFinal) return candidate.isFinal ? candidate : current;
1281
- return compareSegmentTimestamps(candidate.endTimestamp, current.endTimestamp) > 0 || candidate.text.length > current.text.length ? candidate : current;
1863
+ function parseTranscriptSegments(value) {
1864
+ if (!Array.isArray(value)) return;
1865
+ return value.flatMap((segment) => {
1866
+ const record = asRecord(segment);
1867
+ if (!record) return [];
1868
+ return [{
1869
+ documentId: stringValue(record.document_id),
1870
+ endTimestamp: stringValue(record.end_timestamp),
1871
+ id: stringValue(record.id),
1872
+ isFinal: Boolean(record.is_final),
1873
+ source: stringValue(record.source),
1874
+ startTimestamp: stringValue(record.start_timestamp),
1875
+ text: stringValue(record.text)
1876
+ }];
1877
+ });
1282
1878
  }
1283
- function normaliseTranscriptSegments(segments) {
1284
- const selected = /* @__PURE__ */ new Map();
1285
- for (const segment of [...segments].sort(compareTranscriptSegments)) {
1286
- const key = transcriptSegmentKey(segment);
1287
- const current = selected.get(key);
1288
- selected.set(key, preferredTranscriptSegment(current, segment));
1879
+ function parseCacheContents(contents) {
1880
+ const outer = parseJsonString(contents);
1881
+ if (!outer) throw new Error("failed to parse cache JSON");
1882
+ const rawCache = outer.cache;
1883
+ let cachePayload;
1884
+ if (typeof rawCache === "string") cachePayload = parseJsonString(rawCache);
1885
+ else cachePayload = asRecord(rawCache);
1886
+ const state = cachePayload ? asRecord(cachePayload.state) : void 0;
1887
+ if (!state) throw new Error("failed to parse cache state");
1888
+ const rawDocuments = asRecord(state.documents) ?? {};
1889
+ const rawTranscripts = asRecord(state.transcripts) ?? {};
1890
+ const documents = {};
1891
+ for (const [id, rawDocument] of Object.entries(rawDocuments)) {
1892
+ const document = parseCacheDocument(id, rawDocument);
1893
+ if (document) documents[id] = document;
1289
1894
  }
1290
- const resolved = [...selected.values()].sort(compareTranscriptSegments);
1291
- if (resolved.some((segment) => segment.isFinal)) return resolved.filter((segment) => segment.isFinal);
1292
- return resolved;
1895
+ const transcripts = {};
1896
+ for (const [id, rawTranscript] of Object.entries(rawTranscripts)) {
1897
+ const segments = parseTranscriptSegments(rawTranscript);
1898
+ if (segments) transcripts[id] = segments;
1899
+ }
1900
+ return {
1901
+ documents,
1902
+ transcripts
1903
+ };
1293
1904
  }
1294
- function buildTranscriptExport(document, segments, rawSegments = segments) {
1295
- const renderedSegments = segments.map((segment) => ({
1296
- endTimestamp: segment.endTimestamp,
1297
- id: segment.id,
1298
- isFinal: segment.isFinal,
1299
- source: segment.source,
1300
- speaker: transcriptSpeakerLabel(segment),
1301
- startTimestamp: segment.startTimestamp,
1302
- text: segment.text
1303
- }));
1905
+ //#endregion
1906
+ //#region src/client/auth.ts
1907
+ const execFileAsync$1 = promisify(execFile);
1908
+ const DEFAULT_CLIENT_ID = "client_GranolaMac";
1909
+ const KEYCHAIN_SERVICE_NAME = "com.granola.toolkit";
1910
+ const KEYCHAIN_ACCOUNT_NAME = "session";
1911
+ const WORKOS_AUTH_URL = "https://api.workos.com/user_management/authenticate";
1912
+ function numberValue(value) {
1913
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
1914
+ }
1915
+ function parseSessionRecord(record) {
1916
+ const accessToken = stringValue(record.access_token);
1917
+ if (!accessToken.trim()) return;
1304
1918
  return {
1305
- createdAt: document.createdAt,
1306
- id: document.id,
1307
- raw: {
1308
- document,
1309
- segments: rawSegments
1310
- },
1311
- segments: renderedSegments,
1312
- title: document.title,
1313
- updatedAt: document.updatedAt
1919
+ accessToken,
1920
+ clientId: stringValue(record.client_id) || DEFAULT_CLIENT_ID,
1921
+ expiresIn: numberValue(record.expires_in),
1922
+ externalId: stringValue(record.external_id) || void 0,
1923
+ obtainedAt: stringValue(record.obtained_at) || void 0,
1924
+ refreshToken: stringValue(record.refresh_token) || void 0,
1925
+ sessionId: stringValue(record.session_id) || void 0,
1926
+ signInMethod: stringValue(record.sign_in_method) || void 0,
1927
+ tokenType: stringValue(record.token_type) || void 0
1314
1928
  };
1315
1929
  }
1316
- function renderTranscriptExport(transcript, format = "text") {
1317
- switch (format) {
1318
- case "json": return toJson({
1319
- createdAt: transcript.createdAt,
1320
- id: transcript.id,
1321
- segments: transcript.segments,
1322
- title: transcript.title,
1323
- updatedAt: transcript.updatedAt
1324
- });
1325
- case "raw": return toJson(transcript.raw);
1326
- case "yaml": return toYaml({
1327
- createdAt: transcript.createdAt,
1328
- id: transcript.id,
1329
- segments: transcript.segments,
1330
- title: transcript.title,
1331
- updatedAt: transcript.updatedAt
1930
+ function parseNestedRecord(value) {
1931
+ if (typeof value === "string") return parseJsonString(value);
1932
+ return asRecord(value);
1933
+ }
1934
+ function getSessionFromSupabaseContents(supabaseContents) {
1935
+ const wrapper = parseJsonString(supabaseContents);
1936
+ if (!wrapper) throw new Error("failed to parse supabase.json");
1937
+ const workOsSession = parseSessionRecord(parseNestedRecord(wrapper.workos_tokens) ?? {});
1938
+ if (workOsSession) return workOsSession;
1939
+ const cognitoSession = parseSessionRecord(parseNestedRecord(wrapper.cognito_tokens) ?? {});
1940
+ if (cognitoSession) return cognitoSession;
1941
+ const legacySession = parseSessionRecord(wrapper);
1942
+ if (legacySession) return legacySession;
1943
+ throw new Error("access token not found in supabase.json");
1944
+ }
1945
+ function getAccessTokenFromSupabaseContents(supabaseContents) {
1946
+ return getSessionFromSupabaseContents(supabaseContents).accessToken;
1947
+ }
1948
+ var SupabaseFileTokenSource = class {
1949
+ constructor(filePath) {
1950
+ this.filePath = filePath;
1951
+ }
1952
+ async loadAccessToken() {
1953
+ return getAccessTokenFromSupabaseContents(await readFile(this.filePath, "utf8"));
1954
+ }
1955
+ };
1956
+ var SupabaseFileSessionSource = class {
1957
+ constructor(filePath) {
1958
+ this.filePath = filePath;
1959
+ }
1960
+ async loadSession() {
1961
+ return getSessionFromSupabaseContents(await readFile(this.filePath, "utf8"));
1962
+ }
1963
+ };
1964
+ var NoopTokenStore = class {
1965
+ async clearToken() {}
1966
+ async readToken() {}
1967
+ async writeToken(_token) {}
1968
+ };
1969
+ var FileSessionStore = class {
1970
+ constructor(filePath = defaultSessionFilePath()) {
1971
+ this.filePath = filePath;
1972
+ }
1973
+ async clearSession() {
1974
+ try {
1975
+ await unlink(this.filePath);
1976
+ } catch {}
1977
+ }
1978
+ async readSession() {
1979
+ try {
1980
+ const parsed = parseJsonString(await readFile(this.filePath, "utf8"));
1981
+ return parsed?.accessToken ? parsed : void 0;
1982
+ } catch {
1983
+ return;
1984
+ }
1985
+ }
1986
+ async writeSession(session) {
1987
+ await mkdir(dirname(this.filePath), { recursive: true });
1988
+ await writeFile(this.filePath, `${JSON.stringify(session, null, 2)}\n`, {
1989
+ encoding: "utf8",
1990
+ mode: 384
1332
1991
  });
1333
- case "text": break;
1334
1992
  }
1335
- return formatTranscriptText(transcript);
1336
- }
1337
- function formatTranscriptText(transcript) {
1338
- if (transcript.segments.length === 0) return "";
1339
- const header = [
1340
- "=".repeat(80),
1341
- transcript.title || transcript.id,
1342
- `ID: ${transcript.id}`,
1343
- transcript.createdAt ? `Created: ${transcript.createdAt}` : "",
1344
- transcript.updatedAt ? `Updated: ${transcript.updatedAt}` : "",
1345
- `Segments: ${transcript.segments.length}`,
1346
- "=".repeat(80),
1347
- ""
1348
- ].filter(Boolean);
1349
- const body = transcript.segments.map((segment) => {
1350
- return `[${formatTimestampForTranscript(segment.startTimestamp)}] ${segment.speaker}: ${segment.text}`;
1351
- });
1352
- return `${[...header, ...body].join("\n").trimEnd()}\n`;
1353
- }
1354
- function transcriptFilename(document) {
1355
- return sanitiseFilename(document.title || document.id, "untitled");
1356
- }
1357
- function transcriptFileExtension(format) {
1358
- switch (format) {
1359
- case "json": return ".json";
1360
- case "raw": return ".raw.json";
1361
- case "text": return ".txt";
1362
- case "yaml": return ".yaml";
1993
+ };
1994
+ var KeychainSessionStore = class {
1995
+ async clearSession() {
1996
+ try {
1997
+ await execFileAsync$1("security", [
1998
+ "delete-generic-password",
1999
+ "-s",
2000
+ KEYCHAIN_SERVICE_NAME,
2001
+ "-a",
2002
+ KEYCHAIN_ACCOUNT_NAME
2003
+ ]);
2004
+ } catch {}
2005
+ }
2006
+ async readSession() {
2007
+ try {
2008
+ const { stdout } = await execFileAsync$1("security", [
2009
+ "find-generic-password",
2010
+ "-s",
2011
+ KEYCHAIN_SERVICE_NAME,
2012
+ "-a",
2013
+ KEYCHAIN_ACCOUNT_NAME,
2014
+ "-w"
2015
+ ]);
2016
+ const parsed = parseJsonString(stdout.trim());
2017
+ return parsed?.accessToken ? parsed : void 0;
2018
+ } catch {
2019
+ return;
2020
+ }
2021
+ }
2022
+ async writeSession(session) {
2023
+ await execFileAsync$1("security", [
2024
+ "add-generic-password",
2025
+ "-U",
2026
+ "-s",
2027
+ KEYCHAIN_SERVICE_NAME,
2028
+ "-a",
2029
+ KEYCHAIN_ACCOUNT_NAME,
2030
+ "-w",
2031
+ JSON.stringify(session)
2032
+ ]);
2033
+ }
2034
+ };
2035
+ var CachedTokenProvider = class {
2036
+ #token;
2037
+ constructor(source, store = new NoopTokenStore()) {
2038
+ this.source = source;
2039
+ this.store = store;
2040
+ }
2041
+ async getAccessToken() {
2042
+ if (this.#token) return this.#token;
2043
+ const storedToken = await this.store.readToken();
2044
+ if (storedToken?.trim()) {
2045
+ this.#token = storedToken;
2046
+ return storedToken;
2047
+ }
2048
+ const token = await this.source.loadAccessToken();
2049
+ this.#token = token;
2050
+ await this.store.writeToken(token);
2051
+ return token;
2052
+ }
2053
+ async invalidate() {
2054
+ this.#token = void 0;
2055
+ await this.store.clearToken();
2056
+ }
2057
+ };
2058
+ var StoredSessionTokenProvider = class {
2059
+ #session;
2060
+ constructor(store, options = {}) {
2061
+ this.store = store;
2062
+ this.options = options;
2063
+ }
2064
+ async loadSession() {
2065
+ if (this.#session) return this.#session;
2066
+ const storedSession = await this.store.readSession();
2067
+ if (storedSession?.accessToken.trim()) {
2068
+ this.#session = storedSession;
2069
+ return storedSession;
2070
+ }
2071
+ if (!this.options.source) throw new Error("no stored Granola session found");
2072
+ const sourcedSession = await this.options.source.loadSession();
2073
+ this.#session = sourcedSession;
2074
+ return sourcedSession;
2075
+ }
2076
+ async getAccessToken() {
2077
+ return (await this.loadSession()).accessToken;
1363
2078
  }
1364
- }
1365
- async function writeTranscripts(cacheData, outputDir, format = "text", options = {}) {
1366
- return await syncManagedExports({
1367
- items: Object.entries(cacheData.transcripts).filter(([, segments]) => segments.length > 0).sort(([leftId], [rightId]) => {
1368
- const leftDocument = cacheData.documents[leftId];
1369
- const rightDocument = cacheData.documents[rightId];
1370
- return compareStrings(leftDocument?.title || leftId, rightDocument?.title || rightId) || compareStrings(leftId, rightId);
1371
- }).flatMap(([documentId, segments]) => {
1372
- const document = cacheData.documents[documentId] ?? {
1373
- createdAt: "",
1374
- id: documentId,
1375
- title: documentId,
1376
- updatedAt: ""
1377
- };
1378
- const content = renderTranscriptExport(buildTranscriptExport(document, normaliseTranscriptSegments(segments), segments), format);
1379
- if (!content) return [];
1380
- return [{
1381
- content,
1382
- extension: transcriptFileExtension(format),
1383
- id: document.id,
1384
- preferredStem: transcriptFilename(document),
1385
- sourceUpdatedAt: document.updatedAt
1386
- }];
2079
+ async invalidate() {
2080
+ const session = await this.loadSession().catch(() => void 0);
2081
+ if (session?.refreshToken && session.clientId) try {
2082
+ const refreshedSession = await refreshGranolaSession(session, this.options.fetchImpl);
2083
+ this.#session = refreshedSession;
2084
+ await this.store.writeSession(refreshedSession);
2085
+ return;
2086
+ } catch {
2087
+ if (!this.options.source) {
2088
+ this.#session = void 0;
2089
+ await this.store.clearSession();
2090
+ throw new Error("failed to refresh stored Granola session");
2091
+ }
2092
+ }
2093
+ if (this.options.source) {
2094
+ const sourcedSession = await this.options.source.loadSession();
2095
+ this.#session = sourcedSession;
2096
+ await this.store.writeSession(sourcedSession);
2097
+ return;
2098
+ }
2099
+ this.#session = void 0;
2100
+ await this.store.clearSession();
2101
+ }
2102
+ };
2103
+ async function refreshGranolaSession(session, fetchImpl = fetch) {
2104
+ if (!session.refreshToken?.trim()) throw new Error("refresh token not available");
2105
+ const response = await fetchImpl(WORKOS_AUTH_URL, {
2106
+ body: JSON.stringify({
2107
+ client_id: session.clientId,
2108
+ grant_type: "refresh_token",
2109
+ refresh_token: session.refreshToken
1387
2110
  }),
1388
- kind: "transcripts",
1389
- onProgress: options.onProgress,
1390
- outputDir
2111
+ headers: { "Content-Type": "application/json" },
2112
+ method: "POST"
1391
2113
  });
2114
+ if (!response.ok) throw new Error(`failed to refresh session: ${response.status} ${response.statusText}`);
2115
+ const refreshed = parseSessionRecord(await response.json());
2116
+ if (!refreshed) throw new Error("failed to parse refreshed session");
2117
+ return {
2118
+ ...session,
2119
+ ...refreshed,
2120
+ clientId: refreshed.clientId || session.clientId,
2121
+ obtainedAt: refreshed.obtainedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
2122
+ refreshToken: refreshed.refreshToken ?? session.refreshToken
2123
+ };
1392
2124
  }
1393
- //#endregion
1394
- //#region src/meetings.ts
1395
- function parseTimestamp(value) {
1396
- if (!value.trim()) return;
1397
- const timestamp = Date.parse(value);
1398
- return Number.isNaN(timestamp) ? void 0 : timestamp;
2125
+ function defaultSessionFilePath() {
2126
+ const home = homedir();
2127
+ return platform() === "darwin" ? join(home, "Library", "Application Support", "granola-toolkit", "session.json") : join(home, ".config", "granola-toolkit", "session.json");
1399
2128
  }
1400
- function compareTimestampsDescending(left, right) {
1401
- const leftTimestamp = parseTimestamp(left);
1402
- const rightTimestamp = parseTimestamp(right);
1403
- if (leftTimestamp != null && rightTimestamp != null) return rightTimestamp - leftTimestamp;
1404
- if (leftTimestamp != null) return -1;
1405
- if (rightTimestamp != null) return 1;
1406
- return compareStrings(right, left);
2129
+ function createDefaultSessionStore() {
2130
+ return platform() === "darwin" ? new KeychainSessionStore() : new FileSessionStore();
1407
2131
  }
1408
- function compareMeetingDocuments(left, right) {
1409
- 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);
2132
+ //#endregion
2133
+ //#region src/client/default-auth.ts
2134
+ function hasStoredSession(session) {
2135
+ return Boolean(session?.accessToken.trim());
1410
2136
  }
1411
- function compareMeetingDocumentsByTitle(left, right) {
1412
- return compareStrings(left.title || left.id, right.title || right.id) || compareTimestampsDescending(latestDocumentTimestamp(left), latestDocumentTimestamp(right)) || compareStrings(left.id, right.id);
2137
+ function resolveActiveMode(options) {
2138
+ if (options.preferredMode === "stored-session" && options.storedSessionAvailable) return "stored-session";
2139
+ if (options.preferredMode === "supabase-file" && options.supabaseAvailable) return "supabase-file";
2140
+ if (options.storedSessionAvailable) return "stored-session";
2141
+ return "supabase-file";
1413
2142
  }
1414
- function compareMeetingDocumentsBySort(left, right, sort) {
1415
- switch (sort) {
1416
- case "title-asc": return compareMeetingDocumentsByTitle(left, right);
1417
- case "title-desc": return -compareMeetingDocumentsByTitle(left, right);
1418
- case "updated-asc": return -compareMeetingDocuments(left, right);
1419
- default: return compareMeetingDocuments(left, right);
1420
- }
2143
+ function missingSupabaseError() {
2144
+ return /* @__PURE__ */ new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
1421
2145
  }
1422
- function compareMeetingSummariesByUpdated(left, right) {
1423
- return compareTimestampsDescending(left.updatedAt, right.updatedAt) || compareTimestampsDescending(left.createdAt, right.createdAt) || compareStrings(left.title || left.id, right.title || right.id) || compareStrings(left.id, right.id);
2146
+ function buildDefaultGranolaAuthInfo(config, options = {}) {
2147
+ const existsSyncImpl = options.existsSyncImpl ?? existsSync;
2148
+ const session = options.session;
2149
+ const storedSessionAvailable = hasStoredSession(session);
2150
+ const supabasePath = config.supabase || void 0;
2151
+ const supabaseAvailable = Boolean(supabasePath && existsSyncImpl(supabasePath));
2152
+ return {
2153
+ clientId: session?.clientId,
2154
+ lastError: options.lastError,
2155
+ mode: resolveActiveMode({
2156
+ preferredMode: options.preferredMode,
2157
+ storedSessionAvailable,
2158
+ supabaseAvailable
2159
+ }),
2160
+ refreshAvailable: Boolean(session?.refreshToken?.trim()),
2161
+ signInMethod: session?.signInMethod,
2162
+ storedSessionAvailable,
2163
+ supabaseAvailable,
2164
+ supabasePath
2165
+ };
1424
2166
  }
1425
- function compareMeetingSummariesByTitle(left, right) {
1426
- return compareStrings(left.title || left.id, right.title || right.id) || compareTimestampsDescending(left.updatedAt, right.updatedAt) || compareStrings(left.id, right.id);
2167
+ async function inspectDefaultGranolaAuth(config, options = {}) {
2168
+ const sessionStore = options.sessionStore ?? createDefaultSessionStore();
2169
+ const session = options.session ?? await sessionStore.readSession();
2170
+ return buildDefaultGranolaAuthInfo(config, {
2171
+ existsSyncImpl: options.existsSyncImpl,
2172
+ lastError: options.lastError,
2173
+ preferredMode: options.preferredMode,
2174
+ session
2175
+ });
1427
2176
  }
1428
- function compareMeetingSummariesBySort(left, right, sort) {
1429
- switch (sort) {
1430
- case "title-asc": return compareMeetingSummariesByTitle(left, right);
1431
- case "title-desc": return -compareMeetingSummariesByTitle(left, right);
1432
- case "updated-asc": return -compareMeetingSummariesByUpdated(left, right);
1433
- default: return compareMeetingSummariesByUpdated(left, right);
2177
+ var DefaultAuthController = class {
2178
+ #lastError;
2179
+ #preferredMode;
2180
+ constructor(config, options = {}) {
2181
+ this.config = config;
2182
+ this.options = options;
2183
+ }
2184
+ sessionStore() {
2185
+ return this.options.sessionStore ?? createDefaultSessionStore();
2186
+ }
2187
+ readSession() {
2188
+ return this.sessionStore().readSession();
2189
+ }
2190
+ resolveSupabasePath(overridePath) {
2191
+ const supabasePath = overridePath?.trim() || this.config.supabase || "";
2192
+ if (!supabasePath) throw missingSupabaseError();
2193
+ if (!(this.options.existsSyncImpl ?? existsSync)(supabasePath)) throw new Error(`supabase.json not found: ${supabasePath}`);
2194
+ return supabasePath;
2195
+ }
2196
+ sessionSource(supabasePath) {
2197
+ return this.options.sessionSourceFactory?.(supabasePath) ?? new SupabaseFileSessionSource(supabasePath);
2198
+ }
2199
+ async inspect() {
2200
+ const session = await this.readSession();
2201
+ return buildDefaultGranolaAuthInfo(this.config, {
2202
+ existsSyncImpl: this.options.existsSyncImpl,
2203
+ lastError: this.#lastError,
2204
+ preferredMode: this.#preferredMode,
2205
+ session
2206
+ });
2207
+ }
2208
+ async login(options = {}) {
2209
+ const supabasePath = this.resolveSupabasePath(options.supabasePath);
2210
+ const session = await this.sessionSource(supabasePath).loadSession();
2211
+ await this.sessionStore().writeSession(session);
2212
+ this.#lastError = void 0;
2213
+ this.#preferredMode = "stored-session";
2214
+ return await this.inspect();
2215
+ }
2216
+ async logout() {
2217
+ await this.sessionStore().clearSession();
2218
+ this.#lastError = void 0;
2219
+ this.#preferredMode = void 0;
2220
+ return await this.inspect();
2221
+ }
2222
+ async refresh() {
2223
+ const session = await this.readSession();
2224
+ if (!hasStoredSession(session)) {
2225
+ this.#lastError = "no stored Granola session found";
2226
+ throw new Error(this.#lastError);
2227
+ }
2228
+ try {
2229
+ const refreshed = await refreshGranolaSession(session, this.options.fetchImpl);
2230
+ await this.sessionStore().writeSession(refreshed);
2231
+ this.#lastError = void 0;
2232
+ this.#preferredMode = "stored-session";
2233
+ return await this.inspect();
2234
+ } catch (error) {
2235
+ this.#lastError = error instanceof Error ? error.message : String(error);
2236
+ throw error;
2237
+ }
2238
+ }
2239
+ async switchMode(mode) {
2240
+ const state = await this.inspect();
2241
+ if (mode === "stored-session" && !state.storedSessionAvailable) {
2242
+ this.#lastError = "no stored Granola session found";
2243
+ throw new Error(this.#lastError);
2244
+ }
2245
+ if (mode === "supabase-file") this.resolveSupabasePath();
2246
+ this.#lastError = void 0;
2247
+ this.#preferredMode = mode;
2248
+ return await this.inspect();
1434
2249
  }
2250
+ };
2251
+ function createDefaultGranolaAuthController(config, options = {}) {
2252
+ return new DefaultAuthController(config, options);
1435
2253
  }
1436
- function serialiseNote(note) {
1437
- return {
1438
- content: note.content,
1439
- contentSource: note.contentSource,
1440
- createdAt: note.createdAt,
1441
- id: note.id,
1442
- tags: [...note.tags],
1443
- title: note.title,
1444
- updatedAt: note.updatedAt
1445
- };
2254
+ //#endregion
2255
+ //#region src/client/parsers.ts
2256
+ function parseProseMirrorDoc(value, options = {}) {
2257
+ if (value == null) return;
2258
+ if (typeof value === "string") {
2259
+ const trimmed = value.trim();
2260
+ if (!trimmed) return;
2261
+ if (options.skipHtmlStrings && trimmed.startsWith("<")) return;
2262
+ const parsed = parseJsonString(trimmed);
2263
+ if (!parsed) return;
2264
+ return parseProseMirrorDoc(parsed, options);
2265
+ }
2266
+ const record = asRecord(value);
2267
+ if (!record || record.type !== "doc") return;
2268
+ return record;
1446
2269
  }
1447
- function serialiseTranscript(transcript) {
2270
+ function parseLastViewedPanel(value) {
2271
+ const panel = asRecord(value);
2272
+ if (!panel) return;
1448
2273
  return {
1449
- createdAt: transcript.createdAt,
1450
- id: transcript.id,
1451
- segments: transcript.segments.map((segment) => ({ ...segment })),
1452
- title: transcript.title,
1453
- updatedAt: transcript.updatedAt
1454
- };
1455
- }
1456
- function cacheDocumentForMeeting(document, cacheData) {
1457
- return cacheData?.documents[document.id] ?? {
1458
- createdAt: document.createdAt,
1459
- id: document.id,
1460
- title: document.title,
1461
- updatedAt: latestDocumentTimestamp(document)
2274
+ affinityNoteId: stringValue(panel.affinity_note_id),
2275
+ content: parseProseMirrorDoc(panel.content, { skipHtmlStrings: true }),
2276
+ contentUpdatedAt: stringValue(panel.content_updated_at),
2277
+ createdAt: stringValue(panel.created_at),
2278
+ deletedAt: stringValue(panel.deleted_at),
2279
+ documentId: stringValue(panel.document_id),
2280
+ generatedLines: Array.isArray(panel.generated_lines) ? panel.generated_lines : [],
2281
+ id: stringValue(panel.id),
2282
+ lastViewedAt: stringValue(panel.last_viewed_at),
2283
+ originalContent: stringValue(panel.original_content),
2284
+ suggestedQuestions: panel.suggested_questions,
2285
+ templateSlug: stringValue(panel.template_slug),
2286
+ title: stringValue(panel.title),
2287
+ updatedAt: stringValue(panel.updated_at)
1462
2288
  };
1463
2289
  }
1464
- function buildMeetingTranscript(document, cacheData) {
1465
- if (!cacheData) return {
1466
- loaded: false,
1467
- segmentCount: 0,
1468
- transcript: null,
1469
- transcriptRecord: null,
1470
- transcriptText: null
1471
- };
1472
- const rawSegments = cacheData.transcripts[document.id] ?? [];
1473
- const normalisedSegments = normaliseTranscriptSegments(rawSegments);
1474
- if (normalisedSegments.length === 0) return {
1475
- loaded: true,
1476
- segmentCount: 0,
1477
- transcript: null,
1478
- transcriptRecord: null,
1479
- transcriptText: null
1480
- };
1481
- const transcript = buildTranscriptExport(cacheDocumentForMeeting(document, cacheData), normalisedSegments, rawSegments);
2290
+ function parseDocument(value) {
2291
+ const record = asRecord(value);
2292
+ if (!record) throw new Error("document payload is not an object");
1482
2293
  return {
1483
- loaded: true,
1484
- segmentCount: transcript.segments.length,
1485
- transcript: serialiseTranscript(transcript),
1486
- transcriptRecord: transcript,
1487
- transcriptText: renderTranscriptExport(transcript, "text")
2294
+ content: stringValue(record.content),
2295
+ createdAt: stringValue(record.created_at),
2296
+ id: stringValue(record.id),
2297
+ lastViewedPanel: parseLastViewedPanel(record.last_viewed_panel),
2298
+ notes: parseProseMirrorDoc(record.notes),
2299
+ notesPlain: stringValue(record.notes_plain),
2300
+ tags: stringArray(record.tags),
2301
+ title: stringValue(record.title),
2302
+ updatedAt: stringValue(record.updated_at)
1488
2303
  };
1489
2304
  }
1490
- function matchesMeetingSearch(document, search) {
1491
- const query = search.trim().toLowerCase();
1492
- if (!query) return true;
1493
- return [
1494
- document.id,
1495
- document.title,
1496
- ...document.tags
1497
- ].some((value) => value.toLowerCase().includes(query));
1498
- }
1499
- function matchesMeetingSummarySearch(meeting, search) {
1500
- const query = search.trim().toLowerCase();
1501
- if (!query) return true;
1502
- return [
1503
- meeting.id,
1504
- meeting.title,
1505
- ...meeting.tags
1506
- ].some((value) => value.toLowerCase().includes(query));
1507
- }
1508
- function parseDateFilter(value, label) {
1509
- const trimmed = value?.trim();
1510
- if (!trimmed) return;
1511
- const candidate = /^\d{4}-\d{2}-\d{2}$/.test(trimmed) ? `${trimmed}T${label === "updatedFrom" ? "00:00:00.000" : "23:59:59.999"}` : trimmed;
1512
- const timestamp = Date.parse(candidate);
1513
- if (Number.isNaN(timestamp)) throw new Error(`invalid ${label}: expected ISO timestamp or YYYY-MM-DD`);
1514
- return timestamp;
1515
- }
1516
- function matchesUpdatedRange(document, updatedFrom, updatedTo) {
1517
- const from = parseDateFilter(updatedFrom, "updatedFrom");
1518
- const to = parseDateFilter(updatedTo, "updatedTo");
1519
- const updatedAt = parseTimestamp(latestDocumentTimestamp(document));
1520
- if (updatedAt == null) return from == null && to == null;
1521
- if (from != null && updatedAt < from) return false;
1522
- if (to != null && updatedAt > to) return false;
1523
- return true;
1524
- }
1525
- function matchesMeetingSummaryUpdatedRange(meeting, updatedFrom, updatedTo) {
1526
- const from = parseDateFilter(updatedFrom, "updatedFrom");
1527
- const to = parseDateFilter(updatedTo, "updatedTo");
1528
- const updatedAt = parseTimestamp(meeting.updatedAt || meeting.createdAt);
1529
- if (updatedAt == null) return from == null && to == null;
1530
- if (from != null && updatedAt < from) return false;
1531
- if (to != null && updatedAt > to) return false;
1532
- return true;
2305
+ //#endregion
2306
+ //#region src/client/granola.ts
2307
+ const DEFAULT_CLIENT_VERSION = "5.354.0";
2308
+ const DOCUMENTS_URL = "https://api.granola.ai/v2/get-documents";
2309
+ function resolveClientVersion(value) {
2310
+ return value?.trim() || process.env.GRANOLA_CLIENT_VERSION?.trim() || DEFAULT_CLIENT_VERSION;
1533
2311
  }
1534
- function truncate(value, width) {
1535
- if (value.length <= width) return value.padEnd(width);
1536
- return `${value.slice(0, Math.max(0, width - 1))}…`;
2312
+ var GranolaApiClient = class {
2313
+ clientVersion;
2314
+ documentsUrl;
2315
+ constructor(httpClient, options = DOCUMENTS_URL) {
2316
+ this.httpClient = httpClient;
2317
+ if (typeof options === "string") {
2318
+ this.documentsUrl = options;
2319
+ this.clientVersion = resolveClientVersion();
2320
+ return;
2321
+ }
2322
+ this.documentsUrl = options.documentsUrl ?? DOCUMENTS_URL;
2323
+ this.clientVersion = resolveClientVersion(options.clientVersion);
2324
+ }
2325
+ async listDocuments(options) {
2326
+ const documents = [];
2327
+ const limit = options.limit ?? 100;
2328
+ let offset = 0;
2329
+ for (;;) {
2330
+ const response = await this.httpClient.postJson(this.documentsUrl, {
2331
+ include_last_viewed_panel: true,
2332
+ limit,
2333
+ offset
2334
+ }, {
2335
+ headers: {
2336
+ "User-Agent": `Granola/${this.clientVersion}`,
2337
+ "X-Client-Version": this.clientVersion
2338
+ },
2339
+ timeoutMs: options.timeoutMs
2340
+ });
2341
+ if (!response.ok) {
2342
+ const body = (await response.text()).slice(0, 500);
2343
+ throw new Error(`failed to get documents: ${response.status} ${response.statusText}${body ? `: ${body}` : ""}`);
2344
+ }
2345
+ const payload = await response.json();
2346
+ if (!Array.isArray(payload.docs)) throw new Error("failed to parse documents response");
2347
+ const page = payload.docs.map(parseDocument);
2348
+ documents.push(...page);
2349
+ if (page.length < limit) break;
2350
+ offset += limit;
2351
+ }
2352
+ return documents;
2353
+ }
2354
+ };
2355
+ //#endregion
2356
+ //#region src/client/http.ts
2357
+ const RETRYABLE_STATUS_CODES = new Set([
2358
+ 429,
2359
+ 500,
2360
+ 502,
2361
+ 503,
2362
+ 504
2363
+ ]);
2364
+ function sleep(delayMs) {
2365
+ return new Promise((resolve) => {
2366
+ setTimeout(resolve, delayMs);
2367
+ });
1537
2368
  }
1538
- function formatMeetingDate(value) {
1539
- return value.trim().slice(0, 10) || "-";
2369
+ function parseRetryAfter(headerValue) {
2370
+ if (!headerValue?.trim()) return;
2371
+ if (/^\d+$/.test(headerValue.trim())) return Number(headerValue.trim()) * 1e3;
2372
+ const retryAt = Date.parse(headerValue);
2373
+ if (Number.isNaN(retryAt)) return;
2374
+ return Math.max(0, retryAt - Date.now());
1540
2375
  }
1541
- function formatTranscriptStatus(meeting) {
1542
- if (!meeting.transcriptLoaded) return "n/a";
1543
- if (meeting.transcriptSegmentCount === 0) return "none";
1544
- return String(meeting.transcriptSegmentCount);
2376
+ var AuthenticatedHttpClient = class {
2377
+ fetchImpl;
2378
+ constructor(options) {
2379
+ this.fetchImpl = options.fetchImpl ?? fetch;
2380
+ this.logger = options.logger;
2381
+ this.maxRetries = options.maxRetries ?? 2;
2382
+ this.retryBaseDelayMs = options.retryBaseDelayMs ?? 500;
2383
+ this.retryMaxDelayMs = options.retryMaxDelayMs ?? 5e3;
2384
+ this.sleepImpl = options.sleepImpl ?? sleep;
2385
+ this.tokenProvider = options.tokenProvider;
2386
+ }
2387
+ logger;
2388
+ maxRetries;
2389
+ retryBaseDelayMs;
2390
+ retryMaxDelayMs;
2391
+ sleepImpl;
2392
+ tokenProvider;
2393
+ async retry(options, attempt, reason, response) {
2394
+ const retryAfterMs = parseRetryAfter(response?.headers.get("retry-after") ?? null);
2395
+ const delayMs = Math.min(retryAfterMs ?? this.retryBaseDelayMs * 2 ** attempt, this.retryMaxDelayMs);
2396
+ this.logger?.warn?.(`${reason}; retrying in ${delayMs}ms (${attempt + 1}/${this.maxRetries})`);
2397
+ await this.sleepImpl(delayMs);
2398
+ return this.request(options, attempt + 1);
2399
+ }
2400
+ async request(options, attempt = 0) {
2401
+ const { retryOnUnauthorized = true, timeoutMs, url } = options;
2402
+ const accessToken = await this.tokenProvider.getAccessToken();
2403
+ let response;
2404
+ try {
2405
+ response = await this.fetchImpl(url, {
2406
+ body: options.body,
2407
+ headers: {
2408
+ ...options.headers,
2409
+ Authorization: `Bearer ${accessToken}`
2410
+ },
2411
+ method: options.method ?? "GET",
2412
+ signal: AbortSignal.timeout(timeoutMs)
2413
+ });
2414
+ } catch (error) {
2415
+ if (attempt < this.maxRetries) {
2416
+ const message = error instanceof Error ? error.message : String(error);
2417
+ return this.retry(options, attempt, `request failed: ${message}`);
2418
+ }
2419
+ throw error;
2420
+ }
2421
+ if (response.status === 401 && retryOnUnauthorized) {
2422
+ this.logger?.warn?.("request returned 401; invalidating token provider and retrying once");
2423
+ await this.tokenProvider.invalidate();
2424
+ return this.request({
2425
+ ...options,
2426
+ retryOnUnauthorized: false
2427
+ }, attempt);
2428
+ }
2429
+ if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < this.maxRetries) return this.retry(options, attempt, `request returned ${response.status} ${response.statusText || ""}`.trim(), response);
2430
+ return response;
2431
+ }
2432
+ async postJson(url, body, options = { timeoutMs: 3e4 }) {
2433
+ return this.request({
2434
+ ...options,
2435
+ body: JSON.stringify(body),
2436
+ headers: {
2437
+ Accept: "*/*",
2438
+ "Content-Type": "application/json",
2439
+ ...options.headers
2440
+ },
2441
+ method: "POST",
2442
+ url
2443
+ });
2444
+ }
2445
+ };
2446
+ //#endregion
2447
+ //#region src/client/default.ts
2448
+ async function createDefaultGranolaRuntime(config, logger = console, options = {}) {
2449
+ const auth = await inspectDefaultGranolaAuth(config, { preferredMode: options.preferredMode });
2450
+ if (!auth.storedSessionAvailable && !config.supabase) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
2451
+ if (!auth.storedSessionAvailable && config.supabase && !existsSync(config.supabase)) throw new Error(`supabase.json not found: ${config.supabase}`);
2452
+ const sessionStore = createDefaultSessionStore();
2453
+ return {
2454
+ auth,
2455
+ client: new GranolaApiClient(new AuthenticatedHttpClient({
2456
+ logger,
2457
+ tokenProvider: auth.mode === "stored-session" ? new StoredSessionTokenProvider(sessionStore, { source: config.supabase && existsSync(config.supabase) ? new SupabaseFileSessionSource(config.supabase) : void 0 }) : new CachedTokenProvider(new SupabaseFileTokenSource(config.supabase), new NoopTokenStore())
2458
+ }))
2459
+ };
1545
2460
  }
1546
- function formatTranscriptLines(transcript) {
1547
- if (!transcript || transcript.segments.length === 0) return "";
1548
- return transcript.segments.map((segment) => `[${formatTimestampForTranscript(segment.startTimestamp)}] ${segment.speaker}: ${segment.text}`).join("\n");
2461
+ async function loadOptionalGranolaCache(cacheFile) {
2462
+ if (!cacheFile || !existsSync(cacheFile)) return;
2463
+ return parseCacheContents(await readFile(cacheFile, "utf8"));
1549
2464
  }
1550
- function buildMeetingSummary(document, cacheData) {
1551
- const note = buildNoteExport(document);
1552
- const transcript = buildMeetingTranscript(document, cacheData);
2465
+ //#endregion
2466
+ //#region src/export-jobs.ts
2467
+ const EXPORT_JOBS_VERSION = 1;
2468
+ const MAX_EXPORT_JOBS = 100;
2469
+ function normaliseJob(value) {
2470
+ const record = asRecord(value);
2471
+ if (!record) return;
2472
+ const id = stringValue(record.id);
2473
+ const kind = stringValue(record.kind);
2474
+ const status = stringValue(record.status);
2475
+ const format = stringValue(record.format);
2476
+ const outputDir = stringValue(record.outputDir);
2477
+ const startedAt = stringValue(record.startedAt);
2478
+ const itemCount = typeof record.itemCount === "number" && Number.isFinite(record.itemCount) ? record.itemCount : 0;
2479
+ const written = typeof record.written === "number" && Number.isFinite(record.written) ? record.written : 0;
2480
+ const completedCount = typeof record.completedCount === "number" && Number.isFinite(record.completedCount) ? record.completedCount : written;
2481
+ if (!id || !format || !outputDir || !startedAt || kind !== "notes" && kind !== "transcripts" || status !== "running" && status !== "completed" && status !== "failed") return;
1553
2482
  return {
1554
- createdAt: document.createdAt,
1555
- id: document.id,
1556
- noteContentSource: note.contentSource,
1557
- tags: [...document.tags],
1558
- title: document.title,
1559
- transcriptLoaded: transcript.loaded,
1560
- transcriptSegmentCount: transcript.segmentCount,
1561
- updatedAt: latestDocumentTimestamp(document)
2483
+ completedCount,
2484
+ error: stringValue(record.error) || void 0,
2485
+ finishedAt: stringValue(record.finishedAt) || void 0,
2486
+ format,
2487
+ id,
2488
+ itemCount,
2489
+ kind,
2490
+ outputDir,
2491
+ startedAt,
2492
+ status,
2493
+ written
1562
2494
  };
1563
2495
  }
1564
- function buildMeetingRecord(document, cacheData) {
1565
- const note = buildNoteExport(document);
1566
- const transcript = buildMeetingTranscript(document, cacheData);
2496
+ function normaliseJobsFile(parsed) {
2497
+ const record = asRecord(parsed);
2498
+ if (!record || record.version !== EXPORT_JOBS_VERSION || !Array.isArray(record.jobs)) return {
2499
+ jobs: [],
2500
+ version: EXPORT_JOBS_VERSION
2501
+ };
1567
2502
  return {
1568
- meeting: {
1569
- createdAt: document.createdAt,
1570
- id: document.id,
1571
- noteContentSource: note.contentSource,
1572
- tags: [...document.tags],
1573
- title: document.title,
1574
- transcriptLoaded: transcript.loaded,
1575
- transcriptSegmentCount: transcript.segmentCount,
1576
- updatedAt: latestDocumentTimestamp(document)
1577
- },
1578
- note: serialiseNote(note),
1579
- noteMarkdown: renderNoteExport(note, "markdown"),
1580
- transcript: transcript.transcript,
1581
- transcriptText: transcript.transcriptText
2503
+ jobs: record.jobs.map((job) => normaliseJob(job)).filter((job) => Boolean(job)).slice(0, MAX_EXPORT_JOBS),
2504
+ version: EXPORT_JOBS_VERSION
1582
2505
  };
1583
2506
  }
1584
- function listMeetings(documents, options = {}) {
1585
- const limit = options.limit ?? 20;
1586
- const sort = options.sort ?? "updated-desc";
1587
- return documents.filter((document) => options.search ? matchesMeetingSearch(document, options.search) : true).filter((document) => matchesUpdatedRange(document, options.updatedFrom, options.updatedTo)).sort((left, right) => compareMeetingDocumentsBySort(left, right, sort)).slice(0, limit).map((document) => buildMeetingSummary(document, options.cacheData));
1588
- }
1589
- function filterMeetingSummaries(meetings, options = {}) {
1590
- const limit = options.limit ?? 20;
1591
- const sort = options.sort ?? "updated-desc";
1592
- return meetings.filter((meeting) => options.search ? matchesMeetingSummarySearch(meeting, options.search) : true).filter((meeting) => matchesMeetingSummaryUpdatedRange(meeting, options.updatedFrom, options.updatedTo)).sort((left, right) => compareMeetingSummariesBySort(left, right, sort)).slice(0, limit).map((meeting) => ({
1593
- ...meeting,
1594
- tags: [...meeting.tags]
1595
- }));
1596
- }
1597
- function resolveMeetingQuery(documents, query) {
1598
- const trimmed = query.trim();
1599
- if (!trimmed) throw new Error("meeting query is required");
1600
- const lower = trimmed.toLowerCase();
1601
- const exactId = documents.find((document) => document.id === trimmed);
1602
- if (exactId) return exactId;
1603
- const exactTitleMatches = documents.filter((document) => document.title.toLowerCase() === lower);
1604
- if (exactTitleMatches.length === 1) return exactTitleMatches[0];
1605
- const prefixMatches = documents.filter((document) => document.id.startsWith(trimmed));
1606
- if (prefixMatches.length === 1) return prefixMatches[0];
1607
- const titleMatches = documents.filter((document) => document.title.toLowerCase().includes(lower)).sort(compareMeetingDocuments);
1608
- if (titleMatches.length === 1) return titleMatches[0];
1609
- if (exactTitleMatches.length > 1 || prefixMatches.length > 1 || titleMatches.length > 1) throw new Error(`ambiguous meeting query: ${trimmed}`);
1610
- throw new Error(`meeting not found: ${trimmed}`);
2507
+ function createExportJobId(kind) {
2508
+ return `${kind}-${randomUUID()}`;
1611
2509
  }
1612
- function resolveMeeting(documents, id) {
1613
- const exactMatch = documents.find((document) => document.id === id);
1614
- if (exactMatch) return exactMatch;
1615
- const matches = documents.filter((document) => document.id.startsWith(id));
1616
- if (matches.length === 1) return matches[0];
1617
- if (matches.length > 1) {
1618
- const sample = matches.slice(0, 5).map((document) => document.id.slice(0, 8)).join(", ");
1619
- throw new Error(`ambiguous meeting id: ${id} matches ${matches.length} meetings (${sample})`);
1620
- }
1621
- throw new Error(`meeting not found: ${id}`);
2510
+ function defaultExportJobsFilePath() {
2511
+ const home = homedir();
2512
+ return platform() === "darwin" ? join(home, "Library", "Application Support", "granola-toolkit", "export-jobs.json") : join(home, ".config", "granola-toolkit", "export-jobs.json");
1622
2513
  }
1623
- function renderMeetingList(meetings, format = "text") {
1624
- switch (format) {
1625
- case "json": return toJson(meetings);
1626
- case "yaml": return toYaml(meetings);
1627
- case "text": break;
2514
+ var FileExportJobStore = class {
2515
+ constructor(filePath = defaultExportJobsFilePath()) {
2516
+ this.filePath = filePath;
1628
2517
  }
1629
- if (meetings.length === 0) return "No meetings found\n";
1630
- 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)}`];
1631
- for (const meeting of meetings) lines.push([
1632
- meeting.id.slice(0, 8).padEnd(10),
1633
- formatMeetingDate(meeting.updatedAt || meeting.createdAt).padEnd(10),
1634
- truncate(meeting.title || meeting.id, 42),
1635
- truncate(meeting.noteContentSource, 18),
1636
- formatTranscriptStatus(meeting)
1637
- ].join(" "));
1638
- return `${lines.join("\n").trimEnd()}\n`;
1639
- }
1640
- function renderMeetingView(record, format = "text") {
1641
- switch (format) {
1642
- case "json": return toJson(record);
1643
- case "yaml": return toYaml(record);
1644
- case "text": break;
2518
+ async readJobs() {
2519
+ try {
2520
+ return normaliseJobsFile(parseJsonString(await readFile(this.filePath, "utf8"))).jobs;
2521
+ } catch {
2522
+ return [];
2523
+ }
1645
2524
  }
1646
- const tags = record.meeting.tags.length > 0 ? record.meeting.tags.join(", ") : "(none)";
1647
- const transcriptStatus = !record.meeting.transcriptLoaded ? "cache not loaded" : record.meeting.transcriptSegmentCount === 0 ? "no transcript segments" : `${record.meeting.transcriptSegmentCount} segment(s)`;
1648
- return `${[
1649
- `# ${record.meeting.title || record.meeting.id}`,
1650
- "",
1651
- `ID: ${record.meeting.id}`,
1652
- `Created: ${record.meeting.createdAt || "-"}`,
1653
- `Updated: ${record.meeting.updatedAt || "-"}`,
1654
- `Tags: ${tags}`,
1655
- `Note source: ${record.meeting.noteContentSource}`,
1656
- `Transcript: ${transcriptStatus}`,
1657
- "",
1658
- "## Notes",
1659
- "",
1660
- record.note.content.trim() || "(no notes)",
1661
- "",
1662
- "## Transcript",
1663
- "",
1664
- formatTranscriptLines(record.transcript) || (record.meeting.transcriptLoaded ? "(no transcript segments)" : "(Granola cache not loaded)"),
1665
- ""
1666
- ].join("\n").trimEnd()}\n`;
1667
- }
1668
- function renderMeetingExport(record, format = "json") {
1669
- switch (format) {
1670
- case "json": return toJson(record);
1671
- case "yaml": return toYaml(record);
2525
+ async writeJobs(jobs) {
2526
+ const payload = {
2527
+ jobs: jobs.slice(0, MAX_EXPORT_JOBS),
2528
+ version: EXPORT_JOBS_VERSION
2529
+ };
2530
+ await mkdir(dirname(this.filePath), { recursive: true });
2531
+ await writeFile(this.filePath, `${JSON.stringify(payload, null, 2)}\n`, {
2532
+ encoding: "utf8",
2533
+ mode: 384
2534
+ });
1672
2535
  }
1673
- }
1674
- function renderMeetingNotes(document, format = "markdown") {
1675
- return renderNoteExport(buildNoteExport(document), format);
1676
- }
1677
- function renderMeetingTranscript(document, cacheData, format = "text") {
1678
- const transcript = buildMeetingTranscript(document, cacheData).transcriptRecord;
1679
- if (!transcript) return "";
1680
- return renderTranscriptExport(transcript, format);
2536
+ };
2537
+ function createDefaultExportJobStore() {
2538
+ return new FileExportJobStore();
1681
2539
  }
1682
2540
  //#endregion
1683
2541
  //#region src/meeting-index.ts
@@ -4784,11 +5642,53 @@ const serveCommand = {
4784
5642
  console.log(" POST /exports/notes");
4785
5643
  console.log(" POST /exports/jobs/:id/rerun");
4786
5644
  console.log(" POST /exports/transcripts");
5645
+ console.log(`Attach: granola attach ${server.url.href}`);
5646
+ if (password) console.log("Attach password: add --password <value>");
4787
5647
  await waitForShutdown(async () => await server.close());
4788
5648
  return 0;
4789
5649
  }
4790
5650
  };
4791
5651
  //#endregion
5652
+ //#region src/commands/tui.ts
5653
+ function tuiHelp() {
5654
+ return `Granola tui
5655
+
5656
+ Usage:
5657
+ granola tui [options]
5658
+
5659
+ Options:
5660
+ --meeting <id> Open the workspace focused on a specific meeting
5661
+ --cache <path> Path to Granola cache JSON
5662
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
5663
+ --supabase <path> Path to supabase.json
5664
+ --debug Enable debug logging
5665
+ --config <path> Path to .granola.toml
5666
+ -h, --help Show help
5667
+ `;
5668
+ }
5669
+ const tuiCommand = {
5670
+ description: "Start the Granola Toolkit terminal workspace",
5671
+ flags: {
5672
+ cache: { type: "string" },
5673
+ help: { type: "boolean" },
5674
+ meeting: { type: "string" },
5675
+ timeout: { type: "string" }
5676
+ },
5677
+ help: tuiHelp,
5678
+ name: "tui",
5679
+ async run({ commandFlags, globalFlags }) {
5680
+ const config = await loadConfig({
5681
+ globalFlags,
5682
+ subcommandFlags: commandFlags
5683
+ });
5684
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
5685
+ debug(config.debug, "supabase", config.supabase);
5686
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
5687
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
5688
+ return await runGranolaTui(await createGranolaApp(config, { surface: "tui" }), { initialMeetingId: typeof commandFlags.meeting === "string" && commandFlags.meeting.trim() ? commandFlags.meeting.trim() : void 0 });
5689
+ }
5690
+ };
5691
+ //#endregion
4792
5692
  //#region src/commands/transcripts.ts
4793
5693
  function transcriptsHelp() {
4794
5694
  return `Granola transcripts
@@ -4900,11 +5800,13 @@ Options:
4900
5800
  //#endregion
4901
5801
  //#region src/commands/index.ts
4902
5802
  const commands = [
5803
+ attachCommand,
4903
5804
  authCommand,
4904
5805
  exportsCommand,
4905
5806
  meetingCommand,
4906
5807
  notesCommand,
4907
5808
  serveCommand,
5809
+ tuiCommand,
4908
5810
  transcriptsCommand,
4909
5811
  {
4910
5812
  description: "Start the Granola Toolkit web workspace",
@@ -4969,6 +5871,8 @@ const commands = [
4969
5871
  console.log(" POST /exports/notes");
4970
5872
  console.log(" POST /exports/jobs/:id/rerun");
4971
5873
  console.log(" POST /exports/transcripts");
5874
+ console.log(`Attach: granola attach ${server.url.href}`);
5875
+ if (password) console.log("Attach password: add --password <value>");
4972
5876
  if (openBrowser) try {
4973
5877
  await openExternalUrl(server.url);
4974
5878
  } catch (error) {
@@ -5068,6 +5972,7 @@ Global options:
5068
5972
  -h, --help Show help
5069
5973
 
5070
5974
  Examples:
5975
+ granola attach http://127.0.0.1:4123
5071
5976
  granola notes --supabase "${granolaSupabaseCandidates()[0] ?? "/path/to/supabase.json"}"
5072
5977
  granola transcripts --cache "${granolaCacheCandidates()[0] ?? "/path/to/cache-v3.json"}"
5073
5978
  `;