@chriswiegman/hugo-tools 0.2.0 → 0.2.2

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