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.
- package/README.md +43 -1
- package/dist/cli.js +2283 -1378
- package/package.json +3 -1
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,235 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
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/
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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
|
-
|
|
206
|
-
|
|
385
|
+
entries: {},
|
|
386
|
+
kind,
|
|
387
|
+
version: EXPORT_STATE_VERSION
|
|
207
388
|
};
|
|
208
389
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
250
|
-
return
|
|
421
|
+
function hashContent(content) {
|
|
422
|
+
return createHash("sha256").update(content).digest("hex");
|
|
251
423
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
|
430
|
-
|
|
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
|
|
434
|
-
return
|
|
545
|
+
function toJson(value) {
|
|
546
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
435
547
|
}
|
|
436
548
|
//#endregion
|
|
437
|
-
//#region src/
|
|
438
|
-
function
|
|
439
|
-
return
|
|
549
|
+
//#region src/prosemirror.ts
|
|
550
|
+
function repeatIndent(level) {
|
|
551
|
+
return " ".repeat(level);
|
|
440
552
|
}
|
|
441
|
-
function
|
|
442
|
-
|
|
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
|
|
448
|
-
return
|
|
556
|
+
function renderInline(nodes = []) {
|
|
557
|
+
return nodes.map((node) => renderInlineNode(node)).join("");
|
|
449
558
|
}
|
|
450
|
-
function
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
559
|
-
|
|
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
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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/
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
|
766
|
-
|
|
767
|
-
|
|
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/
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
|
812
|
-
return
|
|
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
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
|
|
842
|
-
return
|
|
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
|
|
858
|
-
const
|
|
859
|
-
|
|
860
|
-
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
|
889
|
-
|
|
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
|
|
892
|
-
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
return
|
|
902
|
-
|
|
903
|
-
return
|
|
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
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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/
|
|
981
|
-
function
|
|
982
|
-
if (value
|
|
983
|
-
|
|
984
|
-
|
|
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
|
|
1010
|
-
|
|
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
|
|
1013
|
-
return
|
|
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
|
-
|
|
1016
|
-
|
|
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
|
|
1021
|
-
|
|
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
|
|
1024
|
-
return
|
|
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
|
|
1027
|
-
return
|
|
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
|
|
1045
|
-
switch (
|
|
1046
|
-
case "
|
|
1047
|
-
case "
|
|
1048
|
-
case "
|
|
1049
|
-
default: return
|
|
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
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
|
1057
|
-
return
|
|
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
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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
|
|
1074
|
-
|
|
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
|
|
1077
|
-
|
|
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
|
|
1080
|
-
|
|
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
|
|
1083
|
-
const
|
|
1084
|
-
if (
|
|
1085
|
-
const
|
|
1086
|
-
const
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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
|
|
1131
|
-
|
|
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
|
|
1134
|
-
|
|
1135
|
-
|
|
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
|
|
1144
|
-
|
|
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
|
|
1149
|
-
if (!
|
|
1150
|
-
|
|
1151
|
-
|
|
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
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
|
1179
|
-
const
|
|
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
|
-
|
|
1186
|
-
tags: document.tags,
|
|
1089
|
+
noteContentSource: note.contentSource,
|
|
1090
|
+
tags: [...document.tags],
|
|
1187
1091
|
title: document.title,
|
|
1188
|
-
|
|
1092
|
+
transcriptLoaded: transcript.loaded,
|
|
1093
|
+
transcriptSegmentCount: transcript.segmentCount,
|
|
1094
|
+
updatedAt: latestDocumentTimestamp(document)
|
|
1189
1095
|
};
|
|
1190
1096
|
}
|
|
1191
|
-
function
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
createdAt:
|
|
1197
|
-
id:
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
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
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
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
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
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
|
|
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
|
|
1235
|
-
case "
|
|
1236
|
-
case "
|
|
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
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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/
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
return [
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
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
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
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
|
-
|
|
1276
|
-
|
|
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
|
|
1279
|
-
if (!
|
|
1280
|
-
|
|
1281
|
-
|
|
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
|
|
1284
|
-
const
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
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
|
|
1291
|
-
|
|
1292
|
-
|
|
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
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
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
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
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
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
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
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
return
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
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
|
-
|
|
1389
|
-
|
|
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
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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
|
|
1401
|
-
|
|
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
|
-
|
|
1409
|
-
|
|
2132
|
+
//#endregion
|
|
2133
|
+
//#region src/client/default-auth.ts
|
|
2134
|
+
function hasStoredSession(session) {
|
|
2135
|
+
return Boolean(session?.accessToken.trim());
|
|
1410
2136
|
}
|
|
1411
|
-
function
|
|
1412
|
-
|
|
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
|
|
1415
|
-
|
|
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
|
|
1423
|
-
|
|
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
|
|
1426
|
-
|
|
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
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
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
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
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
|
|
2270
|
+
function parseLastViewedPanel(value) {
|
|
2271
|
+
const panel = asRecord(value);
|
|
2272
|
+
if (!panel) return;
|
|
1448
2273
|
return {
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
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
|
|
1465
|
-
|
|
1466
|
-
|
|
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
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
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
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
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
|
|
1539
|
-
|
|
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
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
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
|
|
1547
|
-
if (!
|
|
1548
|
-
return
|
|
2461
|
+
async function loadOptionalGranolaCache(cacheFile) {
|
|
2462
|
+
if (!cacheFile || !existsSync(cacheFile)) return;
|
|
2463
|
+
return parseCacheContents(await readFile(cacheFile, "utf8"));
|
|
1549
2464
|
}
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
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
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
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
|
|
1565
|
-
const
|
|
1566
|
-
|
|
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
|
-
|
|
1569
|
-
|
|
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
|
|
1585
|
-
|
|
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
|
|
1613
|
-
const
|
|
1614
|
-
|
|
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
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
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
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
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
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
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
|
|
1675
|
-
return
|
|
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
|
`;
|