granola-toolkit 0.18.0 → 0.20.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 +20 -0
- package/dist/cli.js +1033 -37
- 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
|
|
@@ -187,12 +192,27 @@ The initial server API includes:
|
|
|
187
192
|
- `GET /state`
|
|
188
193
|
- `GET /events` for server-sent state updates
|
|
189
194
|
- `GET /meetings`
|
|
195
|
+
- `GET /meetings/resolve?q=<query>`
|
|
190
196
|
- `GET /meetings/:id`
|
|
191
197
|
- `POST /exports/notes`
|
|
192
198
|
- `POST /exports/transcripts`
|
|
193
199
|
|
|
194
200
|
This is the foundation for the future `granola web` client and any attachable TUI flows.
|
|
195
201
|
|
|
202
|
+
### Web
|
|
203
|
+
|
|
204
|
+
`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`.
|
|
205
|
+
|
|
206
|
+
The initial browser client includes:
|
|
207
|
+
|
|
208
|
+
- a searchable meeting list
|
|
209
|
+
- sort and updated-date filters
|
|
210
|
+
- quick open by meeting id or title
|
|
211
|
+
- a meeting detail view with notes and transcript panes
|
|
212
|
+
- app-state status from the shared core
|
|
213
|
+
- note and transcript export actions backed by the same local API
|
|
214
|
+
- stronger empty and error states for list/detail failures
|
|
215
|
+
|
|
196
216
|
## Auth
|
|
197
217
|
|
|
198
218
|
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",
|
|
@@ -1275,6 +1275,17 @@ function compareTimestampsDescending(left, right) {
|
|
|
1275
1275
|
function compareMeetingDocuments(left, right) {
|
|
1276
1276
|
return compareTimestampsDescending(latestDocumentTimestamp(left), latestDocumentTimestamp(right)) || compareTimestampsDescending(left.createdAt, right.createdAt) || compareStrings(left.title || left.id, right.title || right.id) || compareStrings(left.id, right.id);
|
|
1277
1277
|
}
|
|
1278
|
+
function compareMeetingDocumentsByTitle(left, right) {
|
|
1279
|
+
return compareStrings(left.title || left.id, right.title || right.id) || compareTimestampsDescending(latestDocumentTimestamp(left), latestDocumentTimestamp(right)) || compareStrings(left.id, right.id);
|
|
1280
|
+
}
|
|
1281
|
+
function compareMeetingDocumentsBySort(left, right, sort) {
|
|
1282
|
+
switch (sort) {
|
|
1283
|
+
case "title-asc": return compareMeetingDocumentsByTitle(left, right);
|
|
1284
|
+
case "title-desc": return -compareMeetingDocumentsByTitle(left, right);
|
|
1285
|
+
case "updated-asc": return -compareMeetingDocuments(left, right);
|
|
1286
|
+
default: return compareMeetingDocuments(left, right);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1278
1289
|
function serialiseNote(note) {
|
|
1279
1290
|
return {
|
|
1280
1291
|
content: note.content,
|
|
@@ -1338,6 +1349,23 @@ function matchesMeetingSearch(document, search) {
|
|
|
1338
1349
|
...document.tags
|
|
1339
1350
|
].some((value) => value.toLowerCase().includes(query));
|
|
1340
1351
|
}
|
|
1352
|
+
function parseDateFilter(value, label) {
|
|
1353
|
+
const trimmed = value?.trim();
|
|
1354
|
+
if (!trimmed) return;
|
|
1355
|
+
const candidate = /^\d{4}-\d{2}-\d{2}$/.test(trimmed) ? `${trimmed}T${label === "updatedFrom" ? "00:00:00.000" : "23:59:59.999"}` : trimmed;
|
|
1356
|
+
const timestamp = Date.parse(candidate);
|
|
1357
|
+
if (Number.isNaN(timestamp)) throw new Error(`invalid ${label}: expected ISO timestamp or YYYY-MM-DD`);
|
|
1358
|
+
return timestamp;
|
|
1359
|
+
}
|
|
1360
|
+
function matchesUpdatedRange(document, updatedFrom, updatedTo) {
|
|
1361
|
+
const from = parseDateFilter(updatedFrom, "updatedFrom");
|
|
1362
|
+
const to = parseDateFilter(updatedTo, "updatedTo");
|
|
1363
|
+
const updatedAt = parseTimestamp(latestDocumentTimestamp(document));
|
|
1364
|
+
if (updatedAt == null) return from == null && to == null;
|
|
1365
|
+
if (from != null && updatedAt < from) return false;
|
|
1366
|
+
if (to != null && updatedAt > to) return false;
|
|
1367
|
+
return true;
|
|
1368
|
+
}
|
|
1341
1369
|
function truncate(value, width) {
|
|
1342
1370
|
if (value.length <= width) return value.padEnd(width);
|
|
1343
1371
|
return `${value.slice(0, Math.max(0, width - 1))}…`;
|
|
@@ -1390,7 +1418,23 @@ function buildMeetingRecord(document, cacheData) {
|
|
|
1390
1418
|
}
|
|
1391
1419
|
function listMeetings(documents, options = {}) {
|
|
1392
1420
|
const limit = options.limit ?? 20;
|
|
1393
|
-
|
|
1421
|
+
const sort = options.sort ?? "updated-desc";
|
|
1422
|
+
return documents.filter((document) => options.search ? matchesMeetingSearch(document, options.search) : true).filter((document) => matchesUpdatedRange(document, options.updatedFrom, options.updatedTo)).sort((left, right) => compareMeetingDocumentsBySort(left, right, sort)).slice(0, limit).map((document) => buildMeetingSummary(document, options.cacheData));
|
|
1423
|
+
}
|
|
1424
|
+
function resolveMeetingQuery(documents, query) {
|
|
1425
|
+
const trimmed = query.trim();
|
|
1426
|
+
if (!trimmed) throw new Error("meeting query is required");
|
|
1427
|
+
const lower = trimmed.toLowerCase();
|
|
1428
|
+
const exactId = documents.find((document) => document.id === trimmed);
|
|
1429
|
+
if (exactId) return exactId;
|
|
1430
|
+
const exactTitleMatches = documents.filter((document) => document.title.toLowerCase() === lower);
|
|
1431
|
+
if (exactTitleMatches.length === 1) return exactTitleMatches[0];
|
|
1432
|
+
const prefixMatches = documents.filter((document) => document.id.startsWith(trimmed));
|
|
1433
|
+
if (prefixMatches.length === 1) return prefixMatches[0];
|
|
1434
|
+
const titleMatches = documents.filter((document) => document.title.toLowerCase().includes(lower)).sort(compareMeetingDocuments);
|
|
1435
|
+
if (titleMatches.length === 1) return titleMatches[0];
|
|
1436
|
+
if (exactTitleMatches.length > 1 || prefixMatches.length > 1 || titleMatches.length > 1) throw new Error(`ambiguous meeting query: ${trimmed}`);
|
|
1437
|
+
throw new Error(`meeting not found: ${trimmed}`);
|
|
1394
1438
|
}
|
|
1395
1439
|
function resolveMeeting(documents, id) {
|
|
1396
1440
|
const exactMatch = documents.find((document) => document.id === id);
|
|
@@ -1612,10 +1656,16 @@ var GranolaApp = class {
|
|
|
1612
1656
|
const meetings = listMeetings(await this.listDocuments(), {
|
|
1613
1657
|
cacheData: await this.loadCache(),
|
|
1614
1658
|
limit: options.limit,
|
|
1615
|
-
search: options.search
|
|
1659
|
+
search: options.search,
|
|
1660
|
+
sort: options.sort,
|
|
1661
|
+
updatedFrom: options.updatedFrom,
|
|
1662
|
+
updatedTo: options.updatedTo
|
|
1616
1663
|
});
|
|
1617
1664
|
this.setUiState({
|
|
1618
1665
|
meetingSearch: options.search,
|
|
1666
|
+
meetingSort: options.sort,
|
|
1667
|
+
meetingUpdatedFrom: options.updatedFrom,
|
|
1668
|
+
meetingUpdatedTo: options.updatedTo,
|
|
1619
1669
|
selectedMeetingId: void 0,
|
|
1620
1670
|
view: "meeting-list"
|
|
1621
1671
|
});
|
|
@@ -1636,6 +1686,21 @@ var GranolaApp = class {
|
|
|
1636
1686
|
meeting
|
|
1637
1687
|
};
|
|
1638
1688
|
}
|
|
1689
|
+
async findMeeting(query, options = {}) {
|
|
1690
|
+
const documents = await this.listDocuments();
|
|
1691
|
+
const cacheData = await this.loadCache({ required: options.requireCache });
|
|
1692
|
+
const document = resolveMeetingQuery(documents, query);
|
|
1693
|
+
const meeting = buildMeetingRecord(document, cacheData);
|
|
1694
|
+
this.setUiState({
|
|
1695
|
+
selectedMeetingId: document.id,
|
|
1696
|
+
view: "meeting-detail"
|
|
1697
|
+
});
|
|
1698
|
+
return {
|
|
1699
|
+
cacheData,
|
|
1700
|
+
document,
|
|
1701
|
+
meeting
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1639
1704
|
async exportNotes(format = "markdown") {
|
|
1640
1705
|
const documents = await this.listDocuments();
|
|
1641
1706
|
const written = await writeNotes(documents, this.config.notes.output, format);
|
|
@@ -1767,6 +1832,41 @@ async function loadConfig(options) {
|
|
|
1767
1832
|
function debug(enabled, ...values) {
|
|
1768
1833
|
if (enabled) console.error("[debug]", ...values);
|
|
1769
1834
|
}
|
|
1835
|
+
function parsePort(value) {
|
|
1836
|
+
if (value === void 0) return;
|
|
1837
|
+
if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid port: expected a non-negative integer");
|
|
1838
|
+
const port = Number(value);
|
|
1839
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error("invalid port: expected a value between 0 and 65535");
|
|
1840
|
+
return port;
|
|
1841
|
+
}
|
|
1842
|
+
function pickHostname(value, fallback = "127.0.0.1") {
|
|
1843
|
+
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
1844
|
+
}
|
|
1845
|
+
async function waitForShutdown(close) {
|
|
1846
|
+
await new Promise((resolve, reject) => {
|
|
1847
|
+
let closing = false;
|
|
1848
|
+
const cleanup = () => {
|
|
1849
|
+
process.off("SIGINT", handleSignal);
|
|
1850
|
+
process.off("SIGTERM", handleSignal);
|
|
1851
|
+
};
|
|
1852
|
+
const finish = async () => {
|
|
1853
|
+
if (closing) return;
|
|
1854
|
+
closing = true;
|
|
1855
|
+
cleanup();
|
|
1856
|
+
try {
|
|
1857
|
+
await close();
|
|
1858
|
+
resolve();
|
|
1859
|
+
} catch (error) {
|
|
1860
|
+
reject(error);
|
|
1861
|
+
}
|
|
1862
|
+
};
|
|
1863
|
+
const handleSignal = () => {
|
|
1864
|
+
finish();
|
|
1865
|
+
};
|
|
1866
|
+
process.on("SIGINT", handleSignal);
|
|
1867
|
+
process.on("SIGTERM", handleSignal);
|
|
1868
|
+
});
|
|
1869
|
+
}
|
|
1770
1870
|
//#endregion
|
|
1771
1871
|
//#region src/commands/meeting.ts
|
|
1772
1872
|
function meetingHelp() {
|
|
@@ -2032,6 +2132,788 @@ function resolveNoteFormat(value) {
|
|
|
2032
2132
|
}
|
|
2033
2133
|
}
|
|
2034
2134
|
//#endregion
|
|
2135
|
+
//#region src/server/web.ts
|
|
2136
|
+
function renderGranolaWebPage() {
|
|
2137
|
+
return `<!doctype html>
|
|
2138
|
+
<html lang="en">
|
|
2139
|
+
<head>
|
|
2140
|
+
<meta charset="utf-8" />
|
|
2141
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2142
|
+
<title>Granola Toolkit</title>
|
|
2143
|
+
<style>
|
|
2144
|
+
:root {
|
|
2145
|
+
--bg: #f2ede2;
|
|
2146
|
+
--panel: rgba(255, 252, 247, 0.86);
|
|
2147
|
+
--panel-strong: #fffaf2;
|
|
2148
|
+
--line: rgba(36, 39, 44, 0.12);
|
|
2149
|
+
--ink: #1d242c;
|
|
2150
|
+
--muted: #5d6b77;
|
|
2151
|
+
--accent: #0d6a6d;
|
|
2152
|
+
--accent-soft: rgba(13, 106, 109, 0.12);
|
|
2153
|
+
--warm: #a34f2f;
|
|
2154
|
+
--ok: #246b4f;
|
|
2155
|
+
--error: #9d2c2c;
|
|
2156
|
+
--shadow: 0 24px 80px rgba(40, 32, 16, 0.12);
|
|
2157
|
+
--radius: 24px;
|
|
2158
|
+
--mono: "SF Mono", "IBM Plex Mono", "Cascadia Code", monospace;
|
|
2159
|
+
--serif: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif;
|
|
2160
|
+
--sans: "Avenir Next", "Segoe UI", sans-serif;
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
* { box-sizing: border-box; }
|
|
2164
|
+
|
|
2165
|
+
body {
|
|
2166
|
+
margin: 0;
|
|
2167
|
+
min-height: 100vh;
|
|
2168
|
+
font-family: var(--sans);
|
|
2169
|
+
color: var(--ink);
|
|
2170
|
+
background:
|
|
2171
|
+
radial-gradient(circle at top left, rgba(163, 79, 47, 0.18), transparent 32%),
|
|
2172
|
+
radial-gradient(circle at right 12%, rgba(13, 106, 109, 0.16), transparent 28%),
|
|
2173
|
+
linear-gradient(180deg, #f8f2e8 0%, var(--bg) 100%);
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
.shell {
|
|
2177
|
+
display: grid;
|
|
2178
|
+
grid-template-columns: 320px minmax(0, 1fr);
|
|
2179
|
+
gap: 18px;
|
|
2180
|
+
min-height: 100vh;
|
|
2181
|
+
padding: 24px;
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
.pane {
|
|
2185
|
+
background: var(--panel);
|
|
2186
|
+
backdrop-filter: blur(18px);
|
|
2187
|
+
border: 1px solid var(--line);
|
|
2188
|
+
border-radius: var(--radius);
|
|
2189
|
+
box-shadow: var(--shadow);
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
.sidebar {
|
|
2193
|
+
display: grid;
|
|
2194
|
+
grid-template-rows: auto auto 1fr;
|
|
2195
|
+
overflow: hidden;
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
.hero, .toolbar, .detail-head {
|
|
2199
|
+
padding: 22px 24px;
|
|
2200
|
+
border-bottom: 1px solid var(--line);
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
.hero h1 {
|
|
2204
|
+
margin: 0;
|
|
2205
|
+
font-family: var(--serif);
|
|
2206
|
+
font-size: clamp(2rem, 3vw, 2.8rem);
|
|
2207
|
+
font-weight: 600;
|
|
2208
|
+
letter-spacing: -0.04em;
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
.hero p, .toolbar p {
|
|
2212
|
+
margin: 8px 0 0;
|
|
2213
|
+
color: var(--muted);
|
|
2214
|
+
line-height: 1.5;
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
.search,
|
|
2218
|
+
.select,
|
|
2219
|
+
.field-input {
|
|
2220
|
+
width: 100%;
|
|
2221
|
+
margin-top: 16px;
|
|
2222
|
+
padding: 12px 14px;
|
|
2223
|
+
border: 1px solid var(--line);
|
|
2224
|
+
border-radius: 999px;
|
|
2225
|
+
background: rgba(255, 255, 255, 0.7);
|
|
2226
|
+
color: var(--ink);
|
|
2227
|
+
font: inherit;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
.field-row {
|
|
2231
|
+
display: grid;
|
|
2232
|
+
gap: 10px;
|
|
2233
|
+
margin-top: 12px;
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
.field-row--inline {
|
|
2237
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
.field-label {
|
|
2241
|
+
display: block;
|
|
2242
|
+
margin-bottom: 6px;
|
|
2243
|
+
color: var(--muted);
|
|
2244
|
+
font-size: 0.78rem;
|
|
2245
|
+
font-weight: 700;
|
|
2246
|
+
letter-spacing: 0.08em;
|
|
2247
|
+
text-transform: uppercase;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
.meeting-list {
|
|
2251
|
+
padding: 14px;
|
|
2252
|
+
overflow: auto;
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
.meeting-row {
|
|
2256
|
+
width: 100%;
|
|
2257
|
+
display: grid;
|
|
2258
|
+
gap: 4px;
|
|
2259
|
+
text-align: left;
|
|
2260
|
+
margin: 0 0 10px;
|
|
2261
|
+
padding: 14px 16px;
|
|
2262
|
+
border: 1px solid transparent;
|
|
2263
|
+
border-radius: 18px;
|
|
2264
|
+
background: rgba(255, 255, 255, 0.72);
|
|
2265
|
+
color: inherit;
|
|
2266
|
+
cursor: pointer;
|
|
2267
|
+
transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
.meeting-row:hover,
|
|
2271
|
+
.meeting-row[data-selected="true"] {
|
|
2272
|
+
transform: translateY(-1px);
|
|
2273
|
+
border-color: rgba(13, 106, 109, 0.25);
|
|
2274
|
+
background: var(--panel-strong);
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
.meeting-row__title {
|
|
2278
|
+
font-weight: 600;
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
.meeting-row__meta {
|
|
2282
|
+
color: var(--muted);
|
|
2283
|
+
font-size: 0.92rem;
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
.meeting-empty {
|
|
2287
|
+
padding: 18px;
|
|
2288
|
+
color: var(--muted);
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
.meeting-empty--error {
|
|
2292
|
+
color: var(--error);
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
.detail {
|
|
2296
|
+
display: grid;
|
|
2297
|
+
grid-template-rows: auto auto 1fr;
|
|
2298
|
+
min-width: 0;
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
.detail-head {
|
|
2302
|
+
display: flex;
|
|
2303
|
+
align-items: center;
|
|
2304
|
+
justify-content: space-between;
|
|
2305
|
+
gap: 18px;
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
.detail-head h2 {
|
|
2309
|
+
margin: 0;
|
|
2310
|
+
font-family: var(--serif);
|
|
2311
|
+
font-size: clamp(1.8rem, 2.4vw, 2.4rem);
|
|
2312
|
+
font-weight: 600;
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
.state-badge {
|
|
2316
|
+
padding: 10px 14px;
|
|
2317
|
+
border-radius: 999px;
|
|
2318
|
+
background: var(--accent-soft);
|
|
2319
|
+
color: var(--accent);
|
|
2320
|
+
font-size: 0.92rem;
|
|
2321
|
+
font-weight: 700;
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
.state-badge[data-tone="busy"] { color: var(--warm); background: rgba(163, 79, 47, 0.12); }
|
|
2325
|
+
.state-badge[data-tone="error"] { color: var(--error); background: rgba(157, 44, 44, 0.12); }
|
|
2326
|
+
.state-badge[data-tone="ok"] { color: var(--ok); background: rgba(36, 107, 79, 0.12); }
|
|
2327
|
+
|
|
2328
|
+
.toolbar {
|
|
2329
|
+
display: flex;
|
|
2330
|
+
flex-wrap: wrap;
|
|
2331
|
+
align-items: center;
|
|
2332
|
+
justify-content: space-between;
|
|
2333
|
+
gap: 14px;
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
.toolbar-actions {
|
|
2337
|
+
display: flex;
|
|
2338
|
+
flex-wrap: wrap;
|
|
2339
|
+
gap: 10px;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
.toolbar-form {
|
|
2343
|
+
display: grid;
|
|
2344
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
2345
|
+
gap: 10px;
|
|
2346
|
+
width: min(440px, 100%);
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
.button {
|
|
2350
|
+
border: 0;
|
|
2351
|
+
border-radius: 999px;
|
|
2352
|
+
padding: 12px 16px;
|
|
2353
|
+
font: inherit;
|
|
2354
|
+
font-weight: 700;
|
|
2355
|
+
cursor: pointer;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
.button--primary {
|
|
2359
|
+
background: var(--ink);
|
|
2360
|
+
color: white;
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
.button--secondary {
|
|
2364
|
+
background: rgba(255, 255, 255, 0.72);
|
|
2365
|
+
color: var(--ink);
|
|
2366
|
+
border: 1px solid var(--line);
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
.status-grid {
|
|
2370
|
+
display: grid;
|
|
2371
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
2372
|
+
gap: 14px;
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
.status-label {
|
|
2376
|
+
display: block;
|
|
2377
|
+
margin-bottom: 6px;
|
|
2378
|
+
color: var(--muted);
|
|
2379
|
+
font-size: 0.78rem;
|
|
2380
|
+
letter-spacing: 0.08em;
|
|
2381
|
+
text-transform: uppercase;
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
.detail-meta {
|
|
2385
|
+
display: flex;
|
|
2386
|
+
flex-wrap: wrap;
|
|
2387
|
+
gap: 10px;
|
|
2388
|
+
padding: 0 24px 18px;
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
.detail-chip {
|
|
2392
|
+
padding: 10px 12px;
|
|
2393
|
+
border-radius: 999px;
|
|
2394
|
+
background: rgba(255, 255, 255, 0.72);
|
|
2395
|
+
border: 1px solid var(--line);
|
|
2396
|
+
color: var(--muted);
|
|
2397
|
+
font-size: 0.88rem;
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
.detail-body {
|
|
2401
|
+
padding: 0 24px 24px;
|
|
2402
|
+
overflow: auto;
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
.detail-section {
|
|
2406
|
+
margin-bottom: 20px;
|
|
2407
|
+
padding: 20px;
|
|
2408
|
+
background: rgba(255, 255, 255, 0.72);
|
|
2409
|
+
border: 1px solid var(--line);
|
|
2410
|
+
border-radius: 20px;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
.detail-section h2 {
|
|
2414
|
+
margin: 0 0 14px;
|
|
2415
|
+
font-size: 1rem;
|
|
2416
|
+
letter-spacing: 0.08em;
|
|
2417
|
+
text-transform: uppercase;
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
.detail-pre {
|
|
2421
|
+
margin: 0;
|
|
2422
|
+
white-space: pre-wrap;
|
|
2423
|
+
word-break: break-word;
|
|
2424
|
+
font-family: var(--mono);
|
|
2425
|
+
line-height: 1.55;
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
.empty {
|
|
2429
|
+
margin: 24px;
|
|
2430
|
+
padding: 24px;
|
|
2431
|
+
border-radius: 20px;
|
|
2432
|
+
background: rgba(255, 255, 255, 0.72);
|
|
2433
|
+
border: 1px dashed rgba(36, 39, 44, 0.2);
|
|
2434
|
+
color: var(--muted);
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
@media (max-width: 900px) {
|
|
2438
|
+
.shell {
|
|
2439
|
+
grid-template-columns: 1fr;
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
.field-row--inline,
|
|
2443
|
+
.toolbar-form {
|
|
2444
|
+
grid-template-columns: 1fr;
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
</style>
|
|
2448
|
+
</head>
|
|
2449
|
+
<body>
|
|
2450
|
+
<div class="shell">
|
|
2451
|
+
<aside class="pane sidebar">
|
|
2452
|
+
<section class="hero">
|
|
2453
|
+
<h1>Granola Toolkit</h1>
|
|
2454
|
+
<p>Browser workspace for meetings, notes, transcripts, and export flows on top of one local server instance.</p>
|
|
2455
|
+
<input class="search" data-search placeholder="Search meetings, ids, or tags" />
|
|
2456
|
+
<div class="field-row field-row--inline">
|
|
2457
|
+
<label>
|
|
2458
|
+
<span class="field-label">Sort</span>
|
|
2459
|
+
<select class="select" data-sort>
|
|
2460
|
+
<option value="updated-desc">Newest first</option>
|
|
2461
|
+
<option value="updated-asc">Oldest first</option>
|
|
2462
|
+
<option value="title-asc">Title A-Z</option>
|
|
2463
|
+
<option value="title-desc">Title Z-A</option>
|
|
2464
|
+
</select>
|
|
2465
|
+
</label>
|
|
2466
|
+
<label>
|
|
2467
|
+
<span class="field-label">Updated From</span>
|
|
2468
|
+
<input class="field-input" data-updated-from type="date" />
|
|
2469
|
+
</label>
|
|
2470
|
+
</div>
|
|
2471
|
+
<label class="field-row">
|
|
2472
|
+
<span class="field-label">Updated To</span>
|
|
2473
|
+
<input class="field-input" data-updated-to type="date" />
|
|
2474
|
+
</label>
|
|
2475
|
+
</section>
|
|
2476
|
+
<section class="toolbar">
|
|
2477
|
+
<div>
|
|
2478
|
+
<p>Meetings are loaded from the shared server state so this view can later coexist with the terminal UI.</p>
|
|
2479
|
+
</div>
|
|
2480
|
+
<div class="toolbar-form">
|
|
2481
|
+
<input class="field-input" data-quick-open placeholder="Quick open by id or title" />
|
|
2482
|
+
<button class="button button--secondary" data-quick-open-button>Open</button>
|
|
2483
|
+
</div>
|
|
2484
|
+
</section>
|
|
2485
|
+
<section class="meeting-list" data-meeting-list></section>
|
|
2486
|
+
</aside>
|
|
2487
|
+
<main class="pane detail">
|
|
2488
|
+
<section class="detail-head">
|
|
2489
|
+
<div>
|
|
2490
|
+
<h2>Meeting Workspace</h2>
|
|
2491
|
+
<div data-app-state></div>
|
|
2492
|
+
</div>
|
|
2493
|
+
<div class="state-badge" data-state-badge data-tone="idle">Connecting…</div>
|
|
2494
|
+
</section>
|
|
2495
|
+
<section class="toolbar">
|
|
2496
|
+
<div class="toolbar-actions">
|
|
2497
|
+
<button class="button button--primary" data-refresh>Refresh</button>
|
|
2498
|
+
<button class="button button--secondary" data-export-notes>Export Notes</button>
|
|
2499
|
+
<button class="button button--secondary" data-export-transcripts>Export Transcripts</button>
|
|
2500
|
+
</div>
|
|
2501
|
+
<p>Initial beta web client. It speaks to the same local API that future TUI and attach flows will use.</p>
|
|
2502
|
+
</section>
|
|
2503
|
+
<div class="detail-meta" data-detail-meta></div>
|
|
2504
|
+
<div class="detail-body" data-detail-body>
|
|
2505
|
+
<div class="empty" data-empty>Select a meeting to inspect its notes and transcript.</div>
|
|
2506
|
+
</div>
|
|
2507
|
+
</main>
|
|
2508
|
+
</div>
|
|
2509
|
+
<script type="module">
|
|
2510
|
+
${String.raw`
|
|
2511
|
+
const state = {
|
|
2512
|
+
appState: null,
|
|
2513
|
+
detailError: "",
|
|
2514
|
+
listError: "",
|
|
2515
|
+
meetings: [],
|
|
2516
|
+
quickOpen: "",
|
|
2517
|
+
search: "",
|
|
2518
|
+
selectedMeeting: null,
|
|
2519
|
+
selectedMeetingId: null,
|
|
2520
|
+
sort: "updated-desc",
|
|
2521
|
+
updatedFrom: "",
|
|
2522
|
+
updatedTo: "",
|
|
2523
|
+
};
|
|
2524
|
+
|
|
2525
|
+
const els = {
|
|
2526
|
+
appState: document.querySelector("[data-app-state]"),
|
|
2527
|
+
detailBody: document.querySelector("[data-detail-body]"),
|
|
2528
|
+
detailMeta: document.querySelector("[data-detail-meta]"),
|
|
2529
|
+
empty: document.querySelector("[data-empty]"),
|
|
2530
|
+
list: document.querySelector("[data-meeting-list]"),
|
|
2531
|
+
noteButton: document.querySelector("[data-export-notes]"),
|
|
2532
|
+
quickOpen: document.querySelector("[data-quick-open]"),
|
|
2533
|
+
quickOpenButton: document.querySelector("[data-quick-open-button]"),
|
|
2534
|
+
refreshButton: document.querySelector("[data-refresh]"),
|
|
2535
|
+
search: document.querySelector("[data-search]"),
|
|
2536
|
+
sort: document.querySelector("[data-sort]"),
|
|
2537
|
+
stateBadge: document.querySelector("[data-state-badge]"),
|
|
2538
|
+
transcriptButton: document.querySelector("[data-export-transcripts]"),
|
|
2539
|
+
updatedFrom: document.querySelector("[data-updated-from]"),
|
|
2540
|
+
updatedTo: document.querySelector("[data-updated-to]"),
|
|
2541
|
+
};
|
|
2542
|
+
|
|
2543
|
+
function escapeHtml(value) {
|
|
2544
|
+
return value
|
|
2545
|
+
.replaceAll("&", "&")
|
|
2546
|
+
.replaceAll("<", "<")
|
|
2547
|
+
.replaceAll(">", ">")
|
|
2548
|
+
.replaceAll('"', """);
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
function setStatus(label, tone = "idle") {
|
|
2552
|
+
els.stateBadge.textContent = label;
|
|
2553
|
+
els.stateBadge.dataset.tone = tone;
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
function syncFilterInputs() {
|
|
2557
|
+
els.quickOpen.value = state.quickOpen;
|
|
2558
|
+
els.search.value = state.search;
|
|
2559
|
+
els.sort.value = state.sort;
|
|
2560
|
+
els.updatedFrom.value = state.updatedFrom;
|
|
2561
|
+
els.updatedTo.value = state.updatedTo;
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
function currentFilterSummary() {
|
|
2565
|
+
const parts = [];
|
|
2566
|
+
|
|
2567
|
+
if (state.search) {
|
|
2568
|
+
parts.push('search "' + state.search + '"');
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
if (state.updatedFrom) {
|
|
2572
|
+
parts.push("from " + state.updatedFrom);
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
if (state.updatedTo) {
|
|
2576
|
+
parts.push("to " + state.updatedTo);
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
return parts.join(", ");
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
function renderAppState() {
|
|
2583
|
+
if (!state.appState) {
|
|
2584
|
+
els.appState.innerHTML = "<p>Waiting for server state…</p>";
|
|
2585
|
+
return;
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
const appState = state.appState;
|
|
2589
|
+
const authMode = appState.auth.mode === "stored-session" ? "Stored session" : "supabase.json";
|
|
2590
|
+
const docs = appState.documents.loaded ? String(appState.documents.count) : "not loaded";
|
|
2591
|
+
const cache = appState.cache.loaded
|
|
2592
|
+
? appState.cache.transcriptCount + " transcript sets"
|
|
2593
|
+
: appState.cache.configured
|
|
2594
|
+
? "configured"
|
|
2595
|
+
: "not configured";
|
|
2596
|
+
|
|
2597
|
+
els.appState.innerHTML = [
|
|
2598
|
+
'<div class="status-grid">',
|
|
2599
|
+
'<div><span class="status-label">Surface</span><strong>' + escapeHtml(appState.ui.surface) + "</strong></div>",
|
|
2600
|
+
'<div><span class="status-label">View</span><strong>' + escapeHtml(appState.ui.view) + "</strong></div>",
|
|
2601
|
+
'<div><span class="status-label">Auth</span><strong>' + escapeHtml(authMode) + "</strong></div>",
|
|
2602
|
+
'<div><span class="status-label">Documents</span><strong>' + escapeHtml(docs) + "</strong></div>",
|
|
2603
|
+
'<div><span class="status-label">Cache</span><strong>' + escapeHtml(cache) + "</strong></div>",
|
|
2604
|
+
"</div>",
|
|
2605
|
+
].join("");
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
function renderMeetingList() {
|
|
2609
|
+
if (state.listError) {
|
|
2610
|
+
els.list.innerHTML =
|
|
2611
|
+
'<div class="meeting-empty meeting-empty--error">' + escapeHtml(state.listError) + "</div>";
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
if (state.meetings.length === 0) {
|
|
2616
|
+
state.selectedMeetingId = null;
|
|
2617
|
+
state.selectedMeeting = null;
|
|
2618
|
+
const filterSummary = currentFilterSummary();
|
|
2619
|
+
const message = filterSummary
|
|
2620
|
+
? "No meetings match " + filterSummary + "."
|
|
2621
|
+
: "No meetings yet. Try Refresh.";
|
|
2622
|
+
els.list.innerHTML = '<div class="meeting-empty">' + escapeHtml(message) + "</div>";
|
|
2623
|
+
renderMeetingDetail();
|
|
2624
|
+
return;
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
const visibleIds = new Set(state.meetings.map((meeting) => meeting.id));
|
|
2628
|
+
if (!state.selectedMeetingId || !visibleIds.has(state.selectedMeetingId)) {
|
|
2629
|
+
state.selectedMeetingId = state.meetings[0]?.id || null;
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
els.list.innerHTML = state.meetings
|
|
2633
|
+
.map((meeting) => {
|
|
2634
|
+
const selected = meeting.id === state.selectedMeetingId ? ' data-selected="true"' : "";
|
|
2635
|
+
const tags = meeting.tags.length ? meeting.tags.map((tag) => "#" + tag).join(" ") : "untagged";
|
|
2636
|
+
return [
|
|
2637
|
+
'<button class="meeting-row"' + selected + ' data-meeting-id="' + escapeHtml(meeting.id) + '">',
|
|
2638
|
+
'<span class="meeting-row__title">' + escapeHtml(meeting.title || meeting.id) + "</span>",
|
|
2639
|
+
'<span class="meeting-row__meta">' + escapeHtml(tags) + "</span>",
|
|
2640
|
+
'<span class="meeting-row__meta">' + escapeHtml(meeting.updatedAt.slice(0, 10) || "unknown") + "</span>",
|
|
2641
|
+
"</button>",
|
|
2642
|
+
].join("");
|
|
2643
|
+
})
|
|
2644
|
+
.join("");
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
function renderMeetingDetail() {
|
|
2648
|
+
if (state.detailError) {
|
|
2649
|
+
els.empty.hidden = false;
|
|
2650
|
+
els.empty.textContent = state.detailError;
|
|
2651
|
+
els.detailMeta.innerHTML = "";
|
|
2652
|
+
els.detailBody.innerHTML = "";
|
|
2653
|
+
return;
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
const record = state.selectedMeeting;
|
|
2657
|
+
if (!record) {
|
|
2658
|
+
els.empty.hidden = false;
|
|
2659
|
+
els.empty.textContent = "Select a meeting to inspect its notes and transcript.";
|
|
2660
|
+
els.detailMeta.innerHTML = "";
|
|
2661
|
+
els.detailBody.innerHTML = "";
|
|
2662
|
+
return;
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
els.empty.hidden = true;
|
|
2666
|
+
els.detailMeta.innerHTML = [
|
|
2667
|
+
'<div class="detail-chip">ID: ' + escapeHtml(record.meeting.id) + "</div>",
|
|
2668
|
+
'<div class="detail-chip">Source: ' + escapeHtml(record.meeting.noteContentSource) + "</div>",
|
|
2669
|
+
'<div class="detail-chip">Transcript: ' + escapeHtml(String(record.meeting.transcriptSegmentCount)) + " segments</div>",
|
|
2670
|
+
].join("");
|
|
2671
|
+
|
|
2672
|
+
els.detailBody.innerHTML = [
|
|
2673
|
+
'<section class="detail-section">',
|
|
2674
|
+
"<h2>Notes</h2>",
|
|
2675
|
+
'<pre class="detail-pre">' + escapeHtml(record.noteMarkdown || "") + "</pre>",
|
|
2676
|
+
"</section>",
|
|
2677
|
+
'<section class="detail-section">',
|
|
2678
|
+
"<h2>Transcript</h2>",
|
|
2679
|
+
'<pre class="detail-pre">' + escapeHtml(record.transcriptText || "(Transcript unavailable)") + "</pre>",
|
|
2680
|
+
"</section>",
|
|
2681
|
+
].join("");
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
async function fetchJson(path, init) {
|
|
2685
|
+
const response = await fetch(path, init);
|
|
2686
|
+
const payload = await response.json().catch(() => ({}));
|
|
2687
|
+
if (!response.ok) {
|
|
2688
|
+
throw new Error(payload.error || response.statusText || "Request failed");
|
|
2689
|
+
}
|
|
2690
|
+
return payload;
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
function buildMeetingsQuery(limit = 100) {
|
|
2694
|
+
const params = new URLSearchParams();
|
|
2695
|
+
params.set("limit", String(limit));
|
|
2696
|
+
params.set("sort", state.sort);
|
|
2697
|
+
|
|
2698
|
+
if (state.search) {
|
|
2699
|
+
params.set("search", state.search);
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
if (state.updatedFrom) {
|
|
2703
|
+
params.set("updatedFrom", state.updatedFrom);
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
if (state.updatedTo) {
|
|
2707
|
+
params.set("updatedTo", state.updatedTo);
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
return "?" + params.toString();
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
async function loadMeetings(options = {}) {
|
|
2714
|
+
const preferredMeetingId = options.preferredMeetingId || state.selectedMeetingId;
|
|
2715
|
+
|
|
2716
|
+
try {
|
|
2717
|
+
state.listError = "";
|
|
2718
|
+
const payload = await fetchJson("/meetings" + buildMeetingsQuery());
|
|
2719
|
+
state.meetings = payload.meetings || [];
|
|
2720
|
+
|
|
2721
|
+
if (preferredMeetingId && state.meetings.some((meeting) => meeting.id === preferredMeetingId)) {
|
|
2722
|
+
state.selectedMeetingId = preferredMeetingId;
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
renderMeetingList();
|
|
2726
|
+
if (state.selectedMeetingId) {
|
|
2727
|
+
await loadMeeting(state.selectedMeetingId);
|
|
2728
|
+
return;
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
state.detailError = "";
|
|
2732
|
+
renderMeetingDetail();
|
|
2733
|
+
} catch (error) {
|
|
2734
|
+
state.listError = error instanceof Error ? error.message : String(error);
|
|
2735
|
+
state.selectedMeeting = null;
|
|
2736
|
+
state.detailError = state.listError;
|
|
2737
|
+
renderMeetingList();
|
|
2738
|
+
renderMeetingDetail();
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
async function loadMeeting(id) {
|
|
2743
|
+
state.selectedMeetingId = id;
|
|
2744
|
+
renderMeetingList();
|
|
2745
|
+
|
|
2746
|
+
try {
|
|
2747
|
+
state.detailError = "";
|
|
2748
|
+
const payload = await fetchJson("/meetings/" + encodeURIComponent(id));
|
|
2749
|
+
state.selectedMeeting = payload.meeting || null;
|
|
2750
|
+
renderMeetingDetail();
|
|
2751
|
+
} catch (error) {
|
|
2752
|
+
state.selectedMeeting = null;
|
|
2753
|
+
state.detailError = error instanceof Error ? error.message : String(error);
|
|
2754
|
+
renderMeetingDetail();
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
async function quickOpenMeeting() {
|
|
2759
|
+
const query = els.quickOpen.value.trim();
|
|
2760
|
+
if (!query) {
|
|
2761
|
+
setStatus("Enter a title or id", "error");
|
|
2762
|
+
return;
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
setStatus("Opening meeting…", "busy");
|
|
2766
|
+
|
|
2767
|
+
try {
|
|
2768
|
+
state.quickOpen = query;
|
|
2769
|
+
const payload = await fetchJson("/meetings/resolve?q=" + encodeURIComponent(query));
|
|
2770
|
+
state.search = "";
|
|
2771
|
+
state.updatedFrom = "";
|
|
2772
|
+
state.updatedTo = "";
|
|
2773
|
+
syncFilterInputs();
|
|
2774
|
+
await loadMeetings({
|
|
2775
|
+
preferredMeetingId: payload.document.id,
|
|
2776
|
+
});
|
|
2777
|
+
setStatus("Connected", "ok");
|
|
2778
|
+
} catch (error) {
|
|
2779
|
+
state.detailError = error instanceof Error ? error.message : String(error);
|
|
2780
|
+
renderMeetingDetail();
|
|
2781
|
+
setStatus("Quick open failed", "error");
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
async function refreshAll() {
|
|
2786
|
+
setStatus("Refreshing…", "busy");
|
|
2787
|
+
const [appState] = await Promise.all([fetchJson("/state"), loadMeetings()]);
|
|
2788
|
+
state.appState = appState;
|
|
2789
|
+
renderAppState();
|
|
2790
|
+
setStatus("Connected", "ok");
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
async function exportNotes() {
|
|
2794
|
+
setStatus("Exporting notes…", "busy");
|
|
2795
|
+
await fetchJson("/exports/notes", {
|
|
2796
|
+
body: JSON.stringify({ format: "markdown" }),
|
|
2797
|
+
headers: { "content-type": "application/json" },
|
|
2798
|
+
method: "POST",
|
|
2799
|
+
});
|
|
2800
|
+
await refreshAll();
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
async function exportTranscripts() {
|
|
2804
|
+
setStatus("Exporting transcripts…", "busy");
|
|
2805
|
+
await fetchJson("/exports/transcripts", {
|
|
2806
|
+
body: JSON.stringify({ format: "text" }),
|
|
2807
|
+
headers: { "content-type": "application/json" },
|
|
2808
|
+
method: "POST",
|
|
2809
|
+
});
|
|
2810
|
+
await refreshAll();
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
els.list.addEventListener("click", (event) => {
|
|
2814
|
+
if (!(event.target instanceof Element)) {
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
const button = event.target.closest("[data-meeting-id]");
|
|
2819
|
+
if (!button) return;
|
|
2820
|
+
void loadMeeting(button.dataset.meetingId);
|
|
2821
|
+
});
|
|
2822
|
+
|
|
2823
|
+
els.refreshButton.addEventListener("click", () => {
|
|
2824
|
+
void refreshAll();
|
|
2825
|
+
});
|
|
2826
|
+
|
|
2827
|
+
els.noteButton.addEventListener("click", () => {
|
|
2828
|
+
void exportNotes();
|
|
2829
|
+
});
|
|
2830
|
+
|
|
2831
|
+
els.transcriptButton.addEventListener("click", () => {
|
|
2832
|
+
void exportTranscripts();
|
|
2833
|
+
});
|
|
2834
|
+
|
|
2835
|
+
els.search.addEventListener("input", (event) => {
|
|
2836
|
+
if (!(event.target instanceof HTMLInputElement)) {
|
|
2837
|
+
return;
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
state.search = event.target.value.trim();
|
|
2841
|
+
void loadMeetings();
|
|
2842
|
+
});
|
|
2843
|
+
|
|
2844
|
+
els.sort.addEventListener("change", (event) => {
|
|
2845
|
+
if (!(event.target instanceof HTMLSelectElement)) {
|
|
2846
|
+
return;
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
state.sort = event.target.value;
|
|
2850
|
+
void loadMeetings();
|
|
2851
|
+
});
|
|
2852
|
+
|
|
2853
|
+
els.updatedFrom.addEventListener("change", (event) => {
|
|
2854
|
+
if (!(event.target instanceof HTMLInputElement)) {
|
|
2855
|
+
return;
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
state.updatedFrom = event.target.value;
|
|
2859
|
+
void loadMeetings();
|
|
2860
|
+
});
|
|
2861
|
+
|
|
2862
|
+
els.updatedTo.addEventListener("change", (event) => {
|
|
2863
|
+
if (!(event.target instanceof HTMLInputElement)) {
|
|
2864
|
+
return;
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
state.updatedTo = event.target.value;
|
|
2868
|
+
void loadMeetings();
|
|
2869
|
+
});
|
|
2870
|
+
|
|
2871
|
+
els.quickOpen.addEventListener("input", (event) => {
|
|
2872
|
+
if (!(event.target instanceof HTMLInputElement)) {
|
|
2873
|
+
return;
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
state.quickOpen = event.target.value;
|
|
2877
|
+
});
|
|
2878
|
+
|
|
2879
|
+
els.quickOpen.addEventListener("keydown", (event) => {
|
|
2880
|
+
if (!(event.target instanceof HTMLInputElement)) {
|
|
2881
|
+
return;
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
if (event.key === "Enter") {
|
|
2885
|
+
event.preventDefault();
|
|
2886
|
+
void quickOpenMeeting();
|
|
2887
|
+
}
|
|
2888
|
+
});
|
|
2889
|
+
|
|
2890
|
+
els.quickOpenButton.addEventListener("click", () => {
|
|
2891
|
+
void quickOpenMeeting();
|
|
2892
|
+
});
|
|
2893
|
+
|
|
2894
|
+
const events = new EventSource("/events");
|
|
2895
|
+
events.addEventListener("state.updated", (event) => {
|
|
2896
|
+
const payload = JSON.parse(event.data);
|
|
2897
|
+
state.appState = payload.state;
|
|
2898
|
+
renderAppState();
|
|
2899
|
+
});
|
|
2900
|
+
events.addEventListener("error", () => {
|
|
2901
|
+
setStatus("Disconnected", "error");
|
|
2902
|
+
});
|
|
2903
|
+
|
|
2904
|
+
syncFilterInputs();
|
|
2905
|
+
|
|
2906
|
+
void refreshAll().catch((error) => {
|
|
2907
|
+
setStatus("Error", "error");
|
|
2908
|
+
els.empty.hidden = false;
|
|
2909
|
+
els.empty.textContent = error.message;
|
|
2910
|
+
});
|
|
2911
|
+
`}
|
|
2912
|
+
<\/script>
|
|
2913
|
+
</body>
|
|
2914
|
+
</html>`;
|
|
2915
|
+
}
|
|
2916
|
+
//#endregion
|
|
2035
2917
|
//#region src/server/http.ts
|
|
2036
2918
|
function parseInteger(value) {
|
|
2037
2919
|
if (!value?.trim()) return;
|
|
@@ -2040,6 +2922,17 @@ function parseInteger(value) {
|
|
|
2040
2922
|
if (!Number.isInteger(parsed) || parsed < 1) throw new Error("invalid limit: expected a positive integer");
|
|
2041
2923
|
return parsed;
|
|
2042
2924
|
}
|
|
2925
|
+
function parseMeetingSort(value) {
|
|
2926
|
+
switch (value) {
|
|
2927
|
+
case null:
|
|
2928
|
+
case "": return;
|
|
2929
|
+
case "title-asc":
|
|
2930
|
+
case "title-desc":
|
|
2931
|
+
case "updated-asc":
|
|
2932
|
+
case "updated-desc": return value;
|
|
2933
|
+
default: throw new Error("invalid sort: expected updated-desc, updated-asc, title-asc, or title-desc");
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2043
2936
|
function sendJson(response, body, init = {}) {
|
|
2044
2937
|
const payload = `${JSON.stringify(body, null, 2)}\n`;
|
|
2045
2938
|
response.writeHead(init.status ?? 200, {
|
|
@@ -2055,6 +2948,13 @@ function sendText(response, body, status = 200) {
|
|
|
2055
2948
|
});
|
|
2056
2949
|
response.end(body);
|
|
2057
2950
|
}
|
|
2951
|
+
function sendHtml(response, body, status = 200) {
|
|
2952
|
+
response.writeHead(status, {
|
|
2953
|
+
"content-length": Buffer.byteLength(body),
|
|
2954
|
+
"content-type": "text/html; charset=utf-8"
|
|
2955
|
+
});
|
|
2956
|
+
response.end(body);
|
|
2957
|
+
}
|
|
2058
2958
|
async function readJsonBody(request) {
|
|
2059
2959
|
const chunks = [];
|
|
2060
2960
|
for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
@@ -2091,6 +2991,7 @@ function transcriptFormatFromBody(value) {
|
|
|
2091
2991
|
}
|
|
2092
2992
|
}
|
|
2093
2993
|
async function startGranolaServer(app, options = {}) {
|
|
2994
|
+
const enableWebClient = options.enableWebClient ?? false;
|
|
2094
2995
|
const hostname = options.hostname ?? "127.0.0.1";
|
|
2095
2996
|
const port = options.port ?? 0;
|
|
2096
2997
|
const server = createServer(async (request, response) => {
|
|
@@ -2098,6 +2999,10 @@ async function startGranolaServer(app, options = {}) {
|
|
|
2098
2999
|
const url = new URL(request.url ?? "/", `http://${hostname}`);
|
|
2099
3000
|
const path = url.pathname;
|
|
2100
3001
|
try {
|
|
3002
|
+
if (method === "GET" && path === "/" && enableWebClient) {
|
|
3003
|
+
sendHtml(response, renderGranolaWebPage());
|
|
3004
|
+
return;
|
|
3005
|
+
}
|
|
2101
3006
|
if (method === "GET" && path === "/health") {
|
|
2102
3007
|
sendJson(response, {
|
|
2103
3008
|
ok: true,
|
|
@@ -2133,15 +3038,30 @@ async function startGranolaServer(app, options = {}) {
|
|
|
2133
3038
|
if (method === "GET" && path === "/meetings") {
|
|
2134
3039
|
const limit = parseInteger(url.searchParams.get("limit"));
|
|
2135
3040
|
const search = url.searchParams.get("search")?.trim() || void 0;
|
|
3041
|
+
const sort = parseMeetingSort(url.searchParams.get("sort"));
|
|
3042
|
+
const updatedFrom = url.searchParams.get("updatedFrom")?.trim() || void 0;
|
|
3043
|
+
const updatedTo = url.searchParams.get("updatedTo")?.trim() || void 0;
|
|
2136
3044
|
sendJson(response, {
|
|
2137
3045
|
meetings: await app.listMeetings({
|
|
2138
3046
|
limit,
|
|
2139
|
-
search
|
|
3047
|
+
search,
|
|
3048
|
+
sort,
|
|
3049
|
+
updatedFrom,
|
|
3050
|
+
updatedTo
|
|
2140
3051
|
}),
|
|
2141
|
-
search
|
|
3052
|
+
search,
|
|
3053
|
+
sort,
|
|
3054
|
+
updatedFrom,
|
|
3055
|
+
updatedTo
|
|
2142
3056
|
});
|
|
2143
3057
|
return;
|
|
2144
3058
|
}
|
|
3059
|
+
if (method === "GET" && path === "/meetings/resolve") {
|
|
3060
|
+
const query = url.searchParams.get("q")?.trim();
|
|
3061
|
+
if (!query) throw new Error("meeting query is required");
|
|
3062
|
+
sendJson(response, await app.findMeeting(query, { requireCache: url.searchParams.get("includeTranscript") === "true" }));
|
|
3063
|
+
return;
|
|
3064
|
+
}
|
|
2145
3065
|
if (method === "GET" && path.startsWith("/meetings/")) {
|
|
2146
3066
|
const id = decodeURIComponent(path.slice(10));
|
|
2147
3067
|
if (!id) throw new Error("meeting id is required");
|
|
@@ -2212,13 +3132,6 @@ Options:
|
|
|
2212
3132
|
-h, --help Show help
|
|
2213
3133
|
`;
|
|
2214
3134
|
}
|
|
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
3135
|
const serveCommand = {
|
|
2223
3136
|
description: "Start a local Granola API server",
|
|
2224
3137
|
flags: {
|
|
@@ -2240,7 +3153,7 @@ const serveCommand = {
|
|
|
2240
3153
|
debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
|
|
2241
3154
|
debug(config.debug, "timeoutMs", config.notes.timeoutMs);
|
|
2242
3155
|
const server = await startGranolaServer(await createGranolaApp(config, { surface: "server" }), {
|
|
2243
|
-
hostname:
|
|
3156
|
+
hostname: pickHostname(commandFlags.hostname),
|
|
2244
3157
|
port: parsePort(commandFlags.port)
|
|
2245
3158
|
});
|
|
2246
3159
|
console.log(`Granola server listening on ${server.url.href}`);
|
|
@@ -2252,26 +3165,7 @@ const serveCommand = {
|
|
|
2252
3165
|
console.log(" GET /meetings/:id");
|
|
2253
3166
|
console.log(" POST /exports/notes");
|
|
2254
3167
|
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
|
-
});
|
|
3168
|
+
await waitForShutdown(async () => await server.close());
|
|
2275
3169
|
return 0;
|
|
2276
3170
|
}
|
|
2277
3171
|
};
|
|
@@ -2331,13 +3225,115 @@ function resolveTranscriptFormat(value) {
|
|
|
2331
3225
|
}
|
|
2332
3226
|
}
|
|
2333
3227
|
//#endregion
|
|
3228
|
+
//#region src/browser.ts
|
|
3229
|
+
const execFileAsync = promisify(execFile);
|
|
3230
|
+
function getBrowserOpenCommand(url, platform = process.platform) {
|
|
3231
|
+
const href = String(url);
|
|
3232
|
+
switch (platform) {
|
|
3233
|
+
case "darwin": return {
|
|
3234
|
+
args: [href],
|
|
3235
|
+
file: "open"
|
|
3236
|
+
};
|
|
3237
|
+
case "win32": return {
|
|
3238
|
+
args: [
|
|
3239
|
+
"/c",
|
|
3240
|
+
"start",
|
|
3241
|
+
"",
|
|
3242
|
+
href
|
|
3243
|
+
],
|
|
3244
|
+
file: "cmd"
|
|
3245
|
+
};
|
|
3246
|
+
default: return {
|
|
3247
|
+
args: [href],
|
|
3248
|
+
file: "xdg-open"
|
|
3249
|
+
};
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
async function openExternalUrl(url, options = {}) {
|
|
3253
|
+
const command = getBrowserOpenCommand(url, options.platform);
|
|
3254
|
+
await (options.run ?? (async (file, args) => {
|
|
3255
|
+
await execFileAsync(file, args);
|
|
3256
|
+
}))(command.file, command.args);
|
|
3257
|
+
}
|
|
3258
|
+
//#endregion
|
|
3259
|
+
//#region src/commands/web.ts
|
|
3260
|
+
function webHelp() {
|
|
3261
|
+
return `Granola web
|
|
3262
|
+
|
|
3263
|
+
Usage:
|
|
3264
|
+
granola web [options]
|
|
3265
|
+
|
|
3266
|
+
Options:
|
|
3267
|
+
--hostname <value> Hostname to bind (default: 127.0.0.1)
|
|
3268
|
+
--port <value> Port to bind (default: 0 for any available port)
|
|
3269
|
+
--cache <path> Path to Granola cache JSON
|
|
3270
|
+
--timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
|
|
3271
|
+
--supabase <path> Path to supabase.json
|
|
3272
|
+
--open[=true|false] Open the browser automatically (default: true)
|
|
3273
|
+
--debug Enable debug logging
|
|
3274
|
+
--config <path> Path to .granola.toml
|
|
3275
|
+
-h, --help Show help
|
|
3276
|
+
`;
|
|
3277
|
+
}
|
|
3278
|
+
//#endregion
|
|
2334
3279
|
//#region src/commands/index.ts
|
|
2335
3280
|
const commands = [
|
|
2336
3281
|
authCommand,
|
|
2337
3282
|
meetingCommand,
|
|
2338
3283
|
notesCommand,
|
|
2339
3284
|
serveCommand,
|
|
2340
|
-
transcriptsCommand
|
|
3285
|
+
transcriptsCommand,
|
|
3286
|
+
{
|
|
3287
|
+
description: "Start the Granola Toolkit web workspace",
|
|
3288
|
+
flags: {
|
|
3289
|
+
cache: { type: "string" },
|
|
3290
|
+
help: { type: "boolean" },
|
|
3291
|
+
hostname: { type: "string" },
|
|
3292
|
+
open: { type: "boolean" },
|
|
3293
|
+
port: { type: "string" },
|
|
3294
|
+
timeout: { type: "string" }
|
|
3295
|
+
},
|
|
3296
|
+
help: webHelp,
|
|
3297
|
+
name: "web",
|
|
3298
|
+
async run({ commandFlags, globalFlags }) {
|
|
3299
|
+
const config = await loadConfig({
|
|
3300
|
+
globalFlags,
|
|
3301
|
+
subcommandFlags: commandFlags
|
|
3302
|
+
});
|
|
3303
|
+
debug(config.debug, "using config", config.configFileUsed ?? "(none)");
|
|
3304
|
+
debug(config.debug, "supabase", config.supabase);
|
|
3305
|
+
debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
|
|
3306
|
+
debug(config.debug, "timeoutMs", config.notes.timeoutMs);
|
|
3307
|
+
const app = await createGranolaApp(config, { surface: "web" });
|
|
3308
|
+
const hostname = pickHostname(commandFlags.hostname);
|
|
3309
|
+
const port = parsePort(commandFlags.port);
|
|
3310
|
+
const openBrowser = commandFlags.open !== false;
|
|
3311
|
+
const server = await startGranolaServer(app, {
|
|
3312
|
+
enableWebClient: true,
|
|
3313
|
+
hostname,
|
|
3314
|
+
port
|
|
3315
|
+
});
|
|
3316
|
+
console.log(`Granola Toolkit web workspace listening on ${server.url.href}`);
|
|
3317
|
+
console.log("Routes:");
|
|
3318
|
+
console.log(" GET /");
|
|
3319
|
+
console.log(" GET /health");
|
|
3320
|
+
console.log(" GET /state");
|
|
3321
|
+
console.log(" GET /events");
|
|
3322
|
+
console.log(" GET /meetings");
|
|
3323
|
+
console.log(" GET /meetings/:id");
|
|
3324
|
+
console.log(" POST /exports/notes");
|
|
3325
|
+
console.log(" POST /exports/transcripts");
|
|
3326
|
+
if (openBrowser) try {
|
|
3327
|
+
await openExternalUrl(server.url);
|
|
3328
|
+
} catch (error) {
|
|
3329
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3330
|
+
console.error(`failed to open browser automatically: ${message}`);
|
|
3331
|
+
console.error(`open ${server.url.href} manually`);
|
|
3332
|
+
}
|
|
3333
|
+
await waitForShutdown(async () => await server.close());
|
|
3334
|
+
return 0;
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
2341
3337
|
];
|
|
2342
3338
|
const commandMap = new Map(commands.map((command) => [command.name, command]));
|
|
2343
3339
|
//#endregion
|