granola-toolkit 0.19.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 +4 -0
- package/dist/cli.js +354 -23
- 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,12 @@ 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
|
|
209
|
+
- sort and updated-date filters
|
|
210
|
+
- quick open by meeting id or title
|
|
208
211
|
- a meeting detail view with notes and transcript panes
|
|
209
212
|
- app-state status from the shared core
|
|
210
213
|
- note and transcript export actions backed by the same local API
|
|
214
|
+
- stronger empty and error states for list/detail failures
|
|
211
215
|
|
|
212
216
|
## Auth
|
|
213
217
|
|
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,13 @@ 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
|
+
|
|
2251
2349
|
.button {
|
|
2252
2350
|
border: 0;
|
|
2253
2351
|
border-radius: 999px;
|
|
@@ -2340,6 +2438,11 @@ function renderGranolaWebPage() {
|
|
|
2340
2438
|
.shell {
|
|
2341
2439
|
grid-template-columns: 1fr;
|
|
2342
2440
|
}
|
|
2441
|
+
|
|
2442
|
+
.field-row--inline,
|
|
2443
|
+
.toolbar-form {
|
|
2444
|
+
grid-template-columns: 1fr;
|
|
2445
|
+
}
|
|
2343
2446
|
}
|
|
2344
2447
|
</style>
|
|
2345
2448
|
</head>
|
|
@@ -2350,9 +2453,34 @@ function renderGranolaWebPage() {
|
|
|
2350
2453
|
<h1>Granola Toolkit</h1>
|
|
2351
2454
|
<p>Browser workspace for meetings, notes, transcripts, and export flows on top of one local server instance.</p>
|
|
2352
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>
|
|
2353
2475
|
</section>
|
|
2354
2476
|
<section class="toolbar">
|
|
2355
|
-
<
|
|
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>
|
|
2356
2484
|
</section>
|
|
2357
2485
|
<section class="meeting-list" data-meeting-list></section>
|
|
2358
2486
|
</aside>
|
|
@@ -2381,11 +2509,17 @@ function renderGranolaWebPage() {
|
|
|
2381
2509
|
<script type="module">
|
|
2382
2510
|
${String.raw`
|
|
2383
2511
|
const state = {
|
|
2384
|
-
meetings: [],
|
|
2385
|
-
selectedMeetingId: null,
|
|
2386
|
-
selectedMeeting: null,
|
|
2387
2512
|
appState: null,
|
|
2513
|
+
detailError: "",
|
|
2514
|
+
listError: "",
|
|
2515
|
+
meetings: [],
|
|
2516
|
+
quickOpen: "",
|
|
2388
2517
|
search: "",
|
|
2518
|
+
selectedMeeting: null,
|
|
2519
|
+
selectedMeetingId: null,
|
|
2520
|
+
sort: "updated-desc",
|
|
2521
|
+
updatedFrom: "",
|
|
2522
|
+
updatedTo: "",
|
|
2389
2523
|
};
|
|
2390
2524
|
|
|
2391
2525
|
const els = {
|
|
@@ -2395,10 +2529,15 @@ const els = {
|
|
|
2395
2529
|
empty: document.querySelector("[data-empty]"),
|
|
2396
2530
|
list: document.querySelector("[data-meeting-list]"),
|
|
2397
2531
|
noteButton: document.querySelector("[data-export-notes]"),
|
|
2532
|
+
quickOpen: document.querySelector("[data-quick-open]"),
|
|
2533
|
+
quickOpenButton: document.querySelector("[data-quick-open-button]"),
|
|
2398
2534
|
refreshButton: document.querySelector("[data-refresh]"),
|
|
2399
2535
|
search: document.querySelector("[data-search]"),
|
|
2536
|
+
sort: document.querySelector("[data-sort]"),
|
|
2400
2537
|
stateBadge: document.querySelector("[data-state-badge]"),
|
|
2401
2538
|
transcriptButton: document.querySelector("[data-export-transcripts]"),
|
|
2539
|
+
updatedFrom: document.querySelector("[data-updated-from]"),
|
|
2540
|
+
updatedTo: document.querySelector("[data-updated-to]"),
|
|
2402
2541
|
};
|
|
2403
2542
|
|
|
2404
2543
|
function escapeHtml(value) {
|
|
@@ -2414,6 +2553,32 @@ function setStatus(label, tone = "idle") {
|
|
|
2414
2553
|
els.stateBadge.dataset.tone = tone;
|
|
2415
2554
|
}
|
|
2416
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
|
+
|
|
2417
2582
|
function renderAppState() {
|
|
2418
2583
|
if (!state.appState) {
|
|
2419
2584
|
els.appState.innerHTML = "<p>Waiting for server state…</p>";
|
|
@@ -2441,10 +2606,20 @@ function renderAppState() {
|
|
|
2441
2606
|
}
|
|
2442
2607
|
|
|
2443
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
|
+
|
|
2444
2615
|
if (state.meetings.length === 0) {
|
|
2445
2616
|
state.selectedMeetingId = null;
|
|
2446
2617
|
state.selectedMeeting = null;
|
|
2447
|
-
|
|
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>";
|
|
2448
2623
|
renderMeetingDetail();
|
|
2449
2624
|
return;
|
|
2450
2625
|
}
|
|
@@ -2470,9 +2645,18 @@ function renderMeetingList() {
|
|
|
2470
2645
|
}
|
|
2471
2646
|
|
|
2472
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
|
+
|
|
2473
2656
|
const record = state.selectedMeeting;
|
|
2474
2657
|
if (!record) {
|
|
2475
2658
|
els.empty.hidden = false;
|
|
2659
|
+
els.empty.textContent = "Select a meeting to inspect its notes and transcript.";
|
|
2476
2660
|
els.detailMeta.innerHTML = "";
|
|
2477
2661
|
els.detailBody.innerHTML = "";
|
|
2478
2662
|
return;
|
|
@@ -2506,17 +2690,51 @@ async function fetchJson(path, init) {
|
|
|
2506
2690
|
return payload;
|
|
2507
2691
|
}
|
|
2508
2692
|
|
|
2509
|
-
|
|
2510
|
-
const
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
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);
|
|
2515
2700
|
}
|
|
2516
|
-
|
|
2517
|
-
if (state.
|
|
2518
|
-
|
|
2519
|
-
}
|
|
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();
|
|
2520
2738
|
renderMeetingDetail();
|
|
2521
2739
|
}
|
|
2522
2740
|
}
|
|
@@ -2524,9 +2742,44 @@ async function loadMeetings() {
|
|
|
2524
2742
|
async function loadMeeting(id) {
|
|
2525
2743
|
state.selectedMeetingId = id;
|
|
2526
2744
|
renderMeetingList();
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
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
|
+
}
|
|
2530
2783
|
}
|
|
2531
2784
|
|
|
2532
2785
|
async function refreshAll() {
|
|
@@ -2588,6 +2841,56 @@ els.search.addEventListener("input", (event) => {
|
|
|
2588
2841
|
void loadMeetings();
|
|
2589
2842
|
});
|
|
2590
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
|
+
|
|
2591
2894
|
const events = new EventSource("/events");
|
|
2592
2895
|
events.addEventListener("state.updated", (event) => {
|
|
2593
2896
|
const payload = JSON.parse(event.data);
|
|
@@ -2598,6 +2901,8 @@ events.addEventListener("error", () => {
|
|
|
2598
2901
|
setStatus("Disconnected", "error");
|
|
2599
2902
|
});
|
|
2600
2903
|
|
|
2904
|
+
syncFilterInputs();
|
|
2905
|
+
|
|
2601
2906
|
void refreshAll().catch((error) => {
|
|
2602
2907
|
setStatus("Error", "error");
|
|
2603
2908
|
els.empty.hidden = false;
|
|
@@ -2617,6 +2922,17 @@ function parseInteger(value) {
|
|
|
2617
2922
|
if (!Number.isInteger(parsed) || parsed < 1) throw new Error("invalid limit: expected a positive integer");
|
|
2618
2923
|
return parsed;
|
|
2619
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
|
+
}
|
|
2620
2936
|
function sendJson(response, body, init = {}) {
|
|
2621
2937
|
const payload = `${JSON.stringify(body, null, 2)}\n`;
|
|
2622
2938
|
response.writeHead(init.status ?? 200, {
|
|
@@ -2722,15 +3038,30 @@ async function startGranolaServer(app, options = {}) {
|
|
|
2722
3038
|
if (method === "GET" && path === "/meetings") {
|
|
2723
3039
|
const limit = parseInteger(url.searchParams.get("limit"));
|
|
2724
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;
|
|
2725
3044
|
sendJson(response, {
|
|
2726
3045
|
meetings: await app.listMeetings({
|
|
2727
3046
|
limit,
|
|
2728
|
-
search
|
|
3047
|
+
search,
|
|
3048
|
+
sort,
|
|
3049
|
+
updatedFrom,
|
|
3050
|
+
updatedTo
|
|
2729
3051
|
}),
|
|
2730
|
-
search
|
|
3052
|
+
search,
|
|
3053
|
+
sort,
|
|
3054
|
+
updatedFrom,
|
|
3055
|
+
updatedTo
|
|
2731
3056
|
});
|
|
2732
3057
|
return;
|
|
2733
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
|
+
}
|
|
2734
3065
|
if (method === "GET" && path.startsWith("/meetings/")) {
|
|
2735
3066
|
const id = decodeURIComponent(path.slice(10));
|
|
2736
3067
|
if (!id) throw new Error("meeting id is required");
|