granola-toolkit 0.19.0 → 0.21.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 +6 -1
- package/dist/cli.js +514 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -192,6 +192,7 @@ The initial server API includes:
|
|
|
192
192
|
- `GET /state`
|
|
193
193
|
- `GET /events` for server-sent state updates
|
|
194
194
|
- `GET /meetings`
|
|
195
|
+
- `GET /meetings/resolve?q=<query>`
|
|
195
196
|
- `GET /meetings/:id`
|
|
196
197
|
- `POST /exports/notes`
|
|
197
198
|
- `POST /exports/transcripts`
|
|
@@ -205,9 +206,13 @@ This is the foundation for the future `granola web` client and any attachable TU
|
|
|
205
206
|
The initial browser client includes:
|
|
206
207
|
|
|
207
208
|
- a searchable meeting list
|
|
208
|
-
-
|
|
209
|
+
- sort and updated-date filters
|
|
210
|
+
- quick open by meeting id or title
|
|
211
|
+
- a focused meeting workspace with notes, transcript, metadata, and raw tabs
|
|
212
|
+
- keyboard-first workspace switching with `1`-`4`, `[` and `]`
|
|
209
213
|
- app-state status from the shared core
|
|
210
214
|
- note and transcript export actions backed by the same local API
|
|
215
|
+
- stronger empty and error states for list/detail failures
|
|
211
216
|
|
|
212
217
|
## Auth
|
|
213
218
|
|
package/dist/cli.js
CHANGED
|
@@ -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);
|
|
@@ -2149,7 +2214,9 @@ function renderGranolaWebPage() {
|
|
|
2149
2214
|
line-height: 1.5;
|
|
2150
2215
|
}
|
|
2151
2216
|
|
|
2152
|
-
.search
|
|
2217
|
+
.search,
|
|
2218
|
+
.select,
|
|
2219
|
+
.field-input {
|
|
2153
2220
|
width: 100%;
|
|
2154
2221
|
margin-top: 16px;
|
|
2155
2222
|
padding: 12px 14px;
|
|
@@ -2160,6 +2227,26 @@ function renderGranolaWebPage() {
|
|
|
2160
2227
|
font: inherit;
|
|
2161
2228
|
}
|
|
2162
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
|
+
|
|
2163
2250
|
.meeting-list {
|
|
2164
2251
|
padding: 14px;
|
|
2165
2252
|
overflow: auto;
|
|
@@ -2201,6 +2288,10 @@ function renderGranolaWebPage() {
|
|
|
2201
2288
|
color: var(--muted);
|
|
2202
2289
|
}
|
|
2203
2290
|
|
|
2291
|
+
.meeting-empty--error {
|
|
2292
|
+
color: var(--error);
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2204
2295
|
.detail {
|
|
2205
2296
|
display: grid;
|
|
2206
2297
|
grid-template-rows: auto auto 1fr;
|
|
@@ -2248,6 +2339,44 @@ function renderGranolaWebPage() {
|
|
|
2248
2339
|
gap: 10px;
|
|
2249
2340
|
}
|
|
2250
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
|
+
.workspace-tabs {
|
|
2350
|
+
display: flex;
|
|
2351
|
+
flex-wrap: wrap;
|
|
2352
|
+
align-items: center;
|
|
2353
|
+
gap: 10px;
|
|
2354
|
+
padding: 0 24px 18px;
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
.workspace-tab {
|
|
2358
|
+
border: 1px solid var(--line);
|
|
2359
|
+
border-radius: 999px;
|
|
2360
|
+
padding: 10px 14px;
|
|
2361
|
+
background: rgba(255, 255, 255, 0.72);
|
|
2362
|
+
color: var(--muted);
|
|
2363
|
+
cursor: pointer;
|
|
2364
|
+
font: inherit;
|
|
2365
|
+
font-weight: 700;
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
.workspace-tab[data-selected="true"] {
|
|
2369
|
+
background: var(--ink);
|
|
2370
|
+
color: white;
|
|
2371
|
+
border-color: var(--ink);
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
.workspace-hint {
|
|
2375
|
+
color: var(--muted);
|
|
2376
|
+
font-size: 0.86rem;
|
|
2377
|
+
margin-left: auto;
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2251
2380
|
.button {
|
|
2252
2381
|
border: 0;
|
|
2253
2382
|
border-radius: 999px;
|
|
@@ -2304,6 +2433,17 @@ function renderGranolaWebPage() {
|
|
|
2304
2433
|
overflow: auto;
|
|
2305
2434
|
}
|
|
2306
2435
|
|
|
2436
|
+
.workspace-grid {
|
|
2437
|
+
display: grid;
|
|
2438
|
+
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
|
|
2439
|
+
gap: 18px;
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
.workspace-sidebar,
|
|
2443
|
+
.workspace-main {
|
|
2444
|
+
margin-bottom: 0;
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2307
2447
|
.detail-section {
|
|
2308
2448
|
margin-bottom: 20px;
|
|
2309
2449
|
padding: 20px;
|
|
@@ -2340,6 +2480,16 @@ function renderGranolaWebPage() {
|
|
|
2340
2480
|
.shell {
|
|
2341
2481
|
grid-template-columns: 1fr;
|
|
2342
2482
|
}
|
|
2483
|
+
|
|
2484
|
+
.field-row--inline,
|
|
2485
|
+
.toolbar-form,
|
|
2486
|
+
.workspace-grid {
|
|
2487
|
+
grid-template-columns: 1fr;
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
.workspace-hint {
|
|
2491
|
+
margin-left: 0;
|
|
2492
|
+
}
|
|
2343
2493
|
}
|
|
2344
2494
|
</style>
|
|
2345
2495
|
</head>
|
|
@@ -2350,9 +2500,34 @@ function renderGranolaWebPage() {
|
|
|
2350
2500
|
<h1>Granola Toolkit</h1>
|
|
2351
2501
|
<p>Browser workspace for meetings, notes, transcripts, and export flows on top of one local server instance.</p>
|
|
2352
2502
|
<input class="search" data-search placeholder="Search meetings, ids, or tags" />
|
|
2503
|
+
<div class="field-row field-row--inline">
|
|
2504
|
+
<label>
|
|
2505
|
+
<span class="field-label">Sort</span>
|
|
2506
|
+
<select class="select" data-sort>
|
|
2507
|
+
<option value="updated-desc">Newest first</option>
|
|
2508
|
+
<option value="updated-asc">Oldest first</option>
|
|
2509
|
+
<option value="title-asc">Title A-Z</option>
|
|
2510
|
+
<option value="title-desc">Title Z-A</option>
|
|
2511
|
+
</select>
|
|
2512
|
+
</label>
|
|
2513
|
+
<label>
|
|
2514
|
+
<span class="field-label">Updated From</span>
|
|
2515
|
+
<input class="field-input" data-updated-from type="date" />
|
|
2516
|
+
</label>
|
|
2517
|
+
</div>
|
|
2518
|
+
<label class="field-row">
|
|
2519
|
+
<span class="field-label">Updated To</span>
|
|
2520
|
+
<input class="field-input" data-updated-to type="date" />
|
|
2521
|
+
</label>
|
|
2353
2522
|
</section>
|
|
2354
2523
|
<section class="toolbar">
|
|
2355
|
-
<
|
|
2524
|
+
<div>
|
|
2525
|
+
<p>Meetings are loaded from the shared server state so this view can later coexist with the terminal UI.</p>
|
|
2526
|
+
</div>
|
|
2527
|
+
<div class="toolbar-form">
|
|
2528
|
+
<input class="field-input" data-quick-open placeholder="Quick open by id or title" />
|
|
2529
|
+
<button class="button button--secondary" data-quick-open-button>Open</button>
|
|
2530
|
+
</div>
|
|
2356
2531
|
</section>
|
|
2357
2532
|
<section class="meeting-list" data-meeting-list></section>
|
|
2358
2533
|
</aside>
|
|
@@ -2372,6 +2547,13 @@ function renderGranolaWebPage() {
|
|
|
2372
2547
|
</div>
|
|
2373
2548
|
<p>Initial beta web client. It speaks to the same local API that future TUI and attach flows will use.</p>
|
|
2374
2549
|
</section>
|
|
2550
|
+
<nav class="workspace-tabs">
|
|
2551
|
+
<button class="workspace-tab" data-workspace-tab="notes">Notes</button>
|
|
2552
|
+
<button class="workspace-tab" data-workspace-tab="transcript">Transcript</button>
|
|
2553
|
+
<button class="workspace-tab" data-workspace-tab="metadata">Metadata</button>
|
|
2554
|
+
<button class="workspace-tab" data-workspace-tab="raw">Raw</button>
|
|
2555
|
+
<span class="workspace-hint">1-4 switch tabs, [ and ] cycle</span>
|
|
2556
|
+
</nav>
|
|
2375
2557
|
<div class="detail-meta" data-detail-meta></div>
|
|
2376
2558
|
<div class="detail-body" data-detail-body>
|
|
2377
2559
|
<div class="empty" data-empty>Select a meeting to inspect its notes and transcript.</div>
|
|
@@ -2381,11 +2563,19 @@ function renderGranolaWebPage() {
|
|
|
2381
2563
|
<script type="module">
|
|
2382
2564
|
${String.raw`
|
|
2383
2565
|
const state = {
|
|
2384
|
-
meetings: [],
|
|
2385
|
-
selectedMeetingId: null,
|
|
2386
|
-
selectedMeeting: null,
|
|
2387
2566
|
appState: null,
|
|
2567
|
+
detailError: "",
|
|
2568
|
+
listError: "",
|
|
2569
|
+
meetings: [],
|
|
2570
|
+
quickOpen: "",
|
|
2388
2571
|
search: "",
|
|
2572
|
+
selectedMeeting: null,
|
|
2573
|
+
selectedMeetingBundle: null,
|
|
2574
|
+
selectedMeetingId: null,
|
|
2575
|
+
sort: "updated-desc",
|
|
2576
|
+
updatedFrom: "",
|
|
2577
|
+
updatedTo: "",
|
|
2578
|
+
workspaceTab: "notes",
|
|
2389
2579
|
};
|
|
2390
2580
|
|
|
2391
2581
|
const els = {
|
|
@@ -2395,10 +2585,16 @@ const els = {
|
|
|
2395
2585
|
empty: document.querySelector("[data-empty]"),
|
|
2396
2586
|
list: document.querySelector("[data-meeting-list]"),
|
|
2397
2587
|
noteButton: document.querySelector("[data-export-notes]"),
|
|
2588
|
+
quickOpen: document.querySelector("[data-quick-open]"),
|
|
2589
|
+
quickOpenButton: document.querySelector("[data-quick-open-button]"),
|
|
2398
2590
|
refreshButton: document.querySelector("[data-refresh]"),
|
|
2399
2591
|
search: document.querySelector("[data-search]"),
|
|
2592
|
+
sort: document.querySelector("[data-sort]"),
|
|
2400
2593
|
stateBadge: document.querySelector("[data-state-badge]"),
|
|
2401
2594
|
transcriptButton: document.querySelector("[data-export-transcripts]"),
|
|
2595
|
+
updatedFrom: document.querySelector("[data-updated-from]"),
|
|
2596
|
+
updatedTo: document.querySelector("[data-updated-to]"),
|
|
2597
|
+
workspaceTabs: document.querySelectorAll("[data-workspace-tab]"),
|
|
2402
2598
|
};
|
|
2403
2599
|
|
|
2404
2600
|
function escapeHtml(value) {
|
|
@@ -2414,6 +2610,38 @@ function setStatus(label, tone = "idle") {
|
|
|
2414
2610
|
els.stateBadge.dataset.tone = tone;
|
|
2415
2611
|
}
|
|
2416
2612
|
|
|
2613
|
+
function syncFilterInputs() {
|
|
2614
|
+
els.quickOpen.value = state.quickOpen;
|
|
2615
|
+
els.search.value = state.search;
|
|
2616
|
+
els.sort.value = state.sort;
|
|
2617
|
+
els.updatedFrom.value = state.updatedFrom;
|
|
2618
|
+
els.updatedTo.value = state.updatedTo;
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
function currentFilterSummary() {
|
|
2622
|
+
const parts = [];
|
|
2623
|
+
|
|
2624
|
+
if (state.search) {
|
|
2625
|
+
parts.push('search "' + state.search + '"');
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
if (state.updatedFrom) {
|
|
2629
|
+
parts.push("from " + state.updatedFrom);
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
if (state.updatedTo) {
|
|
2633
|
+
parts.push("to " + state.updatedTo);
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
return parts.join(", ");
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
function renderWorkspaceTabs() {
|
|
2640
|
+
for (const button of els.workspaceTabs) {
|
|
2641
|
+
button.dataset.selected = button.dataset.workspaceTab === state.workspaceTab ? "true" : "false";
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2417
2645
|
function renderAppState() {
|
|
2418
2646
|
if (!state.appState) {
|
|
2419
2647
|
els.appState.innerHTML = "<p>Waiting for server state…</p>";
|
|
@@ -2441,10 +2669,21 @@ function renderAppState() {
|
|
|
2441
2669
|
}
|
|
2442
2670
|
|
|
2443
2671
|
function renderMeetingList() {
|
|
2672
|
+
if (state.listError) {
|
|
2673
|
+
els.list.innerHTML =
|
|
2674
|
+
'<div class="meeting-empty meeting-empty--error">' + escapeHtml(state.listError) + "</div>";
|
|
2675
|
+
return;
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2444
2678
|
if (state.meetings.length === 0) {
|
|
2445
2679
|
state.selectedMeetingId = null;
|
|
2446
2680
|
state.selectedMeeting = null;
|
|
2447
|
-
|
|
2681
|
+
state.selectedMeetingBundle = null;
|
|
2682
|
+
const filterSummary = currentFilterSummary();
|
|
2683
|
+
const message = filterSummary
|
|
2684
|
+
? "No meetings match " + filterSummary + "."
|
|
2685
|
+
: "No meetings yet. Try Refresh.";
|
|
2686
|
+
els.list.innerHTML = '<div class="meeting-empty">' + escapeHtml(message) + "</div>";
|
|
2448
2687
|
renderMeetingDetail();
|
|
2449
2688
|
return;
|
|
2450
2689
|
}
|
|
@@ -2470,9 +2709,20 @@ function renderMeetingList() {
|
|
|
2470
2709
|
}
|
|
2471
2710
|
|
|
2472
2711
|
function renderMeetingDetail() {
|
|
2712
|
+
renderWorkspaceTabs();
|
|
2713
|
+
|
|
2714
|
+
if (state.detailError) {
|
|
2715
|
+
els.empty.hidden = false;
|
|
2716
|
+
els.empty.textContent = state.detailError;
|
|
2717
|
+
els.detailMeta.innerHTML = "";
|
|
2718
|
+
els.detailBody.innerHTML = "";
|
|
2719
|
+
return;
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2473
2722
|
const record = state.selectedMeeting;
|
|
2474
2723
|
if (!record) {
|
|
2475
2724
|
els.empty.hidden = false;
|
|
2725
|
+
els.empty.textContent = "Select a meeting to inspect its notes and transcript.";
|
|
2476
2726
|
els.detailMeta.innerHTML = "";
|
|
2477
2727
|
els.detailBody.innerHTML = "";
|
|
2478
2728
|
return;
|
|
@@ -2485,15 +2735,46 @@ function renderMeetingDetail() {
|
|
|
2485
2735
|
'<div class="detail-chip">Transcript: ' + escapeHtml(String(record.meeting.transcriptSegmentCount)) + " segments</div>",
|
|
2486
2736
|
].join("");
|
|
2487
2737
|
|
|
2738
|
+
const bundle = state.selectedMeetingBundle;
|
|
2739
|
+
const metadataLines = [
|
|
2740
|
+
"Title: " + (record.meeting.title || record.meeting.id),
|
|
2741
|
+
"Created: " + record.meeting.createdAt,
|
|
2742
|
+
"Updated: " + record.meeting.updatedAt,
|
|
2743
|
+
"Tags: " + (record.meeting.tags.length ? record.meeting.tags.join(", ") : "none"),
|
|
2744
|
+
"Transcript loaded: " + (record.meeting.transcriptLoaded ? "yes" : "no"),
|
|
2745
|
+
].join("\n");
|
|
2746
|
+
|
|
2747
|
+
let mainTitle = "Notes";
|
|
2748
|
+
let mainBody = record.noteMarkdown || "";
|
|
2749
|
+
|
|
2750
|
+
switch (state.workspaceTab) {
|
|
2751
|
+
case "transcript":
|
|
2752
|
+
mainTitle = "Transcript";
|
|
2753
|
+
mainBody = record.transcriptText || "(Transcript unavailable)";
|
|
2754
|
+
break;
|
|
2755
|
+
case "metadata":
|
|
2756
|
+
mainTitle = "Metadata";
|
|
2757
|
+
mainBody = metadataLines;
|
|
2758
|
+
break;
|
|
2759
|
+
case "raw":
|
|
2760
|
+
mainTitle = "Raw Bundle";
|
|
2761
|
+
mainBody = JSON.stringify(bundle || record, null, 2);
|
|
2762
|
+
break;
|
|
2763
|
+
default:
|
|
2764
|
+
break;
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2488
2767
|
els.detailBody.innerHTML = [
|
|
2489
|
-
'<
|
|
2490
|
-
"
|
|
2491
|
-
|
|
2492
|
-
"</
|
|
2493
|
-
|
|
2494
|
-
"
|
|
2495
|
-
|
|
2768
|
+
'<div class="workspace-grid">',
|
|
2769
|
+
'<aside class="detail-section workspace-sidebar">',
|
|
2770
|
+
"<h2>Meeting Metadata</h2>",
|
|
2771
|
+
'<pre class="detail-pre">' + escapeHtml(metadataLines) + "</pre>",
|
|
2772
|
+
"</aside>",
|
|
2773
|
+
'<section class="detail-section workspace-main">',
|
|
2774
|
+
"<h2>" + escapeHtml(mainTitle) + "</h2>",
|
|
2775
|
+
'<pre class="detail-pre">' + escapeHtml(mainBody) + "</pre>",
|
|
2496
2776
|
"</section>",
|
|
2777
|
+
"</div>",
|
|
2497
2778
|
].join("");
|
|
2498
2779
|
}
|
|
2499
2780
|
|
|
@@ -2506,17 +2787,52 @@ async function fetchJson(path, init) {
|
|
|
2506
2787
|
return payload;
|
|
2507
2788
|
}
|
|
2508
2789
|
|
|
2509
|
-
|
|
2510
|
-
const
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2790
|
+
function buildMeetingsQuery(limit = 100) {
|
|
2791
|
+
const params = new URLSearchParams();
|
|
2792
|
+
params.set("limit", String(limit));
|
|
2793
|
+
params.set("sort", state.sort);
|
|
2794
|
+
|
|
2795
|
+
if (state.search) {
|
|
2796
|
+
params.set("search", state.search);
|
|
2515
2797
|
}
|
|
2516
|
-
|
|
2517
|
-
if (state.
|
|
2518
|
-
|
|
2519
|
-
}
|
|
2798
|
+
|
|
2799
|
+
if (state.updatedFrom) {
|
|
2800
|
+
params.set("updatedFrom", state.updatedFrom);
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
if (state.updatedTo) {
|
|
2804
|
+
params.set("updatedTo", state.updatedTo);
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
return "?" + params.toString();
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
async function loadMeetings(options = {}) {
|
|
2811
|
+
const preferredMeetingId = options.preferredMeetingId || state.selectedMeetingId;
|
|
2812
|
+
|
|
2813
|
+
try {
|
|
2814
|
+
state.listError = "";
|
|
2815
|
+
const payload = await fetchJson("/meetings" + buildMeetingsQuery());
|
|
2816
|
+
state.meetings = payload.meetings || [];
|
|
2817
|
+
|
|
2818
|
+
if (preferredMeetingId && state.meetings.some((meeting) => meeting.id === preferredMeetingId)) {
|
|
2819
|
+
state.selectedMeetingId = preferredMeetingId;
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
renderMeetingList();
|
|
2823
|
+
if (state.selectedMeetingId) {
|
|
2824
|
+
await loadMeeting(state.selectedMeetingId);
|
|
2825
|
+
return;
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
state.detailError = "";
|
|
2829
|
+
renderMeetingDetail();
|
|
2830
|
+
} catch (error) {
|
|
2831
|
+
state.listError = error instanceof Error ? error.message : String(error);
|
|
2832
|
+
state.selectedMeeting = null;
|
|
2833
|
+
state.selectedMeetingBundle = null;
|
|
2834
|
+
state.detailError = state.listError;
|
|
2835
|
+
renderMeetingList();
|
|
2520
2836
|
renderMeetingDetail();
|
|
2521
2837
|
}
|
|
2522
2838
|
}
|
|
@@ -2524,9 +2840,46 @@ async function loadMeetings() {
|
|
|
2524
2840
|
async function loadMeeting(id) {
|
|
2525
2841
|
state.selectedMeetingId = id;
|
|
2526
2842
|
renderMeetingList();
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2843
|
+
|
|
2844
|
+
try {
|
|
2845
|
+
state.detailError = "";
|
|
2846
|
+
const payload = await fetchJson("/meetings/" + encodeURIComponent(id));
|
|
2847
|
+
state.selectedMeetingBundle = payload;
|
|
2848
|
+
state.selectedMeeting = payload.meeting || null;
|
|
2849
|
+
renderMeetingDetail();
|
|
2850
|
+
} catch (error) {
|
|
2851
|
+
state.selectedMeeting = null;
|
|
2852
|
+
state.selectedMeetingBundle = null;
|
|
2853
|
+
state.detailError = error instanceof Error ? error.message : String(error);
|
|
2854
|
+
renderMeetingDetail();
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
async function quickOpenMeeting() {
|
|
2859
|
+
const query = els.quickOpen.value.trim();
|
|
2860
|
+
if (!query) {
|
|
2861
|
+
setStatus("Enter a title or id", "error");
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
setStatus("Opening meeting…", "busy");
|
|
2866
|
+
|
|
2867
|
+
try {
|
|
2868
|
+
state.quickOpen = query;
|
|
2869
|
+
const payload = await fetchJson("/meetings/resolve?q=" + encodeURIComponent(query));
|
|
2870
|
+
state.search = "";
|
|
2871
|
+
state.updatedFrom = "";
|
|
2872
|
+
state.updatedTo = "";
|
|
2873
|
+
syncFilterInputs();
|
|
2874
|
+
await loadMeetings({
|
|
2875
|
+
preferredMeetingId: payload.document.id,
|
|
2876
|
+
});
|
|
2877
|
+
setStatus("Connected", "ok");
|
|
2878
|
+
} catch (error) {
|
|
2879
|
+
state.detailError = error instanceof Error ? error.message : String(error);
|
|
2880
|
+
renderMeetingDetail();
|
|
2881
|
+
setStatus("Quick open failed", "error");
|
|
2882
|
+
}
|
|
2530
2883
|
}
|
|
2531
2884
|
|
|
2532
2885
|
async function refreshAll() {
|
|
@@ -2588,6 +2941,109 @@ els.search.addEventListener("input", (event) => {
|
|
|
2588
2941
|
void loadMeetings();
|
|
2589
2942
|
});
|
|
2590
2943
|
|
|
2944
|
+
els.sort.addEventListener("change", (event) => {
|
|
2945
|
+
if (!(event.target instanceof HTMLSelectElement)) {
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
state.sort = event.target.value;
|
|
2950
|
+
void loadMeetings();
|
|
2951
|
+
});
|
|
2952
|
+
|
|
2953
|
+
els.updatedFrom.addEventListener("change", (event) => {
|
|
2954
|
+
if (!(event.target instanceof HTMLInputElement)) {
|
|
2955
|
+
return;
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
state.updatedFrom = event.target.value;
|
|
2959
|
+
void loadMeetings();
|
|
2960
|
+
});
|
|
2961
|
+
|
|
2962
|
+
els.updatedTo.addEventListener("change", (event) => {
|
|
2963
|
+
if (!(event.target instanceof HTMLInputElement)) {
|
|
2964
|
+
return;
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
state.updatedTo = event.target.value;
|
|
2968
|
+
void loadMeetings();
|
|
2969
|
+
});
|
|
2970
|
+
|
|
2971
|
+
els.quickOpen.addEventListener("input", (event) => {
|
|
2972
|
+
if (!(event.target instanceof HTMLInputElement)) {
|
|
2973
|
+
return;
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
state.quickOpen = event.target.value;
|
|
2977
|
+
});
|
|
2978
|
+
|
|
2979
|
+
els.quickOpen.addEventListener("keydown", (event) => {
|
|
2980
|
+
if (!(event.target instanceof HTMLInputElement)) {
|
|
2981
|
+
return;
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
if (event.key === "Enter") {
|
|
2985
|
+
event.preventDefault();
|
|
2986
|
+
void quickOpenMeeting();
|
|
2987
|
+
}
|
|
2988
|
+
});
|
|
2989
|
+
|
|
2990
|
+
els.quickOpenButton.addEventListener("click", () => {
|
|
2991
|
+
void quickOpenMeeting();
|
|
2992
|
+
});
|
|
2993
|
+
|
|
2994
|
+
els.workspaceTabs.forEach((button) => {
|
|
2995
|
+
button.addEventListener("click", () => {
|
|
2996
|
+
state.workspaceTab = button.dataset.workspaceTab || "notes";
|
|
2997
|
+
renderMeetingDetail();
|
|
2998
|
+
});
|
|
2999
|
+
});
|
|
3000
|
+
|
|
3001
|
+
document.addEventListener("keydown", (event) => {
|
|
3002
|
+
if (
|
|
3003
|
+
event.target instanceof HTMLInputElement ||
|
|
3004
|
+
event.target instanceof HTMLSelectElement ||
|
|
3005
|
+
event.target instanceof HTMLTextAreaElement
|
|
3006
|
+
) {
|
|
3007
|
+
return;
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
const tabs = ["notes", "transcript", "metadata", "raw"];
|
|
3011
|
+
if (event.key === "1") {
|
|
3012
|
+
state.workspaceTab = "notes";
|
|
3013
|
+
renderMeetingDetail();
|
|
3014
|
+
return;
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
if (event.key === "2") {
|
|
3018
|
+
state.workspaceTab = "transcript";
|
|
3019
|
+
renderMeetingDetail();
|
|
3020
|
+
return;
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
if (event.key === "3") {
|
|
3024
|
+
state.workspaceTab = "metadata";
|
|
3025
|
+
renderMeetingDetail();
|
|
3026
|
+
return;
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
if (event.key === "4") {
|
|
3030
|
+
state.workspaceTab = "raw";
|
|
3031
|
+
renderMeetingDetail();
|
|
3032
|
+
return;
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
const currentIndex = tabs.indexOf(state.workspaceTab);
|
|
3036
|
+
if (event.key === "]") {
|
|
3037
|
+
state.workspaceTab = tabs[(currentIndex + 1) % tabs.length];
|
|
3038
|
+
renderMeetingDetail();
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
if (event.key === "[") {
|
|
3042
|
+
state.workspaceTab = tabs[(currentIndex + tabs.length - 1) % tabs.length];
|
|
3043
|
+
renderMeetingDetail();
|
|
3044
|
+
}
|
|
3045
|
+
});
|
|
3046
|
+
|
|
2591
3047
|
const events = new EventSource("/events");
|
|
2592
3048
|
events.addEventListener("state.updated", (event) => {
|
|
2593
3049
|
const payload = JSON.parse(event.data);
|
|
@@ -2598,6 +3054,8 @@ events.addEventListener("error", () => {
|
|
|
2598
3054
|
setStatus("Disconnected", "error");
|
|
2599
3055
|
});
|
|
2600
3056
|
|
|
3057
|
+
syncFilterInputs();
|
|
3058
|
+
|
|
2601
3059
|
void refreshAll().catch((error) => {
|
|
2602
3060
|
setStatus("Error", "error");
|
|
2603
3061
|
els.empty.hidden = false;
|
|
@@ -2617,6 +3075,17 @@ function parseInteger(value) {
|
|
|
2617
3075
|
if (!Number.isInteger(parsed) || parsed < 1) throw new Error("invalid limit: expected a positive integer");
|
|
2618
3076
|
return parsed;
|
|
2619
3077
|
}
|
|
3078
|
+
function parseMeetingSort(value) {
|
|
3079
|
+
switch (value) {
|
|
3080
|
+
case null:
|
|
3081
|
+
case "": return;
|
|
3082
|
+
case "title-asc":
|
|
3083
|
+
case "title-desc":
|
|
3084
|
+
case "updated-asc":
|
|
3085
|
+
case "updated-desc": return value;
|
|
3086
|
+
default: throw new Error("invalid sort: expected updated-desc, updated-asc, title-asc, or title-desc");
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
2620
3089
|
function sendJson(response, body, init = {}) {
|
|
2621
3090
|
const payload = `${JSON.stringify(body, null, 2)}\n`;
|
|
2622
3091
|
response.writeHead(init.status ?? 200, {
|
|
@@ -2722,15 +3191,30 @@ async function startGranolaServer(app, options = {}) {
|
|
|
2722
3191
|
if (method === "GET" && path === "/meetings") {
|
|
2723
3192
|
const limit = parseInteger(url.searchParams.get("limit"));
|
|
2724
3193
|
const search = url.searchParams.get("search")?.trim() || void 0;
|
|
3194
|
+
const sort = parseMeetingSort(url.searchParams.get("sort"));
|
|
3195
|
+
const updatedFrom = url.searchParams.get("updatedFrom")?.trim() || void 0;
|
|
3196
|
+
const updatedTo = url.searchParams.get("updatedTo")?.trim() || void 0;
|
|
2725
3197
|
sendJson(response, {
|
|
2726
3198
|
meetings: await app.listMeetings({
|
|
2727
3199
|
limit,
|
|
2728
|
-
search
|
|
3200
|
+
search,
|
|
3201
|
+
sort,
|
|
3202
|
+
updatedFrom,
|
|
3203
|
+
updatedTo
|
|
2729
3204
|
}),
|
|
2730
|
-
search
|
|
3205
|
+
search,
|
|
3206
|
+
sort,
|
|
3207
|
+
updatedFrom,
|
|
3208
|
+
updatedTo
|
|
2731
3209
|
});
|
|
2732
3210
|
return;
|
|
2733
3211
|
}
|
|
3212
|
+
if (method === "GET" && path === "/meetings/resolve") {
|
|
3213
|
+
const query = url.searchParams.get("q")?.trim();
|
|
3214
|
+
if (!query) throw new Error("meeting query is required");
|
|
3215
|
+
sendJson(response, await app.findMeeting(query, { requireCache: url.searchParams.get("includeTranscript") === "true" }));
|
|
3216
|
+
return;
|
|
3217
|
+
}
|
|
2734
3218
|
if (method === "GET" && path.startsWith("/meetings/")) {
|
|
2735
3219
|
const id = decodeURIComponent(path.slice(10));
|
|
2736
3220
|
if (!id) throw new Error("meeting id is required");
|