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.
Files changed (3) hide show
  1. package/README.md +4 -0
  2. package/dist/cli.js +354 -23
  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,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
- 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,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
- <p>Meetings are loaded from the shared server state so this view can later coexist with the terminal UI.</p>
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
- els.list.innerHTML = '<div class="meeting-empty">No meetings yet. Try Refresh.</div>';
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
- 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;
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
- renderMeetingList();
2517
- if (state.selectedMeetingId) {
2518
- await loadMeeting(state.selectedMeetingId);
2519
- } else {
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
- const payload = await fetchJson("/meetings/" + encodeURIComponent(id));
2528
- state.selectedMeeting = payload.meeting || null;
2529
- renderMeetingDetail();
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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",