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.
- package/README.md +18 -0
- package/dist/cli.js +501 -18
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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);
|