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.
- package/README.md +18 -0
- package/dist/cli.js +945 -449
- 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
|
-
|
|
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
|
}
|
|
@@ -2132,436 +2440,8 @@ function resolveNoteFormat(value) {
|
|
|
2132
2440
|
}
|
|
2133
2441
|
}
|
|
2134
2442
|
//#endregion
|
|
2135
|
-
//#region src/
|
|
2136
|
-
|
|
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);
|