granola-toolkit 0.21.0 → 0.22.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 +18 -0
  2. package/dist/cli.js +945 -449
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import { homedir, platform } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
7
  import { promisify } from "node:util";
8
8
  import { NodeHtmlMarkdown } from "node-html-markdown";
9
- import { createHash } from "node:crypto";
9
+ import { createHash, randomUUID } from "node:crypto";
10
10
  import { createServer } from "node:http";
11
11
  //#region src/utils.ts
12
12
  const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g;
@@ -718,6 +718,81 @@ async function loadOptionalGranolaCache(cacheFile) {
718
718
  return parseCacheContents(await readFile(cacheFile, "utf8"));
719
719
  }
720
720
  //#endregion
721
+ //#region src/export-jobs.ts
722
+ const EXPORT_JOBS_VERSION = 1;
723
+ const MAX_EXPORT_JOBS = 100;
724
+ function normaliseJob(value) {
725
+ const record = asRecord(value);
726
+ if (!record) return;
727
+ const id = stringValue(record.id);
728
+ const kind = stringValue(record.kind);
729
+ const status = stringValue(record.status);
730
+ const format = stringValue(record.format);
731
+ const outputDir = stringValue(record.outputDir);
732
+ const startedAt = stringValue(record.startedAt);
733
+ const itemCount = typeof record.itemCount === "number" && Number.isFinite(record.itemCount) ? record.itemCount : 0;
734
+ const written = typeof record.written === "number" && Number.isFinite(record.written) ? record.written : 0;
735
+ const completedCount = typeof record.completedCount === "number" && Number.isFinite(record.completedCount) ? record.completedCount : written;
736
+ if (!id || !format || !outputDir || !startedAt || kind !== "notes" && kind !== "transcripts" || status !== "running" && status !== "completed" && status !== "failed") return;
737
+ return {
738
+ completedCount,
739
+ error: stringValue(record.error) || void 0,
740
+ finishedAt: stringValue(record.finishedAt) || void 0,
741
+ format,
742
+ id,
743
+ itemCount,
744
+ kind,
745
+ outputDir,
746
+ startedAt,
747
+ status,
748
+ written
749
+ };
750
+ }
751
+ function normaliseJobsFile(parsed) {
752
+ const record = asRecord(parsed);
753
+ if (!record || record.version !== EXPORT_JOBS_VERSION || !Array.isArray(record.jobs)) return {
754
+ jobs: [],
755
+ version: EXPORT_JOBS_VERSION
756
+ };
757
+ return {
758
+ jobs: record.jobs.map((job) => normaliseJob(job)).filter((job) => Boolean(job)).slice(0, MAX_EXPORT_JOBS),
759
+ version: EXPORT_JOBS_VERSION
760
+ };
761
+ }
762
+ function createExportJobId(kind) {
763
+ return `${kind}-${randomUUID()}`;
764
+ }
765
+ function defaultExportJobsFilePath() {
766
+ const home = homedir();
767
+ return platform() === "darwin" ? join(home, "Library", "Application Support", "granola-toolkit", "export-jobs.json") : join(home, ".config", "granola-toolkit", "export-jobs.json");
768
+ }
769
+ var FileExportJobStore = class {
770
+ constructor(filePath = defaultExportJobsFilePath()) {
771
+ this.filePath = filePath;
772
+ }
773
+ async readJobs() {
774
+ try {
775
+ return normaliseJobsFile(parseJsonString(await readFile(this.filePath, "utf8"))).jobs;
776
+ } catch {
777
+ return [];
778
+ }
779
+ }
780
+ async writeJobs(jobs) {
781
+ const payload = {
782
+ jobs: jobs.slice(0, MAX_EXPORT_JOBS),
783
+ version: EXPORT_JOBS_VERSION
784
+ };
785
+ await mkdir(dirname(this.filePath), { recursive: true });
786
+ await writeFile(this.filePath, `${JSON.stringify(payload, null, 2)}\n`, {
787
+ encoding: "utf8",
788
+ mode: 384
789
+ });
790
+ }
791
+ };
792
+ function createDefaultExportJobStore() {
793
+ return new FileExportJobStore();
794
+ }
795
+ //#endregion
721
796
  //#region src/export-state.ts
722
797
  const EXPORT_STATE_VERSION = 1;
