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.
Files changed (3) hide show
  1. package/README.md +6 -1
  2. package/dist/cli.js +514 -30
  3. 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
- - a meeting detail view with notes and transcript panes
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
- return documents.filter((document) => options.search ? matchesMeetingSearch(document, options.search) : true).sort(compareMeetingDocuments).slice(0, limit).map((document) => buildMeetingSummary(document, options.cacheData));
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
- <p>Meetings are loaded from the shared server state so this view can later coexist with the terminal UI.</p>
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
- els.list.innerHTML = '<div class="meeting-empty">No meetings yet. Try Refresh.</div>';
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
- '<section class="detail-section">',
2490
- "<h2>Notes</h2>",
2491
- '<pre class="detail-pre">' + escapeHtml(record.noteMarkdown || "") + "</pre>",
2492
- "</section>",
2493
- '<section class="detail-section">',
2494
- "<h2>Transcript</h2>",
2495
- '<pre class="detail-pre">' + escapeHtml(record.transcriptText || "(Transcript unavailable)") + "</pre>",
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
- async function loadMeetings() {
2510
- const query = state.search ? "?search=" + encodeURIComponent(state.search) + "&limit=50" : "?limit=50";
2511
- const payload = await fetchJson("/meetings" + query);
2512
- state.meetings = payload.meetings || [];
2513
- if (!state.selectedMeetingId && state.meetings[0]) {
2514
- state.selectedMeetingId = state.meetings[0].id;
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
- renderMeetingList();
2517
- if (state.selectedMeetingId) {
2518
- await loadMeeting(state.selectedMeetingId);
2519
- } else {
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
- const payload = await fetchJson("/meetings/" + encodeURIComponent(id));
2528
- state.selectedMeeting = payload.meeting || null;
2529
- renderMeetingDetail();
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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",