granola-toolkit 0.17.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 +42 -0
- package/dist/cli.js +937 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,7 +35,9 @@ granola --help
|
|
|
35
35
|
granola auth login
|
|
36
36
|
granola meeting --help
|
|
37
37
|
granola notes --help
|
|
38
|
+
granola serve --help
|
|
38
39
|
granola transcripts --help
|
|
40
|
+
granola web --help
|
|
39
41
|
```
|
|
40
42
|
|
|
41
43
|
The published package exposes both `granola` and `granola-toolkit` as executable names.
|
|
@@ -47,7 +49,9 @@ vp pack
|
|
|
47
49
|
node dist/cli.js --help
|
|
48
50
|
node dist/cli.js meeting --help
|
|
49
51
|
node dist/cli.js notes --help
|
|
52
|
+
node dist/cli.js serve --help
|
|
50
53
|
node dist/cli.js transcripts --help
|
|
54
|
+
node dist/cli.js web --help
|
|
51
55
|
```
|
|
52
56
|
|
|
53
57
|
You can also use the package scripts:
|
|
@@ -89,6 +93,17 @@ granola meeting transcript 1234abcd --format json
|
|
|
89
93
|
granola meeting export 1234abcd --format yaml
|
|
90
94
|
```
|
|
91
95
|
|
|
96
|
+
Run the local API server:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
granola serve
|
|
100
|
+
granola serve --port 4096
|
|
101
|
+
granola serve --hostname 0.0.0.0 --port 4096
|
|
102
|
+
|
|
103
|
+
granola web
|
|
104
|
+
granola web --open=false --port 4096
|
|
105
|
+
```
|
|
106
|
+
|
|
92
107
|
## How It Works
|
|
93
108
|
|
|
94
109
|
### Notes
|
|
@@ -167,6 +182,33 @@ The machine-readable `export` command includes:
|
|
|
167
182
|
- structured note data plus rendered Markdown
|
|
168
183
|
- structured transcript data plus rendered transcript text when available
|
|
169
184
|
|
|
185
|
+
### Server
|
|
186
|
+
|
|
187
|
+
`serve` starts a long-lived local `Granola Toolkit` server on one shared app instance.
|
|
188
|
+
|
|
189
|
+
The initial server API includes:
|
|
190
|
+
|
|
191
|
+
- `GET /health`
|
|
192
|
+
- `GET /state`
|
|
193
|
+
- `GET /events` for server-sent state updates
|
|
194
|
+
- `GET /meetings`
|
|
195
|
+
- `GET /meetings/:id`
|
|
196
|
+
- `POST /exports/notes`
|
|
197
|
+
- `POST /exports/transcripts`
|
|
198
|
+
|
|
199
|
+
This is the foundation for the future `granola web` client and any attachable TUI flows.
|
|
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
|
+
|
|
170
212
|
## Auth
|
|
171
213
|
|
|
172
214
|
If you do not want to keep passing `--supabase`, import the desktop app session once:
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { dirname, join } from "node:path";
|
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { NodeHtmlMarkdown } from "node-html-markdown";
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
|
+
import { createServer } from "node:http";
|
|
10
11
|
//#region src/utils.ts
|
|
11
12
|
const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g;
|
|
12
13
|
const CONTROL_CHARACTERS = /\p{Cc}/gu;
|
|
@@ -153,7 +154,7 @@ function transcriptSpeakerLabel(segment) {
|
|
|
153
154
|
}
|
|
154
155
|
//#endregion
|
|
155
156
|
//#region src/client/auth.ts
|
|
156
|
-
const execFileAsync = promisify(execFile);
|
|
157
|
+
const execFileAsync$1 = promisify(execFile);
|
|
157
158
|
const DEFAULT_CLIENT_ID = "client_GranolaMac";
|
|
158
159
|
const KEYCHAIN_SERVICE_NAME = "com.granola.toolkit";
|
|
159
160
|
const KEYCHAIN_ACCOUNT_NAME = "session";
|
|
@@ -243,7 +244,7 @@ var FileSessionStore = class {
|
|
|
243
244
|
var KeychainSessionStore = class {
|
|
244
245
|
async clearSession() {
|
|
245
246
|
try {
|
|
246
|
-
await execFileAsync("security", [
|
|
247
|
+
await execFileAsync$1("security", [
|
|
247
248
|
"delete-generic-password",
|
|
248
249
|
"-s",
|
|
249
250
|
KEYCHAIN_SERVICE_NAME,
|
|
@@ -254,7 +255,7 @@ var KeychainSessionStore = class {
|
|
|
254
255
|
}
|
|
255
256
|
async readSession() {
|
|
256
257
|
try {
|
|
257
|
-
const { stdout } = await execFileAsync("security", [
|
|
258
|
+
const { stdout } = await execFileAsync$1("security", [
|
|
258
259
|
"find-generic-password",
|
|
259
260
|
"-s",
|
|
260
261
|
KEYCHAIN_SERVICE_NAME,
|
|
@@ -269,7 +270,7 @@ var KeychainSessionStore = class {
|
|
|
269
270
|
}
|
|
270
271
|
}
|
|
271
272
|
async writeSession(session) {
|
|
272
|
-
await execFileAsync("security", [
|
|
273
|
+
await execFileAsync$1("security", [
|
|
273
274
|
"add-generic-password",
|
|
274
275
|
"-U",
|
|
275
276
|
"-s",
|
|
@@ -1517,6 +1518,7 @@ var GranolaApp = class {
|
|
|
1517
1518
|
#cacheResolved = false;
|
|
1518
1519
|
#granolaClient;
|
|
1519
1520
|
#documents;
|
|
1521
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
1520
1522
|
#state;
|
|
1521
1523
|
constructor(config, deps, options = {}) {
|
|
1522
1524
|
this.config = config;
|
|
@@ -1526,16 +1528,31 @@ var GranolaApp = class {
|
|
|
1526
1528
|
getState() {
|
|
1527
1529
|
return cloneState(this.#state);
|
|
1528
1530
|
}
|
|
1531
|
+
subscribe(listener) {
|
|
1532
|
+
this.#listeners.add(listener);
|
|
1533
|
+
return () => {
|
|
1534
|
+
this.#listeners.delete(listener);
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1529
1537
|
setUiState(patch) {
|
|
1530
1538
|
this.#state.ui = {
|
|
1531
1539
|
...this.#state.ui,
|
|
1532
1540
|
...patch
|
|
1533
1541
|
};
|
|
1542
|
+
this.emitStateUpdate();
|
|
1534
1543
|
return this.getState();
|
|
1535
1544
|
}
|
|
1536
1545
|
nowIso() {
|
|
1537
1546
|
return (this.deps.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
|
|
1538
1547
|
}
|
|
1548
|
+
emitStateUpdate() {
|
|
1549
|
+
const event = {
|
|
1550
|
+
state: this.getState(),
|
|
1551
|
+
timestamp: this.nowIso(),
|
|
1552
|
+
type: "state.updated"
|
|
1553
|
+
};
|
|
1554
|
+
for (const listener of this.#listeners) listener(event);
|
|
1555
|
+
}
|
|
1539
1556
|
async getGranolaClient() {
|
|
1540
1557
|
if (this.#granolaClient) return this.#granolaClient;
|
|
1541
1558
|
if (this.deps.granolaClient) {
|
|
@@ -1546,6 +1563,7 @@ var GranolaApp = class {
|
|
|
1546
1563
|
const runtime = await this.deps.createGranolaClient();
|
|
1547
1564
|
this.#granolaClient = runtime.client;
|
|
1548
1565
|
this.#state.auth = { ...runtime.auth };
|
|
1566
|
+
this.emitStateUpdate();
|
|
1549
1567
|
return this.#granolaClient;
|
|
1550
1568
|
}
|
|
1551
1569
|
missingCacheError() {
|
|
@@ -1560,6 +1578,7 @@ var GranolaApp = class {
|
|
|
1560
1578
|
loaded: true,
|
|
1561
1579
|
loadedAt: this.nowIso()
|
|
1562
1580
|
};
|
|
1581
|
+
this.emitStateUpdate();
|
|
1563
1582
|
return documents;
|
|
1564
1583
|
}
|
|
1565
1584
|
async loadCache(options = {}) {
|
|
@@ -1585,6 +1604,7 @@ var GranolaApp = class {
|
|
|
1585
1604
|
loadedAt: cacheData ? this.nowIso() : void 0,
|
|
1586
1605
|
transcriptCount: cacheData ? transcriptCount(cacheData) : 0
|
|
1587
1606
|
};
|
|
1607
|
+
this.emitStateUpdate();
|
|
1588
1608
|
if (options.required && !cacheData) throw this.missingCacheError();
|
|
1589
1609
|
return cacheData;
|
|
1590
1610
|
}
|
|
@@ -1626,6 +1646,7 @@ var GranolaApp = class {
|
|
|
1626
1646
|
ranAt: this.nowIso(),
|
|
1627
1647
|
written
|
|
1628
1648
|
};
|
|
1649
|
+
this.emitStateUpdate();
|
|
1629
1650
|
this.setUiState({ view: "notes-export" });
|
|
1630
1651
|
return {
|
|
1631
1652
|
documentCount: documents.length,
|
|
@@ -1647,6 +1668,7 @@ var GranolaApp = class {
|
|
|
1647
1668
|
ranAt: this.nowIso(),
|
|
1648
1669
|
written
|
|
1649
1670
|
};
|
|
1671
|
+
this.emitStateUpdate();
|
|
1650
1672
|
this.setUiState({ view: "transcripts-export" });
|
|
1651
1673
|
return {
|
|
1652
1674
|
cacheData,
|
|
@@ -1745,6 +1767,41 @@ async function loadConfig(options) {
|
|
|
1745
1767
|
function debug(enabled, ...values) {
|
|
1746
1768
|
if (enabled) console.error("[debug]", ...values);
|
|
1747
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
|
+
}
|
|
1748
1805
|
//#endregion
|
|
1749
1806
|
//#region src/commands/meeting.ts
|
|
1750
1807
|
function meetingHelp() {
|
|
@@ -2010,6 +2067,778 @@ function resolveNoteFormat(value) {
|
|
|
2010
2067
|
}
|
|
2011
2068
|
}
|
|
2012
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
|
|
2612
|
+
//#region src/server/http.ts
|
|
2613
|
+
function parseInteger(value) {
|
|
2614
|
+
if (!value?.trim()) return;
|
|
2615
|
+
if (!/^\d+$/.test(value)) throw new Error("invalid limit: expected a positive integer");
|
|
2616
|
+
const parsed = Number(value);
|
|
2617
|
+
if (!Number.isInteger(parsed) || parsed < 1) throw new Error("invalid limit: expected a positive integer");
|
|
2618
|
+
return parsed;
|
|
2619
|
+
}
|
|
2620
|
+
function sendJson(response, body, init = {}) {
|
|
2621
|
+
const payload = `${JSON.stringify(body, null, 2)}\n`;
|
|
2622
|
+
response.writeHead(init.status ?? 200, {
|
|
2623
|
+
"content-length": Buffer.byteLength(payload),
|
|
2624
|
+
"content-type": "application/json; charset=utf-8"
|
|
2625
|
+
});
|
|
2626
|
+
response.end(payload);
|
|
2627
|
+
}
|
|
2628
|
+
function sendText(response, body, status = 200) {
|
|
2629
|
+
response.writeHead(status, {
|
|
2630
|
+
"content-length": Buffer.byteLength(body),
|
|
2631
|
+
"content-type": "text/plain; charset=utf-8"
|
|
2632
|
+
});
|
|
2633
|
+
response.end(body);
|
|
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
|
+
}
|
|
2642
|
+
async function readJsonBody(request) {
|
|
2643
|
+
const chunks = [];
|
|
2644
|
+
for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
2645
|
+
if (chunks.length === 0) return {};
|
|
2646
|
+
try {
|
|
2647
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
2648
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("request body must be a JSON object");
|
|
2649
|
+
return parsed;
|
|
2650
|
+
} catch (error) {
|
|
2651
|
+
throw new Error(error instanceof Error ? error.message : "failed to parse JSON body");
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
function formatSseEvent(event) {
|
|
2655
|
+
return `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
|
|
2656
|
+
}
|
|
2657
|
+
function noteFormatFromBody(value) {
|
|
2658
|
+
switch (value) {
|
|
2659
|
+
case void 0:
|
|
2660
|
+
case "markdown": return "markdown";
|
|
2661
|
+
case "json":
|
|
2662
|
+
case "raw":
|
|
2663
|
+
case "yaml": return value;
|
|
2664
|
+
default: throw new Error("invalid notes format: expected markdown, json, yaml, or raw");
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
function transcriptFormatFromBody(value) {
|
|
2668
|
+
switch (value) {
|
|
2669
|
+
case void 0:
|
|
2670
|
+
case "text": return "text";
|
|
2671
|
+
case "json":
|
|
2672
|
+
case "raw":
|
|
2673
|
+
case "yaml": return value;
|
|
2674
|
+
default: throw new Error("invalid transcript format: expected text, json, yaml, or raw");
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
async function startGranolaServer(app, options = {}) {
|
|
2678
|
+
const enableWebClient = options.enableWebClient ?? false;
|
|
2679
|
+
const hostname = options.hostname ?? "127.0.0.1";
|
|
2680
|
+
const port = options.port ?? 0;
|
|
2681
|
+
const server = createServer(async (request, response) => {
|
|
2682
|
+
const method = request.method ?? "GET";
|
|
2683
|
+
const url = new URL(request.url ?? "/", `http://${hostname}`);
|
|
2684
|
+
const path = url.pathname;
|
|
2685
|
+
try {
|
|
2686
|
+
if (method === "GET" && path === "/" && enableWebClient) {
|
|
2687
|
+
sendHtml(response, renderGranolaWebPage());
|
|
2688
|
+
return;
|
|
2689
|
+
}
|
|
2690
|
+
if (method === "GET" && path === "/health") {
|
|
2691
|
+
sendJson(response, {
|
|
2692
|
+
ok: true,
|
|
2693
|
+
service: "granola-toolkit",
|
|
2694
|
+
version: app.config ? void 0 : void 0
|
|
2695
|
+
});
|
|
2696
|
+
return;
|
|
2697
|
+
}
|
|
2698
|
+
if (method === "GET" && path === "/state") {
|
|
2699
|
+
sendJson(response, app.getState());
|
|
2700
|
+
return;
|
|
2701
|
+
}
|
|
2702
|
+
if (method === "GET" && path === "/events") {
|
|
2703
|
+
response.writeHead(200, {
|
|
2704
|
+
"cache-control": "no-cache, no-transform",
|
|
2705
|
+
connection: "keep-alive",
|
|
2706
|
+
"content-type": "text/event-stream; charset=utf-8"
|
|
2707
|
+
});
|
|
2708
|
+
response.write(formatSseEvent({
|
|
2709
|
+
state: app.getState(),
|
|
2710
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2711
|
+
type: "state.updated"
|
|
2712
|
+
}));
|
|
2713
|
+
const unsubscribe = app.subscribe((event) => {
|
|
2714
|
+
response.write(formatSseEvent(event));
|
|
2715
|
+
});
|
|
2716
|
+
request.on("close", () => {
|
|
2717
|
+
unsubscribe();
|
|
2718
|
+
response.end();
|
|
2719
|
+
});
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
if (method === "GET" && path === "/meetings") {
|
|
2723
|
+
const limit = parseInteger(url.searchParams.get("limit"));
|
|
2724
|
+
const search = url.searchParams.get("search")?.trim() || void 0;
|
|
2725
|
+
sendJson(response, {
|
|
2726
|
+
meetings: await app.listMeetings({
|
|
2727
|
+
limit,
|
|
2728
|
+
search
|
|
2729
|
+
}),
|
|
2730
|
+
search
|
|
2731
|
+
});
|
|
2732
|
+
return;
|
|
2733
|
+
}
|
|
2734
|
+
if (method === "GET" && path.startsWith("/meetings/")) {
|
|
2735
|
+
const id = decodeURIComponent(path.slice(10));
|
|
2736
|
+
if (!id) throw new Error("meeting id is required");
|
|
2737
|
+
sendJson(response, await app.getMeeting(id, { requireCache: url.searchParams.get("includeTranscript") === "true" }));
|
|
2738
|
+
return;
|
|
2739
|
+
}
|
|
2740
|
+
if (method === "POST" && path === "/exports/notes") {
|
|
2741
|
+
const body = await readJsonBody(request);
|
|
2742
|
+
sendJson(response, await app.exportNotes(noteFormatFromBody(body.format)), { status: 202 });
|
|
2743
|
+
return;
|
|
2744
|
+
}
|
|
2745
|
+
if (method === "POST" && path === "/exports/transcripts") {
|
|
2746
|
+
const body = await readJsonBody(request);
|
|
2747
|
+
sendJson(response, await app.exportTranscripts(transcriptFormatFromBody(body.format)), { status: 202 });
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
sendText(response, "Not found\n", 404);
|
|
2751
|
+
} catch (error) {
|
|
2752
|
+
sendJson(response, { error: error instanceof Error ? error.message : String(error) }, { status: 400 });
|
|
2753
|
+
}
|
|
2754
|
+
});
|
|
2755
|
+
await new Promise((resolve, reject) => {
|
|
2756
|
+
server.once("error", reject);
|
|
2757
|
+
server.listen(port, hostname, () => {
|
|
2758
|
+
server.off("error", reject);
|
|
2759
|
+
resolve();
|
|
2760
|
+
});
|
|
2761
|
+
});
|
|
2762
|
+
const address = server.address();
|
|
2763
|
+
if (!address || typeof address === "string") throw new Error("failed to resolve server address");
|
|
2764
|
+
const resolved = address;
|
|
2765
|
+
const url = new URL(`http://${hostname}:${resolved.port}`);
|
|
2766
|
+
return {
|
|
2767
|
+
app,
|
|
2768
|
+
async close() {
|
|
2769
|
+
await new Promise((resolve, reject) => {
|
|
2770
|
+
server.close((error) => {
|
|
2771
|
+
if (error) {
|
|
2772
|
+
reject(error);
|
|
2773
|
+
return;
|
|
2774
|
+
}
|
|
2775
|
+
resolve();
|
|
2776
|
+
});
|
|
2777
|
+
});
|
|
2778
|
+
},
|
|
2779
|
+
hostname,
|
|
2780
|
+
port: resolved.port,
|
|
2781
|
+
server,
|
|
2782
|
+
url
|
|
2783
|
+
};
|
|
2784
|
+
}
|
|
2785
|
+
//#endregion
|
|
2786
|
+
//#region src/commands/serve.ts
|
|
2787
|
+
function serveHelp() {
|
|
2788
|
+
return `Granola serve
|
|
2789
|
+
|
|
2790
|
+
Usage:
|
|
2791
|
+
granola serve [options]
|
|
2792
|
+
|
|
2793
|
+
Options:
|
|
2794
|
+
--hostname <value> Hostname to bind (default: 127.0.0.1)
|
|
2795
|
+
--port <value> Port to bind (default: 0 for any available port)
|
|
2796
|
+
--cache <path> Path to Granola cache JSON
|
|
2797
|
+
--timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
|
|
2798
|
+
--supabase <path> Path to supabase.json
|
|
2799
|
+
--debug Enable debug logging
|
|
2800
|
+
--config <path> Path to .granola.toml
|
|
2801
|
+
-h, --help Show help
|
|
2802
|
+
`;
|
|
2803
|
+
}
|
|
2804
|
+
const serveCommand = {
|
|
2805
|
+
description: "Start a local Granola API server",
|
|
2806
|
+
flags: {
|
|
2807
|
+
cache: { type: "string" },
|
|
2808
|
+
help: { type: "boolean" },
|
|
2809
|
+
hostname: { type: "string" },
|
|
2810
|
+
port: { type: "string" },
|
|
2811
|
+
timeout: { type: "string" }
|
|
2812
|
+
},
|
|
2813
|
+
help: serveHelp,
|
|
2814
|
+
name: "serve",
|
|
2815
|
+
async run({ commandFlags, globalFlags }) {
|
|
2816
|
+
const config = await loadConfig({
|
|
2817
|
+
globalFlags,
|
|
2818
|
+
subcommandFlags: commandFlags
|
|
2819
|
+
});
|
|
2820
|
+
debug(config.debug, "using config", config.configFileUsed ?? "(none)");
|
|
2821
|
+
debug(config.debug, "supabase", config.supabase);
|
|
2822
|
+
debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
|
|
2823
|
+
debug(config.debug, "timeoutMs", config.notes.timeoutMs);
|
|
2824
|
+
const server = await startGranolaServer(await createGranolaApp(config, { surface: "server" }), {
|
|
2825
|
+
hostname: pickHostname(commandFlags.hostname),
|
|
2826
|
+
port: parsePort(commandFlags.port)
|
|
2827
|
+
});
|
|
2828
|
+
console.log(`Granola server listening on ${server.url.href}`);
|
|
2829
|
+
console.log("Endpoints:");
|
|
2830
|
+
console.log(" GET /health");
|
|
2831
|
+
console.log(" GET /state");
|
|
2832
|
+
console.log(" GET /events");
|
|
2833
|
+
console.log(" GET /meetings");
|
|
2834
|
+
console.log(" GET /meetings/:id");
|
|
2835
|
+
console.log(" POST /exports/notes");
|
|
2836
|
+
console.log(" POST /exports/transcripts");
|
|
2837
|
+
await waitForShutdown(async () => await server.close());
|
|
2838
|
+
return 0;
|
|
2839
|
+
}
|
|
2840
|
+
};
|
|
2841
|
+
//#endregion
|
|
2013
2842
|
//#region src/commands/transcripts.ts
|
|
2014
2843
|
function transcriptsHelp() {
|
|
2015
2844
|
return `Granola transcripts
|
|
@@ -2065,12 +2894,115 @@ function resolveTranscriptFormat(value) {
|
|
|
2065
2894
|
}
|
|
2066
2895
|
}
|
|
2067
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
|
|
2068
2948
|
//#region src/commands/index.ts
|
|
2069
2949
|
const commands = [
|
|
2070
2950
|
authCommand,
|
|
2071
2951
|
meetingCommand,
|
|
2072
2952
|
notesCommand,
|
|
2073
|
-
|
|
2953
|
+
serveCommand,
|
|
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
|
+
}
|
|
2074
3006
|
];
|
|
2075
3007
|
const commandMap = new Map(commands.map((command) => [command.name, command]));
|
|
2076
3008
|
//#endregion
|