granola-toolkit 0.24.0 → 0.26.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 +38 -0
- package/dist/cli.js +1006 -51
- package/package.json +3 -1
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { execFile } from "node:child_process";
|
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
9
|
import { createHash, randomUUID } from "node:crypto";
|
|
10
10
|
import { createServer } from "node:http";
|
|
11
|
+
import { Input, ProcessTerminal, TUI, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
11
12
|
//#region src/utils.ts
|
|
12
13
|
const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g;
|
|
13
14
|
const CONTROL_CHARACTERS = /\p{Cc}/gu;
|
|
@@ -2355,6 +2356,22 @@ function parsePort(value) {
|
|
|
2355
2356
|
function pickHostname(value, fallback = "127.0.0.1") {
|
|
2356
2357
|
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
2357
2358
|
}
|
|
2359
|
+
function parseNetworkMode(value, fallback = "local") {
|
|
2360
|
+
switch (value) {
|
|
2361
|
+
case void 0: return fallback;
|
|
2362
|
+
case "lan":
|
|
2363
|
+
case "local": return value;
|
|
2364
|
+
default: throw new Error("invalid network mode: expected local or lan");
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
function resolveServerHostname(networkMode, hostnameFlag) {
|
|
2368
|
+
if (hostnameFlag !== void 0) return pickHostname(hostnameFlag, networkMode === "lan" ? "0.0.0.0" : "127.0.0.1");
|
|
2369
|
+
return networkMode === "lan" ? "0.0.0.0" : "127.0.0.1";
|
|
2370
|
+
}
|
|
2371
|
+
function parseTrustedOrigins(value) {
|
|
2372
|
+
if (typeof value !== "string" || !value.trim()) return [];
|
|
2373
|
+
return value.split(",").map((origin) => origin.trim()).filter(Boolean);
|
|
2374
|
+
}
|
|
2358
2375
|
async function waitForShutdown(close) {
|
|
2359
2376
|
await new Promise((resolve, reject) => {
|
|
2360
2377
|
let closing = false;
|
|
@@ -2845,6 +2862,8 @@ function resolveNoteFormat(value) {
|
|
|
2845
2862
|
//#endregion
|
|
2846
2863
|
//#region src/web/client-script.ts
|
|
2847
2864
|
const granolaWebClientScript = String.raw`
|
|
2865
|
+
const serverConfig = window.__GRANOLA_SERVER__ || { passwordRequired: false };
|
|
2866
|
+
|
|
2848
2867
|
const state = {
|
|
2849
2868
|
appState: null,
|
|
2850
2869
|
detailError: "",
|
|
@@ -2856,6 +2875,7 @@ const state = {
|
|
|
2856
2875
|
selectedMeetingBundle: null,
|
|
2857
2876
|
selectedMeetingId: null,
|
|
2858
2877
|
meetingSource: "live",
|
|
2878
|
+
serverLocked: Boolean(serverConfig.passwordRequired),
|
|
2859
2879
|
sort: "updated-desc",
|
|
2860
2880
|
updatedFrom: "",
|
|
2861
2881
|
updatedTo: "",
|
|
@@ -2875,9 +2895,13 @@ const els = {
|
|
|
2875
2895
|
quickOpenButton: document.querySelector("[data-quick-open-button]"),
|
|
2876
2896
|
refreshButton: document.querySelector("[data-refresh]"),
|
|
2877
2897
|
search: document.querySelector("[data-search]"),
|
|
2898
|
+
securityPanel: document.querySelector("[data-security-panel]"),
|
|
2899
|
+
serverPassword: document.querySelector("[data-server-password]"),
|
|
2900
|
+
lockServerButton: document.querySelector("[data-lock-server]"),
|
|
2878
2901
|
sort: document.querySelector("[data-sort]"),
|
|
2879
2902
|
stateBadge: document.querySelector("[data-state-badge]"),
|
|
2880
2903
|
transcriptButton: document.querySelector("[data-export-transcripts]"),
|
|
2904
|
+
unlockServerButton: document.querySelector("[data-unlock-server]"),
|
|
2881
2905
|
updatedFrom: document.querySelector("[data-updated-from]"),
|
|
2882
2906
|
updatedTo: document.querySelector("[data-updated-to]"),
|
|
2883
2907
|
workspaceTabs: document.querySelectorAll("[data-workspace-tab]"),
|
|
@@ -2932,6 +2956,7 @@ function renderAppState() {
|
|
|
2932
2956
|
if (!state.appState) {
|
|
2933
2957
|
els.appState.innerHTML = "<p>Waiting for server state…</p>";
|
|
2934
2958
|
els.authPanel.innerHTML = "<p>Waiting for auth state…</p>";
|
|
2959
|
+
renderSecurityPanel();
|
|
2935
2960
|
return;
|
|
2936
2961
|
}
|
|
2937
2962
|
|
|
@@ -2960,10 +2985,15 @@ function renderAppState() {
|
|
|
2960
2985
|
"</div>",
|
|
2961
2986
|
].join("");
|
|
2962
2987
|
|
|
2988
|
+
renderSecurityPanel();
|
|
2963
2989
|
renderAuthPanel();
|
|
2964
2990
|
renderExportJobs();
|
|
2965
2991
|
}
|
|
2966
2992
|
|
|
2993
|
+
function renderSecurityPanel() {
|
|
2994
|
+
els.securityPanel.hidden = !state.serverLocked;
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2967
2997
|
function authActionButton(label, action, disabled) {
|
|
2968
2998
|
return (
|
|
2969
2999
|
'<button class="button button--secondary" data-auth-action="' +
|
|
@@ -3181,7 +3211,14 @@ async function fetchJson(path, init) {
|
|
|
3181
3211
|
const response = await fetch(path, init);
|
|
3182
3212
|
const payload = await response.json().catch(() => ({}));
|
|
3183
3213
|
if (!response.ok) {
|
|
3184
|
-
|
|
3214
|
+
if (payload.authRequired) {
|
|
3215
|
+
state.serverLocked = true;
|
|
3216
|
+
renderSecurityPanel();
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
const error = new Error(payload.error || response.statusText || "Request failed");
|
|
3220
|
+
error.authRequired = Boolean(payload.authRequired);
|
|
3221
|
+
throw error;
|
|
3185
3222
|
}
|
|
3186
3223
|
return payload;
|
|
3187
3224
|
}
|
|
@@ -3289,17 +3326,28 @@ async function quickOpenMeeting() {
|
|
|
3289
3326
|
|
|
3290
3327
|
async function refreshAll(forceLiveMeetings = false) {
|
|
3291
3328
|
setStatus("Refreshing…", "busy");
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3329
|
+
try {
|
|
3330
|
+
const [appState, authState] = await Promise.all([
|
|
3331
|
+
fetchJson("/state"),
|
|
3332
|
+
fetchJson("/auth/status"),
|
|
3333
|
+
loadMeetings({ refresh: forceLiveMeetings }),
|
|
3334
|
+
]);
|
|
3335
|
+
state.serverLocked = false;
|
|
3336
|
+
state.appState = {
|
|
3337
|
+
...appState,
|
|
3338
|
+
auth: authState,
|
|
3339
|
+
};
|
|
3340
|
+
renderAppState();
|
|
3341
|
+
setStatus(forceLiveMeetings ? "Live data refreshed" : state.meetingSource === "index" ? "Loaded from index" : "Connected", "ok");
|
|
3342
|
+
} catch (error) {
|
|
3343
|
+
if (error.authRequired) {
|
|
3344
|
+
setStatus("Server locked", "error");
|
|
3345
|
+
renderSecurityPanel();
|
|
3346
|
+
return;
|
|
3347
|
+
}
|
|
3348
|
+
|
|
3349
|
+
throw error;
|
|
3350
|
+
}
|
|
3303
3351
|
}
|
|
3304
3352
|
|
|
3305
3353
|
async function syncAuthState() {
|
|
@@ -3401,6 +3449,50 @@ async function switchAuthMode(mode) {
|
|
|
3401
3449
|
}
|
|
3402
3450
|
}
|
|
3403
3451
|
|
|
3452
|
+
async function unlockServer() {
|
|
3453
|
+
const password = els.serverPassword.value;
|
|
3454
|
+
if (!password.trim()) {
|
|
3455
|
+
setStatus("Enter the server password", "error");
|
|
3456
|
+
return;
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
setStatus("Unlocking server…", "busy");
|
|
3460
|
+
try {
|
|
3461
|
+
await fetchJson("/auth/unlock", {
|
|
3462
|
+
body: JSON.stringify({ password }),
|
|
3463
|
+
headers: { "content-type": "application/json" },
|
|
3464
|
+
method: "POST",
|
|
3465
|
+
});
|
|
3466
|
+
els.serverPassword.value = "";
|
|
3467
|
+
state.serverLocked = false;
|
|
3468
|
+
await refreshAll(true);
|
|
3469
|
+
} catch (error) {
|
|
3470
|
+
setStatus("Unlock failed", "error");
|
|
3471
|
+
state.detailError = error instanceof Error ? error.message : String(error);
|
|
3472
|
+
renderMeetingDetail();
|
|
3473
|
+
}
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3476
|
+
async function lockServer() {
|
|
3477
|
+
try {
|
|
3478
|
+
await fetchJson("/auth/lock", {
|
|
3479
|
+
method: "POST",
|
|
3480
|
+
});
|
|
3481
|
+
} catch {}
|
|
3482
|
+
|
|
3483
|
+
state.serverLocked = true;
|
|
3484
|
+
state.appState = null;
|
|
3485
|
+
state.meetings = [];
|
|
3486
|
+
state.selectedMeeting = null;
|
|
3487
|
+
state.selectedMeetingBundle = null;
|
|
3488
|
+
state.detailError = "";
|
|
3489
|
+
els.serverPassword.value = "";
|
|
3490
|
+
renderSecurityPanel();
|
|
3491
|
+
renderMeetingList();
|
|
3492
|
+
renderMeetingDetail();
|
|
3493
|
+
setStatus("Server locked", "error");
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3404
3496
|
els.list.addEventListener("click", (event) => {
|
|
3405
3497
|
if (!(event.target instanceof Element)) {
|
|
3406
3498
|
return;
|
|
@@ -3454,6 +3546,25 @@ els.authPanel.addEventListener("click", (event) => {
|
|
|
3454
3546
|
void switchAuthMode(modeButton.dataset.authMode);
|
|
3455
3547
|
});
|
|
3456
3548
|
|
|
3549
|
+
els.unlockServerButton.addEventListener("click", () => {
|
|
3550
|
+
void unlockServer();
|
|
3551
|
+
});
|
|
3552
|
+
|
|
3553
|
+
els.lockServerButton.addEventListener("click", () => {
|
|
3554
|
+
void lockServer();
|
|
3555
|
+
});
|
|
3556
|
+
|
|
3557
|
+
els.serverPassword.addEventListener("keydown", (event) => {
|
|
3558
|
+
if (!(event.target instanceof HTMLInputElement)) {
|
|
3559
|
+
return;
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
if (event.key === "Enter") {
|
|
3563
|
+
event.preventDefault();
|
|
3564
|
+
void unlockServer();
|
|
3565
|
+
}
|
|
3566
|
+
});
|
|
3567
|
+
|
|
3457
3568
|
els.refreshButton.addEventListener("click", () => {
|
|
3458
3569
|
void refreshAll(true);
|
|
3459
3570
|
});
|
|
@@ -3600,6 +3711,7 @@ events.addEventListener("error", () => {
|
|
|
3600
3711
|
});
|
|
3601
3712
|
|
|
3602
3713
|
syncFilterInputs();
|
|
3714
|
+
renderSecurityPanel();
|
|
3603
3715
|
|
|
3604
3716
|
void refreshAll().catch((error) => {
|
|
3605
3717
|
setStatus("Error", "error");
|
|
@@ -3663,6 +3775,19 @@ const granolaWebMarkup = String.raw`
|
|
|
3663
3775
|
</div>
|
|
3664
3776
|
<p>Initial beta web client. It speaks to the same local API that future TUI and attach flows will use.</p>
|
|
3665
3777
|
</section>
|
|
3778
|
+
<section class="security-panel" data-security-panel hidden>
|
|
3779
|
+
<div class="security-panel__head">
|
|
3780
|
+
<h3>Server Access</h3>
|
|
3781
|
+
<p>This server is locked with a password. Unlock it to load meetings and live state.</p>
|
|
3782
|
+
</div>
|
|
3783
|
+
<div class="security-panel__body">
|
|
3784
|
+
<input class="field-input" data-server-password type="password" placeholder="Server password" />
|
|
3785
|
+
<div class="toolbar-actions">
|
|
3786
|
+
<button class="button button--primary" data-unlock-server>Unlock</button>
|
|
3787
|
+
<button class="button button--secondary" data-lock-server>Lock</button>
|
|
3788
|
+
</div>
|
|
3789
|
+
</div>
|
|
3790
|
+
</section>
|
|
3666
3791
|
<section class="auth-panel">
|
|
3667
3792
|
<div class="auth-panel__head">
|
|
3668
3793
|
<h3>Auth Session</h3>
|
|
@@ -3900,10 +4025,12 @@ body {
|
|
|
3900
4025
|
}
|
|
3901
4026
|
|
|
3902
4027
|
.auth-panel,
|
|
4028
|
+
.security-panel,
|
|
3903
4029
|
.jobs-panel {
|
|
3904
4030
|
padding: 0 24px 18px;
|
|
3905
4031
|
}
|
|
3906
4032
|
|
|
4033
|
+
.security-panel__head h3,
|
|
3907
4034
|
.auth-panel__head h3,
|
|
3908
4035
|
.jobs-panel__head h3 {
|
|
3909
4036
|
margin: 0;
|
|
@@ -3912,6 +4039,7 @@ body {
|
|
|
3912
4039
|
text-transform: uppercase;
|
|
3913
4040
|
}
|
|
3914
4041
|
|
|
4042
|
+
.security-panel__head p,
|
|
3915
4043
|
.auth-panel__head p,
|
|
3916
4044
|
.jobs-panel__head p {
|
|
3917
4045
|
margin: 6px 0 0;
|
|
@@ -3919,6 +4047,7 @@ body {
|
|
|
3919
4047
|
font-size: 0.9rem;
|
|
3920
4048
|
}
|
|
3921
4049
|
|
|
4050
|
+
.security-panel__body,
|
|
3922
4051
|
.auth-panel__body {
|
|
3923
4052
|
display: grid;
|
|
3924
4053
|
gap: 12px;
|
|
@@ -4169,7 +4298,7 @@ body {
|
|
|
4169
4298
|
`;
|
|
4170
4299
|
//#endregion
|
|
4171
4300
|
//#region src/server/web.ts
|
|
4172
|
-
function renderGranolaWebPage() {
|
|
4301
|
+
function renderGranolaWebPage(options = {}) {
|
|
4173
4302
|
return `<!doctype html>
|
|
4174
4303
|
<html lang="en">
|
|
4175
4304
|
<head>
|
|
@@ -4183,6 +4312,7 @@ ${granolaWebStyles}
|
|
|
4183
4312
|
<body>
|
|
4184
4313
|
${granolaWebMarkup}
|
|
4185
4314
|
<script type="module">
|
|
4315
|
+
window.__GRANOLA_SERVER__ = ${JSON.stringify({ passwordRequired: options.serverPasswordRequired ?? false })};
|
|
4186
4316
|
${granolaWebClientScript}
|
|
4187
4317
|
<\/script>
|
|
4188
4318
|
</body>
|
|
@@ -4190,6 +4320,7 @@ ${granolaWebClientScript}
|
|
|
4190
4320
|
}
|
|
4191
4321
|
//#endregion
|
|
4192
4322
|
//#region src/server/http.ts
|
|
4323
|
+
const PASSWORD_COOKIE_NAME = "granola_toolkit_password";
|
|
4193
4324
|
function parseInteger(value) {
|
|
4194
4325
|
if (!value?.trim()) return;
|
|
4195
4326
|
if (!/^\d+$/.test(value)) throw new Error("invalid limit: expected a positive integer");
|
|
@@ -4219,24 +4350,31 @@ function sendJson(response, body, init = {}) {
|
|
|
4219
4350
|
const payload = `${JSON.stringify(body, null, 2)}\n`;
|
|
4220
4351
|
response.writeHead(init.status ?? 200, {
|
|
4221
4352
|
"content-length": Buffer.byteLength(payload),
|
|
4222
|
-
"content-type": "application/json; charset=utf-8"
|
|
4353
|
+
"content-type": "application/json; charset=utf-8",
|
|
4354
|
+
...init.headers
|
|
4223
4355
|
});
|
|
4224
4356
|
response.end(payload);
|
|
4225
4357
|
}
|
|
4226
|
-
function sendText(response, body, status = 200) {
|
|
4358
|
+
function sendText(response, body, status = 200, headers = {}) {
|
|
4227
4359
|
response.writeHead(status, {
|
|
4228
4360
|
"content-length": Buffer.byteLength(body),
|
|
4229
|
-
"content-type": "text/plain; charset=utf-8"
|
|
4361
|
+
"content-type": "text/plain; charset=utf-8",
|
|
4362
|
+
...headers
|
|
4230
4363
|
});
|
|
4231
4364
|
response.end(body);
|
|
4232
4365
|
}
|
|
4233
|
-
function sendHtml(response, body, status = 200) {
|
|
4366
|
+
function sendHtml(response, body, status = 200, headers = {}) {
|
|
4234
4367
|
response.writeHead(status, {
|
|
4235
4368
|
"content-length": Buffer.byteLength(body),
|
|
4236
|
-
"content-type": "text/html; charset=utf-8"
|
|
4369
|
+
"content-type": "text/html; charset=utf-8",
|
|
4370
|
+
...headers
|
|
4237
4371
|
});
|
|
4238
4372
|
response.end(body);
|
|
4239
4373
|
}
|
|
4374
|
+
function sendNoContent(response, status = 204, headers = {}) {
|
|
4375
|
+
response.writeHead(status, headers);
|
|
4376
|
+
response.end();
|
|
4377
|
+
}
|
|
4240
4378
|
async function readJsonBody(request) {
|
|
4241
4379
|
const chunks = [];
|
|
4242
4380
|
for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
@@ -4272,17 +4410,90 @@ function transcriptFormatFromBody(value) {
|
|
|
4272
4410
|
default: throw new Error("invalid transcript format: expected text, json, yaml, or raw");
|
|
4273
4411
|
}
|
|
4274
4412
|
}
|
|
4413
|
+
function parseCookies(request) {
|
|
4414
|
+
const header = request.headers.cookie;
|
|
4415
|
+
if (!header) return {};
|
|
4416
|
+
const cookies = {};
|
|
4417
|
+
for (const chunk of header.split(";")) {
|
|
4418
|
+
const [name, ...valueParts] = chunk.trim().split("=");
|
|
4419
|
+
if (!name) continue;
|
|
4420
|
+
cookies[name] = decodeURIComponent(valueParts.join("="));
|
|
4421
|
+
}
|
|
4422
|
+
return cookies;
|
|
4423
|
+
}
|
|
4424
|
+
function passwordCookieHeader(password) {
|
|
4425
|
+
return `${PASSWORD_COOKIE_NAME}=${encodeURIComponent(password)}; HttpOnly; Path=/; SameSite=Strict`;
|
|
4426
|
+
}
|
|
4427
|
+
function clearPasswordCookieHeader() {
|
|
4428
|
+
return `${PASSWORD_COOKIE_NAME}=; HttpOnly; Path=/; Max-Age=0; SameSite=Strict`;
|
|
4429
|
+
}
|
|
4430
|
+
function allowedOriginHeaders(origin) {
|
|
4431
|
+
return {
|
|
4432
|
+
"access-control-allow-credentials": "true",
|
|
4433
|
+
"access-control-allow-headers": "content-type, x-granola-password",
|
|
4434
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
4435
|
+
"access-control-allow-origin": origin,
|
|
4436
|
+
vary: "Origin"
|
|
4437
|
+
};
|
|
4438
|
+
}
|
|
4439
|
+
function isTrustedOrigin(origin, request, trustedOrigins) {
|
|
4440
|
+
if (!origin) return true;
|
|
4441
|
+
try {
|
|
4442
|
+
const parsed = new URL(origin);
|
|
4443
|
+
const host = request.headers.host;
|
|
4444
|
+
if (host && parsed.host === host) return true;
|
|
4445
|
+
} catch {
|
|
4446
|
+
return false;
|
|
4447
|
+
}
|
|
4448
|
+
return trustedOrigins.includes(origin);
|
|
4449
|
+
}
|
|
4450
|
+
function isPasswordAuthenticated(request, password) {
|
|
4451
|
+
const headerPassword = request.headers["x-granola-password"];
|
|
4452
|
+
if (typeof headerPassword === "string" && headerPassword === password) return true;
|
|
4453
|
+
const authorization = request.headers.authorization;
|
|
4454
|
+
if (authorization?.startsWith("Bearer ")) return authorization.slice(7) === password;
|
|
4455
|
+
return parseCookies(request)[PASSWORD_COOKIE_NAME] === password;
|
|
4456
|
+
}
|
|
4457
|
+
function publicRoute(path, enableWebClient) {
|
|
4458
|
+
return path === "/health" || path === "/auth/unlock" || enableWebClient && path === "/";
|
|
4459
|
+
}
|
|
4275
4460
|
async function startGranolaServer(app, options = {}) {
|
|
4276
4461
|
const enableWebClient = options.enableWebClient ?? false;
|
|
4277
4462
|
const hostname = options.hostname ?? "127.0.0.1";
|
|
4278
4463
|
const port = options.port ?? 0;
|
|
4464
|
+
const security = {
|
|
4465
|
+
password: options.security?.password?.trim() || void 0,
|
|
4466
|
+
trustedOrigins: (options.security?.trustedOrigins ?? []).map((origin) => origin.trim()).filter(Boolean)
|
|
4467
|
+
};
|
|
4279
4468
|
const server = createServer(async (request, response) => {
|
|
4280
4469
|
const method = request.method ?? "GET";
|
|
4281
4470
|
const url = new URL(request.url ?? "/", `http://${hostname}`);
|
|
4282
4471
|
const path = url.pathname;
|
|
4472
|
+
const origin = request.headers.origin?.trim();
|
|
4473
|
+
const trustedOrigin = isTrustedOrigin(origin, request, security.trustedOrigins);
|
|
4474
|
+
const originHeaders = origin && trustedOrigin ? allowedOriginHeaders(origin) : {};
|
|
4283
4475
|
try {
|
|
4476
|
+
if (origin && !trustedOrigin) {
|
|
4477
|
+
sendJson(response, { error: `origin not trusted: ${origin}` }, {
|
|
4478
|
+
headers: originHeaders,
|
|
4479
|
+
status: 403
|
|
4480
|
+
});
|
|
4481
|
+
return;
|
|
4482
|
+
}
|
|
4483
|
+
if (method === "OPTIONS") {
|
|
4484
|
+
if (!origin) {
|
|
4485
|
+
sendNoContent(response, 204);
|
|
4486
|
+
return;
|
|
4487
|
+
}
|
|
4488
|
+
if (!trustedOrigin) {
|
|
4489
|
+
sendNoContent(response, 403);
|
|
4490
|
+
return;
|
|
4491
|
+
}
|
|
4492
|
+
sendNoContent(response, 204, originHeaders);
|
|
4493
|
+
return;
|
|
4494
|
+
}
|
|
4284
4495
|
if (method === "GET" && path === "/" && enableWebClient) {
|
|
4285
|
-
sendHtml(response, renderGranolaWebPage());
|
|
4496
|
+
sendHtml(response, renderGranolaWebPage({ serverPasswordRequired: Boolean(security.password) }), 200, originHeaders);
|
|
4286
4497
|
return;
|
|
4287
4498
|
}
|
|
4288
4499
|
if (method === "GET" && path === "/health") {
|
|
@@ -4290,22 +4501,69 @@ async function startGranolaServer(app, options = {}) {
|
|
|
4290
4501
|
ok: true,
|
|
4291
4502
|
service: "granola-toolkit",
|
|
4292
4503
|
version: app.config ? void 0 : void 0
|
|
4504
|
+
}, { headers: originHeaders });
|
|
4505
|
+
return;
|
|
4506
|
+
}
|
|
4507
|
+
if (method === "POST" && path === "/auth/unlock") {
|
|
4508
|
+
if (!security.password) {
|
|
4509
|
+
sendJson(response, {
|
|
4510
|
+
ok: true,
|
|
4511
|
+
passwordRequired: false
|
|
4512
|
+
}, { headers: originHeaders });
|
|
4513
|
+
return;
|
|
4514
|
+
}
|
|
4515
|
+
const body = await readJsonBody(request);
|
|
4516
|
+
const password = typeof body.password === "string" && body.password.trim() ? body.password : void 0;
|
|
4517
|
+
if (!password || password !== security.password) {
|
|
4518
|
+
sendJson(response, {
|
|
4519
|
+
authRequired: true,
|
|
4520
|
+
error: "invalid server password"
|
|
4521
|
+
}, {
|
|
4522
|
+
headers: originHeaders,
|
|
4523
|
+
status: 401
|
|
4524
|
+
});
|
|
4525
|
+
return;
|
|
4526
|
+
}
|
|
4527
|
+
sendJson(response, {
|
|
4528
|
+
ok: true,
|
|
4529
|
+
passwordRequired: true
|
|
4530
|
+
}, { headers: {
|
|
4531
|
+
...originHeaders,
|
|
4532
|
+
"set-cookie": passwordCookieHeader(security.password)
|
|
4533
|
+
} });
|
|
4534
|
+
return;
|
|
4535
|
+
}
|
|
4536
|
+
if (security.password && !publicRoute(path, enableWebClient) && !isPasswordAuthenticated(request, security.password)) {
|
|
4537
|
+
sendJson(response, {
|
|
4538
|
+
authRequired: true,
|
|
4539
|
+
error: "server password required"
|
|
4540
|
+
}, {
|
|
4541
|
+
headers: originHeaders,
|
|
4542
|
+
status: 401
|
|
4293
4543
|
});
|
|
4294
4544
|
return;
|
|
4295
4545
|
}
|
|
4296
4546
|
if (method === "GET" && path === "/state") {
|
|
4297
|
-
sendJson(response, app.getState());
|
|
4547
|
+
sendJson(response, app.getState(), { headers: originHeaders });
|
|
4298
4548
|
return;
|
|
4299
4549
|
}
|
|
4300
4550
|
if (method === "GET" && path === "/auth/status") {
|
|
4301
|
-
sendJson(response, await app.inspectAuth());
|
|
4551
|
+
sendJson(response, await app.inspectAuth(), { headers: originHeaders });
|
|
4552
|
+
return;
|
|
4553
|
+
}
|
|
4554
|
+
if (method === "POST" && path === "/auth/lock") {
|
|
4555
|
+
sendJson(response, { ok: true }, { headers: {
|
|
4556
|
+
...originHeaders,
|
|
4557
|
+
"set-cookie": clearPasswordCookieHeader()
|
|
4558
|
+
} });
|
|
4302
4559
|
return;
|
|
4303
4560
|
}
|
|
4304
4561
|
if (method === "GET" && path === "/events") {
|
|
4305
4562
|
response.writeHead(200, {
|
|
4306
4563
|
"cache-control": "no-cache, no-transform",
|
|
4307
4564
|
connection: "keep-alive",
|
|
4308
|
-
"content-type": "text/event-stream; charset=utf-8"
|
|
4565
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
4566
|
+
...originHeaders
|
|
4309
4567
|
});
|
|
4310
4568
|
response.write(formatSseEvent({
|
|
4311
4569
|
state: app.getState(),
|
|
@@ -4344,64 +4602,76 @@ async function startGranolaServer(app, options = {}) {
|
|
|
4344
4602
|
sort,
|
|
4345
4603
|
updatedFrom,
|
|
4346
4604
|
updatedTo
|
|
4347
|
-
});
|
|
4605
|
+
}, { headers: originHeaders });
|
|
4348
4606
|
return;
|
|
4349
4607
|
}
|
|
4350
4608
|
if (method === "GET" && path === "/meetings/resolve") {
|
|
4351
4609
|
const query = url.searchParams.get("q")?.trim();
|
|
4352
4610
|
if (!query) throw new Error("meeting query is required");
|
|
4353
|
-
sendJson(response, await app.findMeeting(query, { requireCache: url.searchParams.get("includeTranscript") === "true" }));
|
|
4611
|
+
sendJson(response, await app.findMeeting(query, { requireCache: url.searchParams.get("includeTranscript") === "true" }), { headers: originHeaders });
|
|
4354
4612
|
return;
|
|
4355
4613
|
}
|
|
4356
4614
|
if (method === "GET" && path.startsWith("/meetings/")) {
|
|
4357
4615
|
const id = decodeURIComponent(path.slice(10));
|
|
4358
4616
|
if (!id) throw new Error("meeting id is required");
|
|
4359
|
-
sendJson(response, await app.getMeeting(id, { requireCache: url.searchParams.get("includeTranscript") === "true" }));
|
|
4617
|
+
sendJson(response, await app.getMeeting(id, { requireCache: url.searchParams.get("includeTranscript") === "true" }), { headers: originHeaders });
|
|
4360
4618
|
return;
|
|
4361
4619
|
}
|
|
4362
4620
|
if (method === "POST" && path === "/auth/login") {
|
|
4363
4621
|
const body = await readJsonBody(request);
|
|
4364
4622
|
const supabasePath = typeof body.supabasePath === "string" && body.supabasePath.trim() ? body.supabasePath.trim() : void 0;
|
|
4365
|
-
sendJson(response, await app.loginAuth({ supabasePath }));
|
|
4623
|
+
sendJson(response, await app.loginAuth({ supabasePath }), { headers: originHeaders });
|
|
4366
4624
|
return;
|
|
4367
4625
|
}
|
|
4368
4626
|
if (method === "POST" && path === "/auth/logout") {
|
|
4369
|
-
sendJson(response, await app.logoutAuth());
|
|
4627
|
+
sendJson(response, await app.logoutAuth(), { headers: originHeaders });
|
|
4370
4628
|
return;
|
|
4371
4629
|
}
|
|
4372
4630
|
if (method === "POST" && path === "/auth/refresh") {
|
|
4373
|
-
sendJson(response, await app.refreshAuth());
|
|
4631
|
+
sendJson(response, await app.refreshAuth(), { headers: originHeaders });
|
|
4374
4632
|
return;
|
|
4375
4633
|
}
|
|
4376
4634
|
if (method === "POST" && path === "/auth/mode") {
|
|
4377
4635
|
const body = await readJsonBody(request);
|
|
4378
|
-
sendJson(response, await app.switchAuthMode(parseAuthMode(body.mode)));
|
|
4636
|
+
sendJson(response, await app.switchAuthMode(parseAuthMode(body.mode)), { headers: originHeaders });
|
|
4379
4637
|
return;
|
|
4380
4638
|
}
|
|
4381
4639
|
if (method === "POST" && path === "/exports/notes") {
|
|
4382
4640
|
const body = await readJsonBody(request);
|
|
4383
|
-
sendJson(response, await app.exportNotes(noteFormatFromBody(body.format)), {
|
|
4641
|
+
sendJson(response, await app.exportNotes(noteFormatFromBody(body.format)), {
|
|
4642
|
+
headers: originHeaders,
|
|
4643
|
+
status: 202
|
|
4644
|
+
});
|
|
4384
4645
|
return;
|
|
4385
4646
|
}
|
|
4386
4647
|
if (method === "GET" && path === "/exports/jobs") {
|
|
4387
4648
|
const limit = parseInteger(url.searchParams.get("limit"));
|
|
4388
|
-
sendJson(response, await app.listExportJobs({ limit }));
|
|
4649
|
+
sendJson(response, await app.listExportJobs({ limit }), { headers: originHeaders });
|
|
4389
4650
|
return;
|
|
4390
4651
|
}
|
|
4391
4652
|
if (method === "POST" && path.startsWith("/exports/jobs/") && path.endsWith("/rerun")) {
|
|
4392
4653
|
const id = decodeURIComponent(path.slice(14, -6));
|
|
4393
4654
|
if (!id) throw new Error("export job id is required");
|
|
4394
|
-
sendJson(response, await app.rerunExportJob(id), {
|
|
4655
|
+
sendJson(response, await app.rerunExportJob(id), {
|
|
4656
|
+
headers: originHeaders,
|
|
4657
|
+
status: 202
|
|
4658
|
+
});
|
|
4395
4659
|
return;
|
|
4396
4660
|
}
|
|
4397
4661
|
if (method === "POST" && path === "/exports/transcripts") {
|
|
4398
4662
|
const body = await readJsonBody(request);
|
|
4399
|
-
sendJson(response, await app.exportTranscripts(transcriptFormatFromBody(body.format)), {
|
|
4663
|
+
sendJson(response, await app.exportTranscripts(transcriptFormatFromBody(body.format)), {
|
|
4664
|
+
headers: originHeaders,
|
|
4665
|
+
status: 202
|
|
4666
|
+
});
|
|
4400
4667
|
return;
|
|
4401
4668
|
}
|
|
4402
|
-
sendText(response, "Not found\n", 404);
|
|
4669
|
+
sendText(response, "Not found\n", 404, originHeaders);
|
|
4403
4670
|
} catch (error) {
|
|
4404
|
-
sendJson(response, { error: error instanceof Error ? error.message : String(error) }, {
|
|
4671
|
+
sendJson(response, { error: error instanceof Error ? error.message : String(error) }, {
|
|
4672
|
+
headers: originHeaders,
|
|
4673
|
+
status: 400
|
|
4674
|
+
});
|
|
4405
4675
|
}
|
|
4406
4676
|
});
|
|
4407
4677
|
await new Promise((resolve, reject) => {
|
|
@@ -4443,14 +4713,17 @@ Usage:
|
|
|
4443
4713
|
granola serve [options]
|
|
4444
4714
|
|
|
4445
4715
|
Options:
|
|
4446
|
-
--
|
|
4447
|
-
--
|
|
4448
|
-
--
|
|
4449
|
-
--
|
|
4450
|
-
--
|
|
4451
|
-
--
|
|
4452
|
-
--
|
|
4453
|
-
|
|
4716
|
+
--network <mode> Network mode: local or lan (default: local)
|
|
4717
|
+
--hostname <value> Hostname to bind (overrides network default)
|
|
4718
|
+
--port <value> Port to bind (default: 0 for any available port)
|
|
4719
|
+
--password <value> Optional server password for API and browser access
|
|
4720
|
+
--trusted-origins <v> Comma-separated extra browser origins to trust
|
|
4721
|
+
--cache <path> Path to Granola cache JSON
|
|
4722
|
+
--timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
|
|
4723
|
+
--supabase <path> Path to supabase.json
|
|
4724
|
+
--debug Enable debug logging
|
|
4725
|
+
--config <path> Path to .granola.toml
|
|
4726
|
+
-h, --help Show help
|
|
4454
4727
|
`;
|
|
4455
4728
|
}
|
|
4456
4729
|
const serveCommand = {
|
|
@@ -4459,8 +4732,11 @@ const serveCommand = {
|
|
|
4459
4732
|
cache: { type: "string" },
|
|
4460
4733
|
help: { type: "boolean" },
|
|
4461
4734
|
hostname: { type: "string" },
|
|
4735
|
+
network: { type: "string" },
|
|
4736
|
+
password: { type: "string" },
|
|
4462
4737
|
port: { type: "string" },
|
|
4463
|
-
timeout: { type: "string" }
|
|
4738
|
+
timeout: { type: "string" },
|
|
4739
|
+
"trusted-origins": { type: "string" }
|
|
4464
4740
|
},
|
|
4465
4741
|
help: serveHelp,
|
|
4466
4742
|
name: "serve",
|
|
@@ -4473,13 +4749,29 @@ const serveCommand = {
|
|
|
4473
4749
|
debug(config.debug, "supabase", config.supabase);
|
|
4474
4750
|
debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
|
|
4475
4751
|
debug(config.debug, "timeoutMs", config.notes.timeoutMs);
|
|
4476
|
-
const
|
|
4477
|
-
|
|
4478
|
-
|
|
4752
|
+
const app = await createGranolaApp(config, { surface: "server" });
|
|
4753
|
+
const networkMode = parseNetworkMode(commandFlags.network);
|
|
4754
|
+
const hostname = resolveServerHostname(networkMode, commandFlags.hostname);
|
|
4755
|
+
const port = parsePort(commandFlags.port);
|
|
4756
|
+
const password = typeof commandFlags.password === "string" && commandFlags.password.trim() ? commandFlags.password : void 0;
|
|
4757
|
+
const trustedOrigins = parseTrustedOrigins(commandFlags["trusted-origins"]);
|
|
4758
|
+
const server = await startGranolaServer(app, {
|
|
4759
|
+
hostname,
|
|
4760
|
+
port,
|
|
4761
|
+
security: {
|
|
4762
|
+
password,
|
|
4763
|
+
trustedOrigins
|
|
4764
|
+
}
|
|
4479
4765
|
});
|
|
4480
4766
|
console.log(`Granola server listening on ${server.url.href}`);
|
|
4767
|
+
console.log(`Network mode: ${networkMode}`);
|
|
4768
|
+
if (password) console.log("Server password protection: enabled");
|
|
4769
|
+
else if (networkMode === "lan") console.log("Warning: LAN mode is enabled without a server password");
|
|
4770
|
+
if (trustedOrigins.length > 0) console.log(`Trusted origins: ${trustedOrigins.join(", ")}`);
|
|
4481
4771
|
console.log("Endpoints:");
|
|
4482
4772
|
console.log(" GET /health");
|
|
4773
|
+
console.log(" POST /auth/unlock");
|
|
4774
|
+
console.log(" POST /auth/lock");
|
|
4483
4775
|
console.log(" GET /auth/status");
|
|
4484
4776
|
console.log(" GET /state");
|
|
4485
4777
|
console.log(" GET /events");
|
|
@@ -4498,6 +4790,649 @@ const serveCommand = {
|
|
|
4498
4790
|
}
|
|
4499
4791
|
};
|
|
4500
4792
|
//#endregion
|
|
4793
|
+
//#region src/tui/helpers.ts
|
|
4794
|
+
function splitQuery(query) {
|
|
4795
|
+
return query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
4796
|
+
}
|
|
4797
|
+
function scoreMeetingTerm(meeting, term) {
|
|
4798
|
+
const title = meeting.title.toLowerCase();
|
|
4799
|
+
const id = meeting.id.toLowerCase();
|
|
4800
|
+
const tags = meeting.tags.map((tag) => tag.toLowerCase());
|
|
4801
|
+
if (title === term || id === term) return 0;
|
|
4802
|
+
if (title.startsWith(term)) return 1;
|
|
4803
|
+
if (id.startsWith(term)) return 2;
|
|
4804
|
+
if (title.includes(term)) return 3;
|
|
4805
|
+
if (id.includes(term)) return 4;
|
|
4806
|
+
if (tags.some((tag) => tag.includes(term))) return 5;
|
|
4807
|
+
}
|
|
4808
|
+
function buildGranolaTuiQuickOpenItems(meetings, query) {
|
|
4809
|
+
const terms = splitQuery(query);
|
|
4810
|
+
return meetings.map((meeting) => {
|
|
4811
|
+
const score = terms.reduce((current, term) => {
|
|
4812
|
+
const termScore = scoreMeetingTerm(meeting, term);
|
|
4813
|
+
if (termScore === void 0) return;
|
|
4814
|
+
return (current ?? 0) + termScore;
|
|
4815
|
+
}, 0);
|
|
4816
|
+
if (terms.length > 0 && score === void 0) return;
|
|
4817
|
+
const tags = meeting.tags.length > 0 ? meeting.tags.map((tag) => `#${tag}`).join(" ") : "untagged";
|
|
4818
|
+
return {
|
|
4819
|
+
description: `${meeting.updatedAt.slice(0, 10)} | ${tags} | ${meeting.id}`,
|
|
4820
|
+
id: meeting.id,
|
|
4821
|
+
label: meeting.title || meeting.id,
|
|
4822
|
+
score: score ?? 99
|
|
4823
|
+
};
|
|
4824
|
+
}).filter((item) => item !== void 0).sort((left, right) => {
|
|
4825
|
+
if (left.score !== right.score) return left.score - right.score;
|
|
4826
|
+
if (left.description !== right.description) return right.description.localeCompare(left.description);
|
|
4827
|
+
return left.label.localeCompare(right.label);
|
|
4828
|
+
});
|
|
4829
|
+
}
|
|
4830
|
+
function renderGranolaTuiMeetingTab(bundle, tab) {
|
|
4831
|
+
const summary = bundle.meeting.meeting;
|
|
4832
|
+
switch (tab) {
|
|
4833
|
+
case "metadata": return [
|
|
4834
|
+
`Title: ${summary.title || summary.id}`,
|
|
4835
|
+
`ID: ${summary.id}`,
|
|
4836
|
+
`Created: ${summary.createdAt}`,
|
|
4837
|
+
`Updated: ${summary.updatedAt}`,
|
|
4838
|
+
`Tags: ${summary.tags.length > 0 ? summary.tags.join(", ") : "none"}`,
|
|
4839
|
+
`Notes source: ${summary.noteContentSource}`,
|
|
4840
|
+
`Transcript loaded: ${summary.transcriptLoaded ? "yes" : "no"}`,
|
|
4841
|
+
`Transcript segments: ${summary.transcriptSegmentCount}`
|
|
4842
|
+
].join("\n");
|
|
4843
|
+
case "raw": return JSON.stringify(bundle, null, 2);
|
|
4844
|
+
case "transcript": {
|
|
4845
|
+
const transcript = renderMeetingTranscript(bundle.document, bundle.cacheData, "text").trim();
|
|
4846
|
+
if (transcript) return transcript;
|
|
4847
|
+
return bundle.cacheData ? "(Transcript unavailable)" : "(Granola cache not loaded)";
|
|
4848
|
+
}
|
|
4849
|
+
default: return renderMeetingNotes(bundle.document, "markdown").trim();
|
|
4850
|
+
}
|
|
4851
|
+
}
|
|
4852
|
+
function buildGranolaTuiSummary(state, meetingSource) {
|
|
4853
|
+
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}`;
|
|
4854
|
+
}
|
|
4855
|
+
//#endregion
|
|
4856
|
+
//#region src/tui/theme.ts
|
|
4857
|
+
const RESET = "\x1B[0m";
|
|
4858
|
+
function colour(code, text) {
|
|
4859
|
+
return `\x1b[${code}m${text}${RESET}`;
|
|
4860
|
+
}
|
|
4861
|
+
const granolaTuiTheme = {
|
|
4862
|
+
accent(text) {
|
|
4863
|
+
return colour("36", text);
|
|
4864
|
+
},
|
|
4865
|
+
dim(text) {
|
|
4866
|
+
return colour("2", text);
|
|
4867
|
+
},
|
|
4868
|
+
error(text) {
|
|
4869
|
+
return colour("31", text);
|
|
4870
|
+
},
|
|
4871
|
+
info(text) {
|
|
4872
|
+
return colour("32", text);
|
|
4873
|
+
},
|
|
4874
|
+
selected(text) {
|
|
4875
|
+
return colour("7", text);
|
|
4876
|
+
},
|
|
4877
|
+
strong(text) {
|
|
4878
|
+
return colour("1", text);
|
|
4879
|
+
},
|
|
4880
|
+
warning(text) {
|
|
4881
|
+
return colour("33", text);
|
|
4882
|
+
}
|
|
4883
|
+
};
|
|
4884
|
+
//#endregion
|
|
4885
|
+
//#region src/tui/palette.ts
|
|
4886
|
+
function padLine$1(text, width) {
|
|
4887
|
+
const clipped = truncateToWidth(text, width, "");
|
|
4888
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
4889
|
+
}
|
|
4890
|
+
function frameLine(text, width) {
|
|
4891
|
+
return `| ${padLine$1(text, Math.max(1, width - 4))} |`;
|
|
4892
|
+
}
|
|
4893
|
+
var GranolaTuiQuickOpenPalette = class {
|
|
4894
|
+
focused = false;
|
|
4895
|
+
#input = new Input();
|
|
4896
|
+
#matches;
|
|
4897
|
+
#selectedIndex = 0;
|
|
4898
|
+
constructor(options) {
|
|
4899
|
+
this.options = options;
|
|
4900
|
+
this.#matches = buildGranolaTuiQuickOpenItems(this.options.meetings, "");
|
|
4901
|
+
this.#input.onEscape = () => {
|
|
4902
|
+
this.options.onCancel();
|
|
4903
|
+
};
|
|
4904
|
+
this.#input.onSubmit = () => {
|
|
4905
|
+
this.chooseSelection();
|
|
4906
|
+
};
|
|
4907
|
+
}
|
|
4908
|
+
get query() {
|
|
4909
|
+
return this.#input.getValue();
|
|
4910
|
+
}
|
|
4911
|
+
updateMatches() {
|
|
4912
|
+
this.#matches = buildGranolaTuiQuickOpenItems(this.options.meetings, this.query);
|
|
4913
|
+
this.#selectedIndex = Math.max(0, Math.min(this.#selectedIndex, this.#matches.length - 1));
|
|
4914
|
+
}
|
|
4915
|
+
async chooseSelection() {
|
|
4916
|
+
const selected = this.#matches[this.#selectedIndex];
|
|
4917
|
+
if (selected) {
|
|
4918
|
+
await this.options.onPick(selected.id);
|
|
4919
|
+
return;
|
|
4920
|
+
}
|
|
4921
|
+
if (this.query.trim()) {
|
|
4922
|
+
await this.options.onResolveQuery(this.query.trim());
|
|
4923
|
+
return;
|
|
4924
|
+
}
|
|
4925
|
+
this.options.onCancel();
|
|
4926
|
+
}
|
|
4927
|
+
invalidate() {}
|
|
4928
|
+
handleInput(data) {
|
|
4929
|
+
if (matchesKey(data, "up")) {
|
|
4930
|
+
this.#selectedIndex = Math.max(0, this.#selectedIndex - 1);
|
|
4931
|
+
return;
|
|
4932
|
+
}
|
|
4933
|
+
if (matchesKey(data, "down")) {
|
|
4934
|
+
this.#selectedIndex = Math.min(this.#matches.length - 1, this.#selectedIndex + 1);
|
|
4935
|
+
return;
|
|
4936
|
+
}
|
|
4937
|
+
if (matchesKey(data, "pageUp")) {
|
|
4938
|
+
this.#selectedIndex = Math.max(0, this.#selectedIndex - 5);
|
|
4939
|
+
return;
|
|
4940
|
+
}
|
|
4941
|
+
if (matchesKey(data, "pageDown")) {
|
|
4942
|
+
this.#selectedIndex = Math.min(this.#matches.length - 1, this.#selectedIndex + 5);
|
|
4943
|
+
return;
|
|
4944
|
+
}
|
|
4945
|
+
const before = this.query;
|
|
4946
|
+
this.#input.focused = this.focused;
|
|
4947
|
+
this.#input.handleInput(data);
|
|
4948
|
+
if (before !== this.query) {
|
|
4949
|
+
this.#selectedIndex = 0;
|
|
4950
|
+
this.updateMatches();
|
|
4951
|
+
}
|
|
4952
|
+
}
|
|
4953
|
+
render(width) {
|
|
4954
|
+
const lines = [];
|
|
4955
|
+
const bodyWidth = Math.max(32, width);
|
|
4956
|
+
const visibleMatches = this.#matches.slice(0, 8);
|
|
4957
|
+
lines.push(`+${"-".repeat(bodyWidth - 2)}+`);
|
|
4958
|
+
lines.push(frameLine(granolaTuiTheme.strong("Quick Open") + granolaTuiTheme.dim(" title, id, or tag"), bodyWidth));
|
|
4959
|
+
lines.push(frameLine("", bodyWidth));
|
|
4960
|
+
for (const inputLine of this.#input.render(Math.max(1, bodyWidth - 4))) lines.push(frameLine(inputLine, bodyWidth));
|
|
4961
|
+
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));
|
|
4962
|
+
lines.push(frameLine("", bodyWidth));
|
|
4963
|
+
if (visibleMatches.length === 0) lines.push(frameLine(granolaTuiTheme.warning("No matching meetings"), bodyWidth));
|
|
4964
|
+
else for (const [index, item] of visibleMatches.entries()) {
|
|
4965
|
+
const selected = index === this.#selectedIndex;
|
|
4966
|
+
const title = `${selected ? "> " : " "}${item.label}`;
|
|
4967
|
+
const titleLine = selected ? granolaTuiTheme.selected(title) : title;
|
|
4968
|
+
const detailLine = granolaTuiTheme.dim(` ${item.description}`);
|
|
4969
|
+
lines.push(frameLine(titleLine, bodyWidth));
|
|
4970
|
+
lines.push(frameLine(detailLine, bodyWidth));
|
|
4971
|
+
}
|
|
4972
|
+
lines.push(`+${"-".repeat(bodyWidth - 2)}+`);
|
|
4973
|
+
return lines;
|
|
4974
|
+
}
|
|
4975
|
+
};
|
|
4976
|
+
//#endregion
|
|
4977
|
+
//#region src/tui/workspace.ts
|
|
4978
|
+
function padLine(text, width) {
|
|
4979
|
+
const clipped = truncateToWidth(text, width, "");
|
|
4980
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
4981
|
+
}
|
|
4982
|
+
function wrapBlock(text, width) {
|
|
4983
|
+
const lines = [];
|
|
4984
|
+
for (const line of text.split("\n")) {
|
|
4985
|
+
const wrapped = wrapTextWithAnsi(line, Math.max(1, width));
|
|
4986
|
+
if (wrapped.length === 0) {
|
|
4987
|
+
lines.push("");
|
|
4988
|
+
continue;
|
|
4989
|
+
}
|
|
4990
|
+
lines.push(...wrapped);
|
|
4991
|
+
}
|
|
4992
|
+
return lines;
|
|
4993
|
+
}
|
|
4994
|
+
function toneText(tone, text) {
|
|
4995
|
+
switch (tone) {
|
|
4996
|
+
case "error": return granolaTuiTheme.error(text);
|
|
4997
|
+
case "warning": return granolaTuiTheme.warning(text);
|
|
4998
|
+
default: return granolaTuiTheme.info(text);
|
|
4999
|
+
}
|
|
5000
|
+
}
|
|
5001
|
+
var GranolaTuiWorkspace = class {
|
|
5002
|
+
focused = false;
|
|
5003
|
+
#maxMeetings;
|
|
5004
|
+
#appState;
|
|
5005
|
+
#detailError = "";
|
|
5006
|
+
#detailScroll = 0;
|
|
5007
|
+
#detailToken = 0;
|
|
5008
|
+
#listError = "";
|
|
5009
|
+
#listToken = 0;
|
|
5010
|
+
#loadingDetail = false;
|
|
5011
|
+
#loadingMeetings = false;
|
|
5012
|
+
#meetingSource = "live";
|
|
5013
|
+
#meetings = [];
|
|
5014
|
+
#overlay;
|
|
5015
|
+
#selectedMeeting;
|
|
5016
|
+
#selectedMeetingId;
|
|
5017
|
+
#statusMessage = "Loading meetings…";
|
|
5018
|
+
#statusTone = "info";
|
|
5019
|
+
#tab = "notes";
|
|
5020
|
+
#unsubscribe;
|
|
5021
|
+
constructor(tui, app, options) {
|
|
5022
|
+
this.tui = tui;
|
|
5023
|
+
this.app = app;
|
|
5024
|
+
this.options = options;
|
|
5025
|
+
this.#appState = app.getState();
|
|
5026
|
+
this.#maxMeetings = options.maxMeetings ?? 200;
|
|
5027
|
+
}
|
|
5028
|
+
async initialise() {
|
|
5029
|
+
this.#unsubscribe = this.app.subscribe((event) => {
|
|
5030
|
+
this.handleAppUpdate(event);
|
|
5031
|
+
});
|
|
5032
|
+
await this.loadMeetings({
|
|
5033
|
+
preferredMeetingId: this.options.initialMeetingId,
|
|
5034
|
+
setStatus: true
|
|
5035
|
+
});
|
|
5036
|
+
if (this.options.initialMeetingId) await this.loadMeeting(this.options.initialMeetingId, { ensureMeetingVisible: true });
|
|
5037
|
+
else if (this.#selectedMeetingId) this.loadMeeting(this.#selectedMeetingId);
|
|
5038
|
+
}
|
|
5039
|
+
dispose() {
|
|
5040
|
+
this.#unsubscribe?.();
|
|
5041
|
+
this.#unsubscribe = void 0;
|
|
5042
|
+
}
|
|
5043
|
+
invalidate() {}
|
|
5044
|
+
handleAppUpdate(event) {
|
|
5045
|
+
const previousDocumentsLoadedAt = this.#appState.documents.loadedAt;
|
|
5046
|
+
this.#appState = event.state;
|
|
5047
|
+
if (this.#meetingSource === "index" && event.state.documents.loadedAt && event.state.documents.loadedAt !== previousDocumentsLoadedAt && !this.#loadingMeetings) this.loadMeetings({ preferredMeetingId: this.#selectedMeetingId });
|
|
5048
|
+
this.tui.requestRender();
|
|
5049
|
+
}
|
|
5050
|
+
setStatus(message, tone = "info") {
|
|
5051
|
+
this.#statusMessage = message;
|
|
5052
|
+
this.#statusTone = tone;
|
|
5053
|
+
this.tui.requestRender();
|
|
5054
|
+
}
|
|
5055
|
+
normaliseSelectedIndex() {
|
|
5056
|
+
if (this.#meetings.length === 0) return -1;
|
|
5057
|
+
const selectedIndex = this.#selectedMeetingId ? this.#meetings.findIndex((meeting) => meeting.id === this.#selectedMeetingId) : -1;
|
|
5058
|
+
return selectedIndex >= 0 ? selectedIndex : 0;
|
|
5059
|
+
}
|
|
5060
|
+
ensureMeetingVisible(meeting) {
|
|
5061
|
+
const existingIndex = this.#meetings.findIndex((item) => item.id === meeting.id);
|
|
5062
|
+
if (existingIndex >= 0) this.#meetings[existingIndex] = meeting;
|
|
5063
|
+
else this.#meetings.push(meeting);
|
|
5064
|
+
this.#meetings.sort((left, right) => {
|
|
5065
|
+
if (left.updatedAt !== right.updatedAt) return right.updatedAt.localeCompare(left.updatedAt);
|
|
5066
|
+
return left.title.localeCompare(right.title);
|
|
5067
|
+
});
|
|
5068
|
+
}
|
|
5069
|
+
async loadMeetings(options = {}) {
|
|
5070
|
+
const token = ++this.#listToken;
|
|
5071
|
+
this.#loadingMeetings = true;
|
|
5072
|
+
this.#listError = "";
|
|
5073
|
+
if (options.setStatus !== false) this.setStatus(options.forceRefresh ? "Refreshing meetings…" : "Loading meetings…");
|
|
5074
|
+
try {
|
|
5075
|
+
const result = await this.app.listMeetings({
|
|
5076
|
+
forceRefresh: options.forceRefresh,
|
|
5077
|
+
limit: this.#maxMeetings,
|
|
5078
|
+
preferIndex: true
|
|
5079
|
+
});
|
|
5080
|
+
if (token !== this.#listToken) return;
|
|
5081
|
+
this.#meetings = result.meetings;
|
|
5082
|
+
this.#meetingSource = result.source;
|
|
5083
|
+
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;
|
|
5084
|
+
this.#listError = "";
|
|
5085
|
+
this.setStatus(result.source === "index" ? "Loaded meetings from the local index" : "Connected to Granola");
|
|
5086
|
+
} catch (error) {
|
|
5087
|
+
if (token !== this.#listToken) return;
|
|
5088
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5089
|
+
this.#listError = message;
|
|
5090
|
+
this.setStatus(message, "error");
|
|
5091
|
+
throw error;
|
|
5092
|
+
} finally {
|
|
5093
|
+
if (token === this.#listToken) {
|
|
5094
|
+
this.#loadingMeetings = false;
|
|
5095
|
+
this.tui.requestRender();
|
|
5096
|
+
}
|
|
5097
|
+
}
|
|
5098
|
+
}
|
|
5099
|
+
async loadMeeting(meetingId, options = {}) {
|
|
5100
|
+
const token = ++this.#detailToken;
|
|
5101
|
+
this.#loadingDetail = true;
|
|
5102
|
+
this.#detailError = "";
|
|
5103
|
+
this.#selectedMeetingId = meetingId;
|
|
5104
|
+
this.#detailScroll = 0;
|
|
5105
|
+
this.setStatus(`Opening ${meetingId}…`);
|
|
5106
|
+
try {
|
|
5107
|
+
const bundle = options.resolveQuery ? await this.app.findMeeting(meetingId) : await this.app.getMeeting(meetingId);
|
|
5108
|
+
if (token !== this.#detailToken) return;
|
|
5109
|
+
this.#selectedMeeting = bundle;
|
|
5110
|
+
this.#selectedMeetingId = bundle.document.id;
|
|
5111
|
+
if (options.ensureMeetingVisible) this.ensureMeetingVisible(bundle.meeting.meeting);
|
|
5112
|
+
this.setStatus(`Opened ${bundle.meeting.meeting.title || bundle.meeting.meeting.id}`);
|
|
5113
|
+
} catch (error) {
|
|
5114
|
+
if (token !== this.#detailToken) return;
|
|
5115
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5116
|
+
this.#selectedMeeting = void 0;
|
|
5117
|
+
this.#detailError = message;
|
|
5118
|
+
this.setStatus(message, "error");
|
|
5119
|
+
} finally {
|
|
5120
|
+
if (token === this.#detailToken) {
|
|
5121
|
+
this.#loadingDetail = false;
|
|
5122
|
+
this.tui.requestRender();
|
|
5123
|
+
}
|
|
5124
|
+
}
|
|
5125
|
+
}
|
|
5126
|
+
async refresh(forceRefresh) {
|
|
5127
|
+
try {
|
|
5128
|
+
await this.loadMeetings({
|
|
5129
|
+
forceRefresh,
|
|
5130
|
+
preferredMeetingId: this.#selectedMeetingId
|
|
5131
|
+
});
|
|
5132
|
+
if (this.#selectedMeetingId) await this.loadMeeting(this.#selectedMeetingId, { ensureMeetingVisible: true });
|
|
5133
|
+
} catch {}
|
|
5134
|
+
}
|
|
5135
|
+
async moveSelection(delta) {
|
|
5136
|
+
if (this.#meetings.length === 0) return;
|
|
5137
|
+
const currentIndex = this.normaliseSelectedIndex();
|
|
5138
|
+
const nextIndex = Math.max(0, Math.min(this.#meetings.length - 1, currentIndex + delta));
|
|
5139
|
+
const nextMeeting = this.#meetings[nextIndex];
|
|
5140
|
+
if (!nextMeeting || nextMeeting.id === this.#selectedMeetingId) return;
|
|
5141
|
+
await this.loadMeeting(nextMeeting.id);
|
|
5142
|
+
}
|
|
5143
|
+
currentDetailBody(width) {
|
|
5144
|
+
if (this.#detailError) return wrapBlock(this.#detailError, width);
|
|
5145
|
+
if (this.#loadingDetail && !this.#selectedMeeting) return wrapBlock("Loading meeting details…", width);
|
|
5146
|
+
if (!this.#selectedMeeting) return wrapBlock("Select a meeting to inspect its notes, transcript, and metadata.", width);
|
|
5147
|
+
return wrapBlock(renderGranolaTuiMeetingTab(this.#selectedMeeting, this.#tab), width);
|
|
5148
|
+
}
|
|
5149
|
+
detailScrollStep(width, height) {
|
|
5150
|
+
const bodyHeight = Math.max(1, height - 2);
|
|
5151
|
+
const totalLines = this.currentDetailBody(width).length;
|
|
5152
|
+
if (totalLines <= bodyHeight) return 0;
|
|
5153
|
+
return Math.max(1, Math.min(bodyHeight - 1, totalLines - bodyHeight));
|
|
5154
|
+
}
|
|
5155
|
+
scrollDetail(delta) {
|
|
5156
|
+
const totalWidth = this.tui.terminal.columns;
|
|
5157
|
+
const totalHeight = this.tui.terminal.rows;
|
|
5158
|
+
const { detailWidth } = this.resolveLayout(totalWidth);
|
|
5159
|
+
const bodyHeight = Math.max(1, totalHeight - 6);
|
|
5160
|
+
const detailLines = this.currentDetailBody(Math.max(1, detailWidth - 2));
|
|
5161
|
+
const visibleBodyLines = Math.max(1, bodyHeight - 2);
|
|
5162
|
+
const maxScroll = Math.max(0, detailLines.length - visibleBodyLines);
|
|
5163
|
+
this.#detailScroll = Math.max(0, Math.min(maxScroll, this.#detailScroll + delta));
|
|
5164
|
+
this.tui.requestRender();
|
|
5165
|
+
}
|
|
5166
|
+
cycleTab(delta) {
|
|
5167
|
+
const tabs = [
|
|
5168
|
+
"notes",
|
|
5169
|
+
"transcript",
|
|
5170
|
+
"metadata",
|
|
5171
|
+
"raw"
|
|
5172
|
+
];
|
|
5173
|
+
this.#tab = tabs[(tabs.indexOf(this.#tab) + delta + tabs.length) % tabs.length] ?? "notes";
|
|
5174
|
+
this.#detailScroll = 0;
|
|
5175
|
+
this.tui.requestRender();
|
|
5176
|
+
}
|
|
5177
|
+
openQuickOpen() {
|
|
5178
|
+
if (this.#overlay) return;
|
|
5179
|
+
const closeOverlay = () => {
|
|
5180
|
+
this.#overlay?.hide();
|
|
5181
|
+
this.#overlay = void 0;
|
|
5182
|
+
this.tui.setFocus(this);
|
|
5183
|
+
this.tui.requestRender();
|
|
5184
|
+
};
|
|
5185
|
+
const palette = new GranolaTuiQuickOpenPalette({
|
|
5186
|
+
meetings: this.#meetings,
|
|
5187
|
+
onCancel: closeOverlay,
|
|
5188
|
+
onPick: async (meetingId) => {
|
|
5189
|
+
closeOverlay();
|
|
5190
|
+
await this.loadMeeting(meetingId, { ensureMeetingVisible: true });
|
|
5191
|
+
},
|
|
5192
|
+
onResolveQuery: async (query) => {
|
|
5193
|
+
closeOverlay();
|
|
5194
|
+
await this.loadMeeting(query, {
|
|
5195
|
+
ensureMeetingVisible: true,
|
|
5196
|
+
resolveQuery: true
|
|
5197
|
+
});
|
|
5198
|
+
}
|
|
5199
|
+
});
|
|
5200
|
+
this.#overlay = this.tui.showOverlay(palette, {
|
|
5201
|
+
anchor: "center",
|
|
5202
|
+
maxHeight: "60%",
|
|
5203
|
+
minWidth: 48,
|
|
5204
|
+
width: "70%"
|
|
5205
|
+
});
|
|
5206
|
+
this.setStatus("Quick open");
|
|
5207
|
+
}
|
|
5208
|
+
handleInput(data) {
|
|
5209
|
+
if (matchesKey(data, "ctrl+c") || matchesKey(data, "q")) {
|
|
5210
|
+
this.options.onExit();
|
|
5211
|
+
return;
|
|
5212
|
+
}
|
|
5213
|
+
if (matchesKey(data, "r")) {
|
|
5214
|
+
this.refresh(true);
|
|
5215
|
+
return;
|
|
5216
|
+
}
|
|
5217
|
+
if (matchesKey(data, "/") || matchesKey(data, "ctrl+p")) {
|
|
5218
|
+
this.openQuickOpen();
|
|
5219
|
+
return;
|
|
5220
|
+
}
|
|
5221
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
5222
|
+
this.moveSelection(-1);
|
|
5223
|
+
return;
|
|
5224
|
+
}
|
|
5225
|
+
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
5226
|
+
this.moveSelection(1);
|
|
5227
|
+
return;
|
|
5228
|
+
}
|
|
5229
|
+
if (matchesKey(data, "pageUp")) {
|
|
5230
|
+
this.scrollDetail(-Math.max(1, this.detailScrollStep(this.tui.terminal.columns, this.tui.terminal.rows)));
|
|
5231
|
+
return;
|
|
5232
|
+
}
|
|
5233
|
+
if (matchesKey(data, "pageDown")) {
|
|
5234
|
+
this.scrollDetail(this.detailScrollStep(this.tui.terminal.columns, this.tui.terminal.rows));
|
|
5235
|
+
return;
|
|
5236
|
+
}
|
|
5237
|
+
if (matchesKey(data, "1")) {
|
|
5238
|
+
this.#tab = "notes";
|
|
5239
|
+
this.#detailScroll = 0;
|
|
5240
|
+
this.tui.requestRender();
|
|
5241
|
+
return;
|
|
5242
|
+
}
|
|
5243
|
+
if (matchesKey(data, "2")) {
|
|
5244
|
+
this.#tab = "transcript";
|
|
5245
|
+
this.#detailScroll = 0;
|
|
5246
|
+
this.tui.requestRender();
|
|
5247
|
+
return;
|
|
5248
|
+
}
|
|
5249
|
+
if (matchesKey(data, "3")) {
|
|
5250
|
+
this.#tab = "metadata";
|
|
5251
|
+
this.#detailScroll = 0;
|
|
5252
|
+
this.tui.requestRender();
|
|
5253
|
+
return;
|
|
5254
|
+
}
|
|
5255
|
+
if (matchesKey(data, "4")) {
|
|
5256
|
+
this.#tab = "raw";
|
|
5257
|
+
this.#detailScroll = 0;
|
|
5258
|
+
this.tui.requestRender();
|
|
5259
|
+
return;
|
|
5260
|
+
}
|
|
5261
|
+
if (matchesKey(data, "]")) {
|
|
5262
|
+
this.cycleTab(1);
|
|
5263
|
+
return;
|
|
5264
|
+
}
|
|
5265
|
+
if (matchesKey(data, "[")) this.cycleTab(-1);
|
|
5266
|
+
}
|
|
5267
|
+
resolveLayout(width) {
|
|
5268
|
+
const minimumDetailWidth = 24;
|
|
5269
|
+
const minimumListWidth = 24;
|
|
5270
|
+
const available = Math.max(1, width - 3);
|
|
5271
|
+
let listWidth = Math.max(minimumListWidth, Math.min(42, Math.floor(available * .34)));
|
|
5272
|
+
let detailWidth = available - listWidth;
|
|
5273
|
+
if (detailWidth < minimumDetailWidth) {
|
|
5274
|
+
detailWidth = minimumDetailWidth;
|
|
5275
|
+
listWidth = Math.max(minimumListWidth, available - detailWidth);
|
|
5276
|
+
}
|
|
5277
|
+
if (listWidth + detailWidth > available) detailWidth = Math.max(minimumDetailWidth, available - listWidth);
|
|
5278
|
+
return {
|
|
5279
|
+
detailWidth,
|
|
5280
|
+
listWidth
|
|
5281
|
+
};
|
|
5282
|
+
}
|
|
5283
|
+
renderListPane(width, height) {
|
|
5284
|
+
const lines = [];
|
|
5285
|
+
const innerWidth = Math.max(1, width - 2);
|
|
5286
|
+
const header = `${granolaTuiTheme.strong("Meetings")} ${granolaTuiTheme.dim(`(${this.#meetings.length})`)}`;
|
|
5287
|
+
lines.push(padLine(header, innerWidth));
|
|
5288
|
+
if (this.#listError) {
|
|
5289
|
+
lines.push(...wrapBlock(granolaTuiTheme.error(this.#listError), innerWidth).slice(0, height - 1));
|
|
5290
|
+
while (lines.length < height) lines.push(" ".repeat(innerWidth));
|
|
5291
|
+
return lines;
|
|
5292
|
+
}
|
|
5293
|
+
if (this.#meetings.length === 0) {
|
|
5294
|
+
lines.push(...wrapBlock("No meetings available yet.", innerWidth).slice(0, height - 1));
|
|
5295
|
+
while (lines.length < height) lines.push(" ".repeat(innerWidth));
|
|
5296
|
+
return lines;
|
|
5297
|
+
}
|
|
5298
|
+
const selectedIndex = this.normaliseSelectedIndex();
|
|
5299
|
+
const windowSize = Math.max(1, height - 1);
|
|
5300
|
+
const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(windowSize / 2), this.#meetings.length - windowSize));
|
|
5301
|
+
const visibleMeetings = this.#meetings.slice(startIndex, startIndex + windowSize);
|
|
5302
|
+
for (const [offset, meeting] of visibleMeetings.entries()) {
|
|
5303
|
+
const selected = startIndex + offset === selectedIndex;
|
|
5304
|
+
const dateLabel = meeting.updatedAt.slice(0, 10);
|
|
5305
|
+
const prefix = selected ? "> " : " ";
|
|
5306
|
+
const maxTitleWidth = Math.max(6, innerWidth - visibleWidth(prefix) - dateLabel.length - 1);
|
|
5307
|
+
const titleBlock = `${prefix}${truncateToWidth(meeting.title || meeting.id, maxTitleWidth, "")}`;
|
|
5308
|
+
const line = `${titleBlock}${" ".repeat(Math.max(1, innerWidth - visibleWidth(titleBlock) - visibleWidth(dateLabel)))}${granolaTuiTheme.dim(dateLabel)}`;
|
|
5309
|
+
lines.push(selected ? padLine(granolaTuiTheme.selected(line), innerWidth) : padLine(line, innerWidth));
|
|
5310
|
+
}
|
|
5311
|
+
while (lines.length < height) lines.push(" ".repeat(innerWidth));
|
|
5312
|
+
return lines;
|
|
5313
|
+
}
|
|
5314
|
+
renderDetailPane(width, height) {
|
|
5315
|
+
const lines = [];
|
|
5316
|
+
const innerWidth = Math.max(1, width - 2);
|
|
5317
|
+
const tabs = [
|
|
5318
|
+
{
|
|
5319
|
+
id: "notes",
|
|
5320
|
+
label: "1 Notes"
|
|
5321
|
+
},
|
|
5322
|
+
{
|
|
5323
|
+
id: "transcript",
|
|
5324
|
+
label: "2 Transcript"
|
|
5325
|
+
},
|
|
5326
|
+
{
|
|
5327
|
+
id: "metadata",
|
|
5328
|
+
label: "3 Metadata"
|
|
5329
|
+
},
|
|
5330
|
+
{
|
|
5331
|
+
id: "raw",
|
|
5332
|
+
label: "4 Raw"
|
|
5333
|
+
}
|
|
5334
|
+
];
|
|
5335
|
+
const title = this.#selectedMeeting?.meeting.meeting.title || this.#selectedMeetingId || "Meeting";
|
|
5336
|
+
const titleLine = `${granolaTuiTheme.strong(title)} ${granolaTuiTheme.dim(this.#selectedMeeting ? this.#selectedMeeting.meeting.meeting.id : "")}`.trim();
|
|
5337
|
+
lines.push(padLine(titleLine, innerWidth));
|
|
5338
|
+
const tabLine = tabs.map((tab) => tab.id === this.#tab ? granolaTuiTheme.selected(` ${tab.label} `) : ` ${tab.label} `).join(" ");
|
|
5339
|
+
lines.push(padLine(tabLine, innerWidth));
|
|
5340
|
+
const bodyLines = this.currentDetailBody(innerWidth);
|
|
5341
|
+
const bodyHeight = Math.max(1, height - 2);
|
|
5342
|
+
const visibleBody = bodyLines.slice(this.#detailScroll, this.#detailScroll + bodyHeight);
|
|
5343
|
+
lines.push(...visibleBody.map((line) => padLine(line, innerWidth)));
|
|
5344
|
+
while (lines.length < height) lines.push(" ".repeat(innerWidth));
|
|
5345
|
+
return lines;
|
|
5346
|
+
}
|
|
5347
|
+
render(width) {
|
|
5348
|
+
const totalHeight = Math.max(12, this.tui.terminal.rows);
|
|
5349
|
+
const { detailWidth, listWidth } = this.resolveLayout(width);
|
|
5350
|
+
const bodyHeight = Math.max(6, totalHeight - 2 - 2);
|
|
5351
|
+
const selectedLabel = this.#selectedMeeting?.meeting.meeting.title || this.#selectedMeetingId || "none";
|
|
5352
|
+
const headerTitle = padLine(`${granolaTuiTheme.accent("Granola Toolkit TUI")} ${granolaTuiTheme.dim(this.#loadingMeetings ? "loading…" : selectedLabel)}`, width);
|
|
5353
|
+
const headerSummary = padLine(granolaTuiTheme.dim(buildGranolaTuiSummary(this.#appState, this.#meetingSource)), width);
|
|
5354
|
+
const listLines = this.renderListPane(listWidth, bodyHeight);
|
|
5355
|
+
const detailLines = this.renderDetailPane(detailWidth, bodyHeight);
|
|
5356
|
+
const bodyLines = [];
|
|
5357
|
+
for (let index = 0; index < bodyHeight; index += 1) bodyLines.push(`${padLine(listLines[index] ?? "", listWidth)} | ${padLine(detailLines[index] ?? "", detailWidth)}`);
|
|
5358
|
+
const footerStatus = padLine(toneText(this.#statusTone, this.#statusMessage), width);
|
|
5359
|
+
const footerHints = padLine(granolaTuiTheme.dim("/ quick open r refresh 1-4 tabs PgUp/PgDn scroll q quit"), width);
|
|
5360
|
+
return [
|
|
5361
|
+
headerTitle,
|
|
5362
|
+
headerSummary,
|
|
5363
|
+
...bodyLines,
|
|
5364
|
+
footerStatus,
|
|
5365
|
+
footerHints
|
|
5366
|
+
];
|
|
5367
|
+
}
|
|
5368
|
+
};
|
|
5369
|
+
async function runGranolaTui(app, options = {}) {
|
|
5370
|
+
const tui = new TUI(new ProcessTerminal());
|
|
5371
|
+
return await new Promise((resolve, reject) => {
|
|
5372
|
+
const workspace = new GranolaTuiWorkspace(tui, app, {
|
|
5373
|
+
initialMeetingId: options.initialMeetingId,
|
|
5374
|
+
onExit: () => {
|
|
5375
|
+
workspace.dispose();
|
|
5376
|
+
tui.stop();
|
|
5377
|
+
resolve(0);
|
|
5378
|
+
}
|
|
5379
|
+
});
|
|
5380
|
+
(async () => {
|
|
5381
|
+
try {
|
|
5382
|
+
await workspace.initialise();
|
|
5383
|
+
} catch (error) {
|
|
5384
|
+
workspace.dispose();
|
|
5385
|
+
reject(error);
|
|
5386
|
+
return;
|
|
5387
|
+
}
|
|
5388
|
+
tui.addChild(workspace);
|
|
5389
|
+
tui.setFocus(workspace);
|
|
5390
|
+
tui.start();
|
|
5391
|
+
tui.requestRender(true);
|
|
5392
|
+
})();
|
|
5393
|
+
});
|
|
5394
|
+
}
|
|
5395
|
+
//#endregion
|
|
5396
|
+
//#region src/commands/tui.ts
|
|
5397
|
+
function tuiHelp() {
|
|
5398
|
+
return `Granola tui
|
|
5399
|
+
|
|
5400
|
+
Usage:
|
|
5401
|
+
granola tui [options]
|
|
5402
|
+
|
|
5403
|
+
Options:
|
|
5404
|
+
--meeting <id> Open the workspace focused on a specific meeting
|
|
5405
|
+
--cache <path> Path to Granola cache JSON
|
|
5406
|
+
--timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
|
|
5407
|
+
--supabase <path> Path to supabase.json
|
|
5408
|
+
--debug Enable debug logging
|
|
5409
|
+
--config <path> Path to .granola.toml
|
|
5410
|
+
-h, --help Show help
|
|
5411
|
+
`;
|
|
5412
|
+
}
|
|
5413
|
+
const tuiCommand = {
|
|
5414
|
+
description: "Start the Granola Toolkit terminal workspace",
|
|
5415
|
+
flags: {
|
|
5416
|
+
cache: { type: "string" },
|
|
5417
|
+
help: { type: "boolean" },
|
|
5418
|
+
meeting: { type: "string" },
|
|
5419
|
+
timeout: { type: "string" }
|
|
5420
|
+
},
|
|
5421
|
+
help: tuiHelp,
|
|
5422
|
+
name: "tui",
|
|
5423
|
+
async run({ commandFlags, globalFlags }) {
|
|
5424
|
+
const config = await loadConfig({
|
|
5425
|
+
globalFlags,
|
|
5426
|
+
subcommandFlags: commandFlags
|
|
5427
|
+
});
|
|
5428
|
+
debug(config.debug, "using config", config.configFileUsed ?? "(none)");
|
|
5429
|
+
debug(config.debug, "supabase", config.supabase);
|
|
5430
|
+
debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
|
|
5431
|
+
debug(config.debug, "timeoutMs", config.notes.timeoutMs);
|
|
5432
|
+
return await runGranolaTui(await createGranolaApp(config, { surface: "tui" }), { initialMeetingId: typeof commandFlags.meeting === "string" && commandFlags.meeting.trim() ? commandFlags.meeting.trim() : void 0 });
|
|
5433
|
+
}
|
|
5434
|
+
};
|
|
5435
|
+
//#endregion
|
|
4501
5436
|
//#region src/commands/transcripts.ts
|
|
4502
5437
|
function transcriptsHelp() {
|
|
4503
5438
|
return `Granola transcripts
|
|
@@ -4592,8 +5527,11 @@ Usage:
|
|
|
4592
5527
|
granola web [options]
|
|
4593
5528
|
|
|
4594
5529
|
Options:
|
|
4595
|
-
--
|
|
5530
|
+
--network <mode> Network mode: local or lan (default: local)
|
|
5531
|
+
--hostname <value> Hostname to bind (overrides network default)
|
|
4596
5532
|
--port <value> Port to bind (default: 0 for any available port)
|
|
5533
|
+
--password <value> Optional server password for API and browser access
|
|
5534
|
+
--trusted-origins <v> Comma-separated extra browser origins to trust
|
|
4597
5535
|
--cache <path> Path to Granola cache JSON
|
|
4598
5536
|
--timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
|
|
4599
5537
|
--supabase <path> Path to supabase.json
|
|
@@ -4611,6 +5549,7 @@ const commands = [
|
|
|
4611
5549
|
meetingCommand,
|
|
4612
5550
|
notesCommand,
|
|
4613
5551
|
serveCommand,
|
|
5552
|
+
tuiCommand,
|
|
4614
5553
|
transcriptsCommand,
|
|
4615
5554
|
{
|
|
4616
5555
|
description: "Start the Granola Toolkit web workspace",
|
|
@@ -4618,9 +5557,12 @@ const commands = [
|
|
|
4618
5557
|
cache: { type: "string" },
|
|
4619
5558
|
help: { type: "boolean" },
|
|
4620
5559
|
hostname: { type: "string" },
|
|
5560
|
+
network: { type: "string" },
|
|
4621
5561
|
open: { type: "boolean" },
|
|
5562
|
+
password: { type: "string" },
|
|
4622
5563
|
port: { type: "string" },
|
|
4623
|
-
timeout: { type: "string" }
|
|
5564
|
+
timeout: { type: "string" },
|
|
5565
|
+
"trusted-origins": { type: "string" }
|
|
4624
5566
|
},
|
|
4625
5567
|
help: webHelp,
|
|
4626
5568
|
name: "web",
|
|
@@ -4634,18 +5576,31 @@ const commands = [
|
|
|
4634
5576
|
debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
|
|
4635
5577
|
debug(config.debug, "timeoutMs", config.notes.timeoutMs);
|
|
4636
5578
|
const app = await createGranolaApp(config, { surface: "web" });
|
|
4637
|
-
const
|
|
5579
|
+
const networkMode = parseNetworkMode(commandFlags.network);
|
|
5580
|
+
const hostname = resolveServerHostname(networkMode, commandFlags.hostname);
|
|
4638
5581
|
const port = parsePort(commandFlags.port);
|
|
4639
5582
|
const openBrowser = commandFlags.open !== false;
|
|
5583
|
+
const password = typeof commandFlags.password === "string" && commandFlags.password.trim() ? commandFlags.password : void 0;
|
|
5584
|
+
const trustedOrigins = parseTrustedOrigins(commandFlags["trusted-origins"]);
|
|
4640
5585
|
const server = await startGranolaServer(app, {
|
|
4641
5586
|
enableWebClient: true,
|
|
4642
5587
|
hostname,
|
|
4643
|
-
port
|
|
5588
|
+
port,
|
|
5589
|
+
security: {
|
|
5590
|
+
password,
|
|
5591
|
+
trustedOrigins
|
|
5592
|
+
}
|
|
4644
5593
|
});
|
|
4645
5594
|
console.log(`Granola Toolkit web workspace listening on ${server.url.href}`);
|
|
5595
|
+
console.log(`Network mode: ${networkMode}`);
|
|
5596
|
+
if (password) console.log("Server password protection: enabled");
|
|
5597
|
+
else if (networkMode === "lan") console.log("Warning: LAN mode is enabled without a server password");
|
|
5598
|
+
if (trustedOrigins.length > 0) console.log(`Trusted origins: ${trustedOrigins.join(", ")}`);
|
|
4646
5599
|
console.log("Routes:");
|
|
4647
5600
|
console.log(" GET /");
|
|
4648
5601
|
console.log(" GET /health");
|
|
5602
|
+
console.log(" POST /auth/unlock");
|
|
5603
|
+
console.log(" POST /auth/lock");
|
|
4649
5604
|
console.log(" GET /auth/status");
|
|
4650
5605
|
console.log(" GET /state");
|
|
4651
5606
|
console.log(" GET /events");
|