granola-toolkit 0.18.0 → 0.19.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 +16 -0
- package/dist/cli.js +698 -33
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@ granola meeting --help
|
|
|
37
37
|
granola notes --help
|
|
38
38
|
granola serve --help
|
|
39
39
|
granola transcripts --help
|
|
40
|
+
granola web --help
|
|
40
41
|
```
|
|
41
42
|
|
|
42
43
|
The published package exposes both `granola` and `granola-toolkit` as executable names.
|
|
@@ -50,6 +51,7 @@ node dist/cli.js meeting --help
|
|
|
50
51
|
node dist/cli.js notes --help
|
|
51
52
|
node dist/cli.js serve --help
|
|
52
53
|
node dist/cli.js transcripts --help
|
|
54
|
+
node dist/cli.js web --help
|
|
53
55
|
```
|
|
54
56
|
|
|
55
57
|
You can also use the package scripts:
|
|
@@ -97,6 +99,9 @@ Run the local API server:
|
|
|
97
99
|
granola serve
|
|
98
100
|
granola serve --port 4096
|
|
99
101
|
granola serve --hostname 0.0.0.0 --port 4096
|
|
102
|
+
|
|
103
|
+
granola web
|
|
104
|
+
granola web --open=false --port 4096
|
|
100
105
|
```
|
|
101
106
|
|
|
102
107
|
## How It Works
|
|
@@ -193,6 +198,17 @@ The initial server API includes:
|
|
|
193
198
|
|
|
194
199
|
This is the foundation for the future `granola web` client and any attachable TUI flows.
|
|
195
200
|
|
|
201
|
+
### Web
|
|
202
|
+
|
|
203
|
+
`web` starts the same local server as `serve`, enables the browser client at `/`, and opens that workspace in your default browser unless you pass `--open=false`.
|
|
204
|
+
|
|
205
|
+
The initial browser client includes:
|
|
206
|
+
|
|
207
|
+
- a searchable meeting list
|
|
208
|
+
- a meeting detail view with notes and transcript panes
|
|
209
|
+
- app-state status from the shared core
|
|
210
|
+
- note and transcript export actions backed by the same local API
|
|
211
|
+
|
|
196
212
|
## Auth
|
|
197
213
|
|
|
198
214
|
If you do not want to keep passing `--supabase`, import the desktop app session once:
|
package/dist/cli.js
CHANGED
|
@@ -154,7 +154,7 @@ function transcriptSpeakerLabel(segment) {
|
|
|
154
154
|
}
|
|
155
155
|
//#endregion
|
|
156
156
|
//#region src/client/auth.ts
|
|
157
|
-
const execFileAsync = promisify(execFile);
|
|
157
|
+
const execFileAsync$1 = promisify(execFile);
|
|
158
158
|
const DEFAULT_CLIENT_ID = "client_GranolaMac";
|
|
159
159
|
const KEYCHAIN_SERVICE_NAME = "com.granola.toolkit";
|
|
160
160
|
const KEYCHAIN_ACCOUNT_NAME = "session";
|
|
@@ -244,7 +244,7 @@ var FileSessionStore = class {
|
|
|
244
244
|
var KeychainSessionStore = class {
|
|
245
245
|
async clearSession() {
|
|
246
246
|
try {
|
|
247
|
-
await execFileAsync("security", [
|
|
247
|
+
await execFileAsync$1("security", [
|
|
248
248
|
"delete-generic-password",
|
|
249
249
|
"-s",
|
|
250
250
|
KEYCHAIN_SERVICE_NAME,
|
|
@@ -255,7 +255,7 @@ var KeychainSessionStore = class {
|
|
|
255
255
|
}
|
|
256
256
|
async readSession() {
|
|
257
257
|
try {
|
|
258
|
-
const { stdout } = await execFileAsync("security", [
|
|
258
|
+
const { stdout } = await execFileAsync$1("security", [
|
|
259
259
|
"find-generic-password",
|
|
260
260
|
"-s",
|
|
261
261
|
KEYCHAIN_SERVICE_NAME,
|
|
@@ -270,7 +270,7 @@ var KeychainSessionStore = class {
|
|
|
270
270
|
}
|
|
271
271
|
}
|
|
272
272
|
async writeSession(session) {
|
|
273
|
-
await execFileAsync("security", [
|
|
273
|
+
await execFileAsync$1("security", [
|
|
274
274
|
"add-generic-password",
|
|
275
275
|
"-U",
|
|
276
276
|
"-s",
|
|
@@ -1767,6 +1767,41 @@ async function loadConfig(options) {
|
|
|
1767
1767
|
function debug(enabled, ...values) {
|
|
1768
1768
|
if (enabled) console.error("[debug]", ...values);
|
|
1769
1769
|
}
|
|
1770
|
+
function parsePort(value) {
|
|
1771
|
+
if (value === void 0) return;
|
|
1772
|
+
if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid port: expected a non-negative integer");
|
|
1773
|
+
const port = Number(value);
|
|
1774
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error("invalid port: expected a value between 0 and 65535");
|
|
1775
|
+
return port;
|
|
1776
|
+
}
|
|
1777
|
+
function pickHostname(value, fallback = "127.0.0.1") {
|
|
1778
|
+
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
1779
|
+
}
|
|
1780
|
+
async function waitForShutdown(close) {
|
|
1781
|
+
await new Promise((resolve, reject) => {
|
|
1782
|
+
let closing = false;
|
|
1783
|
+
const cleanup = () => {
|
|
1784
|
+
process.off("SIGINT", handleSignal);
|
|
1785
|
+
process.off("SIGTERM", handleSignal);
|
|
1786
|
+
};
|
|
1787
|
+
const finish = async () => {
|
|
1788
|
+
if (closing) return;
|
|
1789
|
+
closing = true;
|
|
1790
|
+
cleanup();
|
|
1791
|
+
try {
|
|
1792
|
+
await close();
|
|
1793
|
+
resolve();
|
|
1794
|
+
} catch (error) {
|
|
1795
|
+
reject(error);
|
|
1796
|
+
}
|
|
1797
|
+
};
|
|
1798
|
+
const handleSignal = () => {
|
|
1799
|
+
finish();
|
|
1800
|
+
};
|
|
1801
|
+
process.on("SIGINT", handleSignal);
|
|
1802
|
+
process.on("SIGTERM", handleSignal);
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1770
1805
|
//#endregion
|
|
1771
1806
|
//#region src/commands/meeting.ts
|
|
1772
1807
|
function meetingHelp() {
|
|
@@ -2032,6 +2067,548 @@ function resolveNoteFormat(value) {
|
|
|
2032
2067
|
}
|
|
2033
2068
|
}
|
|
2034
2069
|
//#endregion
|
|
2070
|
+
//#region src/server/web.ts
|
|
2071
|
+
function renderGranolaWebPage() {
|
|
2072
|
+
return `<!doctype html>
|
|
2073
|
+
<html lang="en">
|
|
2074
|
+
<head>
|
|
2075
|
+
<meta charset="utf-8" />
|
|
2076
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2077
|
+
<title>Granola Toolkit</title>
|
|
2078
|
+
<style>
|
|
2079
|
+
:root {
|
|
2080
|
+
--bg: #f2ede2;
|
|
2081
|
+
--panel: rgba(255, 252, 247, 0.86);
|
|
2082
|
+
--panel-strong: #fffaf2;
|
|
2083
|
+
--line: rgba(36, 39, 44, 0.12);
|
|
2084
|
+
--ink: #1d242c;
|
|
2085
|
+
--muted: #5d6b77;
|
|
2086
|
+
--accent: #0d6a6d;
|
|
2087
|
+
--accent-soft: rgba(13, 106, 109, 0.12);
|
|
2088
|
+
--warm: #a34f2f;
|
|
2089
|
+
--ok: #246b4f;
|
|
2090
|
+
--error: #9d2c2c;
|
|
2091
|
+
--shadow: 0 24px 80px rgba(40, 32, 16, 0.12);
|
|
2092
|
+
--radius: 24px;
|
|
2093
|
+
--mono: "SF Mono", "IBM Plex Mono", "Cascadia Code", monospace;
|
|
2094
|
+
--serif: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif;
|
|
2095
|
+
--sans: "Avenir Next", "Segoe UI", sans-serif;
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
* { box-sizing: border-box; }
|
|
2099
|
+
|
|
2100
|
+
body {
|
|
2101
|
+
margin: 0;
|
|
2102
|
+
min-height: 100vh;
|
|
2103
|
+
font-family: var(--sans);
|
|
2104
|
+
color: var(--ink);
|
|
2105
|
+
background:
|
|
2106
|
+
radial-gradient(circle at top left, rgba(163, 79, 47, 0.18), transparent 32%),
|
|
2107
|
+
radial-gradient(circle at right 12%, rgba(13, 106, 109, 0.16), transparent 28%),
|
|
2108
|
+
linear-gradient(180deg, #f8f2e8 0%, var(--bg) 100%);
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
.shell {
|
|
2112
|
+
display: grid;
|
|
2113
|
+
grid-template-columns: 320px minmax(0, 1fr);
|
|
2114
|
+
gap: 18px;
|
|
2115
|
+
min-height: 100vh;
|
|
2116
|
+
padding: 24px;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
.pane {
|
|
2120
|
+
background: var(--panel);
|
|
2121
|
+
backdrop-filter: blur(18px);
|
|
2122
|
+
border: 1px solid var(--line);
|
|
2123
|
+
border-radius: var(--radius);
|
|
2124
|
+
box-shadow: var(--shadow);
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
.sidebar {
|
|
2128
|
+
display: grid;
|
|
2129
|
+
grid-template-rows: auto auto 1fr;
|
|
2130
|
+
overflow: hidden;
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
.hero, .toolbar, .detail-head {
|
|
2134
|
+
padding: 22px 24px;
|
|
2135
|
+
border-bottom: 1px solid var(--line);
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
.hero h1 {
|
|
2139
|
+
margin: 0;
|
|
2140
|
+
font-family: var(--serif);
|
|
2141
|
+
font-size: clamp(2rem, 3vw, 2.8rem);
|
|
2142
|
+
font-weight: 600;
|
|
2143
|
+
letter-spacing: -0.04em;
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
.hero p, .toolbar p {
|
|
2147
|
+
margin: 8px 0 0;
|
|
2148
|
+
color: var(--muted);
|
|
2149
|
+
line-height: 1.5;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
.search {
|
|
2153
|
+
width: 100%;
|
|
2154
|
+
margin-top: 16px;
|
|
2155
|
+
padding: 12px 14px;
|
|
2156
|
+
border: 1px solid var(--line);
|
|
2157
|
+
border-radius: 999px;
|
|
2158
|
+
background: rgba(255, 255, 255, 0.7);
|
|
2159
|
+
color: var(--ink);
|
|
2160
|
+
font: inherit;
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
.meeting-list {
|
|
2164
|
+
padding: 14px;
|
|
2165
|
+
overflow: auto;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
.meeting-row {
|
|
2169
|
+
width: 100%;
|
|
2170
|
+
display: grid;
|
|
2171
|
+
gap: 4px;
|
|
2172
|
+
text-align: left;
|
|
2173
|
+
margin: 0 0 10px;
|
|
2174
|
+
padding: 14px 16px;
|
|
2175
|
+
border: 1px solid transparent;
|
|
2176
|
+
border-radius: 18px;
|
|
2177
|
+
background: rgba(255, 255, 255, 0.72);
|
|
2178
|
+
color: inherit;
|
|
2179
|
+
cursor: pointer;
|
|
2180
|
+
transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
.meeting-row:hover,
|
|
2184
|
+
.meeting-row[data-selected="true"] {
|
|
2185
|
+
transform: translateY(-1px);
|
|
2186
|
+
border-color: rgba(13, 106, 109, 0.25);
|
|
2187
|
+
background: var(--panel-strong);
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
.meeting-row__title {
|
|
2191
|
+
font-weight: 600;
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
.meeting-row__meta {
|
|
2195
|
+
color: var(--muted);
|
|
2196
|
+
font-size: 0.92rem;
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
.meeting-empty {
|
|
2200
|
+
padding: 18px;
|
|
2201
|
+
color: var(--muted);
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
.detail {
|
|
2205
|
+
display: grid;
|
|
2206
|
+
grid-template-rows: auto auto 1fr;
|
|
2207
|
+
min-width: 0;
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
.detail-head {
|
|
2211
|
+
display: flex;
|
|
2212
|
+
align-items: center;
|
|
2213
|
+
justify-content: space-between;
|
|
2214
|
+
gap: 18px;
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
.detail-head h2 {
|
|
2218
|
+
margin: 0;
|
|
2219
|
+
font-family: var(--serif);
|
|
2220
|
+
font-size: clamp(1.8rem, 2.4vw, 2.4rem);
|
|
2221
|
+
font-weight: 600;
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
.state-badge {
|
|
2225
|
+
padding: 10px 14px;
|
|
2226
|
+
border-radius: 999px;
|
|
2227
|
+
background: var(--accent-soft);
|
|
2228
|
+
color: var(--accent);
|
|
2229
|
+
font-size: 0.92rem;
|
|
2230
|
+
font-weight: 700;
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
.state-badge[data-tone="busy"] { color: var(--warm); background: rgba(163, 79, 47, 0.12); }
|
|
2234
|
+
.state-badge[data-tone="error"] { color: var(--error); background: rgba(157, 44, 44, 0.12); }
|
|
2235
|
+
.state-badge[data-tone="ok"] { color: var(--ok); background: rgba(36, 107, 79, 0.12); }
|
|
2236
|
+
|
|
2237
|
+
.toolbar {
|
|
2238
|
+
display: flex;
|
|
2239
|
+
flex-wrap: wrap;
|
|
2240
|
+
align-items: center;
|
|
2241
|
+
justify-content: space-between;
|
|
2242
|
+
gap: 14px;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
.toolbar-actions {
|
|
2246
|
+
display: flex;
|
|
2247
|
+
flex-wrap: wrap;
|
|
2248
|
+
gap: 10px;
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
.button {
|
|
2252
|
+
border: 0;
|
|
2253
|
+
border-radius: 999px;
|
|
2254
|
+
padding: 12px 16px;
|
|
2255
|
+
font: inherit;
|
|
2256
|
+
font-weight: 700;
|
|
2257
|
+
cursor: pointer;
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
.button--primary {
|
|
2261
|
+
background: var(--ink);
|
|
2262
|
+
color: white;
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
.button--secondary {
|
|
2266
|
+
background: rgba(255, 255, 255, 0.72);
|
|
2267
|
+
color: var(--ink);
|
|
2268
|
+
border: 1px solid var(--line);
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
.status-grid {
|
|
2272
|
+
display: grid;
|
|
2273
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
2274
|
+
gap: 14px;
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
.status-label {
|
|
2278
|
+
display: block;
|
|
2279
|
+
margin-bottom: 6px;
|
|
2280
|
+
color: var(--muted);
|
|
2281
|
+
font-size: 0.78rem;
|
|
2282
|
+
letter-spacing: 0.08em;
|
|
2283
|
+
text-transform: uppercase;
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
.detail-meta {
|
|
2287
|
+
display: flex;
|
|
2288
|
+
flex-wrap: wrap;
|
|
2289
|
+
gap: 10px;
|
|
2290
|
+
padding: 0 24px 18px;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
.detail-chip {
|
|
2294
|
+
padding: 10px 12px;
|
|
2295
|
+
border-radius: 999px;
|
|
2296
|
+
background: rgba(255, 255, 255, 0.72);
|
|
2297
|
+
border: 1px solid var(--line);
|
|
2298
|
+
color: var(--muted);
|
|
2299
|
+
font-size: 0.88rem;
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
.detail-body {
|
|
2303
|
+
padding: 0 24px 24px;
|
|
2304
|
+
overflow: auto;
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
.detail-section {
|
|
2308
|
+
margin-bottom: 20px;
|
|
2309
|
+
padding: 20px;
|
|
2310
|
+
background: rgba(255, 255, 255, 0.72);
|
|
2311
|
+
border: 1px solid var(--line);
|
|
2312
|
+
border-radius: 20px;
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
.detail-section h2 {
|
|
2316
|
+
margin: 0 0 14px;
|
|
2317
|
+
font-size: 1rem;
|
|
2318
|
+
letter-spacing: 0.08em;
|
|
2319
|
+
text-transform: uppercase;
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
.detail-pre {
|
|
2323
|
+
margin: 0;
|
|
2324
|
+
white-space: pre-wrap;
|
|
2325
|
+
word-break: break-word;
|
|
2326
|
+
font-family: var(--mono);
|
|
2327
|
+
line-height: 1.55;
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
.empty {
|
|
2331
|
+
margin: 24px;
|
|
2332
|
+
padding: 24px;
|
|
2333
|
+
border-radius: 20px;
|
|
2334
|
+
background: rgba(255, 255, 255, 0.72);
|
|
2335
|
+
border: 1px dashed rgba(36, 39, 44, 0.2);
|
|
2336
|
+
color: var(--muted);
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
@media (max-width: 900px) {
|
|
2340
|
+
.shell {
|
|
2341
|
+
grid-template-columns: 1fr;
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
</style>
|
|
2345
|
+
</head>
|
|
2346
|
+
<body>
|
|
2347
|
+
<div class="shell">
|
|
2348
|
+
<aside class="pane sidebar">
|
|
2349
|
+
<section class="hero">
|
|
2350
|
+
<h1>Granola Toolkit</h1>
|
|
2351
|
+
<p>Browser workspace for meetings, notes, transcripts, and export flows on top of one local server instance.</p>
|
|
2352
|
+
<input class="search" data-search placeholder="Search meetings, ids, or tags" />
|
|
2353
|
+
</section>
|
|
2354
|
+
<section class="toolbar">
|
|
2355
|
+
<p>Meetings are loaded from the shared server state so this view can later coexist with the terminal UI.</p>
|
|
2356
|
+
</section>
|
|
2357
|
+
<section class="meeting-list" data-meeting-list></section>
|
|
2358
|
+
</aside>
|
|
2359
|
+
<main class="pane detail">
|
|
2360
|
+
<section class="detail-head">
|
|
2361
|
+
<div>
|
|
2362
|
+
<h2>Meeting Workspace</h2>
|
|
2363
|
+
<div data-app-state></div>
|
|
2364
|
+
</div>
|
|
2365
|
+
<div class="state-badge" data-state-badge data-tone="idle">Connecting…</div>
|
|
2366
|
+
</section>
|
|
2367
|
+
<section class="toolbar">
|
|
2368
|
+
<div class="toolbar-actions">
|
|
2369
|
+
<button class="button button--primary" data-refresh>Refresh</button>
|
|
2370
|
+
<button class="button button--secondary" data-export-notes>Export Notes</button>
|
|
2371
|
+
<button class="button button--secondary" data-export-transcripts>Export Transcripts</button>
|
|
2372
|
+
</div>
|
|
2373
|
+
<p>Initial beta web client. It speaks to the same local API that future TUI and attach flows will use.</p>
|
|
2374
|
+
</section>
|
|
2375
|
+
<div class="detail-meta" data-detail-meta></div>
|
|
2376
|
+
<div class="detail-body" data-detail-body>
|
|
2377
|
+
<div class="empty" data-empty>Select a meeting to inspect its notes and transcript.</div>
|
|
2378
|
+
</div>
|
|
2379
|
+
</main>
|
|
2380
|
+
</div>
|
|
2381
|
+
<script type="module">
|
|
2382
|
+
${String.raw`
|
|
2383
|
+
const state = {
|
|
2384
|
+
meetings: [],
|
|
2385
|
+
selectedMeetingId: null,
|
|
2386
|
+
selectedMeeting: null,
|
|
2387
|
+
appState: null,
|
|
2388
|
+
search: "",
|
|
2389
|
+
};
|
|
2390
|
+
|
|
2391
|
+
const els = {
|
|
2392
|
+
appState: document.querySelector("[data-app-state]"),
|
|
2393
|
+
detailBody: document.querySelector("[data-detail-body]"),
|
|
2394
|
+
detailMeta: document.querySelector("[data-detail-meta]"),
|
|
2395
|
+
empty: document.querySelector("[data-empty]"),
|
|
2396
|
+
list: document.querySelector("[data-meeting-list]"),
|
|
2397
|
+
noteButton: document.querySelector("[data-export-notes]"),
|
|
2398
|
+
refreshButton: document.querySelector("[data-refresh]"),
|
|
2399
|
+
search: document.querySelector("[data-search]"),
|
|
2400
|
+
stateBadge: document.querySelector("[data-state-badge]"),
|
|
2401
|
+
transcriptButton: document.querySelector("[data-export-transcripts]"),
|
|
2402
|
+
};
|
|
2403
|
+
|
|
2404
|
+
function escapeHtml(value) {
|
|
2405
|
+
return value
|
|
2406
|
+
.replaceAll("&", "&")
|
|
2407
|
+
.replaceAll("<", "<")
|
|
2408
|
+
.replaceAll(">", ">")
|
|
2409
|
+
.replaceAll('"', """);
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
function setStatus(label, tone = "idle") {
|
|
2413
|
+
els.stateBadge.textContent = label;
|
|
2414
|
+
els.stateBadge.dataset.tone = tone;
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
function renderAppState() {
|
|
2418
|
+
if (!state.appState) {
|
|
2419
|
+
els.appState.innerHTML = "<p>Waiting for server state…</p>";
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
const appState = state.appState;
|
|
2424
|
+
const authMode = appState.auth.mode === "stored-session" ? "Stored session" : "supabase.json";
|
|
2425
|
+
const docs = appState.documents.loaded ? String(appState.documents.count) : "not loaded";
|
|
2426
|
+
const cache = appState.cache.loaded
|
|
2427
|
+
? appState.cache.transcriptCount + " transcript sets"
|
|
2428
|
+
: appState.cache.configured
|
|
2429
|
+
? "configured"
|
|
2430
|
+
: "not configured";
|
|
2431
|
+
|
|
2432
|
+
els.appState.innerHTML = [
|
|
2433
|
+
'<div class="status-grid">',
|
|
2434
|
+
'<div><span class="status-label">Surface</span><strong>' + escapeHtml(appState.ui.surface) + "</strong></div>",
|
|
2435
|
+
'<div><span class="status-label">View</span><strong>' + escapeHtml(appState.ui.view) + "</strong></div>",
|
|
2436
|
+
'<div><span class="status-label">Auth</span><strong>' + escapeHtml(authMode) + "</strong></div>",
|
|
2437
|
+
'<div><span class="status-label">Documents</span><strong>' + escapeHtml(docs) + "</strong></div>",
|
|
2438
|
+
'<div><span class="status-label">Cache</span><strong>' + escapeHtml(cache) + "</strong></div>",
|
|
2439
|
+
"</div>",
|
|
2440
|
+
].join("");
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
function renderMeetingList() {
|
|
2444
|
+
if (state.meetings.length === 0) {
|
|
2445
|
+
state.selectedMeetingId = null;
|
|
2446
|
+
state.selectedMeeting = null;
|
|
2447
|
+
els.list.innerHTML = '<div class="meeting-empty">No meetings yet. Try Refresh.</div>';
|
|
2448
|
+
renderMeetingDetail();
|
|
2449
|
+
return;
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
const visibleIds = new Set(state.meetings.map((meeting) => meeting.id));
|
|
2453
|
+
if (!state.selectedMeetingId || !visibleIds.has(state.selectedMeetingId)) {
|
|
2454
|
+
state.selectedMeetingId = state.meetings[0]?.id || null;
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
els.list.innerHTML = state.meetings
|
|
2458
|
+
.map((meeting) => {
|
|
2459
|
+
const selected = meeting.id === state.selectedMeetingId ? ' data-selected="true"' : "";
|
|
2460
|
+
const tags = meeting.tags.length ? meeting.tags.map((tag) => "#" + tag).join(" ") : "untagged";
|
|
2461
|
+
return [
|
|
2462
|
+
'<button class="meeting-row"' + selected + ' data-meeting-id="' + escapeHtml(meeting.id) + '">',
|
|
2463
|
+
'<span class="meeting-row__title">' + escapeHtml(meeting.title || meeting.id) + "</span>",
|
|
2464
|
+
'<span class="meeting-row__meta">' + escapeHtml(tags) + "</span>",
|
|
2465
|
+
'<span class="meeting-row__meta">' + escapeHtml(meeting.updatedAt.slice(0, 10) || "unknown") + "</span>",
|
|
2466
|
+
"</button>",
|
|
2467
|
+
].join("");
|
|
2468
|
+
})
|
|
2469
|
+
.join("");
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
function renderMeetingDetail() {
|
|
2473
|
+
const record = state.selectedMeeting;
|
|
2474
|
+
if (!record) {
|
|
2475
|
+
els.empty.hidden = false;
|
|
2476
|
+
els.detailMeta.innerHTML = "";
|
|
2477
|
+
els.detailBody.innerHTML = "";
|
|
2478
|
+
return;
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
els.empty.hidden = true;
|
|
2482
|
+
els.detailMeta.innerHTML = [
|
|
2483
|
+
'<div class="detail-chip">ID: ' + escapeHtml(record.meeting.id) + "</div>",
|
|
2484
|
+
'<div class="detail-chip">Source: ' + escapeHtml(record.meeting.noteContentSource) + "</div>",
|
|
2485
|
+
'<div class="detail-chip">Transcript: ' + escapeHtml(String(record.meeting.transcriptSegmentCount)) + " segments</div>",
|
|
2486
|
+
].join("");
|
|
2487
|
+
|
|
2488
|
+
els.detailBody.innerHTML = [
|
|
2489
|
+
'<section class="detail-section">',
|
|
2490
|
+
"<h2>Notes</h2>",
|
|
2491
|
+
'<pre class="detail-pre">' + escapeHtml(record.noteMarkdown || "") + "</pre>",
|
|
2492
|
+
"</section>",
|
|
2493
|
+
'<section class="detail-section">',
|
|
2494
|
+
"<h2>Transcript</h2>",
|
|
2495
|
+
'<pre class="detail-pre">' + escapeHtml(record.transcriptText || "(Transcript unavailable)") + "</pre>",
|
|
2496
|
+
"</section>",
|
|
2497
|
+
].join("");
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
async function fetchJson(path, init) {
|
|
2501
|
+
const response = await fetch(path, init);
|
|
2502
|
+
const payload = await response.json().catch(() => ({}));
|
|
2503
|
+
if (!response.ok) {
|
|
2504
|
+
throw new Error(payload.error || response.statusText || "Request failed");
|
|
2505
|
+
}
|
|
2506
|
+
return payload;
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
async function loadMeetings() {
|
|
2510
|
+
const query = state.search ? "?search=" + encodeURIComponent(state.search) + "&limit=50" : "?limit=50";
|
|
2511
|
+
const payload = await fetchJson("/meetings" + query);
|
|
2512
|
+
state.meetings = payload.meetings || [];
|
|
2513
|
+
if (!state.selectedMeetingId && state.meetings[0]) {
|
|
2514
|
+
state.selectedMeetingId = state.meetings[0].id;
|
|
2515
|
+
}
|
|
2516
|
+
renderMeetingList();
|
|
2517
|
+
if (state.selectedMeetingId) {
|
|
2518
|
+
await loadMeeting(state.selectedMeetingId);
|
|
2519
|
+
} else {
|
|
2520
|
+
renderMeetingDetail();
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
async function loadMeeting(id) {
|
|
2525
|
+
state.selectedMeetingId = id;
|
|
2526
|
+
renderMeetingList();
|
|
2527
|
+
const payload = await fetchJson("/meetings/" + encodeURIComponent(id));
|
|
2528
|
+
state.selectedMeeting = payload.meeting || null;
|
|
2529
|
+
renderMeetingDetail();
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
async function refreshAll() {
|
|
2533
|
+
setStatus("Refreshing…", "busy");
|
|
2534
|
+
const [appState] = await Promise.all([fetchJson("/state"), loadMeetings()]);
|
|
2535
|
+
state.appState = appState;
|
|
2536
|
+
renderAppState();
|
|
2537
|
+
setStatus("Connected", "ok");
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
async function exportNotes() {
|
|
2541
|
+
setStatus("Exporting notes…", "busy");
|
|
2542
|
+
await fetchJson("/exports/notes", {
|
|
2543
|
+
body: JSON.stringify({ format: "markdown" }),
|
|
2544
|
+
headers: { "content-type": "application/json" },
|
|
2545
|
+
method: "POST",
|
|
2546
|
+
});
|
|
2547
|
+
await refreshAll();
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
async function exportTranscripts() {
|
|
2551
|
+
setStatus("Exporting transcripts…", "busy");
|
|
2552
|
+
await fetchJson("/exports/transcripts", {
|
|
2553
|
+
body: JSON.stringify({ format: "text" }),
|
|
2554
|
+
headers: { "content-type": "application/json" },
|
|
2555
|
+
method: "POST",
|
|
2556
|
+
});
|
|
2557
|
+
await refreshAll();
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
els.list.addEventListener("click", (event) => {
|
|
2561
|
+
if (!(event.target instanceof Element)) {
|
|
2562
|
+
return;
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
const button = event.target.closest("[data-meeting-id]");
|
|
2566
|
+
if (!button) return;
|
|
2567
|
+
void loadMeeting(button.dataset.meetingId);
|
|
2568
|
+
});
|
|
2569
|
+
|
|
2570
|
+
els.refreshButton.addEventListener("click", () => {
|
|
2571
|
+
void refreshAll();
|
|
2572
|
+
});
|
|
2573
|
+
|
|
2574
|
+
els.noteButton.addEventListener("click", () => {
|
|
2575
|
+
void exportNotes();
|
|
2576
|
+
});
|
|
2577
|
+
|
|
2578
|
+
els.transcriptButton.addEventListener("click", () => {
|
|
2579
|
+
void exportTranscripts();
|
|
2580
|
+
});
|
|
2581
|
+
|
|
2582
|
+
els.search.addEventListener("input", (event) => {
|
|
2583
|
+
if (!(event.target instanceof HTMLInputElement)) {
|
|
2584
|
+
return;
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
state.search = event.target.value.trim();
|
|
2588
|
+
void loadMeetings();
|
|
2589
|
+
});
|
|
2590
|
+
|
|
2591
|
+
const events = new EventSource("/events");
|
|
2592
|
+
events.addEventListener("state.updated", (event) => {
|
|
2593
|
+
const payload = JSON.parse(event.data);
|
|
2594
|
+
state.appState = payload.state;
|
|
2595
|
+
renderAppState();
|
|
2596
|
+
});
|
|
2597
|
+
events.addEventListener("error", () => {
|
|
2598
|
+
setStatus("Disconnected", "error");
|
|
2599
|
+
});
|
|
2600
|
+
|
|
2601
|
+
void refreshAll().catch((error) => {
|
|
2602
|
+
setStatus("Error", "error");
|
|
2603
|
+
els.empty.hidden = false;
|
|
2604
|
+
els.empty.textContent = error.message;
|
|
2605
|
+
});
|
|
2606
|
+
`}
|
|
2607
|
+
<\/script>
|
|
2608
|
+
</body>
|
|
2609
|
+
</html>`;
|
|
2610
|
+
}
|
|
2611
|
+
//#endregion
|
|
2035
2612
|
//#region src/server/http.ts
|
|
2036
2613
|
function parseInteger(value) {
|
|
2037
2614
|
if (!value?.trim()) return;
|
|
@@ -2055,6 +2632,13 @@ function sendText(response, body, status = 200) {
|
|
|
2055
2632
|
});
|
|
2056
2633
|
response.end(body);
|
|
2057
2634
|
}
|
|
2635
|
+
function sendHtml(response, body, status = 200) {
|
|
2636
|
+
response.writeHead(status, {
|
|
2637
|
+
"content-length": Buffer.byteLength(body),
|
|
2638
|
+
"content-type": "text/html; charset=utf-8"
|
|
2639
|
+
});
|
|
2640
|
+
response.end(body);
|
|
2641
|
+
}
|
|
2058
2642
|
async function readJsonBody(request) {
|
|
2059
2643
|
const chunks = [];
|
|
2060
2644
|
for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
@@ -2091,6 +2675,7 @@ function transcriptFormatFromBody(value) {
|
|
|
2091
2675
|
}
|
|
2092
2676
|
}
|
|
2093
2677
|
async function startGranolaServer(app, options = {}) {
|
|
2678
|
+
const enableWebClient = options.enableWebClient ?? false;
|
|
2094
2679
|
const hostname = options.hostname ?? "127.0.0.1";
|
|
2095
2680
|
const port = options.port ?? 0;
|
|
2096
2681
|
const server = createServer(async (request, response) => {
|
|
@@ -2098,6 +2683,10 @@ async function startGranolaServer(app, options = {}) {
|
|
|
2098
2683
|
const url = new URL(request.url ?? "/", `http://${hostname}`);
|
|
2099
2684
|
const path = url.pathname;
|
|
2100
2685
|
try {
|
|
2686
|
+
if (method === "GET" && path === "/" && enableWebClient) {
|
|
2687
|
+
sendHtml(response, renderGranolaWebPage());
|
|
2688
|
+
return;
|
|
2689
|
+
}
|
|
2101
2690
|
if (method === "GET" && path === "/health") {
|
|
2102
2691
|
sendJson(response, {
|
|
2103
2692
|
ok: true,
|
|
@@ -2212,13 +2801,6 @@ Options:
|
|
|
2212
2801
|
-h, --help Show help
|
|
2213
2802
|
`;
|
|
2214
2803
|
}
|
|
2215
|
-
function parsePort(value) {
|
|
2216
|
-
if (value === void 0) return;
|
|
2217
|
-
if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid port: expected a non-negative integer");
|
|
2218
|
-
const port = Number(value);
|
|
2219
|
-
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error("invalid port: expected a value between 0 and 65535");
|
|
2220
|
-
return port;
|
|
2221
|
-
}
|
|
2222
2804
|
const serveCommand = {
|
|
2223
2805
|
description: "Start a local Granola API server",
|
|
2224
2806
|
flags: {
|
|
@@ -2240,7 +2822,7 @@ const serveCommand = {
|
|
|
2240
2822
|
debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
|
|
2241
2823
|
debug(config.debug, "timeoutMs", config.notes.timeoutMs);
|
|
2242
2824
|
const server = await startGranolaServer(await createGranolaApp(config, { surface: "server" }), {
|
|
2243
|
-
hostname:
|
|
2825
|
+
hostname: pickHostname(commandFlags.hostname),
|
|
2244
2826
|
port: parsePort(commandFlags.port)
|
|
2245
2827
|
});
|
|
2246
2828
|
console.log(`Granola server listening on ${server.url.href}`);
|
|
@@ -2252,26 +2834,7 @@ const serveCommand = {
|
|
|
2252
2834
|
console.log(" GET /meetings/:id");
|
|
2253
2835
|
console.log(" POST /exports/notes");
|
|
2254
2836
|
console.log(" POST /exports/transcripts");
|
|
2255
|
-
await
|
|
2256
|
-
let closing = false;
|
|
2257
|
-
const close = async () => {
|
|
2258
|
-
if (closing) return;
|
|
2259
|
-
closing = true;
|
|
2260
|
-
process.off("SIGINT", handleSignal);
|
|
2261
|
-
process.off("SIGTERM", handleSignal);
|
|
2262
|
-
try {
|
|
2263
|
-
await server.close();
|
|
2264
|
-
resolve();
|
|
2265
|
-
} catch (error) {
|
|
2266
|
-
reject(error);
|
|
2267
|
-
}
|
|
2268
|
-
};
|
|
2269
|
-
const handleSignal = () => {
|
|
2270
|
-
close();
|
|
2271
|
-
};
|
|
2272
|
-
process.on("SIGINT", handleSignal);
|
|
2273
|
-
process.on("SIGTERM", handleSignal);
|
|
2274
|
-
});
|
|
2837
|
+
await waitForShutdown(async () => await server.close());
|
|
2275
2838
|
return 0;
|
|
2276
2839
|
}
|
|
2277
2840
|
};
|
|
@@ -2331,13 +2894,115 @@ function resolveTranscriptFormat(value) {
|
|
|
2331
2894
|
}
|
|
2332
2895
|
}
|
|
2333
2896
|
//#endregion
|
|
2897
|
+
//#region src/browser.ts
|
|
2898
|
+
const execFileAsync = promisify(execFile);
|
|
2899
|
+
function getBrowserOpenCommand(url, platform = process.platform) {
|
|
2900
|
+
const href = String(url);
|
|
2901
|
+
switch (platform) {
|
|
2902
|
+
case "darwin": return {
|
|
2903
|
+
args: [href],
|
|
2904
|
+
file: "open"
|
|
2905
|
+
};
|
|
2906
|
+
case "win32": return {
|
|
2907
|
+
args: [
|
|
2908
|
+
"/c",
|
|
2909
|
+
"start",
|
|
2910
|
+
"",
|
|
2911
|
+
href
|
|
2912
|
+
],
|
|
2913
|
+
file: "cmd"
|
|
2914
|
+
};
|
|
2915
|
+
default: return {
|
|
2916
|
+
args: [href],
|
|
2917
|
+
file: "xdg-open"
|
|
2918
|
+
};
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
async function openExternalUrl(url, options = {}) {
|
|
2922
|
+
const command = getBrowserOpenCommand(url, options.platform);
|
|
2923
|
+
await (options.run ?? (async (file, args) => {
|
|
2924
|
+
await execFileAsync(file, args);
|
|
2925
|
+
}))(command.file, command.args);
|
|
2926
|
+
}
|
|
2927
|
+
//#endregion
|
|
2928
|
+
//#region src/commands/web.ts
|
|
2929
|
+
function webHelp() {
|
|
2930
|
+
return `Granola web
|
|
2931
|
+
|
|
2932
|
+
Usage:
|
|
2933
|
+
granola web [options]
|
|
2934
|
+
|
|
2935
|
+
Options:
|
|
2936
|
+
--hostname <value> Hostname to bind (default: 127.0.0.1)
|
|
2937
|
+
--port <value> Port to bind (default: 0 for any available port)
|
|
2938
|
+
--cache <path> Path to Granola cache JSON
|
|
2939
|
+
--timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
|
|
2940
|
+
--supabase <path> Path to supabase.json
|
|
2941
|
+
--open[=true|false] Open the browser automatically (default: true)
|
|
2942
|
+
--debug Enable debug logging
|
|
2943
|
+
--config <path> Path to .granola.toml
|
|
2944
|
+
-h, --help Show help
|
|
2945
|
+
`;
|
|
2946
|
+
}
|
|
2947
|
+
//#endregion
|
|
2334
2948
|
//#region src/commands/index.ts
|
|
2335
2949
|
const commands = [
|
|
2336
2950
|
authCommand,
|
|
2337
2951
|
meetingCommand,
|
|
2338
2952
|
notesCommand,
|
|
2339
2953
|
serveCommand,
|
|
2340
|
-
transcriptsCommand
|
|
2954
|
+
transcriptsCommand,
|
|
2955
|
+
{
|
|
2956
|
+
description: "Start the Granola Toolkit web workspace",
|
|
2957
|
+
flags: {
|
|
2958
|
+
cache: { type: "string" },
|
|
2959
|
+
help: { type: "boolean" },
|
|
2960
|
+
hostname: { type: "string" },
|
|
2961
|
+
open: { type: "boolean" },
|
|
2962
|
+
port: { type: "string" },
|
|
2963
|
+
timeout: { type: "string" }
|
|
2964
|
+
},
|
|
2965
|
+
help: webHelp,
|
|
2966
|
+
name: "web",
|
|
2967
|
+
async run({ commandFlags, globalFlags }) {
|
|
2968
|
+
const config = await loadConfig({
|
|
2969
|
+
globalFlags,
|
|
2970
|
+
subcommandFlags: commandFlags
|
|
2971
|
+
});
|
|
2972
|
+
debug(config.debug, "using config", config.configFileUsed ?? "(none)");
|
|
2973
|
+
debug(config.debug, "supabase", config.supabase);
|
|
2974
|
+
debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
|
|
2975
|
+
debug(config.debug, "timeoutMs", config.notes.timeoutMs);
|
|
2976
|
+
const app = await createGranolaApp(config, { surface: "web" });
|
|
2977
|
+
const hostname = pickHostname(commandFlags.hostname);
|
|
2978
|
+
const port = parsePort(commandFlags.port);
|
|
2979
|
+
const openBrowser = commandFlags.open !== false;
|
|
2980
|
+
const server = await startGranolaServer(app, {
|
|
2981
|
+
enableWebClient: true,
|
|
2982
|
+
hostname,
|
|
2983
|
+
port
|
|
2984
|
+
});
|
|
2985
|
+
console.log(`Granola Toolkit web workspace listening on ${server.url.href}`);
|
|
2986
|
+
console.log("Routes:");
|
|
2987
|
+
console.log(" GET /");
|
|
2988
|
+
console.log(" GET /health");
|
|
2989
|
+
console.log(" GET /state");
|
|
2990
|
+
console.log(" GET /events");
|
|
2991
|
+
console.log(" GET /meetings");
|
|
2992
|
+
console.log(" GET /meetings/:id");
|
|
2993
|
+
console.log(" POST /exports/notes");
|
|
2994
|
+
console.log(" POST /exports/transcripts");
|
|
2995
|
+
if (openBrowser) try {
|
|
2996
|
+
await openExternalUrl(server.url);
|
|
2997
|
+
} catch (error) {
|
|
2998
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2999
|
+
console.error(`failed to open browser automatically: ${message}`);
|
|
3000
|
+
console.error(`open ${server.url.href} manually`);
|
|
3001
|
+
}
|
|
3002
|
+
await waitForShutdown(async () => await server.close());
|
|
3003
|
+
return 0;
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
2341
3006
|
];
|
|
2342
3007
|
const commandMap = new Map(commands.map((command) => [command.name, command]));
|
|
2343
3008
|
//#endregion
|