granola-toolkit 0.4.0 → 0.5.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 +14 -2
- package/dist/cli.js +168 -44
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -119,9 +119,21 @@ Structured output formats are useful when you want to post-process exports in sc
|
|
|
119
119
|
|
|
120
120
|
### Incremental Writes
|
|
121
121
|
|
|
122
|
-
Both commands
|
|
122
|
+
Both commands keep a small hidden state file in the output directory to track:
|
|
123
123
|
|
|
124
|
-
|
|
124
|
+
- document id to filename
|
|
125
|
+
- content hash
|
|
126
|
+
- source timestamp
|
|
127
|
+
- last export time
|
|
128
|
+
|
|
129
|
+
That state is used to:
|
|
130
|
+
|
|
131
|
+
- keep filenames stable even if a meeting title changes later
|
|
132
|
+
- skip rewrites when the rendered content is unchanged
|
|
133
|
+
- migrate old files cleanly when the output format changes
|
|
134
|
+
- delete stale exports when a document disappears from the source data
|
|
135
|
+
|
|
136
|
+
That makes repeated runs cheap and keeps long-lived export directories much cleaner.
|
|
125
137
|
|
|
126
138
|
## Config
|
|
127
139
|
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
6
7
|
//#region src/utils.ts
|
|
7
8
|
const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g;
|
|
8
9
|
const CONTROL_CHARACTERS = /\p{Cc}/gu;
|
|
@@ -147,16 +148,6 @@ function latestDocumentTimestamp(document) {
|
|
|
147
148
|
});
|
|
148
149
|
return candidates[0] ?? document.updatedAt;
|
|
149
150
|
}
|
|
150
|
-
async function shouldWriteFile(filePath, updatedAt) {
|
|
151
|
-
try {
|
|
152
|
-
const existing = await stat(filePath);
|
|
153
|
-
const updatedTime = new Date(updatedAt);
|
|
154
|
-
if (Number.isNaN(updatedTime.getTime())) return true;
|
|
155
|
-
return updatedTime.getTime() > existing.mtime.getTime();
|
|
156
|
-
} catch {
|
|
157
|
-
return true;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
151
|
async function writeTextFile(filePath, content) {
|
|
161
152
|
await mkdir(dirname(filePath), { recursive: true });
|
|
162
153
|
await writeFile(filePath, content, "utf8");
|
|
@@ -430,6 +421,134 @@ async function loadConfig(options) {
|
|
|
430
421
|
};
|
|
431
422
|
}
|
|
432
423
|
//#endregion
|
|
424
|
+
//#region src/export-state.ts
|
|
425
|
+
const EXPORT_STATE_VERSION = 1;
|
|
426
|
+
function exportStatePath(outputDir, kind) {
|
|
427
|
+
return join(outputDir, `.granola-toolkit-${kind}-state.json`);
|
|
428
|
+
}
|
|
429
|
+
function emptyExportState(kind) {
|
|
430
|
+
return {
|
|
431
|
+
entries: {},
|
|
432
|
+
kind,
|
|
433
|
+
version: EXPORT_STATE_VERSION
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
function normaliseExportState(parsed, kind) {
|
|
437
|
+
const record = asRecord(parsed);
|
|
438
|
+
if (!record || record.version !== EXPORT_STATE_VERSION || record.kind !== kind) return emptyExportState(kind);
|
|
439
|
+
const rawEntries = asRecord(record.entries) ?? {};
|
|
440
|
+
return {
|
|
441
|
+
entries: Object.fromEntries(Object.entries(rawEntries).map(([id, entry]) => {
|
|
442
|
+
const value = asRecord(entry);
|
|
443
|
+
if (!value) return;
|
|
444
|
+
const fileName = stringValue(value.fileName);
|
|
445
|
+
const fileStem = stringValue(value.fileStem);
|
|
446
|
+
if (!fileName || !fileStem) return;
|
|
447
|
+
return [id, {
|
|
448
|
+
contentHash: stringValue(value.contentHash),
|
|
449
|
+
exportedAt: stringValue(value.exportedAt),
|
|
450
|
+
fileName,
|
|
451
|
+
fileStem,
|
|
452
|
+
sourceUpdatedAt: stringValue(value.sourceUpdatedAt)
|
|
453
|
+
}];
|
|
454
|
+
}).filter((entry) => Boolean(entry))),
|
|
455
|
+
kind,
|
|
456
|
+
version: EXPORT_STATE_VERSION
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
async function loadExportState(outputDir, kind) {
|
|
460
|
+
const statePath = exportStatePath(outputDir, kind);
|
|
461
|
+
try {
|
|
462
|
+
return normaliseExportState(parseJsonString(await readUtf8(statePath)), kind);
|
|
463
|
+
} catch {
|
|
464
|
+
return emptyExportState(kind);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
function hashContent(content) {
|
|
468
|
+
return createHash("sha256").update(content).digest("hex");
|
|
469
|
+
}
|
|
470
|
+
function reserveStem(used, preferredStem, existingStem) {
|
|
471
|
+
if (existingStem && (used.get(existingStem) ?? 0) === 0) {
|
|
472
|
+
used.set(existingStem, 1);
|
|
473
|
+
return existingStem;
|
|
474
|
+
}
|
|
475
|
+
return makeUniqueFilename(preferredStem, used);
|
|
476
|
+
}
|
|
477
|
+
async function fileExists(pathname) {
|
|
478
|
+
try {
|
|
479
|
+
await stat(pathname);
|
|
480
|
+
return true;
|
|
481
|
+
} catch {
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function entryChanged(left, right) {
|
|
486
|
+
if (!left) return true;
|
|
487
|
+
return left.contentHash !== right.contentHash || left.exportedAt !== right.exportedAt || left.fileName !== right.fileName || left.fileStem !== right.fileStem || left.sourceUpdatedAt !== right.sourceUpdatedAt;
|
|
488
|
+
}
|
|
489
|
+
async function syncManagedExports({ items, kind, outputDir }) {
|
|
490
|
+
await ensureDirectory(outputDir);
|
|
491
|
+
const previousEntries = (await loadExportState(outputDir, kind)).entries;
|
|
492
|
+
const used = /* @__PURE__ */ new Map();
|
|
493
|
+
const plans = items.map((item) => {
|
|
494
|
+
const existing = previousEntries[item.id];
|
|
495
|
+
const fileStem = reserveStem(used, item.preferredStem, existing?.fileStem);
|
|
496
|
+
return {
|
|
497
|
+
content: item.content,
|
|
498
|
+
contentHash: hashContent(item.content),
|
|
499
|
+
existing,
|
|
500
|
+
fileName: `${fileStem}${item.extension}`,
|
|
501
|
+
fileStem,
|
|
502
|
+
id: item.id,
|
|
503
|
+
sourceUpdatedAt: item.sourceUpdatedAt
|
|
504
|
+
};
|
|
505
|
+
});
|
|
506
|
+
const activeIds = new Set(plans.map((plan) => plan.id));
|
|
507
|
+
const activeFileNames = new Set(plans.map((plan) => plan.fileName));
|
|
508
|
+
const exportedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
509
|
+
const nextEntries = {};
|
|
510
|
+
let written = 0;
|
|
511
|
+
let stateChanged = false;
|
|
512
|
+
for (const plan of plans) {
|
|
513
|
+
const filePath = join(outputDir, plan.fileName);
|
|
514
|
+
const shouldWrite = !plan.existing || plan.existing.contentHash !== plan.contentHash || plan.existing.fileName !== plan.fileName || !await fileExists(filePath);
|
|
515
|
+
if (shouldWrite) {
|
|
516
|
+
await writeTextFile(filePath, plan.content);
|
|
517
|
+
written += 1;
|
|
518
|
+
}
|
|
519
|
+
const nextEntry = {
|
|
520
|
+
contentHash: plan.contentHash,
|
|
521
|
+
exportedAt: shouldWrite ? exportedAt : plan.existing?.exportedAt ?? exportedAt,
|
|
522
|
+
fileName: plan.fileName,
|
|
523
|
+
fileStem: plan.fileStem,
|
|
524
|
+
sourceUpdatedAt: plan.sourceUpdatedAt
|
|
525
|
+
};
|
|
526
|
+
nextEntries[plan.id] = nextEntry;
|
|
527
|
+
stateChanged = stateChanged || entryChanged(plan.existing, nextEntry);
|
|
528
|
+
}
|
|
529
|
+
for (const plan of plans) {
|
|
530
|
+
const previousFileName = plan.existing?.fileName;
|
|
531
|
+
if (previousFileName && previousFileName !== plan.fileName && !activeFileNames.has(previousFileName)) {
|
|
532
|
+
await rm(join(outputDir, previousFileName), { force: true });
|
|
533
|
+
stateChanged = true;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
for (const [id, entry] of Object.entries(previousEntries)) {
|
|
537
|
+
if (activeIds.has(id)) continue;
|
|
538
|
+
if (!activeFileNames.has(entry.fileName)) await rm(join(outputDir, entry.fileName), { force: true });
|
|
539
|
+
stateChanged = true;
|
|
540
|
+
}
|
|
541
|
+
const serialisedState = `${JSON.stringify({
|
|
542
|
+
entries: nextEntries,
|
|
543
|
+
kind,
|
|
544
|
+
version: EXPORT_STATE_VERSION
|
|
545
|
+
}, null, 2)}\n`;
|
|
546
|
+
const statePath = exportStatePath(outputDir, kind);
|
|
547
|
+
const existingState = await fileExists(statePath) ? await readUtf8(statePath) : void 0;
|
|
548
|
+
if (stateChanged || existingState !== serialisedState) await writeTextFile(statePath, serialisedState);
|
|
549
|
+
return written;
|
|
550
|
+
}
|
|
551
|
+
//#endregion
|
|
433
552
|
//#region src/render.ts
|
|
434
553
|
function formatScalar(value) {
|
|
435
554
|
if (value == null) return "null";
|
|
@@ -645,17 +764,20 @@ function noteFileExtension(format) {
|
|
|
645
764
|
}
|
|
646
765
|
}
|
|
647
766
|
async function writeNotes(documents, outputDir, format = "markdown") {
|
|
648
|
-
await
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
767
|
+
return await syncManagedExports({
|
|
768
|
+
items: [...documents].sort((left, right) => compareStrings(left.title || left.id, right.title || right.id) || compareStrings(left.id, right.id)).map((document) => {
|
|
769
|
+
const note = buildNoteExport(document);
|
|
770
|
+
return {
|
|
771
|
+
content: renderNoteExport(note, format),
|
|
772
|
+
extension: noteFileExtension(format),
|
|
773
|
+
id: note.id,
|
|
774
|
+
preferredStem: documentFilename(document),
|
|
775
|
+
sourceUpdatedAt: latestDocumentTimestamp(document)
|
|
776
|
+
};
|
|
777
|
+
}),
|
|
778
|
+
kind: "notes",
|
|
779
|
+
outputDir
|
|
780
|
+
});
|
|
659
781
|
}
|
|
660
782
|
//#endregion
|
|
661
783
|
//#region src/commands/shared.ts
|
|
@@ -853,29 +975,31 @@ function transcriptFileExtension(format) {
|
|
|
853
975
|
}
|
|
854
976
|
}
|
|
855
977
|
async function writeTranscripts(cacheData, outputDir, format = "text") {
|
|
856
|
-
await
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
978
|
+
return await syncManagedExports({
|
|
979
|
+
items: Object.entries(cacheData.transcripts).filter(([, segments]) => segments.length > 0).sort(([leftId], [rightId]) => {
|
|
980
|
+
const leftDocument = cacheData.documents[leftId];
|
|
981
|
+
const rightDocument = cacheData.documents[rightId];
|
|
982
|
+
return compareStrings(leftDocument?.title || leftId, rightDocument?.title || rightId) || compareStrings(leftId, rightId);
|
|
983
|
+
}).flatMap(([documentId, segments]) => {
|
|
984
|
+
const document = cacheData.documents[documentId] ?? {
|
|
985
|
+
createdAt: "",
|
|
986
|
+
id: documentId,
|
|
987
|
+
title: documentId,
|
|
988
|
+
updatedAt: ""
|
|
989
|
+
};
|
|
990
|
+
const content = renderTranscriptExport(buildTranscriptExport(document, segments), format);
|
|
991
|
+
if (!content) return [];
|
|
992
|
+
return [{
|
|
993
|
+
content,
|
|
994
|
+
extension: transcriptFileExtension(format),
|
|
995
|
+
id: document.id,
|
|
996
|
+
preferredStem: transcriptFilename(document),
|
|
997
|
+
sourceUpdatedAt: document.updatedAt
|
|
998
|
+
}];
|
|
999
|
+
}),
|
|
1000
|
+
kind: "transcripts",
|
|
1001
|
+
outputDir
|
|
861
1002
|
});
|
|
862
|
-
const used = /* @__PURE__ */ new Map();
|
|
863
|
-
let written = 0;
|
|
864
|
-
for (const [documentId, segments] of entries) {
|
|
865
|
-
const document = cacheData.documents[documentId] ?? {
|
|
866
|
-
createdAt: "",
|
|
867
|
-
id: documentId,
|
|
868
|
-
title: documentId,
|
|
869
|
-
updatedAt: ""
|
|
870
|
-
};
|
|
871
|
-
const filePath = join(outputDir, `${makeUniqueFilename(transcriptFilename(document), used)}${transcriptFileExtension(format)}`);
|
|
872
|
-
if (!await shouldWriteFile(filePath, document.updatedAt)) continue;
|
|
873
|
-
const content = renderTranscriptExport(buildTranscriptExport(document, segments), format);
|
|
874
|
-
if (!content) continue;
|
|
875
|
-
await writeTextFile(filePath, content);
|
|
876
|
-
written += 1;
|
|
877
|
-
}
|
|
878
|
-
return written;
|
|
879
1003
|
}
|
|
880
1004
|
//#endregion
|
|
881
1005
|
//#region src/commands/transcripts.ts
|