granola-toolkit 0.31.0 → 0.32.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 +3 -0
  2. package/dist/cli.js +214 -11
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -275,10 +275,13 @@ You can deep-link into a specific meeting with either:
275
275
 
276
276
  The initial browser client includes:
277
277
 
278
+ - a dedicated folder pane with an explicit All meetings scope
278
279
  - a searchable meeting list
280
+ - folder-aware meeting browsing with one-click scope changes
279
281
  - a fast local-index warm start for meeting browsing before live documents finish loading
280
282
  - sort and updated-date filters
281
283
  - quick open by meeting id or title
284
+ - browser URL state that preserves the selected folder, meeting, and tab
282
285
  - a focused meeting workspace with notes, transcript, metadata, and raw tabs
283
286
  - keyboard-first workspace switching with `1`-`4`, `[` and `]`
284
287
  - app-state status from the shared core
package/dist/cli.js CHANGED
@@ -4204,10 +4204,13 @@ const workspaceTabs = ["notes", "transcript", "metadata", "raw"];
4204
4204
  const state = {
4205
4205
  appState: null,
4206
4206
  detailError: "",
4207
+ folderError: "",
4208
+ folders: [],
4207
4209
  listError: "",
4208
4210
  meetings: [],
4209
4211
  quickOpen: "",
4210
4212
  search: "",
4213
+ selectedFolderId: null,
4211
4214
  selectedMeeting: null,
4212
4215
  selectedMeetingBundle: null,
4213
4216
  selectedMeetingId: null,
@@ -4225,6 +4228,7 @@ const els = {
4225
4228
  detailBody: document.querySelector("[data-detail-body]"),
4226
4229
  detailMeta: document.querySelector("[data-detail-meta]"),
4227
4230
  empty: document.querySelector("[data-empty]"),
4231
+ folderList: document.querySelector("[data-folder-list]"),
4228
4232
  jobsList: document.querySelector("[data-jobs-list]"),
4229
4233
  list: document.querySelector("[data-meeting-list]"),
4230
4234
  noteButton: document.querySelector("[data-export-notes]"),
@@ -4251,6 +4255,7 @@ function parseWorkspaceTab(value) {
4251
4255
  function startupSelection() {
4252
4256
  const params = new URLSearchParams(window.location.search);
4253
4257
  return {
4258
+ folderId: params.get("folder")?.trim() || "",
4254
4259
  meetingId: params.get("meeting")?.trim() || "",
4255
4260
  workspaceTab: parseWorkspaceTab(params.get("tab")),
4256
4261
  };
@@ -4259,6 +4264,12 @@ function startupSelection() {
4259
4264
  function syncBrowserUrl() {
4260
4265
  const url = new URL(window.location.href);
4261
4266
 
4267
+ if (state.selectedFolderId) {
4268
+ url.searchParams.set("folder", state.selectedFolderId);
4269
+ } else {
4270
+ url.searchParams.delete("folder");
4271
+ }
4272
+
4262
4273
  if (state.selectedMeetingId) {
4263
4274
  url.searchParams.set("meeting", state.selectedMeetingId);
4264
4275
  } else {
@@ -4302,6 +4313,11 @@ function syncFilterInputs() {
4302
4313
  function currentFilterSummary() {
4303
4314
  const parts = [];
4304
4315
 
4316
+ if (state.selectedFolderId) {
4317
+ const folder = state.folders.find((candidate) => candidate.id === state.selectedFolderId);
4318
+ parts.push("folder " + (folder ? '"' + folder.name + '"' : '"' + state.selectedFolderId + '"'));
4319
+ }
4320
+
4305
4321
  if (state.search) {
4306
4322
  parts.push('search "' + state.search + '"');
4307
4323
  }
@@ -4344,6 +4360,9 @@ function renderAppState() {
4344
4360
  : appState.index.available
4345
4361
  ? "available"
4346
4362
  : "not built";
4363
+ const folderStatus = appState.folders.loaded
4364
+ ? appState.folders.count + " folders"
4365
+ : "not loaded";
4347
4366
 
4348
4367
  els.appState.innerHTML = [
4349
4368
  '<div class="status-grid">',
@@ -4351,6 +4370,7 @@ function renderAppState() {
4351
4370
  '<div><span class="status-label">View</span><strong>' + escapeHtml(appState.ui.view) + "</strong></div>",
4352
4371
  '<div><span class="status-label">Auth</span><strong>' + escapeHtml(authMode) + "</strong></div>",
4353
4372
  '<div><span class="status-label">Documents</span><strong>' + escapeHtml(docs) + "</strong></div>",
4373
+ '<div><span class="status-label">Folders</span><strong>' + escapeHtml(folderStatus) + "</strong></div>",
4354
4374
  '<div><span class="status-label">Cache</span><strong>' + escapeHtml(cache) + "</strong></div>",
4355
4375
  '<div><span class="status-label">Index</span><strong>' + escapeHtml(indexStatus) + "</strong></div>",
4356
4376
  "</div>",
@@ -4361,6 +4381,50 @@ function renderAppState() {
4361
4381
  renderExportJobs();
4362
4382
  }
4363
4383
 
4384
+ function renderFolderList() {
4385
+ if (state.folderError) {
4386
+ els.folderList.innerHTML =
4387
+ '<div class="folder-empty folder-empty--error">' + escapeHtml(state.folderError) + "</div>";
4388
+ return;
4389
+ }
4390
+
4391
+ const buttons = [
4392
+ [
4393
+ '<button class="folder-row"' +
4394
+ (state.selectedFolderId ? "" : ' data-selected="true"') +
4395
+ ' data-folder-id="">',
4396
+ '<span class="folder-row__title">All meetings</span>',
4397
+ '<span class="folder-row__meta">Browse the full meeting list.</span>',
4398
+ "</button>",
4399
+ ].join(""),
4400
+ ];
4401
+
4402
+ for (const folder of state.folders) {
4403
+ buttons.push(
4404
+ [
4405
+ '<button class="folder-row"' +
4406
+ (folder.id === state.selectedFolderId ? ' data-selected="true"' : "") +
4407
+ ' data-folder-id="' +
4408
+ escapeHtml(folder.id) +
4409
+ '">',
4410
+ '<span class="folder-row__title">' +
4411
+ escapeHtml((folder.isFavourite ? "★ " : "") + (folder.name || folder.id)) +
4412
+ "</span>",
4413
+ '<span class="folder-row__meta">' +
4414
+ escapeHtml(String(folder.documentCount) + " meetings") +
4415
+ "</span>",
4416
+ "</button>",
4417
+ ].join(""),
4418
+ );
4419
+ }
4420
+
4421
+ if (buttons.length === 1) {
4422
+ buttons.push('<div class="folder-empty">No folders found.</div>');
4423
+ }
4424
+
4425
+ els.folderList.innerHTML = buttons.join("");
4426
+ }
4427
+
4364
4428
  function renderSecurityPanel() {
4365
4429
  els.securityPanel.hidden = !state.serverLocked;
4366
4430
  }
@@ -4504,6 +4568,7 @@ function renderMeetingDetail() {
4504
4568
  "Title: " + (record.meeting.title || record.meeting.id),
4505
4569
  "Created: " + record.meeting.createdAt,
4506
4570
  "Updated: " + record.meeting.updatedAt,
4571
+ "Folders: " + (record.meeting.folders.length ? record.meeting.folders.map((folder) => folder.name).join(", ") : "none"),
4507
4572
  "Tags: " + (record.meeting.tags.length ? record.meeting.tags.join(", ") : "none"),
4508
4573
  "Transcript loaded: " + (record.meeting.transcriptLoaded ? "yes" : "no"),
4509
4574
  ].join("\n");
@@ -4613,6 +4678,10 @@ function buildMeetingsQuery(limit = 100, refresh = false) {
4613
4678
  params.set("updatedTo", state.updatedTo);
4614
4679
  }
4615
4680
 
4681
+ if (state.selectedFolderId) {
4682
+ params.set("folderId", state.selectedFolderId);
4683
+ }
4684
+
4616
4685
  if (refresh) {
4617
4686
  params.set("refresh", "true");
4618
4687
  }
@@ -4620,6 +4689,39 @@ function buildMeetingsQuery(limit = 100, refresh = false) {
4620
4689
  return "?" + params.toString();
4621
4690
  }
4622
4691
 
4692
+ async function loadFolders(options = {}) {
4693
+ const refresh = options.refresh === true;
4694
+
4695
+ try {
4696
+ state.folderError = "";
4697
+ const params = new URLSearchParams();
4698
+ params.set("limit", "100");
4699
+ if (refresh) {
4700
+ params.set("refresh", "true");
4701
+ }
4702
+
4703
+ const payload = await fetchJson("/folders?" + params.toString());
4704
+ state.folders = payload.folders || [];
4705
+ if (
4706
+ state.selectedFolderId &&
4707
+ !state.folders.some((folder) => folder.id === state.selectedFolderId)
4708
+ ) {
4709
+ state.selectedFolderId = null;
4710
+ }
4711
+ } catch (error) {
4712
+ if (error.authRequired) {
4713
+ throw error;
4714
+ }
4715
+
4716
+ state.folderError = error instanceof Error ? error.message : String(error);
4717
+ state.folders = [];
4718
+ state.selectedFolderId = null;
4719
+ }
4720
+
4721
+ renderFolderList();
4722
+ syncBrowserUrl();
4723
+ }
4724
+
4623
4725
  async function loadMeetings(options = {}) {
4624
4726
  const preferredMeetingId = options.preferredMeetingId || state.selectedMeetingId;
4625
4727
  const refresh = options.refresh === true;
@@ -4683,10 +4785,12 @@ async function quickOpenMeeting() {
4683
4785
  try {
4684
4786
  state.quickOpen = query;
4685
4787
  const payload = await fetchJson("/meetings/resolve?q=" + encodeURIComponent(query));
4788
+ state.selectedFolderId = payload.meeting?.meeting?.folders?.[0]?.id || null;
4686
4789
  state.search = "";
4687
4790
  state.updatedFrom = "";
4688
4791
  state.updatedTo = "";
4689
4792
  syncFilterInputs();
4793
+ renderFolderList();
4690
4794
  await loadMeetings({
4691
4795
  preferredMeetingId: payload.document.id,
4692
4796
  });
@@ -4701,11 +4805,9 @@ async function quickOpenMeeting() {
4701
4805
  async function refreshAll(forceLiveMeetings = false) {
4702
4806
  setStatus("Refreshing…", "busy");
4703
4807
  try {
4704
- const [appState, authState] = await Promise.all([
4705
- fetchJson("/state"),
4706
- fetchJson("/auth/status"),
4707
- loadMeetings({ refresh: forceLiveMeetings }),
4708
- ]);
4808
+ await loadFolders({ refresh: forceLiveMeetings });
4809
+ const [appState, authState] = await Promise.all([fetchJson("/state"), fetchJson("/auth/status")]);
4810
+ await loadMeetings({ refresh: forceLiveMeetings });
4709
4811
  state.serverLocked = false;
4710
4812
  state.appState = {
4711
4813
  ...appState,
@@ -4856,17 +4958,40 @@ async function lockServer() {
4856
4958
 
4857
4959
  state.serverLocked = true;
4858
4960
  state.appState = null;
4961
+ state.folders = [];
4859
4962
  state.meetings = [];
4963
+ state.selectedFolderId = null;
4860
4964
  state.selectedMeeting = null;
4861
4965
  state.selectedMeetingBundle = null;
4862
4966
  state.detailError = "";
4967
+ state.folderError = "";
4863
4968
  els.serverPassword.value = "";
4864
4969
  renderSecurityPanel();
4970
+ renderFolderList();
4865
4971
  renderMeetingList();
4866
4972
  renderMeetingDetail();
4867
4973
  setStatus("Server locked", "error");
4868
4974
  }
4869
4975
 
4976
+ els.folderList.addEventListener("click", (event) => {
4977
+ if (!(event.target instanceof Element)) {
4978
+ return;
4979
+ }
4980
+
4981
+ const button = event.target.closest("[data-folder-id]");
4982
+ if (!button) {
4983
+ return;
4984
+ }
4985
+
4986
+ const nextFolderId = button.dataset.folderId || null;
4987
+ state.selectedFolderId = nextFolderId;
4988
+ state.selectedMeetingId = null;
4989
+ state.selectedMeeting = null;
4990
+ state.selectedMeetingBundle = null;
4991
+ renderFolderList();
4992
+ void loadMeetings();
4993
+ });
4994
+
4870
4995
  els.list.addEventListener("click", (event) => {
4871
4996
  if (!(event.target instanceof Element)) {
4872
4997
  return;
@@ -5071,6 +5196,7 @@ document.addEventListener("keydown", (event) => {
5071
5196
  });
5072
5197
 
5073
5198
  const initialSelection = startupSelection();
5199
+ state.selectedFolderId = initialSelection.folderId || null;
5074
5200
  state.selectedMeetingId = initialSelection.meetingId || null;
5075
5201
  state.workspaceTab = initialSelection.workspaceTab;
5076
5202
 
@@ -5086,9 +5212,12 @@ events.addEventListener("state.updated", (event) => {
5086
5212
  payload.state.documents?.loadedAt &&
5087
5213
  payload.state.documents.loadedAt !== previousLoadedAt
5088
5214
  ) {
5089
- void loadMeetings({
5090
- preferredMeetingId: state.selectedMeetingId,
5091
- });
5215
+ void (async () => {
5216
+ await loadFolders();
5217
+ await loadMeetings({
5218
+ preferredMeetingId: state.selectedMeetingId,
5219
+ });
5220
+ })();
5092
5221
  }
5093
5222
  });
5094
5223
  events.addEventListener("error", () => {
@@ -5097,6 +5226,7 @@ events.addEventListener("error", () => {
5097
5226
 
5098
5227
  syncFilterInputs();
5099
5228
  renderSecurityPanel();
5229
+ renderFolderList();
5100
5230
 
5101
5231
  void refreshAll().catch((error) => {
5102
5232
  setStatus("Error", "error");
@@ -5111,7 +5241,7 @@ const granolaWebMarkup = String.raw`
5111
5241
  <aside class="pane sidebar">
5112
5242
  <section class="hero">
5113
5243
  <h1>Granola Toolkit</h1>
5114
- <p>Browser workspace for meetings, notes, transcripts, and export flows on top of one local server instance.</p>
5244
+ <p>Browser workspace for folders, meetings, notes, transcripts, and export flows on top of one local server instance.</p>
5115
5245
  <input class="search" data-search placeholder="Search meetings, ids, or tags" />
5116
5246
  <div class="field-row field-row--inline">
5117
5247
  <label>
@@ -5133,6 +5263,13 @@ const granolaWebMarkup = String.raw`
5133
5263
  <input class="field-input" data-updated-to type="date" />
5134
5264
  </label>
5135
5265
  </section>
5266
+ <section class="folder-panel">
5267
+ <div class="folder-panel__head">
5268
+ <h2>Folders</h2>
5269
+ <p>Pick a folder to scope the meeting browser, or stay on All meetings.</p>
5270
+ </div>
5271
+ <div class="folder-list" data-folder-list></div>
5272
+ </section>
5136
5273
  <section class="toolbar">
5137
5274
  <div>
5138
5275
  <p>Meetings are loaded from the shared server state so this view can later coexist with the terminal UI.</p>
@@ -5254,11 +5391,11 @@ body {
5254
5391
 
5255
5392
  .sidebar {
5256
5393
  display: grid;
5257
- grid-template-rows: auto auto 1fr;
5394
+ grid-template-rows: auto auto auto 1fr;
5258
5395
  overflow: hidden;
5259
5396
  }
5260
5397
 
5261
- .hero, .toolbar, .detail-head {
5398
+ .hero, .toolbar, .detail-head, .folder-panel {
5262
5399
  padding: 22px 24px;
5263
5400
  border-bottom: 1px solid var(--line);
5264
5401
  }
@@ -5315,6 +5452,68 @@ body {
5315
5452
  overflow: auto;
5316
5453
  }
5317
5454
 
5455
+ .folder-panel {
5456
+ display: grid;
5457
+ gap: 14px;
5458
+ }
5459
+
5460
+ .folder-panel__head h2 {
5461
+ margin: 0;
5462
+ font-size: 0.92rem;
5463
+ letter-spacing: 0.08em;
5464
+ text-transform: uppercase;
5465
+ }
5466
+
5467
+ .folder-panel__head p {
5468
+ margin: 6px 0 0;
5469
+ color: var(--muted);
5470
+ font-size: 0.9rem;
5471
+ }
5472
+
5473
+ .folder-list {
5474
+ display: grid;
5475
+ gap: 10px;
5476
+ }
5477
+
5478
+ .folder-row {
5479
+ width: 100%;
5480
+ display: grid;
5481
+ gap: 4px;
5482
+ text-align: left;
5483
+ padding: 12px 14px;
5484
+ border: 1px solid transparent;
5485
+ border-radius: 16px;
5486
+ background: rgba(255, 255, 255, 0.72);
5487
+ color: inherit;
5488
+ cursor: pointer;
5489
+ transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
5490
+ }
5491
+
5492
+ .folder-row:hover,
5493
+ .folder-row[data-selected="true"] {
5494
+ transform: translateY(-1px);
5495
+ border-color: rgba(163, 79, 47, 0.26);
5496
+ background: var(--panel-strong);
5497
+ }
5498
+
5499
+ .folder-row__title {
5500
+ font-weight: 700;
5501
+ }
5502
+
5503
+ .folder-row__meta {
5504
+ color: var(--muted);
5505
+ font-size: 0.88rem;
5506
+ }
5507
+
5508
+ .folder-empty {
5509
+ color: var(--muted);
5510
+ font-size: 0.92rem;
5511
+ }
5512
+
5513
+ .folder-empty--error {
5514
+ color: var(--error);
5515
+ }
5516
+
5318
5517
  .meeting-row {
5319
5518
  width: 100%;
5320
5519
  display: grid;
@@ -6174,7 +6373,11 @@ function printWebRoutes() {
6174
6373
  console.log(" GET /auth/status");
6175
6374
  console.log(" GET /state");
6176
6375
  console.log(" GET /events");
6376
+ console.log(" GET /folders");
6377
+ console.log(" GET /folders/resolve?q=<query>");
6378
+ console.log(" GET /folders/:id");
6177
6379
  console.log(" GET /meetings");
6380
+ console.log(" GET /meetings?folderId=<id>");
6178
6381
  console.log(" GET /meetings/:id");
6179
6382
  console.log(" GET /exports/jobs");
6180
6383
  console.log(" POST /auth/login");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",