@chriswiegman/hugo-tools 0.1.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/src/book.mjs ADDED
@@ -0,0 +1,725 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Goodreads → Hugo Books importer (NO covers)
4
+ * - Writes: content/books/<author-last-first>/<title-slug>.md
5
+ * - Front matter:
6
+ * title, author, rating (0-5), finished ([YYYY-MM-DD...]), links { amazon, openlibrary, goodreads }
7
+ * - Review (if any) becomes body content
8
+ * - finished uses Date Read; if blank, Date Added (only for Exclusive Shelf == "read")
9
+ *
10
+ * Idempotency / no duplicates:
11
+ * - Running this script multiple times will NOT create duplicate files.
12
+ * - Existing files are matched by Goodreads Book ID first, then ISBN, then title slug.
13
+ * - When a match is found at a different path (title changed in Goodreads), the file is
14
+ * renamed to the new slug and the title in front matter is updated automatically.
15
+ * - For an existing book file, finished dates are merged; other fields are left untouched
16
+ * unless a rename occurs (in which case the title is also updated).
17
+ *
18
+ * Usage:
19
+ * node scripts/book.mjs path/to/goodreads.csv
20
+ *
21
+ * Notes:
22
+ * - This script uses global fetch (Node 18+).
23
+ */
24
+
25
+ import fs from "node:fs/promises";
26
+ import { existsSync, readFileSync } from "node:fs";
27
+ import path from "node:path";
28
+
29
+ // -------------------- Config --------------------
30
+
31
+ const PROJECT_ROOT = process.cwd();
32
+
33
+ let _userConfig = {};
34
+ try {
35
+ const cfgPath = path.join(PROJECT_ROOT, ".hugo-tools.json");
36
+ if (existsSync(cfgPath)) {
37
+ _userConfig = JSON.parse(readFileSync(cfgPath, "utf8"));
38
+ }
39
+ } catch {} // eslint-disable-line no-empty
40
+
41
+ const OUTPUT_ROOT = path.join(PROJECT_ROOT, _userConfig.booksDir ?? "content/books");
42
+
43
+ // -------------------- Utilities --------------------
44
+
45
+ function usageAndExit() {
46
+ console.error("Usage: node scripts/book.mjs path/to/goodreads.csv");
47
+ process.exit(1);
48
+ }
49
+
50
+ function slugify(s) {
51
+ return (
52
+ (s || "")
53
+ .trim()
54
+ .toLowerCase()
55
+ .normalize("NFKD")
56
+ .replace(/[\u0300-\u036f]/g, "")
57
+ .replace(/[^a-z0-9\s-]/g, "")
58
+ .replace(/[\s_-]+/g, "-")
59
+ .replace(/^-+|-+$/g, "") || "unknown"
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Minimal CSV parser: commas, quotes, newlines inside quoted fields.
65
+ * Good enough for Goodreads exports.
66
+ */
67
+ function parseCSV(text) {
68
+ const rows = [];
69
+ let row = [];
70
+ let field = "";
71
+ let inQuotes = false;
72
+
73
+ for (let i = 0; i < text.length; i++) {
74
+ const c = text[i];
75
+ const next = text[i + 1];
76
+
77
+ if (inQuotes) {
78
+ if (c === '"' && next === '"') {
79
+ field += '"';
80
+ i++;
81
+ } else if (c === '"') {
82
+ inQuotes = false;
83
+ } else {
84
+ field += c;
85
+ }
86
+ } else {
87
+ if (c === '"') inQuotes = true;
88
+ else if (c === ",") {
89
+ row.push(field);
90
+ field = "";
91
+ } else if (c === "\r") {
92
+ // ignore
93
+ } else if (c === "\n") {
94
+ row.push(field);
95
+ rows.push(row);
96
+ row = [];
97
+ field = "";
98
+ } else {
99
+ field += c;
100
+ }
101
+ }
102
+ }
103
+
104
+ if (field.length > 0 || row.length > 0) {
105
+ row.push(field);
106
+ rows.push(row);
107
+ }
108
+ return rows;
109
+ }
110
+
111
+ /**
112
+ * Goodreads exports ISBN fields like:
113
+ * ="9781455586417" or =""
114
+ * Normalize to digits/X only.
115
+ */
116
+ function cleanIsbn(s) {
117
+ const raw = String(s || "").trim();
118
+ if (!raw) return "";
119
+
120
+ // strip leading '=' and surrounding quotes
121
+ const stripped = raw.replace(/^=+/, "").replace(/^"+|"+$/g, "");
122
+ return stripped.replace(/[^0-9Xx]/g, "").toUpperCase();
123
+ }
124
+
125
+ function toISODate(dateStr) {
126
+ const s = (dateStr || "").trim();
127
+ if (!s) return null;
128
+
129
+ if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
130
+
131
+ // YYYY/MM/DD (your export uses this)
132
+ let m = s.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/);
133
+ if (m) {
134
+ const year = Number(m[1]);
135
+ const month = Number(m[2]);
136
+ const day = Number(m[3]);
137
+ if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
138
+ return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
139
+ }
140
+ }
141
+
142
+ // M/D/YYYY
143
+ m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2}|\d{4})$/);
144
+ if (m) {
145
+ let month = Number(m[1]);
146
+ let day = Number(m[2]);
147
+ let year = Number(m[3]);
148
+ if (year < 100) year += 2000;
149
+ if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
150
+ return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
151
+ }
152
+ }
153
+
154
+ // last resort
155
+ const d = new Date(s);
156
+ if (!Number.isNaN(d.getTime())) {
157
+ const yyyy = d.getFullYear();
158
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
159
+ const dd = String(d.getDate()).padStart(2, "0");
160
+ return `${yyyy}-${mm}-${dd}`;
161
+ }
162
+
163
+ return null;
164
+ }
165
+
166
+ function clampRating(n) {
167
+ if (!Number.isFinite(n)) return 0;
168
+ if (n < 0) return 0;
169
+ if (n > 5) return 5;
170
+ return Math.trunc(n);
171
+ }
172
+
173
+ function escapeYAMLString(s) {
174
+ return String(s ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"');
175
+ }
176
+
177
+ function uniqSortedDates(dates) {
178
+ const set = new Set((dates || []).map((d) => String(d).trim()).filter(Boolean));
179
+ return Array.from(set).sort(); // YYYY-MM-DD sorts lexicographically
180
+ }
181
+
182
+ function keyFor(title, author) {
183
+ return `${title.trim().toLowerCase()}|${author.trim().toLowerCase()}`;
184
+ }
185
+
186
+ function goodreadsBookUrl(bookId) {
187
+ return bookId ? `https://www.goodreads.com/book/show/${bookId}` : "";
188
+ }
189
+
190
+ function openLibraryIsbnUrl(isbn) {
191
+ return isbn ? `https://openlibrary.org/search?isbn=${encodeURIComponent(isbn)}` : "";
192
+ }
193
+
194
+ function amazonSearchLink({ isbn13, isbn10, title, author }) {
195
+ // Search by ISBN13 (most specific) > ISBN10 > title+author — search pages never 404.
196
+ const query = isbn13 || isbn10 || `${title} ${author}`;
197
+ return `https://www.amazon.com/s?k=${encodeURIComponent(query)}`;
198
+ }
199
+
200
+ /**
201
+ * Use "Author l-f" (e.g. "Baldacci, David") → folder "baldacci-david"
202
+ */
203
+ function authorDirFromAuthorLF(authorLF) {
204
+ const a = (authorLF || "").trim();
205
+ if (!a) return "unknown";
206
+
207
+ if (a.includes(",")) {
208
+ const [last, first] = a.split(",", 2).map((x) => (x || "").trim());
209
+ const lastSlug = slugify(last);
210
+ const firstSlug = slugify(first);
211
+ if (lastSlug && firstSlug && firstSlug !== "unknown") return `${lastSlug}-${firstSlug}`;
212
+ return lastSlug || firstSlug || "unknown";
213
+ }
214
+
215
+ return slugify(a);
216
+ }
217
+
218
+ /**
219
+ * Extract existing finished dates from an existing Hugo markdown file.
220
+ *
221
+ * Supports:
222
+ * - finished:
223
+ * - "YYYY-MM-DD"
224
+ * - "YYYY-MM-DD"
225
+ * - finished: "YYYY-MM-DD" (legacy scalar; included as one date)
226
+ */
227
+ function parseExistingMarkdown(md) {
228
+ const m = md.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
229
+ if (!m) return { finished: [] };
230
+
231
+ const fm = m[1];
232
+ const lines = fm.split("\n");
233
+ const finished = [];
234
+
235
+ // list style
236
+ for (let i = 0; i < lines.length; i++) {
237
+ if (lines[i].trim() === "finished:") {
238
+ for (let j = i + 1; j < lines.length; j++) {
239
+ const line = lines[j];
240
+ if (/^[A-Za-z0-9_-]+:/.test(line)) break; // new top-level key
241
+ const mm = line.match(/^\s*-\s*"?(\d{4}-\d{2}-\d{2})"?\s*$/);
242
+ if (mm) finished.push(mm[1]);
243
+ }
244
+ break;
245
+ }
246
+ }
247
+
248
+ // scalar legacy style
249
+ for (const line of lines) {
250
+ const mm = line.match(/^finished:\s*"?(\d{4}-\d{2}-\d{2})"?\s*$/);
251
+ if (mm) {
252
+ finished.push(mm[1]);
253
+ break;
254
+ }
255
+ }
256
+
257
+ return { finished: uniqSortedDates(finished) };
258
+ }
259
+
260
+ async function fileExists(p) {
261
+ try {
262
+ await fs.access(p);
263
+ return true;
264
+ } catch {
265
+ return false;
266
+ }
267
+ }
268
+
269
+ // -------------------- Patch finished block in existing file --------------------
270
+
271
+ /**
272
+ * Replace only the `finished:` block in an existing markdown file.
273
+ * Leaves all other front matter (title, author, rating, links, body) untouched.
274
+ */
275
+ function replaceFinishedBlock(md, newFinished) {
276
+ const block = ["finished:", ...newFinished.map((d) => ` - "${d}"`)];
277
+ const lines = md.split("\n");
278
+
279
+ // Find the "finished:" line
280
+ const finishedIdx = lines.findIndex((l) => /^finished:\s*$/.test(l));
281
+
282
+ if (finishedIdx !== -1) {
283
+ // List form: "finished:\n - ...\n - ..."
284
+ // Find where the list ends (first line that doesn't start with whitespace, after the key)
285
+ let endIdx = finishedIdx + 1;
286
+ while (endIdx < lines.length && /^\s/.test(lines[endIdx])) {
287
+ endIdx++;
288
+ }
289
+ return [...lines.slice(0, finishedIdx), ...block, ...lines.slice(endIdx)].join("\n");
290
+ }
291
+
292
+ // Scalar form: 'finished: "YYYY-MM-DD"' or 'finished: YYYY-MM-DD'
293
+ const scalarIdx = lines.findIndex((l) => /^finished:\s+"?\d{4}-\d{2}-\d{2}"?\s*$/.test(l));
294
+ if (scalarIdx !== -1) {
295
+ return [...lines.slice(0, scalarIdx), ...block, ...lines.slice(scalarIdx + 1)].join("\n");
296
+ }
297
+
298
+ console.warn(" WARNING: could not locate 'finished:' block — file left unchanged");
299
+ return md;
300
+ }
301
+
302
+ /**
303
+ * Update the title field in front matter, leaving everything else untouched.
304
+ */
305
+ function updateTitleInFrontMatter(md, newTitle) {
306
+ return md.replace(/^title:.*$/m, `title: "${escapeYAMLString(newTitle)}"`);
307
+ }
308
+
309
+ // -------------------- Existing-file index --------------------
310
+
311
+ /**
312
+ * Extract the Goodreads book ID from a file's links.goodreads front-matter field.
313
+ * Matches: goodreads: "https://www.goodreads.com/book/show/12345678"
314
+ */
315
+ function extractGoodreadsId(md) {
316
+ const m = md.match(/goodreads:\s*"https:\/\/www\.goodreads\.com\/book\/show\/(\d+)"/);
317
+ return m ? m[1] : null;
318
+ }
319
+
320
+ /**
321
+ * Extract ISBNs stored in a file's links front matter fields.
322
+ * Checks the openlibrary URL (reliable) and the amazon URL (only when it looks like a bare ISBN).
323
+ */
324
+ function extractFileIsbns(md) {
325
+ const isbns = new Set();
326
+
327
+ // openlibrary: "https://openlibrary.org/search?isbn=9780062694430"
328
+ const ol = md.match(/openlibrary:\s*"https:\/\/openlibrary\.org\/search\?isbn=([0-9X]+)"/i);
329
+ if (ol) isbns.add(ol[1].toUpperCase());
330
+
331
+ // amazon: "https://www.amazon.com/s?k=9780062694430" — only when the query is a bare ISBN
332
+ const am = md.match(/amazon:\s*"https:\/\/www\.amazon\.com\/s\?k=([0-9X]{10,13})"/i);
333
+ if (am) isbns.add(am[1].toUpperCase());
334
+
335
+ return Array.from(isbns);
336
+ }
337
+
338
+ /**
339
+ * Scan all existing book files and build lookup maps by Goodreads Book ID and ISBN.
340
+ * These are used to match incoming CSV rows even when the title (and thus slug) has changed.
341
+ */
342
+ async function buildExistingIndex(outputRoot) {
343
+ const byGoodreadsId = new Map(); // bookId → absolute filePath
344
+ const byIsbn = new Map(); // isbn (normalized) → absolute filePath
345
+
346
+ let dirEntries;
347
+ try {
348
+ dirEntries = await fs.readdir(outputRoot, { withFileTypes: true });
349
+ } catch {
350
+ return { byGoodreadsId, byIsbn };
351
+ }
352
+
353
+ for (const entry of dirEntries) {
354
+ if (!entry.isDirectory()) continue;
355
+ const dirPath = path.join(outputRoot, entry.name);
356
+
357
+ let fileEntries;
358
+ try {
359
+ fileEntries = await fs.readdir(dirPath, { withFileTypes: true });
360
+ } catch {
361
+ continue;
362
+ }
363
+
364
+ for (const fileEntry of fileEntries) {
365
+ if (!fileEntry.isFile() || !fileEntry.name.endsWith(".md")) continue;
366
+ const filePath = path.join(dirPath, fileEntry.name);
367
+
368
+ let content;
369
+ try {
370
+ content = await fs.readFile(filePath, "utf8");
371
+ } catch {
372
+ continue;
373
+ }
374
+
375
+ const gid = extractGoodreadsId(content);
376
+ if (gid && !byGoodreadsId.has(gid)) {
377
+ byGoodreadsId.set(gid, filePath);
378
+ }
379
+
380
+ for (const isbn of extractFileIsbns(content)) {
381
+ if (!byIsbn.has(isbn)) byIsbn.set(isbn, filePath);
382
+ }
383
+ }
384
+ }
385
+
386
+ return { byGoodreadsId, byIsbn };
387
+ }
388
+
389
+ /**
390
+ * Find the existing file for a book, preferring Goodreads ID match, then ISBN, then slug.
391
+ * Returns the absolute path if found, or null.
392
+ */
393
+ async function findExistingFile(b, outPath, index) {
394
+ if (b.bookId && index.byGoodreadsId.has(b.bookId)) {
395
+ return index.byGoodreadsId.get(b.bookId);
396
+ }
397
+ if (b.isbn13 && index.byIsbn.has(b.isbn13)) {
398
+ return index.byIsbn.get(b.isbn13);
399
+ }
400
+ if (b.isbn10 && index.byIsbn.has(b.isbn10)) {
401
+ return index.byIsbn.get(b.isbn10);
402
+ }
403
+ if (await fileExists(outPath)) {
404
+ return outPath;
405
+ }
406
+ return null;
407
+ }
408
+
409
+ /**
410
+ * Remove a directory if it is empty (best-effort; ignores errors).
411
+ */
412
+ async function removeIfEmpty(dirPath) {
413
+ try {
414
+ const entries = await fs.readdir(dirPath);
415
+ if (entries.length === 0) await fs.rmdir(dirPath);
416
+ } catch {
417
+ // ignore
418
+ }
419
+ }
420
+
421
+ // -------------------- Writing front matter --------------------
422
+
423
+ function toFrontMatter({ title, author, rating, finished, links }) {
424
+ const lines = [];
425
+ lines.push("---");
426
+ lines.push(`title: "${escapeYAMLString(title)}"`);
427
+ lines.push(`author: "${escapeYAMLString(author)}"`);
428
+ lines.push(`rating: ${rating}`);
429
+ lines.push("finished:");
430
+ for (const d of finished) lines.push(` - "${escapeYAMLString(d)}"`);
431
+
432
+ lines.push("links:");
433
+ lines.push(` amazon: "${escapeYAMLString(links.amazon)}"`);
434
+ lines.push(` openlibrary: "${escapeYAMLString(links.openlibrary)}"`);
435
+ lines.push(` goodreads: "${escapeYAMLString(links.goodreads)}"`);
436
+ lines.push("---");
437
+ return lines.join("\n");
438
+ }
439
+
440
+ // -------------------- Main --------------------
441
+
442
+ const HUGO_CONFIGS = [
443
+ 'hugo.toml', 'hugo.yaml', 'hugo.yml', 'hugo.json',
444
+ 'config.toml', 'config.yaml', 'config.yml', 'config.json',
445
+ 'config',
446
+ ];
447
+
448
+ async function main() {
449
+ if (!HUGO_CONFIGS.some(name => existsSync(path.join(PROJECT_ROOT, name)))) {
450
+ process.stderr.write('Error: no Hugo config file found. Run this command from the root of your Hugo site.\n');
451
+ process.exit(1);
452
+ }
453
+
454
+ const csvPath = process.argv[2];
455
+ if (!csvPath) usageAndExit();
456
+
457
+ const csvText = await fs.readFile(csvPath, "utf8");
458
+ const rows = parseCSV(csvText);
459
+
460
+ if (rows.length < 2) {
461
+ console.error("CSV appears empty or missing header row.");
462
+ process.exit(1);
463
+ }
464
+
465
+ const header = rows[0].map((h) => (h ?? "").trim());
466
+ const idx = (name) => header.indexOf(name);
467
+
468
+ // Columns from your sample
469
+ const iBookId = idx("Book Id");
470
+ const iTitle = idx("Title");
471
+ const iAuthor = idx("Author");
472
+ const iAuthorLF = idx("Author l-f");
473
+ const iIsbn = idx("ISBN");
474
+ const iIsbn13 = idx("ISBN13");
475
+ const iRating = idx("My Rating");
476
+ const iDateRead = idx("Date Read");
477
+ const iDateAdded = idx("Date Added");
478
+ const iShelf = idx("Exclusive Shelf");
479
+ const iReview = idx("My Review");
480
+ const iReadCount = idx("Read Count");
481
+
482
+ const required = [
483
+ ["Book Id", iBookId],
484
+ ["Title", iTitle],
485
+ ["Author", iAuthor],
486
+ ["Author l-f", iAuthorLF],
487
+ ["ISBN", iIsbn],
488
+ ["ISBN13", iIsbn13],
489
+ ["My Rating", iRating],
490
+ ["Date Read", iDateRead],
491
+ ["Date Added", iDateAdded],
492
+ ["Exclusive Shelf", iShelf],
493
+ ["My Review", iReview],
494
+ ["Read Count", iReadCount],
495
+ ];
496
+ const missing = required.filter(([, i]) => i === -1).map(([n]) => n);
497
+ if (missing.length) {
498
+ console.error(`CSV is missing expected columns: ${missing.join(", ")}`);
499
+ process.exit(1);
500
+ }
501
+
502
+ // Aggregate by Title+Author, collecting finished dates from CSV rows
503
+ const books = new Map();
504
+
505
+ let seenRows = 0;
506
+ let importedRows = 0;
507
+
508
+ for (let r = 1; r < rows.length; r++) {
509
+ seenRows++;
510
+ const row = rows[r];
511
+
512
+ const bookId = String(row[iBookId] || "").trim();
513
+ const title = String(row[iTitle] || "").trim();
514
+ const author = String(row[iAuthor] || "").trim();
515
+ const authorLF = String(row[iAuthorLF] || "").trim();
516
+ if (!title || !author) continue;
517
+
518
+ // Only import read shelf so Date Added fallback doesn't imply "finished" for to-read/currently-reading
519
+ const shelf = String(row[iShelf] || "").trim().toLowerCase();
520
+ if (shelf !== "read") continue;
521
+
522
+ importedRows++;
523
+
524
+ const rating = clampRating(Number(String(row[iRating] || "").trim() || 0));
525
+ const review = String(row[iReview] || "").trim();
526
+ const readCount = Number(String(row[iReadCount] || "").trim() || 0);
527
+
528
+ const isbn10 = cleanIsbn(row[iIsbn]);
529
+ const isbn13 = cleanIsbn(row[iIsbn13]);
530
+
531
+ const dateReadISO = toISODate(String(row[iDateRead] || ""));
532
+ const dateAddedISO = toISODate(String(row[iDateAdded] || ""));
533
+ const chosen = dateReadISO || dateAddedISO || null;
534
+
535
+ const k = keyFor(title, author);
536
+ if (!books.has(k)) {
537
+ books.set(k, {
538
+ bookId,
539
+ title,
540
+ author,
541
+ authorLF,
542
+ rating,
543
+ isbn10,
544
+ isbn13,
545
+ readCount,
546
+ finished: [],
547
+ review: review || "",
548
+ });
549
+ }
550
+
551
+ const b = books.get(k);
552
+
553
+ // Keep "best" metadata across duplicate rows
554
+ if (rating > b.rating) b.rating = rating;
555
+ if (!b.review && review) b.review = review;
556
+ if (!b.isbn13 && isbn13) b.isbn13 = isbn13;
557
+ if (!b.isbn10 && isbn10) b.isbn10 = isbn10;
558
+ if (!b.bookId && bookId) b.bookId = bookId;
559
+ if (chosen) b.finished.push(chosen);
560
+ if (Number.isFinite(readCount) && readCount > (b.readCount || 0)) b.readCount = readCount;
561
+ }
562
+
563
+ const bookList = Array.from(books.values());
564
+
565
+ console.log(
566
+ `Parsed rows: ${seenRows}, importing read-shelf rows: ${importedRows}, unique books: ${bookList.length}`
567
+ );
568
+
569
+ // Build index of existing files for smart matching (by Goodreads ID and ISBN)
570
+ console.log("Indexing existing book files...");
571
+ const index = await buildExistingIndex(OUTPUT_ROOT);
572
+ console.log(
573
+ ` Indexed ${index.byGoodreadsId.size} Goodreads IDs, ${index.byIsbn.size} ISBNs`
574
+ );
575
+
576
+ // ---- Write markdown ----
577
+ // - New files: write everything from CSV data.
578
+ // - Existing files matched by Goodreads ID or ISBN: if the title/slug changed, rename
579
+ // the file and update the title in front matter; always merge new finished dates.
580
+ // - Existing files with no changes: skip.
581
+ let written = 0;
582
+ let createdNew = 0;
583
+ let mergedExisting = 0;
584
+ let renamedFiles = 0;
585
+ let skippedUnchanged = 0;
586
+
587
+ for (const b of bookList) {
588
+ const authorDir = authorDirFromAuthorLF(b.authorLF);
589
+ const filename = `${slugify(b.title)}.md`;
590
+ const outPath = path.join(OUTPUT_ROOT, authorDir, filename);
591
+
592
+ const existingPath = await findExistingFile(b, outPath, index);
593
+
594
+ if (existingPath) {
595
+ const titleChanged = existingPath !== outPath;
596
+
597
+ // Read the existing file
598
+ const existingContent = await fs.readFile(existingPath, "utf8");
599
+ const { finished: existingFinished } = parseExistingMarkdown(existingContent);
600
+ const finishedMerged = uniqSortedDates([...existingFinished, ...b.finished]);
601
+
602
+ const hasNewDates =
603
+ finishedMerged.length !== existingFinished.length ||
604
+ finishedMerged.some((d, i) => d !== existingFinished[i]);
605
+
606
+ if (!titleChanged && !hasNewDates) {
607
+ skippedUnchanged++;
608
+ continue;
609
+ }
610
+
611
+ // Apply patches
612
+ let patched = existingContent;
613
+ if (hasNewDates) {
614
+ patched = replaceFinishedBlock(patched, finishedMerged);
615
+ }
616
+ if (titleChanged) {
617
+ patched = updateTitleInFrontMatter(patched, b.title);
618
+ }
619
+
620
+ if (titleChanged) {
621
+ // Write to new path, remove old path, clean up empty author dir
622
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
623
+ await fs.writeFile(outPath, patched, "utf8");
624
+ await fs.unlink(existingPath);
625
+ await removeIfEmpty(path.dirname(existingPath));
626
+
627
+ const relOld = path.relative(OUTPUT_ROOT, existingPath);
628
+ const relNew = path.relative(OUTPUT_ROOT, outPath);
629
+ console.log(` RENAMED: ${relOld}`);
630
+ console.log(` --> ${relNew}`);
631
+ renamedFiles++;
632
+ } else {
633
+ await fs.writeFile(existingPath, patched, "utf8");
634
+ }
635
+
636
+ written++;
637
+ mergedExisting++;
638
+ } else {
639
+ // New file — write full content from CSV data
640
+ createdNew++;
641
+
642
+ const finishedMerged = uniqSortedDates(b.finished);
643
+ const isbnBest = b.isbn13 || b.isbn10;
644
+
645
+ const links = {
646
+ amazon: amazonSearchLink({ isbn13: b.isbn13, isbn10: b.isbn10, title: b.title, author: b.author }),
647
+ openlibrary: openLibraryIsbnUrl(isbnBest),
648
+ goodreads: goodreadsBookUrl(b.bookId),
649
+ };
650
+
651
+ const fm = toFrontMatter({
652
+ title: b.title,
653
+ author: b.author,
654
+ rating: b.rating,
655
+ finished: finishedMerged,
656
+ links,
657
+ });
658
+
659
+ const bodyToWrite = b.review ? `\n\n${b.review}\n` : "\n";
660
+
661
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
662
+ await fs.writeFile(outPath, fm + bodyToWrite, "utf8");
663
+ written++;
664
+ }
665
+ }
666
+
667
+ if (written > 0) {
668
+ const libraryPath = path.join(PROJECT_ROOT, "content", "library.md");
669
+ try {
670
+ const libraryContent = await fs.readFile(libraryPath, "utf8");
671
+ const now = new Date().toISOString().replace(/\.\d{3}Z$/, "+00:00");
672
+ const updated = libraryContent.replace(
673
+ /^date:.*$/m,
674
+ `date: ${now}`
675
+ );
676
+ await fs.writeFile(libraryPath, updated, "utf8");
677
+ console.log(` Updated library.md date to ${now}`);
678
+ } catch (err) {
679
+ console.warn(` WARNING: could not update library.md: ${err.message}`);
680
+ }
681
+ }
682
+
683
+ console.log(
684
+ `Done.\n` +
685
+ ` Books written: ${written}\n` +
686
+ ` New files created: ${createdNew}\n` +
687
+ ` Existing files updated: ${mergedExisting}\n` +
688
+ ` Files renamed: ${renamedFiles}\n` +
689
+ ` Existing files skipped: ${skippedUnchanged}\n` +
690
+ ` Content output: ${OUTPUT_ROOT}\n`
691
+ );
692
+ }
693
+
694
+ // Only run main() when executed directly as a script (not imported by tests)
695
+ const isMain =
696
+ path.resolve(new URL(import.meta.url).pathname) === path.resolve(process.argv[1] ?? "");
697
+ if (isMain) {
698
+ main().catch((err) => {
699
+ console.error(err);
700
+ process.exit(1);
701
+ });
702
+ }
703
+
704
+ export {
705
+ slugify,
706
+ cleanIsbn,
707
+ toISODate,
708
+ clampRating,
709
+ escapeYAMLString,
710
+ uniqSortedDates,
711
+ authorDirFromAuthorLF,
712
+ keyFor,
713
+ goodreadsBookUrl,
714
+ openLibraryIsbnUrl,
715
+ amazonSearchLink,
716
+ parseCSV,
717
+ parseExistingMarkdown,
718
+ replaceFinishedBlock,
719
+ updateTitleInFrontMatter,
720
+ extractGoodreadsId,
721
+ extractFileIsbns,
722
+ toFrontMatter,
723
+ buildExistingIndex,
724
+ findExistingFile,
725
+ };