granola-toolkit 0.21.1 → 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 +501 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -33,6 +33,7 @@ Installed command:
33
33
  ```bash
34
34
  granola --help
35
35
  granola auth login
36
+ granola exports --help
36
37
  granola meeting --help
37
38
  granola notes --help
38
39
  granola serve --help
@@ -47,6 +48,7 @@ Local build:
47
48
  ```bash
48
49
  vp pack
49
50
  node dist/cli.js --help
51
+ node dist/cli.js exports --help
50
52
  node dist/cli.js meeting --help
51
53
  node dist/cli.js notes --help
52
54
  node dist/cli.js serve --help
@@ -73,6 +75,8 @@ granola notes
73
75
 
74
76
  node dist/cli.js notes --supabase "$HOME/Library/Application Support/Granola/supabase.json"
75
77
  node dist/cli.js notes --format json --output ./notes-json
78
+ granola exports list
79
+ granola exports rerun notes-1234abcd
76
80
  ```
77
81
 
78
82
  Export transcripts:
@@ -194,7 +198,9 @@ The initial server API includes:
194
198
  - `GET /meetings`
195
199
  - `GET /meetings/resolve?q=<query>`
196
200
  - `GET /meetings/:id`
201
+ - `GET /exports/jobs`
197
202
  - `POST /exports/notes`
203
+ - `POST /exports/jobs/:id/rerun`
198
204
  - `POST /exports/transcripts`
199
205
 
200
206
  This is the foundation for the future `granola web` client and any attachable TUI flows.
@@ -212,8 +218,20 @@ The initial browser client includes:
212
218
  - keyboard-first workspace switching with `1`-`4`, `[` and `]`
213
219
  - app-state status from the shared core
214
220
  - note and transcript export actions backed by the same local API
221
+ - a recent export-jobs panel with rerun actions
215
222
  - stronger empty and error states for list/detail failures
216
223
 
224
+ ### Export Jobs
225
+
226
+ Exports are now tracked as jobs with:
227
+
228
+ - persistent local history across CLI and web runs
229
+ - running, completed, and failed status
230
+ - per-export progress counters
231
+ - rerun support from `granola exports rerun <job-id>` or the web client
232
+
233
+ Use `granola exports list` to inspect recent jobs from the CLI.
234
+
217
235
  ## Auth
218
236
 
219
237
  If you do not want to keep passing `--supabase`, import the desktop app session once:
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
  }
@@ -2155,6 +2463,7 @@ const els = {
2155
2463
  detailBody: document.querySelector("[data-detail-body]"),
2156
2464
  detailMeta: document.querySelector("[data-detail-meta]"),
2157
2465
  empty: document.querySelector("[data-empty]"),
2466
+ jobsList: document.querySelector("[data-jobs-list]"),
2158
2467
  list: document.querySelector("[data-meeting-list]"),
2159
2468
  noteButton: document.querySelector("[data-export-notes]"),
2160
2469
  quickOpen: document.querySelector("[data-quick-open]"),
@@ -2238,6 +2547,8 @@ function renderAppState() {
2238
2547
  '<div><span class="status-label">Cache</span><strong>' + escapeHtml(cache) + "</strong></div>",
2239
2548
  "</div>",
2240
2549
  ].join("");
2550
+
2551
+ renderExportJobs();
2241
2552
  }
2242
2553
 
2243
2554
  function renderMeetingList() {
@@ -2350,6 +2661,44 @@ function renderMeetingDetail() {
2350
2661
  ].join("");
2351
2662
  }
2352
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
+
2353
2702
  async function fetchJson(path, init) {
2354
2703
  const response = await fetch(path, init);
2355
2704
  const payload = await response.json().catch(() => ({}));
@@ -2482,6 +2831,20 @@ async function exportTranscripts() {
2482
2831
  await refreshAll();
2483
2832
  }
2484
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
+
2485
2848
  els.list.addEventListener("click", (event) => {
2486
2849
  if (!(event.target instanceof Element)) {
2487
2850
  return;
@@ -2492,6 +2855,19 @@ els.list.addEventListener("click", (event) => {
2492
2855
  void loadMeeting(button.dataset.meetingId);
2493
2856
  });
2494
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
+
2495
2871
  els.refreshButton.addEventListener("click", () => {
2496
2872
  void refreshAll();
2497
2873
  });
@@ -2690,6 +3066,13 @@ const granolaWebMarkup = String.raw`
2690
3066
  </div>
2691
3067
  <p>Initial beta web client. It speaks to the same local API that future TUI and attach flows will use.</p>
2692
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>
2693
3076
  <nav class="workspace-tabs">
2694
3077
  <button class="workspace-tab" data-workspace-tab="notes">Notes</button>
2695
3078
  <button class="workspace-tab" data-workspace-tab="transcript">Transcript</button>
@@ -2912,6 +3295,90 @@ body {
2912
3295
  width: min(440px, 100%);
2913
3296
  }
2914
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
+
2915
3382
  .workspace-tabs {
2916
3383
  display: flex;
2917
3384
  flex-wrap: wrap;
@@ -3239,6 +3706,17 @@ async function startGranolaServer(app, options = {}) {
3239
3706
  sendJson(response, await app.exportNotes(noteFormatFromBody(body.format)), { status: 202 });
3240
3707
  return;
3241
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
+ }
3242
3720
  if (method === "POST" && path === "/exports/transcripts") {
3243
3721
  const body = await readJsonBody(request);
3244
3722
  sendJson(response, await app.exportTranscripts(transcriptFormatFromBody(body.format)), { status: 202 });
@@ -3329,7 +3807,9 @@ const serveCommand = {
3329
3807
  console.log(" GET /events");
3330
3808
  console.log(" GET /meetings");
3331
3809
  console.log(" GET /meetings/:id");
3810
+ console.log(" GET /exports/jobs");
3332
3811
  console.log(" POST /exports/notes");
3812
+ console.log(" POST /exports/jobs/:id/rerun");
3333
3813
  console.log(" POST /exports/transcripts");
3334
3814
  await waitForShutdown(async () => await server.close());
3335
3815
  return 0;
@@ -3375,7 +3855,7 @@ const transcriptsCommand = {
3375
3855
  const app = await createGranolaApp(config);
3376
3856
  debug(config.debug, "authMode", app.getState().auth.mode);
3377
3857
  const result = await app.exportTranscripts(format);
3378
- console.log(`✓ Exported ${result.transcriptCount} transcripts to ${result.outputDir}`);
3858
+ console.log(`✓ Exported ${result.transcriptCount} transcripts to ${result.outputDir} (job ${result.job.id})`);
3379
3859
  debug(config.debug, "transcripts written", result.written);
3380
3860
  return 0;
3381
3861
  }
@@ -3445,6 +3925,7 @@ Options:
3445
3925
  //#region src/commands/index.ts
3446
3926
  const commands = [
3447
3927
  authCommand,
3928
+ exportsCommand,
3448
3929
  meetingCommand,
3449
3930
  notesCommand,
3450
3931
  serveCommand,
@@ -3487,7 +3968,9 @@ const commands = [
3487
3968
  console.log(" GET /events");
3488
3969
  console.log(" GET /meetings");
3489
3970
  console.log(" GET /meetings/:id");
3971
+ console.log(" GET /exports/jobs");
3490
3972
  console.log(" POST /exports/notes");
3973
+ console.log(" POST /exports/jobs/:id/rerun");
3491
3974
  console.log(" POST /exports/transcripts");
3492
3975
  if (openBrowser) try {
3493
3976
  await openExternalUrl(server.url);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.21.1",
3
+ "version": "0.22.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",