granola-toolkit 0.31.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/dist/cli.js +355 -23
- 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
|
|
@@ -316,6 +319,7 @@ That keeps the current single-package repo simple, while making a future split i
|
|
|
316
319
|
|
|
317
320
|
The initial terminal workspace includes:
|
|
318
321
|
|
|
322
|
+
- a folder scope inside the navigation pane, including an explicit All meetings view
|
|
319
323
|
- a meeting list pane with keyboard navigation
|
|
320
324
|
- a detail pane with notes, transcript, metadata, and raw views
|
|
321
325
|
- an auth session overlay for import, refresh, source switching, and sign-out
|
|
@@ -324,7 +328,8 @@ The initial terminal workspace includes:
|
|
|
324
328
|
|
|
325
329
|
The main keyboard controls are:
|
|
326
330
|
|
|
327
|
-
- `
|
|
331
|
+
- `h` / `l`, left / right, or `Tab` to switch between folders and meetings
|
|
332
|
+
- `j` / `k` or arrow keys to move within the active folder or meeting list
|
|
328
333
|
- `/` or `Ctrl+P` to open quick open
|
|
329
334
|
- `a` to open auth session actions
|
|
330
335
|
- `1`-`4` to switch detail tabs
|
package/dist/cli.js
CHANGED
|
@@ -1345,6 +1345,7 @@ function renderGranolaTuiMeetingTab(bundle, tab) {
|
|
|
1345
1345
|
`ID: ${summary.id}`,
|
|
1346
1346
|
`Created: ${summary.createdAt}`,
|
|
1347
1347
|
`Updated: ${summary.updatedAt}`,
|
|
1348
|
+
`Folders: ${summary.folders.length > 0 ? summary.folders.map((folder) => folder.name).join(", ") : "none"}`,
|
|
1348
1349
|
`Tags: ${summary.tags.length > 0 ? summary.tags.join(", ") : "none"}`,
|
|
1349
1350
|
`Notes source: ${summary.noteContentSource}`,
|
|
1350
1351
|
`Transcript loaded: ${summary.transcriptLoaded ? "yes" : "no"}`,
|
|
@@ -1360,7 +1361,7 @@ function renderGranolaTuiMeetingTab(bundle, tab) {
|
|
|
1360
1361
|
}
|
|
1361
1362
|
}
|
|
1362
1363
|
function buildGranolaTuiSummary(state, meetingSource) {
|
|
1363
|
-
return `auth ${state.auth.mode === "stored-session" ? "stored" : "supabase"} | ${state.documents.loaded ? `${state.documents.count} docs` : "docs pending"} | ${state.cache.loaded ? `${state.cache.transcriptCount} transcript sets` : state.cache.configured ? "cache configured" : "cache missing"} | ${state.index.loaded ? `${state.index.meetingCount} indexed` : "index pending"} | list ${meetingSource}`;
|
|
1364
|
+
return `auth ${state.auth.mode === "stored-session" ? "stored" : "supabase"} | ${state.documents.loaded ? `${state.documents.count} docs` : "docs pending"} | ${state.folders.loaded ? `${state.folders.count} folders` : "folders pending"} | ${state.cache.loaded ? `${state.cache.transcriptCount} transcript sets` : state.cache.configured ? "cache configured" : "cache missing"} | ${state.index.loaded ? `${state.index.meetingCount} indexed` : "index pending"} | list ${meetingSource}`;
|
|
1364
1365
|
}
|
|
1365
1366
|
//#endregion
|
|
1366
1367
|
//#region src/tui/theme.ts
|
|
@@ -1657,9 +1658,13 @@ var GranolaTuiWorkspace = class {
|
|
|
1657
1658
|
focused = false;
|
|
1658
1659
|
#maxMeetings;
|
|
1659
1660
|
#appState;
|
|
1661
|
+
#activePane = "meetings";
|
|
1660
1662
|
#detailError = "";
|
|
1661
1663
|
#detailScroll = 0;
|
|
1662
1664
|
#detailToken = 0;
|
|
1665
|
+
#folderError = "";
|
|
1666
|
+
#folderToken = 0;
|
|
1667
|
+
#folders = [];
|
|
1663
1668
|
#listError = "";
|
|
1664
1669
|
#listToken = 0;
|
|
1665
1670
|
#loadingDetail = false;
|
|
@@ -1667,6 +1672,7 @@ var GranolaTuiWorkspace = class {
|
|
|
1667
1672
|
#meetingSource = "live";
|
|
1668
1673
|
#meetings = [];
|
|
1669
1674
|
#overlay;
|
|
1675
|
+
#selectedFolderId;
|
|
1670
1676
|
#selectedMeeting;
|
|
1671
1677
|
#selectedMeetingId;
|
|
1672
1678
|
#statusMessage = "Loading meetings…";
|
|
@@ -1679,11 +1685,13 @@ var GranolaTuiWorkspace = class {
|
|
|
1679
1685
|
this.options = options;
|
|
1680
1686
|
this.#appState = app.getState();
|
|
1681
1687
|
this.#maxMeetings = options.maxMeetings ?? 200;
|
|
1688
|
+
this.#selectedFolderId = this.#appState.ui.selectedFolderId;
|
|
1682
1689
|
}
|
|
1683
1690
|
async initialise() {
|
|
1684
1691
|
this.#unsubscribe = this.app.subscribe((event) => {
|
|
1685
1692
|
this.handleAppUpdate(event);
|
|
1686
1693
|
});
|
|
1694
|
+
await this.loadFolders({ setStatus: false });
|
|
1687
1695
|
await this.loadMeetings({
|
|
1688
1696
|
preferredMeetingId: this.options.initialMeetingId,
|
|
1689
1697
|
setStatus: true
|
|
@@ -1699,7 +1707,12 @@ var GranolaTuiWorkspace = class {
|
|
|
1699
1707
|
handleAppUpdate(event) {
|
|
1700
1708
|
const previousDocumentsLoadedAt = this.#appState.documents.loadedAt;
|
|
1701
1709
|
this.#appState = event.state;
|
|
1702
|
-
|
|
1710
|
+
this.#selectedFolderId = event.state.ui.selectedFolderId;
|
|
1711
|
+
this.#selectedMeetingId = event.state.ui.selectedMeetingId ?? this.#selectedMeetingId;
|
|
1712
|
+
if (this.#meetingSource === "index" && event.state.documents.loadedAt && event.state.documents.loadedAt !== previousDocumentsLoadedAt && !this.#loadingMeetings) (async () => {
|
|
1713
|
+
await this.loadFolders({ setStatus: false });
|
|
1714
|
+
await this.loadMeetings({ preferredMeetingId: this.#selectedMeetingId });
|
|
1715
|
+
})();
|
|
1703
1716
|
this.tui.requestRender();
|
|
1704
1717
|
}
|
|
1705
1718
|
setStatus(message, tone = "info") {
|
|
@@ -1712,6 +1725,11 @@ var GranolaTuiWorkspace = class {
|
|
|
1712
1725
|
const selectedIndex = this.#selectedMeetingId ? this.#meetings.findIndex((meeting) => meeting.id === this.#selectedMeetingId) : -1;
|
|
1713
1726
|
return selectedIndex >= 0 ? selectedIndex : 0;
|
|
1714
1727
|
}
|
|
1728
|
+
normaliseSelectedFolderIndex() {
|
|
1729
|
+
if (!this.#selectedFolderId) return 0;
|
|
1730
|
+
const selectedIndex = this.#folders.findIndex((folder) => folder.id === this.#selectedFolderId);
|
|
1731
|
+
return selectedIndex >= 0 ? selectedIndex + 1 : 0;
|
|
1732
|
+
}
|
|
1715
1733
|
ensureMeetingVisible(meeting) {
|
|
1716
1734
|
const existingIndex = this.#meetings.findIndex((item) => item.id === meeting.id);
|
|
1717
1735
|
if (existingIndex >= 0) this.#meetings[existingIndex] = meeting;
|
|
@@ -1721,6 +1739,30 @@ var GranolaTuiWorkspace = class {
|
|
|
1721
1739
|
return left.title.localeCompare(right.title);
|
|
1722
1740
|
});
|
|
1723
1741
|
}
|
|
1742
|
+
async loadFolders(options = {}) {
|
|
1743
|
+
const token = ++this.#folderToken;
|
|
1744
|
+
this.#folderError = "";
|
|
1745
|
+
if (options.setStatus) this.setStatus(options.forceRefresh ? "Refreshing folders…" : "Loading folders…");
|
|
1746
|
+
try {
|
|
1747
|
+
const result = await this.app.listFolders({
|
|
1748
|
+
forceRefresh: options.forceRefresh,
|
|
1749
|
+
limit: 100
|
|
1750
|
+
});
|
|
1751
|
+
if (token !== this.#folderToken) return;
|
|
1752
|
+
this.#folders = result.folders;
|
|
1753
|
+
if (this.#selectedFolderId && !this.#folders.some((folder) => folder.id === this.#selectedFolderId)) this.#selectedFolderId = void 0;
|
|
1754
|
+
this.#folderError = "";
|
|
1755
|
+
} catch (error) {
|
|
1756
|
+
if (token !== this.#folderToken) return;
|
|
1757
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1758
|
+
this.#folderError = message;
|
|
1759
|
+
this.#folders = [];
|
|
1760
|
+
this.#selectedFolderId = void 0;
|
|
1761
|
+
this.setStatus(message, "error");
|
|
1762
|
+
} finally {
|
|
1763
|
+
if (token === this.#folderToken) this.tui.requestRender();
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1724
1766
|
async loadMeetings(options = {}) {
|
|
1725
1767
|
const token = ++this.#listToken;
|
|
1726
1768
|
this.#loadingMeetings = true;
|
|
@@ -1728,6 +1770,7 @@ var GranolaTuiWorkspace = class {
|
|
|
1728
1770
|
if (options.setStatus !== false) this.setStatus(options.forceRefresh ? "Refreshing meetings…" : "Loading meetings…");
|
|
1729
1771
|
try {
|
|
1730
1772
|
const result = await this.app.listMeetings({
|
|
1773
|
+
folderId: this.#selectedFolderId,
|
|
1731
1774
|
forceRefresh: options.forceRefresh,
|
|
1732
1775
|
limit: this.#maxMeetings,
|
|
1733
1776
|
preferIndex: true
|
|
@@ -1736,8 +1779,13 @@ var GranolaTuiWorkspace = class {
|
|
|
1736
1779
|
this.#meetings = result.meetings;
|
|
1737
1780
|
this.#meetingSource = result.source;
|
|
1738
1781
|
this.#selectedMeetingId = options.preferredMeetingId && this.#meetings.some((meeting) => meeting.id === options.preferredMeetingId) ? options.preferredMeetingId : this.#selectedMeetingId && this.#meetings.some((meeting) => meeting.id === this.#selectedMeetingId) ? this.#selectedMeetingId : this.#meetings[0]?.id;
|
|
1782
|
+
if (!this.#selectedMeetingId) {
|
|
1783
|
+
this.#selectedMeeting = void 0;
|
|
1784
|
+
this.#detailError = "";
|
|
1785
|
+
this.#detailScroll = 0;
|
|
1786
|
+
}
|
|
1739
1787
|
this.#listError = "";
|
|
1740
|
-
this.setStatus(result.source === "index" ? "Loaded meetings from the local index" : "Connected to Granola");
|
|
1788
|
+
this.setStatus(result.source === "index" ? "Loaded meetings from the local index" : this.#selectedFolderId ? "Connected to Granola (folder scope)" : "Connected to Granola");
|
|
1741
1789
|
} catch (error) {
|
|
1742
1790
|
if (token !== this.#listToken) return;
|
|
1743
1791
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -1780,6 +1828,10 @@ var GranolaTuiWorkspace = class {
|
|
|
1780
1828
|
}
|
|
1781
1829
|
async refresh(forceRefresh) {
|
|
1782
1830
|
try {
|
|
1831
|
+
await this.loadFolders({
|
|
1832
|
+
forceRefresh,
|
|
1833
|
+
setStatus: false
|
|
1834
|
+
});
|
|
1783
1835
|
await this.loadMeetings({
|
|
1784
1836
|
forceRefresh,
|
|
1785
1837
|
preferredMeetingId: this.#selectedMeetingId
|
|
@@ -1787,7 +1839,7 @@ var GranolaTuiWorkspace = class {
|
|
|
1787
1839
|
if (this.#selectedMeetingId) await this.loadMeeting(this.#selectedMeetingId, { ensureMeetingVisible: true });
|
|
1788
1840
|
} catch {}
|
|
1789
1841
|
}
|
|
1790
|
-
async
|
|
1842
|
+
async moveMeetingSelection(delta) {
|
|
1791
1843
|
if (this.#meetings.length === 0) return;
|
|
1792
1844
|
const currentIndex = this.normaliseSelectedIndex();
|
|
1793
1845
|
const nextIndex = Math.max(0, Math.min(this.#meetings.length - 1, currentIndex + delta));
|
|
@@ -1795,6 +1847,33 @@ var GranolaTuiWorkspace = class {
|
|
|
1795
1847
|
if (!nextMeeting || nextMeeting.id === this.#selectedMeetingId) return;
|
|
1796
1848
|
await this.loadMeeting(nextMeeting.id);
|
|
1797
1849
|
}
|
|
1850
|
+
async moveFolderSelection(delta) {
|
|
1851
|
+
const total = this.#folders.length + 1;
|
|
1852
|
+
const currentIndex = this.normaliseSelectedFolderIndex();
|
|
1853
|
+
const nextIndex = Math.max(0, Math.min(total - 1, currentIndex + delta));
|
|
1854
|
+
const nextFolderId = nextIndex === 0 ? void 0 : this.#folders[nextIndex - 1]?.id;
|
|
1855
|
+
if (nextFolderId === this.#selectedFolderId) return;
|
|
1856
|
+
this.#selectedFolderId = nextFolderId;
|
|
1857
|
+
this.#selectedMeeting = void 0;
|
|
1858
|
+
this.#detailError = "";
|
|
1859
|
+
this.#detailScroll = 0;
|
|
1860
|
+
await this.loadMeetings({
|
|
1861
|
+
preferredMeetingId: this.#selectedMeetingId,
|
|
1862
|
+
setStatus: false
|
|
1863
|
+
});
|
|
1864
|
+
if (this.#selectedMeetingId) {
|
|
1865
|
+
await this.loadMeeting(this.#selectedMeetingId, { ensureMeetingVisible: true });
|
|
1866
|
+
return;
|
|
1867
|
+
}
|
|
1868
|
+
this.tui.requestRender();
|
|
1869
|
+
}
|
|
1870
|
+
async moveSelection(delta) {
|
|
1871
|
+
if (this.#activePane === "folders") {
|
|
1872
|
+
await this.moveFolderSelection(delta);
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
await this.moveMeetingSelection(delta);
|
|
1876
|
+
}
|
|
1798
1877
|
currentDetailBody(width) {
|
|
1799
1878
|
if (this.#detailError) return wrapBlock(this.#detailError, width);
|
|
1800
1879
|
if (this.#loadingDetail && !this.#selectedMeeting) return wrapBlock("Loading meeting details…", width);
|
|
@@ -1832,6 +1911,10 @@ var GranolaTuiWorkspace = class {
|
|
|
1832
1911
|
async reloadAfterAuthChange() {
|
|
1833
1912
|
const preferredMeetingId = this.#selectedMeeting?.document.id ?? this.#selectedMeetingId;
|
|
1834
1913
|
try {
|
|
1914
|
+
await this.loadFolders({
|
|
1915
|
+
forceRefresh: true,
|
|
1916
|
+
setStatus: false
|
|
1917
|
+
});
|
|
1835
1918
|
await this.loadMeetings({
|
|
1836
1919
|
forceRefresh: true,
|
|
1837
1920
|
preferredMeetingId,
|
|
@@ -1956,6 +2039,21 @@ var GranolaTuiWorkspace = class {
|
|
|
1956
2039
|
this.openAuthPanel();
|
|
1957
2040
|
return;
|
|
1958
2041
|
}
|
|
2042
|
+
if (matchesKey(data, "tab")) {
|
|
2043
|
+
this.#activePane = this.#activePane === "folders" ? "meetings" : "folders";
|
|
2044
|
+
this.tui.requestRender();
|
|
2045
|
+
return;
|
|
2046
|
+
}
|
|
2047
|
+
if (matchesKey(data, "left") || matchesKey(data, "h")) {
|
|
2048
|
+
this.#activePane = "folders";
|
|
2049
|
+
this.tui.requestRender();
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
if (matchesKey(data, "right") || matchesKey(data, "l")) {
|
|
2053
|
+
this.#activePane = "meetings";
|
|
2054
|
+
this.tui.requestRender();
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
1959
2057
|
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
1960
2058
|
this.moveSelection(-1);
|
|
1961
2059
|
return;
|
|
@@ -2021,22 +2119,53 @@ var GranolaTuiWorkspace = class {
|
|
|
2021
2119
|
renderListPane(width, height) {
|
|
2022
2120
|
const lines = [];
|
|
2023
2121
|
const innerWidth = Math.max(1, width - 2);
|
|
2024
|
-
const
|
|
2025
|
-
|
|
2122
|
+
const folderEntries = [{
|
|
2123
|
+
id: void 0,
|
|
2124
|
+
label: "All meetings",
|
|
2125
|
+
meta: this.#folders.length > 0 ? `${this.#folders.length} folders` : "global scope"
|
|
2126
|
+
}, ...this.#folders.map((folder) => ({
|
|
2127
|
+
id: folder.id,
|
|
2128
|
+
label: `${folder.isFavourite ? "★ " : ""}${folder.name || folder.id}`,
|
|
2129
|
+
meta: `${folder.documentCount} meetings`
|
|
2130
|
+
}))];
|
|
2131
|
+
const availableRows = Math.max(2, height - 3);
|
|
2132
|
+
const folderWindowSize = Math.min(Math.max(3, Math.min(8, Math.floor(availableRows * .35))), Math.max(1, availableRows - 1));
|
|
2133
|
+
const meetingWindowSize = Math.max(1, availableRows - folderWindowSize);
|
|
2134
|
+
const folderHeader = `${this.#activePane === "folders" ? granolaTuiTheme.accent("Folders") : granolaTuiTheme.strong("Folders")} ${granolaTuiTheme.dim(`(${this.#folders.length})`)}`;
|
|
2135
|
+
lines.push(padLine(folderHeader, innerWidth));
|
|
2136
|
+
if (this.#folderError) {
|
|
2137
|
+
lines.push(...wrapBlock(granolaTuiTheme.error(this.#folderError), innerWidth).slice(0, folderWindowSize));
|
|
2138
|
+
while (lines.length < 1 + folderWindowSize) lines.push(" ".repeat(innerWidth));
|
|
2139
|
+
} else {
|
|
2140
|
+
const selectedFolderIndex = this.normaliseSelectedFolderIndex();
|
|
2141
|
+
const folderStartIndex = Math.max(0, Math.min(selectedFolderIndex - Math.floor(folderWindowSize / 2), folderEntries.length - folderWindowSize));
|
|
2142
|
+
const visibleFolders = folderEntries.slice(folderStartIndex, folderStartIndex + folderWindowSize);
|
|
2143
|
+
for (const [offset, folder] of visibleFolders.entries()) {
|
|
2144
|
+
const selected = folderStartIndex + offset === selectedFolderIndex;
|
|
2145
|
+
const prefix = selected ? "> " : " ";
|
|
2146
|
+
const maxLabelWidth = Math.max(6, innerWidth - visibleWidth(prefix) - visibleWidth(folder.meta) - 1);
|
|
2147
|
+
const labelBlock = `${prefix}${truncateToWidth(folder.label, maxLabelWidth, "")}`;
|
|
2148
|
+
const line = `${labelBlock}${" ".repeat(Math.max(1, innerWidth - visibleWidth(labelBlock) - visibleWidth(folder.meta)))}${granolaTuiTheme.dim(folder.meta)}`;
|
|
2149
|
+
lines.push(selected ? padLine(granolaTuiTheme.selected(line), innerWidth) : padLine(line, innerWidth));
|
|
2150
|
+
}
|
|
2151
|
+
while (lines.length < 1 + folderWindowSize) lines.push(" ".repeat(innerWidth));
|
|
2152
|
+
}
|
|
2153
|
+
lines.push(padLine(granolaTuiTheme.dim(""), innerWidth));
|
|
2154
|
+
const meetingsHeader = `${this.#activePane === "meetings" ? granolaTuiTheme.accent("Meetings") : granolaTuiTheme.strong("Meetings")} ${granolaTuiTheme.dim(`(${this.#meetings.length})`)}`;
|
|
2155
|
+
lines.push(padLine(meetingsHeader, innerWidth));
|
|
2026
2156
|
if (this.#listError) {
|
|
2027
|
-
lines.push(...wrapBlock(granolaTuiTheme.error(this.#listError), innerWidth).slice(0,
|
|
2157
|
+
lines.push(...wrapBlock(granolaTuiTheme.error(this.#listError), innerWidth).slice(0, meetingWindowSize));
|
|
2028
2158
|
while (lines.length < height) lines.push(" ".repeat(innerWidth));
|
|
2029
2159
|
return lines;
|
|
2030
2160
|
}
|
|
2031
2161
|
if (this.#meetings.length === 0) {
|
|
2032
|
-
lines.push(...wrapBlock("No meetings available yet.", innerWidth).slice(0,
|
|
2162
|
+
lines.push(...wrapBlock("No meetings available yet.", innerWidth).slice(0, meetingWindowSize));
|
|
2033
2163
|
while (lines.length < height) lines.push(" ".repeat(innerWidth));
|
|
2034
2164
|
return lines;
|
|
2035
2165
|
}
|
|
2036
2166
|
const selectedIndex = this.normaliseSelectedIndex();
|
|
2037
|
-
const
|
|
2038
|
-
const
|
|
2039
|
-
const visibleMeetings = this.#meetings.slice(startIndex, startIndex + windowSize);
|
|
2167
|
+
const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(meetingWindowSize / 2), this.#meetings.length - meetingWindowSize));
|
|
2168
|
+
const visibleMeetings = this.#meetings.slice(startIndex, startIndex + meetingWindowSize);
|
|
2040
2169
|
for (const [offset, meeting] of visibleMeetings.entries()) {
|
|
2041
2170
|
const selected = startIndex + offset === selectedIndex;
|
|
2042
2171
|
const dateLabel = meeting.updatedAt.slice(0, 10);
|
|
@@ -2094,7 +2223,7 @@ var GranolaTuiWorkspace = class {
|
|
|
2094
2223
|
const bodyLines = [];
|
|
2095
2224
|
for (let index = 0; index < bodyHeight; index += 1) bodyLines.push(`${padLine(listLines[index] ?? "", listWidth)} | ${padLine(detailLines[index] ?? "", detailWidth)}`);
|
|
2096
2225
|
const footerStatus = padLine(toneText(this.#statusTone, this.#statusMessage), width);
|
|
2097
|
-
const footerHints = padLine(granolaTuiTheme.dim("/ quick open a auth r refresh 1-4 tabs PgUp/PgDn scroll q quit"), width);
|
|
2226
|
+
const footerHints = padLine(granolaTuiTheme.dim("h/l or Tab pane j/k move / quick open a auth r refresh 1-4 tabs PgUp/PgDn scroll q quit"), width);
|
|
2098
2227
|
return [
|
|
2099
2228
|
headerTitle,
|
|
2100
2229
|
headerSummary,
|
|
@@ -4204,10 +4333,13 @@ const workspaceTabs = ["notes", "transcript", "metadata", "raw"];
|
|
|
4204
4333
|
const state = {
|
|
4205
4334
|
appState: null,
|
|
4206
4335
|
detailError: "",
|
|
4336
|
+
folderError: "",
|
|
4337
|
+
folders: [],
|
|
4207
4338
|
listError: "",
|
|
4208
4339
|
meetings: [],
|
|
4209
4340
|
quickOpen: "",
|
|
4210
4341
|
search: "",
|
|
4342
|
+
selectedFolderId: null,
|
|
4211
4343
|
selectedMeeting: null,
|
|
4212
4344
|
selectedMeetingBundle: null,
|
|
4213
4345
|
selectedMeetingId: null,
|
|
@@ -4225,6 +4357,7 @@ const els = {
|
|
|
4225
4357
|
detailBody: document.querySelector("[data-detail-body]"),
|
|
4226
4358
|
detailMeta: document.querySelector("[data-detail-meta]"),
|
|
4227
4359
|
empty: document.querySelector("[data-empty]"),
|
|
4360
|
+
folderList: document.querySelector("[data-folder-list]"),
|
|
4228
4361
|
jobsList: document.querySelector("[data-jobs-list]"),
|
|
4229
4362
|
list: document.querySelector("[data-meeting-list]"),
|
|
4230
4363
|
noteButton: document.querySelector("[data-export-notes]"),
|
|
@@ -4251,6 +4384,7 @@ function parseWorkspaceTab(value) {
|
|
|
4251
4384
|
function startupSelection() {
|
|
4252
4385
|
const params = new URLSearchParams(window.location.search);
|
|
4253
4386
|
return {
|
|
4387
|
+
folderId: params.get("folder")?.trim() || "",
|
|
4254
4388
|
meetingId: params.get("meeting")?.trim() || "",
|
|
4255
4389
|
workspaceTab: parseWorkspaceTab(params.get("tab")),
|
|
4256
4390
|
};
|
|
@@ -4259,6 +4393,12 @@ function startupSelection() {
|
|
|
4259
4393
|
function syncBrowserUrl() {
|
|
4260
4394
|
const url = new URL(window.location.href);
|
|
4261
4395
|
|
|
4396
|
+
if (state.selectedFolderId) {
|
|
4397
|
+
url.searchParams.set("folder", state.selectedFolderId);
|
|
4398
|
+
} else {
|
|
4399
|
+
url.searchParams.delete("folder");
|
|
4400
|
+
}
|
|
4401
|
+
|
|
4262
4402
|
if (state.selectedMeetingId) {
|
|
4263
4403
|
url.searchParams.set("meeting", state.selectedMeetingId);
|
|
4264
4404
|
} else {
|
|
@@ -4302,6 +4442,11 @@ function syncFilterInputs() {
|
|
|
4302
4442
|
function currentFilterSummary() {
|
|
4303
4443
|
const parts = [];
|
|
4304
4444
|
|
|
4445
|
+
if (state.selectedFolderId) {
|
|
4446
|
+
const folder = state.folders.find((candidate) => candidate.id === state.selectedFolderId);
|
|
4447
|
+
parts.push("folder " + (folder ? '"' + folder.name + '"' : '"' + state.selectedFolderId + '"'));
|
|
4448
|
+
}
|
|
4449
|
+
|
|
4305
4450
|
if (state.search) {
|
|
4306
4451
|
parts.push('search "' + state.search + '"');
|
|
4307
4452
|
}
|
|
@@ -4344,6 +4489,9 @@ function renderAppState() {
|
|
|
4344
4489
|
: appState.index.available
|
|
4345
4490
|
? "available"
|
|
4346
4491
|
: "not built";
|
|
4492
|
+
const folderStatus = appState.folders.loaded
|
|
4493
|
+
? appState.folders.count + " folders"
|
|
4494
|
+
: "not loaded";
|
|
4347
4495
|
|
|
4348
4496
|
els.appState.innerHTML = [
|
|
4349
4497
|
'<div class="status-grid">',
|
|
@@ -4351,6 +4499,7 @@ function renderAppState() {
|
|
|
4351
4499
|
'<div><span class="status-label">View</span><strong>' + escapeHtml(appState.ui.view) + "</strong></div>",
|
|
4352
4500
|
'<div><span class="status-label">Auth</span><strong>' + escapeHtml(authMode) + "</strong></div>",
|
|
4353
4501
|
'<div><span class="status-label">Documents</span><strong>' + escapeHtml(docs) + "</strong></div>",
|
|
4502
|
+
'<div><span class="status-label">Folders</span><strong>' + escapeHtml(folderStatus) + "</strong></div>",
|
|
4354
4503
|
'<div><span class="status-label">Cache</span><strong>' + escapeHtml(cache) + "</strong></div>",
|
|
4355
4504
|
'<div><span class="status-label">Index</span><strong>' + escapeHtml(indexStatus) + "</strong></div>",
|
|
4356
4505
|
"</div>",
|
|
@@ -4361,6 +4510,50 @@ function renderAppState() {
|
|
|
4361
4510
|
renderExportJobs();
|
|
4362
4511
|
}
|
|
4363
4512
|
|
|
4513
|
+
function renderFolderList() {
|
|
4514
|
+
if (state.folderError) {
|
|
4515
|
+
els.folderList.innerHTML =
|
|
4516
|
+
'<div class="folder-empty folder-empty--error">' + escapeHtml(state.folderError) + "</div>";
|
|
4517
|
+
return;
|
|
4518
|
+
}
|
|
4519
|
+
|
|
4520
|
+
const buttons = [
|
|
4521
|
+
[
|
|
4522
|
+
'<button class="folder-row"' +
|
|
4523
|
+
(state.selectedFolderId ? "" : ' data-selected="true"') +
|
|
4524
|
+
' data-folder-id="">',
|
|
4525
|
+
'<span class="folder-row__title">All meetings</span>',
|
|
4526
|
+
'<span class="folder-row__meta">Browse the full meeting list.</span>',
|
|
4527
|
+
"</button>",
|
|
4528
|
+
].join(""),
|
|
4529
|
+
];
|
|
4530
|
+
|
|
4531
|
+
for (const folder of state.folders) {
|
|
4532
|
+
buttons.push(
|
|
4533
|
+
[
|
|
4534
|
+
'<button class="folder-row"' +
|
|
4535
|
+
(folder.id === state.selectedFolderId ? ' data-selected="true"' : "") +
|
|
4536
|
+
' data-folder-id="' +
|
|
4537
|
+
escapeHtml(folder.id) +
|
|
4538
|
+
'">',
|
|
4539
|
+
'<span class="folder-row__title">' +
|
|
4540
|
+
escapeHtml((folder.isFavourite ? "★ " : "") + (folder.name || folder.id)) +
|
|
4541
|
+
"</span>",
|
|
4542
|
+
'<span class="folder-row__meta">' +
|
|
4543
|
+
escapeHtml(String(folder.documentCount) + " meetings") +
|
|
4544
|
+
"</span>",
|
|
4545
|
+
"</button>",
|
|
4546
|
+
].join(""),
|
|
4547
|
+
);
|
|
4548
|
+
}
|
|
4549
|
+
|
|
4550
|
+
if (buttons.length === 1) {
|
|
4551
|
+
buttons.push('<div class="folder-empty">No folders found.</div>');
|
|
4552
|
+
}
|
|
4553
|
+
|
|
4554
|
+
els.folderList.innerHTML = buttons.join("");
|
|
4555
|
+
}
|
|
4556
|
+
|
|
4364
4557
|
function renderSecurityPanel() {
|
|
4365
4558
|
els.securityPanel.hidden = !state.serverLocked;
|
|
4366
4559
|
}
|
|
@@ -4504,6 +4697,7 @@ function renderMeetingDetail() {
|
|
|
4504
4697
|
"Title: " + (record.meeting.title || record.meeting.id),
|
|
4505
4698
|
"Created: " + record.meeting.createdAt,
|
|
4506
4699
|
"Updated: " + record.meeting.updatedAt,
|
|
4700
|
+
"Folders: " + (record.meeting.folders.length ? record.meeting.folders.map((folder) => folder.name).join(", ") : "none"),
|
|
4507
4701
|
"Tags: " + (record.meeting.tags.length ? record.meeting.tags.join(", ") : "none"),
|
|
4508
4702
|
"Transcript loaded: " + (record.meeting.transcriptLoaded ? "yes" : "no"),
|
|
4509
4703
|
].join("\n");
|
|
@@ -4613,6 +4807,10 @@ function buildMeetingsQuery(limit = 100, refresh = false) {
|
|
|
4613
4807
|
params.set("updatedTo", state.updatedTo);
|
|
4614
4808
|
}
|
|
4615
4809
|
|
|
4810
|
+
if (state.selectedFolderId) {
|
|
4811
|
+
params.set("folderId", state.selectedFolderId);
|
|
4812
|
+
}
|
|
4813
|
+
|
|
4616
4814
|
if (refresh) {
|
|
4617
4815
|
params.set("refresh", "true");
|
|
4618
4816
|
}
|
|
@@ -4620,6 +4818,39 @@ function buildMeetingsQuery(limit = 100, refresh = false) {
|
|
|
4620
4818
|
return "?" + params.toString();
|
|
4621
4819
|
}
|
|
4622
4820
|
|
|
4821
|
+
async function loadFolders(options = {}) {
|
|
4822
|
+
const refresh = options.refresh === true;
|
|
4823
|
+
|
|
4824
|
+
try {
|
|
4825
|
+
state.folderError = "";
|
|
4826
|
+
const params = new URLSearchParams();
|
|
4827
|
+
params.set("limit", "100");
|
|
4828
|
+
if (refresh) {
|
|
4829
|
+
params.set("refresh", "true");
|
|
4830
|
+
}
|
|
4831
|
+
|
|
4832
|
+
const payload = await fetchJson("/folders?" + params.toString());
|
|
4833
|
+
state.folders = payload.folders || [];
|
|
4834
|
+
if (
|
|
4835
|
+
state.selectedFolderId &&
|
|
4836
|
+
!state.folders.some((folder) => folder.id === state.selectedFolderId)
|
|
4837
|
+
) {
|
|
4838
|
+
state.selectedFolderId = null;
|
|
4839
|
+
}
|
|
4840
|
+
} catch (error) {
|
|
4841
|
+
if (error.authRequired) {
|
|
4842
|
+
throw error;
|
|
4843
|
+
}
|
|
4844
|
+
|
|
4845
|
+
state.folderError = error instanceof Error ? error.message : String(error);
|
|
4846
|
+
state.folders = [];
|
|
4847
|
+
state.selectedFolderId = null;
|
|
4848
|
+
}
|
|
4849
|
+
|
|
4850
|
+
renderFolderList();
|
|
4851
|
+
syncBrowserUrl();
|
|
4852
|
+
}
|
|
4853
|
+
|
|
4623
4854
|
async function loadMeetings(options = {}) {
|
|
4624
4855
|
const preferredMeetingId = options.preferredMeetingId || state.selectedMeetingId;
|
|
4625
4856
|
const refresh = options.refresh === true;
|
|
@@ -4683,10 +4914,12 @@ async function quickOpenMeeting() {
|
|
|
4683
4914
|
try {
|
|
4684
4915
|
state.quickOpen = query;
|
|
4685
4916
|
const payload = await fetchJson("/meetings/resolve?q=" + encodeURIComponent(query));
|
|
4917
|
+
state.selectedFolderId = payload.meeting?.meeting?.folders?.[0]?.id || null;
|
|
4686
4918
|
state.search = "";
|
|
4687
4919
|
state.updatedFrom = "";
|
|
4688
4920
|
state.updatedTo = "";
|
|
4689
4921
|
syncFilterInputs();
|
|
4922
|
+
renderFolderList();
|
|
4690
4923
|
await loadMeetings({
|
|
4691
4924
|
preferredMeetingId: payload.document.id,
|
|
4692
4925
|
});
|
|
@@ -4701,11 +4934,9 @@ async function quickOpenMeeting() {
|
|
|
4701
4934
|
async function refreshAll(forceLiveMeetings = false) {
|
|
4702
4935
|
setStatus("Refreshing…", "busy");
|
|
4703
4936
|
try {
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
loadMeetings({ refresh: forceLiveMeetings }),
|
|
4708
|
-
]);
|
|
4937
|
+
await loadFolders({ refresh: forceLiveMeetings });
|
|
4938
|
+
const [appState, authState] = await Promise.all([fetchJson("/state"), fetchJson("/auth/status")]);
|
|
4939
|
+
await loadMeetings({ refresh: forceLiveMeetings });
|
|
4709
4940
|
state.serverLocked = false;
|
|
4710
4941
|
state.appState = {
|
|
4711
4942
|
...appState,
|
|
@@ -4856,17 +5087,40 @@ async function lockServer() {
|
|
|
4856
5087
|
|
|
4857
5088
|
state.serverLocked = true;
|
|
4858
5089
|
state.appState = null;
|
|
5090
|
+
state.folders = [];
|
|
4859
5091
|
state.meetings = [];
|
|
5092
|
+
state.selectedFolderId = null;
|
|
4860
5093
|
state.selectedMeeting = null;
|
|
4861
5094
|
state.selectedMeetingBundle = null;
|
|
4862
5095
|
state.detailError = "";
|
|
5096
|
+
state.folderError = "";
|
|
4863
5097
|
els.serverPassword.value = "";
|
|
4864
5098
|
renderSecurityPanel();
|
|
5099
|
+
renderFolderList();
|
|
4865
5100
|
renderMeetingList();
|
|
4866
5101
|
renderMeetingDetail();
|
|
4867
5102
|
setStatus("Server locked", "error");
|
|
4868
5103
|
}
|
|
4869
5104
|
|
|
5105
|
+
els.folderList.addEventListener("click", (event) => {
|
|
5106
|
+
if (!(event.target instanceof Element)) {
|
|
5107
|
+
return;
|
|
5108
|
+
}
|
|
5109
|
+
|
|
5110
|
+
const button = event.target.closest("[data-folder-id]");
|
|
5111
|
+
if (!button) {
|
|
5112
|
+
return;
|
|
5113
|
+
}
|
|
5114
|
+
|
|
5115
|
+
const nextFolderId = button.dataset.folderId || null;
|
|
5116
|
+
state.selectedFolderId = nextFolderId;
|
|
5117
|
+
state.selectedMeetingId = null;
|
|
5118
|
+
state.selectedMeeting = null;
|
|
5119
|
+
state.selectedMeetingBundle = null;
|
|
5120
|
+
renderFolderList();
|
|
5121
|
+
void loadMeetings();
|
|
5122
|
+
});
|
|
5123
|
+
|
|
4870
5124
|
els.list.addEventListener("click", (event) => {
|
|
4871
5125
|
if (!(event.target instanceof Element)) {
|
|
4872
5126
|
return;
|
|
@@ -5071,6 +5325,7 @@ document.addEventListener("keydown", (event) => {
|
|
|
5071
5325
|
});
|
|
5072
5326
|
|
|
5073
5327
|
const initialSelection = startupSelection();
|
|
5328
|
+
state.selectedFolderId = initialSelection.folderId || null;
|
|
5074
5329
|
state.selectedMeetingId = initialSelection.meetingId || null;
|
|
5075
5330
|
state.workspaceTab = initialSelection.workspaceTab;
|
|
5076
5331
|
|
|
@@ -5086,9 +5341,12 @@ events.addEventListener("state.updated", (event) => {
|
|
|
5086
5341
|
payload.state.documents?.loadedAt &&
|
|
5087
5342
|
payload.state.documents.loadedAt !== previousLoadedAt
|
|
5088
5343
|
) {
|
|
5089
|
-
void
|
|
5090
|
-
|
|
5091
|
-
|
|
5344
|
+
void (async () => {
|
|
5345
|
+
await loadFolders();
|
|
5346
|
+
await loadMeetings({
|
|
5347
|
+
preferredMeetingId: state.selectedMeetingId,
|
|
5348
|
+
});
|
|
5349
|
+
})();
|
|
5092
5350
|
}
|
|
5093
5351
|
});
|
|
5094
5352
|
events.addEventListener("error", () => {
|
|
@@ -5097,6 +5355,7 @@ events.addEventListener("error", () => {
|
|
|
5097
5355
|
|
|
5098
5356
|
syncFilterInputs();
|
|
5099
5357
|
renderSecurityPanel();
|
|
5358
|
+
renderFolderList();
|
|
5100
5359
|
|
|
5101
5360
|
void refreshAll().catch((error) => {
|
|
5102
5361
|
setStatus("Error", "error");
|
|
@@ -5111,7 +5370,7 @@ const granolaWebMarkup = String.raw`
|
|
|
5111
5370
|
<aside class="pane sidebar">
|
|
5112
5371
|
<section class="hero">
|
|
5113
5372
|
<h1>Granola Toolkit</h1>
|
|
5114
|
-
<p>Browser workspace for meetings, notes, transcripts, and export flows on top of one local server instance.</p>
|
|
5373
|
+
<p>Browser workspace for folders, meetings, notes, transcripts, and export flows on top of one local server instance.</p>
|
|
5115
5374
|
<input class="search" data-search placeholder="Search meetings, ids, or tags" />
|
|
5116
5375
|
<div class="field-row field-row--inline">
|
|
5117
5376
|
<label>
|
|
@@ -5133,6 +5392,13 @@ const granolaWebMarkup = String.raw`
|
|
|
5133
5392
|
<input class="field-input" data-updated-to type="date" />
|
|
5134
5393
|
</label>
|
|
5135
5394
|
</section>
|
|
5395
|
+
<section class="folder-panel">
|
|
5396
|
+
<div class="folder-panel__head">
|
|
5397
|
+
<h2>Folders</h2>
|
|
5398
|
+
<p>Pick a folder to scope the meeting browser, or stay on All meetings.</p>
|
|
5399
|
+
</div>
|
|
5400
|
+
<div class="folder-list" data-folder-list></div>
|
|
5401
|
+
</section>
|
|
5136
5402
|
<section class="toolbar">
|
|
5137
5403
|
<div>
|
|
5138
5404
|
<p>Meetings are loaded from the shared server state so this view can later coexist with the terminal UI.</p>
|
|
@@ -5254,11 +5520,11 @@ body {
|
|
|
5254
5520
|
|
|
5255
5521
|
.sidebar {
|
|
5256
5522
|
display: grid;
|
|
5257
|
-
grid-template-rows: auto auto 1fr;
|
|
5523
|
+
grid-template-rows: auto auto auto 1fr;
|
|
5258
5524
|
overflow: hidden;
|
|
5259
5525
|
}
|
|
5260
5526
|
|
|
5261
|
-
.hero, .toolbar, .detail-head {
|
|
5527
|
+
.hero, .toolbar, .detail-head, .folder-panel {
|
|
5262
5528
|
padding: 22px 24px;
|
|
5263
5529
|
border-bottom: 1px solid var(--line);
|
|
5264
5530
|
}
|
|
@@ -5315,6 +5581,68 @@ body {
|
|
|
5315
5581
|
overflow: auto;
|
|
5316
5582
|
}
|
|
5317
5583
|
|
|
5584
|
+
.folder-panel {
|
|
5585
|
+
display: grid;
|
|
5586
|
+
gap: 14px;
|
|
5587
|
+
}
|
|
5588
|
+
|
|
5589
|
+
.folder-panel__head h2 {
|
|
5590
|
+
margin: 0;
|
|
5591
|
+
font-size: 0.92rem;
|
|
5592
|
+
letter-spacing: 0.08em;
|
|
5593
|
+
text-transform: uppercase;
|
|
5594
|
+
}
|
|
5595
|
+
|
|
5596
|
+
.folder-panel__head p {
|
|
5597
|
+
margin: 6px 0 0;
|
|
5598
|
+
color: var(--muted);
|
|
5599
|
+
font-size: 0.9rem;
|
|
5600
|
+
}
|
|
5601
|
+
|
|
5602
|
+
.folder-list {
|
|
5603
|
+
display: grid;
|
|
5604
|
+
gap: 10px;
|
|
5605
|
+
}
|
|
5606
|
+
|
|
5607
|
+
.folder-row {
|
|
5608
|
+
width: 100%;
|
|
5609
|
+
display: grid;
|
|
5610
|
+
gap: 4px;
|
|
5611
|
+
text-align: left;
|
|
5612
|
+
padding: 12px 14px;
|
|
5613
|
+
border: 1px solid transparent;
|
|
5614
|
+
border-radius: 16px;
|
|
5615
|
+
background: rgba(255, 255, 255, 0.72);
|
|
5616
|
+
color: inherit;
|
|
5617
|
+
cursor: pointer;
|
|
5618
|
+
transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
|
|
5619
|
+
}
|
|
5620
|
+
|
|
5621
|
+
.folder-row:hover,
|
|
5622
|
+
.folder-row[data-selected="true"] {
|
|
5623
|
+
transform: translateY(-1px);
|
|
5624
|
+
border-color: rgba(163, 79, 47, 0.26);
|
|
5625
|
+
background: var(--panel-strong);
|
|
5626
|
+
}
|
|
5627
|
+
|
|
5628
|
+
.folder-row__title {
|
|
5629
|
+
font-weight: 700;
|
|
5630
|
+
}
|
|
5631
|
+
|
|
5632
|
+
.folder-row__meta {
|
|
5633
|
+
color: var(--muted);
|
|
5634
|
+
font-size: 0.88rem;
|
|
5635
|
+
}
|
|
5636
|
+
|
|
5637
|
+
.folder-empty {
|
|
5638
|
+
color: var(--muted);
|
|
5639
|
+
font-size: 0.92rem;
|
|
5640
|
+
}
|
|
5641
|
+
|
|
5642
|
+
.folder-empty--error {
|
|
5643
|
+
color: var(--error);
|
|
5644
|
+
}
|
|
5645
|
+
|
|
5318
5646
|
.meeting-row {
|
|
5319
5647
|
width: 100%;
|
|
5320
5648
|
display: grid;
|
|
@@ -6174,7 +6502,11 @@ function printWebRoutes() {
|
|
|
6174
6502
|
console.log(" GET /auth/status");
|
|
6175
6503
|
console.log(" GET /state");
|
|
6176
6504
|
console.log(" GET /events");
|
|
6505
|
+
console.log(" GET /folders");
|
|
6506
|
+
console.log(" GET /folders/resolve?q=<query>");
|
|
6507
|
+
console.log(" GET /folders/:id");
|
|
6177
6508
|
console.log(" GET /meetings");
|
|
6509
|
+
console.log(" GET /meetings?folderId=<id>");
|
|
6178
6510
|
console.log(" GET /meetings/:id");
|
|
6179
6511
|
console.log(" GET /exports/jobs");
|
|
6180
6512
|
console.log(" POST /auth/login");
|