723
798
  function exportStatePath(outputDir, kind) {
@@ -783,7 +858,7 @@ function entryChanged(left, right) {
783
858
  if (!left) return true;
784
859
  return left.contentHash !== right.contentHash || left.exportedAt !== right.exportedAt || left.fileName !== right.fileName || left.fileStem !== right.fileStem || left.sourceUpdatedAt !== right.sourceUpdatedAt;
785
860
  }
786
- async function syncManagedExports({ items, kind, outputDir }) {
861
+ async function syncManagedExports({ items, kind, onProgress, outputDir }) {
787
862
  await ensureDirectory(outputDir);
788
863
  const previousEntries = (await loadExportState(outputDir, kind)).entries;
789
864
  const used = /* @__PURE__ */ new Map();
@@ -804,6 +879,7 @@ async function syncManagedExports({ items, kind, outputDir }) {
804
879
  const activeFileNames = new Set(plans.map((plan) => plan.fileName));
805
880
  const exportedAt = (/* @__PURE__ */ new Date()).toISOString();
806
881
  const nextEntries = {};
882
+ let completed = 0;
807
883
  let written = 0;
808
884
  let stateChanged = false;
809
885
  for (const plan of plans) {
@@ -822,6 +898,12 @@ async function syncManagedExports({ items, kind, outputDir }) {
822
898
  };
823
899
  nextEntries[plan.id] = nextEntry;
824
900
  stateChanged = stateChanged || entryChanged(plan.existing, nextEntry);
901
+ completed += 1;
902
+ if (onProgress) await onProgress({
903
+ completed,
904
+ total: plans.length,
905
+ written
906
+ });
825
907
  }
826
908
  for (const plan of plans) {
827
909
  const previousFileName = plan.existing?.fileName;
@@ -1106,7 +1188,7 @@ function noteFileExtension(format) {
1106
1188
  case "markdown": return ".md";
1107
1189
  }
1108
1190
  }
1109
- async function writeNotes(documents, outputDir, format = "markdown") {
1191
+ async function writeNotes(documents, outputDir, format = "markdown", options = {}) {
1110
1192
  return await syncManagedExports({
1111
1193
  items: [...documents].sort((left, right) => compareStrings(left.title || left.id, right.title || right.id) || compareStrings(left.id, right.id)).map((document) => {
1112
1194
  const note = buildNoteExport(document);
@@ -1119,6 +1201,7 @@ async function writeNotes(documents, outputDir, format = "markdown") {
1119
1201
  };
1120
1202
  }),
1121
1203
  kind: "notes",
1204
+ onProgress: options.onProgress,
1122
1205
  outputDir
1123
1206
  });
1124
1207
  }
@@ -1230,7 +1313,7 @@ function transcriptFileExtension(format) {
1230
1313
  case "yaml": return ".yaml";
1231
1314
  }
1232
1315
  }
1233
- async function writeTranscripts(cacheData, outputDir, format = "text") {
1316
+ async function writeTranscripts(cacheData, outputDir, format = "text", options = {}) {
1234
1317
  return await syncManagedExports({
1235
1318
  items: Object.entries(cacheData.transcripts).filter(([, segments]) => segments.length > 0).sort(([leftId], [rightId]) => {
1236
1319
  const leftDocument = cacheData.documents[leftId];
@@ -1254,6 +1337,7 @@ async function writeTranscripts(cacheData, outputDir, format = "text") {
1254
1337
  }];
1255
1338
  }),
1256
1339
  kind: "transcripts",
1340
+ onProgress: options.onProgress,
1257
1341
  outputDir
1258
1342
  });
1259
1343
  }
@@ -1514,6 +1598,9 @@ function transcriptCount(cacheData) {
1514
1598
  function cloneExportState(state) {
1515
1599
  return state ? { ...state } : void 0;
1516
1600
  }
1601
+ function cloneExportJob(job) {
1602
+ return { ...job };
1603
+ }
1517
1604
  function cloneState(state) {
1518
1605
  return {
1519
1606
  auth: { ...state.auth },
@@ -1525,6 +1612,7 @@ function cloneState(state) {
1525
1612
  },
1526
1613
  documents: { ...state.documents },
1527
1614
  exports: {
1615
+ jobs: state.exports.jobs.map((job) => cloneExportJob(job)),
1528
1616
  notes: cloneExportState(state.exports.notes),
1529
1617
  transcripts: cloneExportState(state.exports.transcripts)
1530
1618
  },
@@ -1550,7 +1638,7 @@ function defaultState(config, auth, surface) {
1550
1638
  count: 0,
1551
1639
  loaded: false
1552
1640
  },
1553
- exports: {},
1641
+ exports: { jobs: [] },
1554
1642
  ui: {
1555
1643
  surface,
1556
1644
  view: "idle"
@@ -1568,6 +1656,7 @@ var GranolaApp = class {
1568
1656
  this.config = config;
1569
1657
  this.deps = deps;
1570
1658
  this.#state = defaultState(config, deps.auth, options.surface ?? "cli");
1659
+ this.#state.exports.jobs = (deps.exportJobs ?? []).map((job) => cloneExportJob(job));
1571
1660
  }
1572
1661
  getState() {
1573
1662
  return cloneState(this.#state);
@@ -1613,6 +1702,55 @@ var GranolaApp = class {
1613
1702
  missingCacheError() {
1614
1703
  return /* @__PURE__ */ new Error(`Granola cache file not found. Pass --cache or create .granola.toml. Expected locations include: ${granolaCacheCandidates().join(", ")}`);
1615
1704
  }
1705
+ async persistExportJobs() {
1706
+ if (!this.deps.exportJobStore) return;
1707
+ await this.deps.exportJobStore.writeJobs(this.#state.exports.jobs);
1708
+ }
1709
+ async updateExportJob(job) {
1710
+ const nextJobs = [cloneExportJob(job), ...this.#state.exports.jobs.filter((candidate) => candidate.id !== job.id).map(cloneExportJob)].slice(0, 100);
1711
+ this.#state.exports.jobs = nextJobs;
1712
+ await this.persistExportJobs();
1713
+ this.emitStateUpdate();
1714
+ return cloneExportJob(job);
1715
+ }
1716
+ async startExportJob(kind, format, itemCount, outputDir) {
1717
+ return await this.updateExportJob({
1718
+ completedCount: 0,
1719
+ format,
1720
+ id: createExportJobId(kind),
1721
+ itemCount,
1722
+ kind,
1723
+ outputDir,
1724
+ startedAt: this.nowIso(),
1725
+ status: "running",
1726
+ written: 0
1727
+ });
1728
+ }
1729
+ async completeExportJob(job, patch) {
1730
+ return await this.updateExportJob({
1731
+ ...job,
1732
+ completedCount: patch.completedCount,
1733
+ finishedAt: this.nowIso(),
1734
+ status: "completed",
1735
+ written: patch.written
1736
+ });
1737
+ }
1738
+ async failExportJob(job, error) {
1739
+ const message = error instanceof Error ? error.message : String(error);
1740
+ return await this.updateExportJob({
1741
+ ...job,
1742
+ error: message,
1743
+ finishedAt: this.nowIso(),
1744
+ status: "failed"
1745
+ });
1746
+ }
1747
+ async setExportJobProgress(job, patch) {
1748
+ return await this.updateExportJob({
1749
+ ...job,
1750
+ completedCount: patch.completedCount,
1751
+ written: patch.written
1752
+ });
1753
+ }
1616
1754
  async listDocuments() {
1617
1755
  if (this.#documents) return this.#documents;
1618
1756
  const documents = await (await this.getGranolaClient()).listDocuments({ timeoutMs: this.config.notes.timeoutMs });
@@ -1701,13 +1839,42 @@ var GranolaApp = class {
1701
1839
  meeting
1702
1840
  };
1703
1841
  }
1842
+ async listExportJobs(options = {}) {
1843
+ const limit = options.limit ?? 20;
1844
+ const jobs = this.#state.exports.jobs.slice(0, limit).map((job) => cloneExportJob(job));
1845
+ this.setUiState({ view: "exports-history" });
1846
+ return { jobs };
1847
+ }
1704
1848
  async exportNotes(format = "markdown") {
1849
+ return await this.runNotesExport({
1850
+ format,
1851
+ outputDir: this.config.notes.output
1852
+ });
1853
+ }
1854
+ async runNotesExport(options) {
1705
1855
  const documents = await this.listDocuments();
1706
- const written = await writeNotes(documents, this.config.notes.output, format);
1856
+ let job = await this.startExportJob("notes", options.format, documents.length, options.outputDir);
1857
+ let written = 0;
1858
+ try {
1859
+ written = await writeNotes(documents, options.outputDir, options.format, { onProgress: async (progress) => {
1860
+ job = await this.setExportJobProgress(job, {
1861
+ completedCount: progress.completed,
1862
+ written: progress.written
1863
+ });
1864
+ } });
1865
+ job = await this.completeExportJob(job, {
1866
+ completedCount: documents.length,
1867
+ written
1868
+ });
1869
+ } catch (error) {
1870
+ await this.failExportJob(job, error);
1871
+ throw error;
1872
+ }
1707
1873
  this.#state.exports.notes = {
1708
- format,
1874
+ format: options.format,
1709
1875
  itemCount: documents.length,
1710
- outputDir: this.config.notes.output,
1876
+ jobId: job.id,
1877
+ outputDir: options.outputDir,
1711
1878
  ranAt: this.nowIso(),
1712
1879
  written
1713
1880
  };
@@ -1716,20 +1883,44 @@ var GranolaApp = class {
1716
1883
  return {
1717
1884
  documentCount: documents.length,
1718
1885
  documents,
1719
- format,
1720
- outputDir: this.config.notes.output,
1886
+ format: options.format,
1887
+ job,
1888
+ outputDir: options.outputDir,
1721
1889
  written
1722
1890
  };
1723
1891
  }
1724
1892
  async exportTranscripts(format = "text") {
1893
+ return await this.runTranscriptsExport({
1894
+ format,
1895
+ outputDir: this.config.transcripts.output
1896
+ });
1897
+ }
1898
+ async runTranscriptsExport(options) {
1725
1899
  const cacheData = await this.loadCache({ required: true });
1726
1900
  if (!cacheData) throw this.missingCacheError();
1727
- const written = await writeTranscripts(cacheData, this.config.transcripts.output, format);
1728
1901
  const count = transcriptCount(cacheData);
1902
+ let job = await this.startExportJob("transcripts", options.format, count, options.outputDir);
1903
+ let written = 0;
1904
+ try {
1905
+ written = await writeTranscripts(cacheData, options.outputDir, options.format, { onProgress: async (progress) => {
1906
+ job = await this.setExportJobProgress(job, {
1907
+ completedCount: progress.completed,
1908
+ written: progress.written
1909
+ });
1910
+ } });
1911
+ job = await this.completeExportJob(job, {
1912
+ completedCount: count,
1913
+ written
1914
+ });
1915
+ } catch (error) {
1916
+ await this.failExportJob(job, error);
1917
+ throw error;
1918
+ }
1729
1919
  this.#state.exports.transcripts = {
1730
- format,
1920
+ format: options.format,
1731
1921
  itemCount: count,
1732
- outputDir: this.config.transcripts.output,
1922
+ jobId: job.id,
1923
+ outputDir: options.outputDir,
1733
1924
  ranAt: this.nowIso(),
1734
1925
  written
1735
1926
  };
@@ -1737,18 +1928,35 @@ var GranolaApp = class {
1737
1928
  this.setUiState({ view: "transcripts-export" });
1738
1929
  return {
1739
1930
  cacheData,
1740
- format,
1741
- outputDir: this.config.transcripts.output,
1931
+ format: options.format,
1932
+ job,
1933
+ outputDir: options.outputDir,
1742
1934
  transcriptCount: count,
1743
1935
  written
1744
1936
  };
1745
1937
  }
1938
+ async rerunExportJob(id) {
1939
+ const job = this.#state.exports.jobs.find((candidate) => candidate.id === id);
1940
+ if (!job) throw new Error(`export job not found: ${id}`);
1941
+ if (job.kind === "notes") return await this.runNotesExport({
1942
+ format: job.format,
1943
+ outputDir: job.outputDir
1944
+ });
1945
+ return await this.runTranscriptsExport({
1946
+ format: job.format,
1947
+ outputDir: job.outputDir
1948
+ });
1949
+ }
1746
1950
  };
1747
1951
  async function createGranolaApp(config, options = {}) {
1952
+ const auth = await inspectDefaultGranolaAuth(config);
1953
+ const exportJobStore = createDefaultExportJobStore();
1748
1954
  return new GranolaApp(config, {
1749
- auth: await inspectDefaultGranolaAuth(config),
1955
+ auth,
1750
1956
  cacheLoader: loadOptionalGranolaCache,
1751
1957
  createGranolaClient: async () => await createDefaultGranolaRuntime(config, options.logger),
1958
+ exportJobs: await exportJobStore.readJobs(),
1959
+ exportJobStore,
1752
1960
  now: options.now
1753
1961
  }, { surface: options.surface });
1754
1962
  }
@@ -1868,6 +2076,106 @@ async function waitForShutdown(close) {
1868
2076
  });
1869
2077
  }
1870
2078
  //#endregion
2079
+ //#region src/commands/exports.ts
2080
+ function exportsHelp() {
2081
+ return `Granola exports
2082
+
2083
+ Usage:
2084
+ granola exports <list|rerun> [options]
2085
+
2086
+ Subcommands:
2087
+ list Show recent export jobs
2088
+ rerun <job-id> Rerun a previous notes or transcripts export job
2089
+
2090
+ Options:
2091
+ --cache <path> Path to Granola cache JSON
2092
+ --format <value> list output format: text, json, yaml (default: text)
2093
+ --limit <n> Number of jobs to show for list (default: 20)
2094
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
2095
+ --supabase <path> Path to supabase.json
2096
+ --debug Enable debug logging
2097
+ --config <path> Path to .granola.toml
2098
+ -h, --help Show help
2099
+ `;
2100
+ }
2101
+ function resolveListFormat$1(value) {
2102
+ switch (value) {
2103
+ case void 0: return "text";
2104
+ case "json":
2105
+ case "text":
2106
+ case "yaml": return value;
2107
+ default: throw new Error("invalid exports format: expected text, json, or yaml");
2108
+ }
2109
+ }
2110
+ function parseLimit$1(value) {
2111
+ if (value === void 0) return 20;
2112
+ if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid exports limit: expected a positive integer");
2113
+ const limit = Number(value);
2114
+ if (!Number.isInteger(limit) || limit < 1) throw new Error("invalid exports limit: expected a positive integer");
2115
+ return limit;
2116
+ }
2117
+ function renderExportJobs(jobs, format) {
2118
+ if (format === "json") return toJson({ jobs });
2119
+ if (format === "yaml") return toYaml({ jobs });
2120
+ if (jobs.length === 0) return "No export jobs\n";
2121
+ return `${["ID KIND STATUS FORMAT ITEMS WRITTEN STARTED", ...jobs.map((job) => {
2122
+ return `${job.id.padEnd(28).slice(0, 28)} ${job.kind.padEnd(12)} ${job.status.padEnd(11)} ${job.format.padEnd(11)} ${String(job.itemCount).padEnd(7)} ${String(job.written).padEnd(8)} ${job.startedAt.slice(0, 19)}`;
2123
+ })].join("\n")}\n`;
2124
+ }
2125
+ const exportsCommand = {
2126
+ description: "List and rerun tracked export jobs",
2127
+ flags: {
2128
+ cache: { type: "string" },
2129
+ format: { type: "string" },
2130
+ help: { type: "boolean" },
2131
+ limit: { type: "string" },
2132
+ timeout: { type: "string" }
2133
+ },
2134
+ help: exportsHelp,
2135
+ name: "exports",
2136
+ async run({ commandArgs, commandFlags, globalFlags }) {
2137
+ const [action, id] = commandArgs;
2138
+ switch (action) {
2139
+ case "list": return await list$1(commandFlags, globalFlags);
2140
+ case "rerun":
2141
+ if (!id) throw new Error("exports rerun requires a job id");
2142
+ return await rerun(id, commandFlags, globalFlags);
2143
+ case void 0:
2144
+ console.log(exportsHelp());
2145
+ return 1;
2146
+ default: throw new Error("invalid exports command: expected list or rerun");
2147
+ }
2148
+ }
2149
+ };
2150
+ async function list$1(commandFlags, globalFlags) {
2151
+ const format = resolveListFormat$1(commandFlags.format);
2152
+ const limit = parseLimit$1(commandFlags.limit);
2153
+ const config = await loadConfig({
2154
+ globalFlags,
2155
+ subcommandFlags: commandFlags
2156
+ });
2157
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
2158
+ const result = await (await createGranolaApp(config)).listExportJobs({ limit });
2159
+ console.log(renderExportJobs(result.jobs, format).trimEnd());
2160
+ return 0;
2161
+ }
2162
+ async function rerun(id, commandFlags, globalFlags) {
2163
+ const config = await loadConfig({
2164
+ globalFlags,
2165
+ subcommandFlags: commandFlags
2166
+ });
2167
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
2168
+ debug(config.debug, "supabase", config.supabase);
2169
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
2170
+ const result = await (await createGranolaApp(config)).rerunExportJob(id);
2171
+ if ("documentCount" in result) {
2172
+ console.log(`✓ Reran notes export job ${result.job.id} to ${result.outputDir} (${result.written}/${result.documentCount} written)`);
2173
+ return 0;
2174
+ }
2175
+ console.log(`✓ Reran transcripts export job ${result.job.id} to ${result.outputDir} (${result.written}/${result.transcriptCount} written)`);
2176
+ return 0;
2177
+ }
2178
+ //#endregion
1871
2179
  //#region src/commands/meeting.ts
1872
2180
  function meetingHelp() {
1873
2181
  return `Granola meeting
@@ -2116,7 +2424,7 @@ const notesCommand = {
2116
2424
  const app = await createGranolaApp(config);
2117
2425
  debug(config.debug, "authMode", app.getState().auth.mode);
2118
2426
  const result = await app.exportNotes(format);
2119
- console.log(`✓ Exported ${result.documentCount} notes to ${result.outputDir}`);
2427
+ console.log(`✓ Exported ${result.documentCount} notes to ${result.outputDir} (job ${result.job.id})`);
2120
2428
  debug(config.debug, "notes written", result.written);
2121
2429
  return 0;
2122
2430
  }
@@ -2132,436 +2440,8 @@ function resolveNoteFormat(value) {
2132
2440
  }
2133
2441
  }
2134
2442
  //#endregion
2135
- //#region src/server/web.ts
2136
- function renderGranolaWebPage() {
2137
- return `<!doctype html>
2138
- <html lang="en">
2139
- <head>
2140
- <meta charset="utf-8" />
2141
- <meta name="viewport" content="width=device-width, initial-scale=1" />
2142
- <title>Granola Toolkit</title>
2143
- <style>
2144
- :root {
2145
- --bg: #f2ede2;
2146
- --panel: rgba(255, 252, 247, 0.86);
2147
- --panel-strong: #fffaf2;
2148
- --line: rgba(36, 39, 44, 0.12);
2149
- --ink: #1d242c;
2150
- --muted: #5d6b77;
2151
- --accent: #0d6a6d;
2152
- --accent-soft: rgba(13, 106, 109, 0.12);
2153
- --warm: #a34f2f;
2154
- --ok: #246b4f;
2155
- --error: #9d2c2c;
2156
- --shadow: 0 24px 80px rgba(40, 32, 16, 0.12);
2157
- --radius: 24px;
2158
- --mono: "SF Mono", "IBM Plex Mono", "Cascadia Code", monospace;
2159
- --serif: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif;
2160
- --sans: "Avenir Next", "Segoe UI", sans-serif;
2161
- }
2162
-
2163
- * { box-sizing: border-box; }
2164
-
2165
- body {
2166
- margin: 0;
2167
- min-height: 100vh;
2168
- font-family: var(--sans);
2169
- color: var(--ink);
2170
- background:
2171
- radial-gradient(circle at top left, rgba(163, 79, 47, 0.18), transparent 32%),
2172
- radial-gradient(circle at right 12%, rgba(13, 106, 109, 0.16), transparent 28%),
2173
- linear-gradient(180deg, #f8f2e8 0%, var(--bg) 100%);
2174
- }
2175
-
2176
- .shell {
2177
- display: grid;
2178
- grid-template-columns: 320px minmax(0, 1fr);
2179
- gap: 18px;
2180
- min-height: 100vh;
2181
- padding: 24px;
2182
- }
2183
-
2184
- .pane {
2185
- background: var(--panel);
2186
- backdrop-filter: blur(18px);
2187
- border: 1px solid var(--line);
2188
- border-radius: var(--radius);
2189
- box-shadow: var(--shadow);
2190
- }
2191
-
2192
- .sidebar {
2193
- display: grid;
2194
- grid-template-rows: auto auto 1fr;
2195
- overflow: hidden;
2196
- }
2197
-
2198
- .hero, .toolbar, .detail-head {
2199
- padding: 22px 24px;
2200
- border-bottom: 1px solid var(--line);
2201
- }
2202
-
2203
- .hero h1 {
2204
- margin: 0;
2205
- font-family: var(--serif);
2206
- font-size: clamp(2rem, 3vw, 2.8rem);
2207
- font-weight: 600;
2208
- letter-spacing: -0.04em;
2209
- }
2210
-
2211
- .hero p, .toolbar p {
2212
- margin: 8px 0 0;
2213
- color: var(--muted);
2214
- line-height: 1.5;
2215
- }
2216
-
2217
- .search,
2218
- .select,
2219
- .field-input {
2220
- width: 100%;
2221
- margin-top: 16px;
2222
- padding: 12px 14px;
2223
- border: 1px solid var(--line);
2224
- border-radius: 999px;
2225
- background: rgba(255, 255, 255, 0.7);
2226
- color: var(--ink);
2227
- font: inherit;
2228
- }
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
-
2250
- .meeting-list {
2251
- padding: 14px;
2252
- overflow: auto;
2253
- }
2254
-
2255
- .meeting-row {
2256
- width: 100%;
2257
- display: grid;
2258
- gap: 4px;
2259
- text-align: left;
2260
- margin: 0 0 10px;
2261
- padding: 14px 16px;
2262
- border: 1px solid transparent;
2263
- border-radius: 18px;
2264
- background: rgba(255, 255, 255, 0.72);
2265
- color: inherit;
2266
- cursor: pointer;
2267
- transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
2268
- }
2269
-
2270
- .meeting-row:hover,
2271
- .meeting-row[data-selected="true"] {
2272
- transform: translateY(-1px);
2273
- border-color: rgba(13, 106, 109, 0.25);
2274
- background: var(--panel-strong);
2275
- }
2276
-
2277
- .meeting-row__title {
2278
- font-weight: 600;
2279
- }
2280
-
2281
- .meeting-row__meta {
2282
- color: var(--muted);
2283
- font-size: 0.92rem;
2284
- }
2285
-
2286
- .meeting-empty {
2287
- padding: 18px;
2288
- color: var(--muted);
2289
- }
2290
-
2291
- .meeting-empty--error {
2292
- color: var(--error);
2293
- }
2294
-
2295
- .detail {
2296
- display: grid;
2297
- grid-template-rows: auto auto 1fr;
2298
- min-width: 0;
2299
- }
2300
-
2301
- .detail-head {
2302
- display: flex;
2303
- align-items: center;
2304
- justify-content: space-between;
2305
- gap: 18px;
2306
- }
2307
-
2308
- .detail-head h2 {
2309
- margin: 0;
2310
- font-family: var(--serif);
2311
- font-size: clamp(1.8rem, 2.4vw, 2.4rem);
2312
- font-weight: 600;
2313
- }
2314
-
2315
- .state-badge {
2316
- padding: 10px 14px;
2317
- border-radius: 999px;
2318
- background: var(--accent-soft);
2319
- color: var(--accent);
2320
- font-size: 0.92rem;
2321
- font-weight: 700;
2322
- }
2323
-
2324
- .state-badge[data-tone="busy"] { color: var(--warm); background: rgba(163, 79, 47, 0.12); }
2325
- .state-badge[data-tone="error"] { color: var(--error); background: rgba(157, 44, 44, 0.12); }
2326
- .state-badge[data-tone="ok"] { color: var(--ok); background: rgba(36, 107, 79, 0.12); }
2327
-
2328
- .toolbar {
2329
- display: flex;
2330
- flex-wrap: wrap;
2331
- align-items: center;
2332
- justify-content: space-between;
2333
- gap: 14px;
2334
- }
2335
-
2336
- .toolbar-actions {
2337
- display: flex;
2338
- flex-wrap: wrap;
2339
- gap: 10px;
2340
- }
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
-
2380
- .button {
2381
- border: 0;
2382
- border-radius: 999px;
2383
- padding: 12px 16px;
2384
- font: inherit;
2385
- font-weight: 700;
2386
- cursor: pointer;
2387
- }
2388
-
2389
- .button--primary {
2390
- background: var(--ink);
2391
- color: white;
2392
- }
2393
-
2394
- .button--secondary {
2395
- background: rgba(255, 255, 255, 0.72);
2396
- color: var(--ink);
2397
- border: 1px solid var(--line);
2398
- }
2399
-
2400
- .status-grid {
2401
- display: grid;
2402
- grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
2403
- gap: 14px;
2404
- }
2405
-
2406
- .status-label {
2407
- display: block;
2408
- margin-bottom: 6px;
2409
- color: var(--muted);
2410
- font-size: 0.78rem;
2411
- letter-spacing: 0.08em;
2412
- text-transform: uppercase;
2413
- }
2414
-
2415
- .detail-meta {
2416
- display: flex;
2417
- flex-wrap: wrap;
2418
- gap: 10px;
2419
- padding: 0 24px 18px;
2420
- }
2421
-
2422
- .detail-chip {
2423
- padding: 10px 12px;
2424
- border-radius: 999px;
2425
- background: rgba(255, 255, 255, 0.72);
2426
- border: 1px solid var(--line);
2427
- color: var(--muted);
2428
- font-size: 0.88rem;
2429
- }
2430
-
2431
- .detail-body {
2432
- padding: 0 24px 24px;
2433
- overflow: auto;
2434
- }
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
-
2447
- .detail-section {
2448
- margin-bottom: 20px;
2449
- padding: 20px;
2450
- background: rgba(255, 255, 255, 0.72);
2451
- border: 1px solid var(--line);
2452
- border-radius: 20px;
2453
- }
2454
-
2455
- .detail-section h2 {
2456
- margin: 0 0 14px;
2457
- font-size: 1rem;
2458
- letter-spacing: 0.08em;
2459
- text-transform: uppercase;
2460
- }
2461
-
2462
- .detail-pre {
2463
- margin: 0;
2464
- white-space: pre-wrap;
2465
- word-break: break-word;
2466
- font-family: var(--mono);
2467
- line-height: 1.55;
2468
- }
2469
-
2470
- .empty {
2471
- margin: 24px;
2472
- padding: 24px;
2473
- border-radius: 20px;
2474
- background: rgba(255, 255, 255, 0.72);
2475
- border: 1px dashed rgba(36, 39, 44, 0.2);
2476
- color: var(--muted);
2477
- }
2478
-
2479
- @media (max-width: 900px) {
2480
- .shell {
2481
- grid-template-columns: 1fr;
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
- }
2493
- }
2494
- </style>
2495
- </head>
2496
- <body>
2497
- <div class="shell">
2498
- <aside class="pane sidebar">
2499
- <section class="hero">
2500
- <h1>Granola Toolkit</h1>
2501
- <p>Browser workspace for meetings, notes, transcripts, and export flows on top of one local server instance.</p>
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>
2522
- </section>
2523
- <section class="toolbar">
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>
2531
- </section>
2532
- <section class="meeting-list" data-meeting-list></section>
2533
- </aside>
2534
- <main class="pane detail">
2535
- <section class="detail-head">
2536
- <div>
2537
- <h2>Meeting Workspace</h2>
2538
- <div data-app-state></div>
2539
- </div>
2540
- <div class="state-badge" data-state-badge data-tone="idle">Connecting…</div>
2541
- </section>
2542
- <section class="toolbar">
2543
- <div class="toolbar-actions">
2544
- <button class="button button--primary" data-refresh>Refresh</button>
2545
- <button class="button button--secondary" data-export-notes>Export Notes</button>
2546
- <button class="button button--secondary" data-export-transcripts>Export Transcripts</button>
2547
- </div>
2548
- <p>Initial beta web client. It speaks to the same local API that future TUI and attach flows will use.</p>
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>
2557
- <div class="detail-meta" data-detail-meta></div>
2558
- <div class="detail-body" data-detail-body>
2559
- <div class="empty" data-empty>Select a meeting to inspect its notes and transcript.</div>
2560
- </div>
2561
- </main>
2562
- </div>
2563
- <script type="module">
2564
- ${String.raw`
2443
+ //#region src/web/client-script.ts
2444
+ const granolaWebClientScript = String.raw`
2565
2445
  const state = {
2566
2446
  appState: null,
2567
2447
  detailError: "",
@@ -2583,6 +2463,7 @@ const els = {
2583
2463
  detailBody: document.querySelector("[data-detail-body]"),
2584
2464
  detailMeta: document.querySelector("[data-detail-meta]"),
2585
2465
  empty: document.querySelector("[data-empty]"),
2466
+ jobsList: document.querySelector("[data-jobs-list]"),
2586
2467
  list: document.querySelector("[data-meeting-list]"),
2587
2468
  noteButton: document.querySelector("[data-export-notes]"),
2588
2469
  quickOpen: document.querySelector("[data-quick-open]"),
@@ -2666,6 +2547,8 @@ function renderAppState() {
2666
2547
  '<div><span class="status-label">Cache</span><strong>' + escapeHtml(cache) + "</strong></div>",
2667
2548
  "</div>",
2668
2549
  ].join("");
2550
+
2551
+ renderExportJobs();
2669
2552
  }
2670
2553
 
2671
2554
  function renderMeetingList() {
@@ -2778,6 +2661,44 @@ function renderMeetingDetail() {
2778
2661
  ].join("");
2779
2662
  }
2780
2663
 
2664
+ function renderExportJobs() {
2665
+ const jobs = state.appState?.exports?.jobs || [];
2666
+ if (jobs.length === 0) {
2667
+ els.jobsList.innerHTML = '<div class="job-empty">No export jobs yet.</div>';
2668
+ return;
2669
+ }
2670
+
2671
+ els.jobsList.innerHTML = jobs
2672
+ .slice(0, 6)
2673
+ .map((job) => {
2674
+ const progress = job.itemCount > 0
2675
+ ? job.completedCount + "/" + job.itemCount + " items"
2676
+ : "0 items";
2677
+ const error = job.error ? '<div class="job-card__meta">' + escapeHtml(job.error) + "</div>" : "";
2678
+ const rerunButton =
2679
+ job.status === "running"
2680
+ ? ""
2681
+ : '<button class="button button--secondary" data-rerun-job-id="' + escapeHtml(job.id) + '">Rerun</button>';
2682
+
2683
+ return [
2684
+ '<article class="job-card">',
2685
+ '<div class="job-card__head">',
2686
+ '<div>',
2687
+ '<div class="job-card__title">' + escapeHtml(job.kind) + " export</div>",
2688
+ '<div class="job-card__meta">' + escapeHtml(job.id) + "</div>",
2689
+ "</div>",
2690
+ '<div class="job-card__status" data-status="' + escapeHtml(job.status) + '">' + escapeHtml(job.status) + "</div>",
2691
+ "</div>",
2692
+ '<div class="job-card__meta">Format: ' + escapeHtml(job.format) + " • " + escapeHtml(progress) + " • Written: " + escapeHtml(String(job.written)) + "</div>",
2693
+ '<div class="job-card__meta">Started: ' + escapeHtml(job.startedAt.slice(0, 19)) + "</div>",
2694
+ error,
2695
+ '<div class="job-card__actions">' + rerunButton + "</div>",
2696
+ "</article>",
2697
+ ].join("");
2698
+ })
2699
+ .join("");
2700
+ }
2701
+
2781
2702
  async function fetchJson(path, init) {
2782
2703
  const response = await fetch(path, init);
2783
2704
  const payload = await response.json().catch(() => ({}));
@@ -2910,6 +2831,20 @@ async function exportTranscripts() {
2910
2831
  await refreshAll();
2911
2832
  }
2912
2833
 
2834
+ async function rerunJob(id) {
2835
+ setStatus("Rerunning export…", "busy");
2836
+ try {
2837
+ await fetchJson("/exports/jobs/" + encodeURIComponent(id) + "/rerun", {
2838
+ method: "POST",
2839
+ });
2840
+ await refreshAll();
2841
+ } catch (error) {
2842
+ setStatus("Rerun failed", "error");
2843
+ state.detailError = error instanceof Error ? error.message : String(error);
2844
+ renderMeetingDetail();
2845
+ }
2846
+ }
2847
+
2913
2848
  els.list.addEventListener("click", (event) => {
2914
2849
  if (!(event.target instanceof Element)) {
2915
2850
  return;
@@ -2920,6 +2855,19 @@ els.list.addEventListener("click", (event) => {
2920
2855
  void loadMeeting(button.dataset.meetingId);
2921
2856
  });
2922
2857
 
2858
+ els.jobsList.addEventListener("click", (event) => {
2859
+ if (!(event.target instanceof Element)) {
2860
+ return;
2861
+ }
2862
+
2863
+ const button = event.target.closest("[data-rerun-job-id]");
2864
+ if (!button) {
2865
+ return;
2866
+ }
2867
+
2868
+ void rerunJob(button.dataset.rerunJobId);
2869
+ });
2870
+
2923
2871
  els.refreshButton.addEventListener("click", () => {
2924
2872
  void refreshAll();
2925
2873
  });
@@ -3061,7 +3009,539 @@ void refreshAll().catch((error) => {
3061
3009
  els.empty.hidden = false;
3062
3010
  els.empty.textContent = error.message;
3063
3011
  });
3064
- `}
3012
+ `;
3013
+ //#endregion
3014
+ //#region src/web/markup.ts
3015
+ const granolaWebMarkup = String.raw`
3016
+ <div class="shell">
3017
+ <aside class="pane sidebar">
3018
+ <section class="hero">
3019
+ <h1>Granola Toolkit</h1>
3020
+ <p>Browser workspace for meetings, notes, transcripts, and export flows on top of one local server instance.</p>
3021
+ <input class="search" data-search placeholder="Search meetings, ids, or tags" />
3022
+ <div class="field-row field-row--inline">
3023
+ <label>
3024
+ <span class="field-label">Sort</span>
3025
+ <select class="select" data-sort>
3026
+ <option value="updated-desc">Newest first</option>
3027
+ <option value="updated-asc">Oldest first</option>
3028
+ <option value="title-asc">Title A-Z</option>
3029
+ <option value="title-desc">Title Z-A</option>
3030
+ </select>
3031
+ </label>
3032
+ <label>
3033
+ <span class="field-label">Updated From</span>
3034
+ <input class="field-input" data-updated-from type="date" />
3035
+ </label>
3036
+ </div>
3037
+ <label class="field-row">
3038
+ <span class="field-label">Updated To</span>
3039
+ <input class="field-input" data-updated-to type="date" />
3040
+ </label>
3041
+ </section>
3042
+ <section class="toolbar">
3043
+ <div>
3044
+ <p>Meetings are loaded from the shared server state so this view can later coexist with the terminal UI.</p>
3045
+ </div>
3046
+ <div class="toolbar-form">
3047
+ <input class="field-input" data-quick-open placeholder="Quick open by id or title" />
3048
+ <button class="button button--secondary" data-quick-open-button>Open</button>
3049
+ </div>
3050
+ </section>
3051
+ <section class="meeting-list" data-meeting-list></section>
3052
+ </aside>
3053
+ <main class="pane detail">
3054
+ <section class="detail-head">
3055
+ <div>
3056
+ <h2>Meeting Workspace</h2>
3057
+ <div data-app-state></div>
3058
+ </div>
3059
+ <div class="state-badge" data-state-badge data-tone="idle">Connecting…</div>
3060
+ </section>
3061
+ <section class="toolbar">
3062
+ <div class="toolbar-actions">
3063
+ <button class="button button--primary" data-refresh>Refresh</button>
3064
+ <button class="button button--secondary" data-export-notes>Export Notes</button>
3065
+ <button class="button button--secondary" data-export-transcripts>Export Transcripts</button>
3066
+ </div>
3067
+ <p>Initial beta web client. It speaks to the same local API that future TUI and attach flows will use.</p>
3068
+ </section>
3069
+ <section class="jobs-panel">
3070
+ <div class="jobs-panel__head">
3071
+ <h3>Recent Export Jobs</h3>
3072
+ <p>Tracked across CLI and web runs.</p>
3073
+ </div>
3074
+ <div class="jobs-list" data-jobs-list></div>
3075
+ </section>
3076
+ <nav class="workspace-tabs">
3077
+ <button class="workspace-tab" data-workspace-tab="notes">Notes</button>
3078
+ <button class="workspace-tab" data-workspace-tab="transcript">Transcript</button>
3079
+ <button class="workspace-tab" data-workspace-tab="metadata">Metadata</button>
3080
+ <button class="workspace-tab" data-workspace-tab="raw">Raw</button>
3081
+ <span class="workspace-hint">1-4 switch tabs, [ and ] cycle</span>
3082
+ </nav>
3083
+ <div class="detail-meta" data-detail-meta></div>
3084
+ <div class="detail-body" data-detail-body>
3085
+ <div class="empty" data-empty>Select a meeting to inspect its notes and transcript.</div>
3086
+ </div>
3087
+ </main>
3088
+ </div>
3089
+ `;
3090
+ //#endregion
3091
+ //#region src/web/styles.ts
3092
+ const granolaWebStyles = String.raw`
3093
+ :root {
3094
+ --bg: #f2ede2;
3095
+ --panel: rgba(255, 252, 247, 0.86);
3096
+ --panel-strong: #fffaf2;
3097
+ --line: rgba(36, 39, 44, 0.12);
3098
+ --ink: #1d242c;
3099
+ --muted: #5d6b77;
3100
+ --accent: #0d6a6d;
3101
+ --accent-soft: rgba(13, 106, 109, 0.12);
3102
+ --warm: #a34f2f;
3103
+ --ok: #246b4f;
3104
+ --error: #9d2c2c;
3105
+ --shadow: 0 24px 80px rgba(40, 32, 16, 0.12);
3106
+ --radius: 24px;
3107
+ --mono: "SF Mono", "IBM Plex Mono", "Cascadia Code", monospace;
3108
+ --serif: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif;
3109
+ --sans: "Avenir Next", "Segoe UI", sans-serif;
3110
+ }
3111
+
3112
+ * { box-sizing: border-box; }
3113
+
3114
+ body {
3115
+ margin: 0;
3116
+ min-height: 100vh;
3117
+ font-family: var(--sans);
3118
+ color: var(--ink);
3119
+ background:
3120
+ radial-gradient(circle at top left, rgba(163, 79, 47, 0.18), transparent 32%),
3121
+ radial-gradient(circle at right 12%, rgba(13, 106, 109, 0.16), transparent 28%),
3122
+ linear-gradient(180deg, #f8f2e8 0%, var(--bg) 100%);
3123
+ }
3124
+
3125
+ .shell {
3126
+ display: grid;
3127
+ grid-template-columns: 320px minmax(0, 1fr);
3128
+ gap: 18px;
3129
+ min-height: 100vh;
3130
+ padding: 24px;
3131
+ }
3132
+
3133
+ .pane {
3134
+ background: var(--panel);
3135
+ backdrop-filter: blur(18px);
3136
+ border: 1px solid var(--line);
3137
+ border-radius: var(--radius);
3138
+ box-shadow: var(--shadow);
3139
+ }
3140
+
3141
+ .sidebar {
3142
+ display: grid;
3143
+ grid-template-rows: auto auto 1fr;
3144
+ overflow: hidden;
3145
+ }
3146
+
3147
+ .hero, .toolbar, .detail-head {
3148
+ padding: 22px 24px;
3149
+ border-bottom: 1px solid var(--line);
3150
+ }
3151
+
3152
+ .hero h1 {
3153
+ margin: 0;
3154
+ font-family: var(--serif);
3155
+ font-size: clamp(2rem, 3vw, 2.8rem);
3156
+ font-weight: 600;
3157
+ letter-spacing: -0.04em;
3158
+ }
3159
+
3160
+ .hero p, .toolbar p {
3161
+ margin: 8px 0 0;
3162
+ color: var(--muted);
3163
+ line-height: 1.5;
3164
+ }
3165
+
3166
+ .search,
3167
+ .select,
3168
+ .field-input {
3169
+ width: 100%;
3170
+ margin-top: 16px;
3171
+ padding: 12px 14px;
3172
+ border: 1px solid var(--line);
3173
+ border-radius: 999px;
3174
+ background: rgba(255, 255, 255, 0.7);
3175
+ color: var(--ink);
3176
+ font: inherit;
3177
+ }
3178
+
3179
+ .field-row {
3180
+ display: grid;
3181
+ gap: 10px;
3182
+ margin-top: 12px;
3183
+ }
3184
+
3185
+ .field-row--inline {
3186
+ grid-template-columns: repeat(2, minmax(0, 1fr));
3187
+ }
3188
+
3189
+ .field-label {
3190
+ display: block;
3191
+ margin-bottom: 6px;
3192
+ color: var(--muted);
3193
+ font-size: 0.78rem;
3194
+ font-weight: 700;
3195
+ letter-spacing: 0.08em;
3196
+ text-transform: uppercase;
3197
+ }
3198
+
3199
+ .meeting-list {
3200
+ padding: 14px;
3201
+ overflow: auto;
3202
+ }
3203
+
3204
+ .meeting-row {
3205
+ width: 100%;
3206
+ display: grid;
3207
+ gap: 4px;
3208
+ text-align: left;
3209
+ margin: 0 0 10px;
3210
+ padding: 14px 16px;
3211
+ border: 1px solid transparent;
3212
+ border-radius: 18px;
3213
+ background: rgba(255, 255, 255, 0.72);
3214
+ color: inherit;
3215
+ cursor: pointer;
3216
+ transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
3217
+ }
3218
+
3219
+ .meeting-row:hover,
3220
+ .meeting-row[data-selected="true"] {
3221
+ transform: translateY(-1px);
3222
+ border-color: rgba(13, 106, 109, 0.25);
3223
+ background: var(--panel-strong);
3224
+ }
3225
+
3226
+ .meeting-row__title {
3227
+ font-weight: 600;
3228
+ }
3229
+
3230
+ .meeting-row__meta {
3231
+ color: var(--muted);
3232
+ font-size: 0.92rem;
3233
+ }
3234
+
3235
+ .meeting-empty {
3236
+ padding: 18px;
3237
+ color: var(--muted);
3238
+ }
3239
+
3240
+ .meeting-empty--error {
3241
+ color: var(--error);
3242
+ }
3243
+
3244
+ .detail {
3245
+ display: grid;
3246
+ grid-template-rows: auto auto 1fr;
3247
+ min-width: 0;
3248
+ }
3249
+
3250
+ .detail-head {
3251
+ display: flex;
3252
+ align-items: center;
3253
+ justify-content: space-between;
3254
+ gap: 18px;
3255
+ }
3256
+
3257
+ .detail-head h2 {
3258
+ margin: 0;
3259
+ font-family: var(--serif);
3260
+ font-size: clamp(1.8rem, 2.4vw, 2.4rem);
3261
+ font-weight: 600;
3262
+ }
3263
+
3264
+ .state-badge {
3265
+ padding: 10px 14px;
3266
+ border-radius: 999px;
3267
+ background: var(--accent-soft);
3268
+ color: var(--accent);
3269
+ font-size: 0.92rem;
3270
+ font-weight: 700;
3271
+ }
3272
+
3273
+ .state-badge[data-tone="busy"] { color: var(--warm); background: rgba(163, 79, 47, 0.12); }
3274
+ .state-badge[data-tone="error"] { color: var(--error); background: rgba(157, 44, 44, 0.12); }
3275
+ .state-badge[data-tone="ok"] { color: var(--ok); background: rgba(36, 107, 79, 0.12); }
3276
+
3277
+ .toolbar {
3278
+ display: flex;
3279
+ flex-wrap: wrap;
3280
+ align-items: center;
3281
+ justify-content: space-between;
3282
+ gap: 14px;
3283
+ }
3284
+
3285
+ .toolbar-actions {
3286
+ display: flex;
3287
+ flex-wrap: wrap;
3288
+ gap: 10px;
3289
+ }
3290
+
3291
+ .toolbar-form {
3292
+ display: grid;
3293
+ grid-template-columns: minmax(0, 1fr) auto;
3294
+ gap: 10px;
3295
+ width: min(440px, 100%);
3296
+ }
3297
+
3298
+ .jobs-panel {
3299
+ padding: 0 24px 18px;
3300
+ }
3301
+
3302
+ .jobs-panel__head h3 {
3303
+ margin: 0;
3304
+ font-size: 0.92rem;
3305
+ letter-spacing: 0.08em;
3306
+ text-transform: uppercase;
3307
+ }
3308
+
3309
+ .jobs-panel__head p {
3310
+ margin: 6px 0 0;
3311
+ color: var(--muted);
3312
+ font-size: 0.9rem;
3313
+ }
3314
+
3315
+ .jobs-list {
3316
+ display: grid;
3317
+ gap: 10px;
3318
+ margin-top: 14px;
3319
+ }
3320
+
3321
+ .job-card {
3322
+ display: grid;
3323
+ gap: 10px;
3324
+ padding: 14px 16px;
3325
+ border: 1px solid var(--line);
3326
+ border-radius: 18px;
3327
+ background: rgba(255, 255, 255, 0.72);
3328
+ }
3329
+
3330
+ .job-card__head {
3331
+ display: flex;
3332
+ flex-wrap: wrap;
3333
+ align-items: center;
3334
+ justify-content: space-between;
3335
+ gap: 10px;
3336
+ }
3337
+
3338
+ .job-card__title {
3339
+ font-weight: 700;
3340
+ }
3341
+
3342
+ .job-card__meta {
3343
+ color: var(--muted);
3344
+ font-size: 0.9rem;
3345
+ }
3346
+
3347
+ .job-card__status {
3348
+ padding: 6px 10px;
3349
+ border-radius: 999px;
3350
+ background: var(--accent-soft);
3351
+ color: var(--accent);
3352
+ font-size: 0.82rem;
3353
+ font-weight: 700;
3354
+ }
3355
+
3356
+ .job-card__status[data-status="running"] {
3357
+ background: rgba(163, 79, 47, 0.12);
3358
+ color: var(--warm);
3359
+ }
3360
+
3361
+ .job-card__status[data-status="failed"] {
3362
+ background: rgba(157, 44, 44, 0.12);
3363
+ color: var(--error);
3364
+ }
3365
+
3366
+ .job-card__status[data-status="completed"] {
3367
+ background: rgba(36, 107, 79, 0.12);
3368
+ color: var(--ok);
3369
+ }
3370
+
3371
+ .job-card__actions {
3372
+ display: flex;
3373
+ flex-wrap: wrap;
3374
+ gap: 8px;
3375
+ }
3376
+
3377
+ .job-empty {
3378
+ color: var(--muted);
3379
+ font-size: 0.92rem;
3380
+ }
3381
+
3382
+ .workspace-tabs {
3383
+ display: flex;
3384
+ flex-wrap: wrap;
3385
+ align-items: center;
3386
+ gap: 10px;
3387
+ padding: 0 24px 18px;
3388
+ }
3389
+
3390
+ .workspace-tab {
3391
+ border: 1px solid var(--line);
3392
+ border-radius: 999px;
3393
+ padding: 10px 14px;
3394
+ background: rgba(255, 255, 255, 0.72);
3395
+ color: var(--muted);
3396
+ cursor: pointer;
3397
+ font: inherit;
3398
+ font-weight: 700;
3399
+ }
3400
+
3401
+ .workspace-tab[data-selected="true"] {
3402
+ background: var(--ink);
3403
+ color: white;
3404
+ border-color: var(--ink);
3405
+ }
3406
+
3407
+ .workspace-hint {
3408
+ color: var(--muted);
3409
+ font-size: 0.86rem;
3410
+ margin-left: auto;
3411
+ }
3412
+
3413
+ .button {
3414
+ border: 0;
3415
+ border-radius: 999px;
3416
+ padding: 12px 16px;
3417
+ font: inherit;
3418
+ font-weight: 700;
3419
+ cursor: pointer;
3420
+ }
3421
+
3422
+ .button--primary {
3423
+ background: var(--ink);
3424
+ color: white;
3425
+ }
3426
+
3427
+ .button--secondary {
3428
+ background: rgba(255, 255, 255, 0.72);
3429
+ color: var(--ink);
3430
+ border: 1px solid var(--line);
3431
+ }
3432
+
3433
+ .status-grid {
3434
+ display: grid;
3435
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
3436
+ gap: 14px;
3437
+ }
3438
+
3439
+ .status-label {
3440
+ display: block;
3441
+ margin-bottom: 6px;
3442
+ color: var(--muted);
3443
+ font-size: 0.78rem;
3444
+ letter-spacing: 0.08em;
3445
+ text-transform: uppercase;
3446
+ }
3447
+
3448
+ .detail-meta {
3449
+ display: flex;
3450
+ flex-wrap: wrap;
3451
+ gap: 10px;
3452
+ padding: 0 24px 18px;
3453
+ }
3454
+
3455
+ .detail-chip {
3456
+ padding: 10px 12px;
3457
+ border-radius: 999px;
3458
+ background: rgba(255, 255, 255, 0.72);
3459
+ border: 1px solid var(--line);
3460
+ color: var(--muted);
3461
+ font-size: 0.88rem;
3462
+ }
3463
+
3464
+ .detail-body {
3465
+ padding: 0 24px 24px;
3466
+ overflow: auto;
3467
+ }
3468
+
3469
+ .workspace-grid {
3470
+ display: grid;
3471
+ grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
3472
+ gap: 18px;
3473
+ }
3474
+
3475
+ .workspace-sidebar,
3476
+ .workspace-main {
3477
+ margin-bottom: 0;
3478
+ }
3479
+
3480
+ .detail-section {
3481
+ margin-bottom: 20px;
3482
+ padding: 20px;
3483
+ background: rgba(255, 255, 255, 0.72);
3484
+ border: 1px solid var(--line);
3485
+ border-radius: 20px;
3486
+ }
3487
+
3488
+ .detail-section h2 {
3489
+ margin: 0 0 14px;
3490
+ font-size: 1rem;
3491
+ letter-spacing: 0.08em;
3492
+ text-transform: uppercase;
3493
+ }
3494
+
3495
+ .detail-pre {
3496
+ margin: 0;
3497
+ white-space: pre-wrap;
3498
+ word-break: break-word;
3499
+ font-family: var(--mono);
3500
+ line-height: 1.55;
3501
+ }
3502
+
3503
+ .empty {
3504
+ margin: 24px;
3505
+ padding: 24px;
3506
+ border-radius: 20px;
3507
+ background: rgba(255, 255, 255, 0.72);
3508
+ border: 1px dashed rgba(36, 39, 44, 0.2);
3509
+ color: var(--muted);
3510
+ }
3511
+
3512
+ @media (max-width: 900px) {
3513
+ .shell {
3514
+ grid-template-columns: 1fr;
3515
+ }
3516
+
3517
+ .field-row--inline,
3518
+ .toolbar-form,
3519
+ .workspace-grid {
3520
+ grid-template-columns: 1fr;
3521
+ }
3522
+
3523
+ .workspace-hint {
3524
+ margin-left: 0;
3525
+ }
3526
+ }
3527
+ `;
3528
+ //#endregion
3529
+ //#region src/server/web.ts
3530
+ function renderGranolaWebPage() {
3531
+ return `<!doctype html>
3532
+ <html lang="en">
3533
+ <head>
3534
+ <meta charset="utf-8" />
3535
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
3536
+ <title>Granola Toolkit</title>
3537
+ <style>
3538
+ ${granolaWebStyles}
3539
+ </style>
3540
+ </head>
3541
+ <body>
3542
+ ${granolaWebMarkup}
3543
+ <script type="module">
3544
+ ${granolaWebClientScript}
3065
3545
  <\/script>
3066
3546
  </body>
3067
3547
  </html>`;
@@ -3226,6 +3706,17 @@ async function startGranolaServer(app, options = {}) {
3226
3706
  sendJson(response, await app.exportNotes(noteFormatFromBody(body.format)), { status: 202 });
3227
3707
  return;
3228
3708
  }
3709
+ if (method === "GET" && path === "/exports/jobs") {
3710
+ const limit = parseInteger(url.searchParams.get("limit"));
3711
+ sendJson(response, await app.listExportJobs({ limit }));
3712
+ return;
3713
+ }
3714
+ if (method === "POST" && path.startsWith("/exports/jobs/") && path.endsWith("/rerun")) {
3715
+ const id = decodeURIComponent(path.slice(14, -6));
3716
+ if (!id) throw new Error("export job id is required");
3717
+ sendJson(response, await app.rerunExportJob(id), { status: 202 });
3718
+ return;
3719
+ }
3229
3720
  if (method === "POST" && path === "/exports/transcripts") {
3230
3721
  const body = await readJsonBody(request);
3231
3722
  sendJson(response, await app.exportTranscripts(transcriptFormatFromBody(body.format)), { status: 202 });
@@ -3316,7 +3807,9 @@ const serveCommand = {
3316
3807
  console.log(" GET /events");
3317
3808
  console.log(" GET /meetings");
3318
3809
  console.log(" GET /meetings/:id");
3810
+ console.log(" GET /exports/jobs");
3319
3811
  console.log(" POST /exports/notes");
3812
+ console.log(" POST /exports/jobs/:id/rerun");
3320
3813
  console.log(" POST /exports/transcripts");
3321
3814
  await waitForShutdown(async () => await server.close());
3322
3815
  return 0;
@@ -3362,7 +3855,7 @@ const transcriptsCommand = {
3362
3855
  const app = await createGranolaApp(config);
3363
3856
  debug(config.debug, "authMode", app.getState().auth.mode);
3364
3857
  const result = await app.exportTranscripts(format);
3365
- console.log(`✓ Exported ${result.transcriptCount} transcripts to ${result.outputDir}`);
3858
+ console.log(`✓ Exported ${result.transcriptCount} transcripts to ${result.outputDir} (job ${result.job.id})`);
3366
3859
  debug(config.debug, "transcripts written", result.written);
3367
3860
  return 0;
3368
3861
  }
@@ -3432,6 +3925,7 @@ Options:
3432
3925
  //#region src/commands/index.ts
3433
3926
  const commands = [
3434
3927
  authCommand,
3928
+ exportsCommand,
3435
3929
  meetingCommand,
3436
3930
  notesCommand,
3437
3931
  serveCommand,
@@ -3474,7 +3968,9 @@ const commands = [
3474
3968
  console.log(" GET /events");
3475
3969
  console.log(" GET /meetings");
3476
3970
  console.log(" GET /meetings/:id");
3971
+ console.log(" GET /exports/jobs");
3477
3972
  console.log(" POST /exports/notes");
3973
+ console.log(" POST /exports/jobs/:id/rerun");
3478
3974
  console.log(" POST /exports/transcripts");
3479
3975
  if (openBrowser) try {
3480
3976
  await openExternalUrl(server.url);