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.
Files changed (3) hide show
  1. package/README.md +14 -2
  2. package/dist/cli.js +168 -44
  3. 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 are incremental. They only rewrite an export file when the source document appears newer than the file already on disk.
122
+ Both commands keep a small hidden state file in the output directory to track:
123
123
 
124
- That means repeated runs are cheap, and you can safely point the CLI at the same output directory over time.
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 ensureDirectory(outputDir);
649
- const sorted = [...documents].sort((left, right) => compareStrings(left.title || left.id, right.title || right.id) || compareStrings(left.id, right.id));
650
- const used = /* @__PURE__ */ new Map();
651
- let written = 0;
652
- for (const document of sorted) {
653
- const filePath = join(outputDir, `${makeUniqueFilename(documentFilename(document), used)}${noteFileExtension(format)}`);
654
- if (!await shouldWriteFile(filePath, latestDocumentTimestamp(document))) continue;
655
- await writeTextFile(filePath, renderNoteExport(buildNoteExport(document), format));
656
- written += 1;
657
- }
658
- return written;
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 ensureDirectory(outputDir);
857
- const entries = Object.entries(cacheData.transcripts).filter(([, segments]) => segments.length > 0).sort(([leftId], [rightId]) => {
858
- const leftDocument = cacheData.documents[leftId];
859
- const rightDocument = cacheData.documents[rightId];
860
- return compareStrings(leftDocument?.title || leftId, rightDocument?.title || rightId) || compareStrings(leftId, rightId);
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "CLI toolkit for exporting and working with Granola notes and transcripts",
5
5
  "keywords": [
6
6
  "cli",