@chriswiegman/hugo-tools 0.2.2 → 0.3.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 CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Goodreads → Hugo Books importer (NO covers)
3
+ * Goodreads → Hugo Books importer
4
4
  * - Writes: content/books/<author-last-first>/<title-slug>.md
5
5
  * - Front matter:
6
- * title, author, rating (0-5), finished ([YYYY-MM-DD...]), links { amazon, openlibrary, goodreads }
6
+ * title, author, rating (0-5), finished ([YYYY-MM-DD...]), links { amazon, openlibrary, goodreads },
7
+ * reference { isbn, asin }, cover
7
8
  * - Review (if any) becomes body content
8
9
  * - finished uses Date Read; if blank, Date Added (only for Exclusive Shelf == "read")
9
10
  *
@@ -17,9 +18,39 @@
17
18
  * (> 0). A Goodreads rating of 0 (unrated) never overwrites a rating already in the file,
18
19
  * since you may have rated the book only in the markdown. Other fields are left untouched
19
20
  * unless a rename occurs (in which case the title is also updated).
21
+ * - reference.isbn/asin are backfilled onto existing files that are missing them, but a
22
+ * value already present in the file (e.g. entered by hand) is never overwritten.
23
+ *
24
+ * Re-reads:
25
+ * - Goodreads' CSV export only ever contains the *most recent* "Date Read" per book, even
26
+ * when "Read Count" shows it was finished more than once. So: if you re-read a book,
27
+ * the next export will carry the new completion date, and — since it differs from what's
28
+ * already on file — it's automatically merged into `finished` as an additional date
29
+ * (nothing is overwritten; dates only ever get added, never replaced or removed).
30
+ * - What the export can't give you is *earlier* read dates for a book already read more
31
+ * than once before this tool started tracking it. When "Read Count" is higher than the
32
+ * number of dates on file, the script prints a `NOTE:` line and leaves the file
33
+ * untouched — add the missing date(s) to `finished` by hand. The note stops appearing
34
+ * once the counts line up.
35
+ *
36
+ * ASIN lookup:
37
+ * - Goodreads exports don't include an ASIN, so it's looked up per ISBN from Open Library,
38
+ * then Google Books, and left blank if neither has one. Configure an Amazon Product
39
+ * Advertising API key in .hugo-tools.json (amazonPaApi: { accessKey, secretKey, partnerTag })
40
+ * to use it as a final fallback.
41
+ *
42
+ * Cover art (opt-in):
43
+ * - Off by default — pass --covers to fetch and save cover art.
44
+ * - Looked up per ISBN from Open Library's cover API, then Google Books, and left blank if
45
+ * neither has one.
46
+ * - Saved to <coversDir>/<author-slug>/<title-slug>.<ext> (coversDir defaults to
47
+ * assets/images/books, configurable via .hugo-tools.json) and referenced from the
48
+ * `cover` front matter field as a Hugo URL path.
49
+ * - Only backfills a blank `cover` field on existing files — a cover already set
50
+ * (e.g. entered by hand) is never overwritten.
20
51
  *
21
52
  * Usage:
22
- * node scripts/book.mjs path/to/goodreads.csv
53
+ * node scripts/book.mjs path/to/goodreads.csv [--covers]
23
54
  *
24
55
  * Notes:
25
56
  * - This script uses global fetch (Node 18+).
@@ -28,6 +59,7 @@
28
59
  import fs from 'node:fs/promises';
29
60
  import { existsSync, readFileSync } from 'node:fs';
30
61
  import path from 'node:path';
62
+ import { createHash, createHmac } from 'node:crypto';
31
63
 
32
64
  // -------------------- Config --------------------
33
65
 
@@ -45,10 +77,17 @@ try {
45
77
 
46
78
  const OUTPUT_ROOT = path.join(PROJECT_ROOT, _userConfig.booksDir ?? 'content/books');
47
79
 
80
+ // Optional Amazon Product Advertising API credentials for ASIN lookup fallback.
81
+ // Set via .hugo-tools.json: { "amazonPaApi": { "accessKey", "secretKey", "partnerTag" } }
82
+ const AMAZON_PA_CONFIG = _userConfig.amazonPaApi ?? null;
83
+
84
+ // Project-relative directory cover art is saved under. Set via .hugo-tools.json: { "coversDir" }
85
+ const COVERS_DIR = _userConfig.coversDir ?? 'assets/images/books';
86
+
48
87
  // -------------------- Utilities --------------------
49
88
 
50
89
  function usageAndExit() {
51
- console.error('Usage: node scripts/book.mjs path/to/goodreads.csv');
90
+ console.error('Usage: node scripts/book.mjs path/to/goodreads.csv [--covers]');
52
91
  process.exit(1);
53
92
  }
54
93
 
@@ -203,6 +242,24 @@ function keyFor(title, author) {
203
242
  return `${title.trim().toLowerCase()}|${author.trim().toLowerCase()}`;
204
243
  }
205
244
 
245
+ /**
246
+ * Strip a UTF-8 BOM if present. Some export tools/editors add one, and it would
247
+ * otherwise corrupt the first header name (e.g. "\uFEFFBook Id") and fail column detection.
248
+ */
249
+ function stripBOM(text) {
250
+ return String(text ?? '').replace(/^\uFEFF/, '');
251
+ }
252
+
253
+ /**
254
+ * Goodreads' CSV export only ever includes the most recent "Date Read" per book, even
255
+ * when "Read Count" shows it was finished more than once — there's no way to recover
256
+ * earlier read dates from the export alone. Flag books where fewer dates are on record
257
+ * than the Goodreads read count, as a nudge to add the missing ones to `finished` by hand.
258
+ */
259
+ function needsReReadDates(readCount, finishedCount) {
260
+ return Number.isFinite(readCount) && readCount > 1 && finishedCount < readCount;
261
+ }
262
+
206
263
  function goodreadsBookUrl(bookId) {
207
264
  return bookId ? `https://www.goodreads.com/book/show/${bookId}` : '';
208
265
  }
@@ -218,6 +275,292 @@ function amazonSearchLink({ isbn13, isbn10, title, author }) {
218
275
  return `https://www.amazon.com/s?k=${encodeURIComponent(query)}`;
219
276
  }
220
277
 
278
+ // -------------------- ASIN lookup --------------------
279
+
280
+ function sleep(ms) {
281
+ return new Promise((resolve) => setTimeout(resolve, ms));
282
+ }
283
+
284
+ /**
285
+ * Open Library sometimes exposes an "amazon" identifier on an edition, which
286
+ * for Kindle editions is the ASIN.
287
+ */
288
+ async function lookupAsinFromOpenLibrary(isbn, fetchImpl = fetch) {
289
+ if (!isbn) return '';
290
+
291
+ try {
292
+ const url = `https://openlibrary.org/api/books?bibkeys=ISBN:${encodeURIComponent(isbn)}&jscmd=data&format=json`;
293
+ const res = await fetchImpl(url);
294
+
295
+ if (!res.ok) return '';
296
+
297
+ const data = await res.json();
298
+ const amazon = data?.[`ISBN:${isbn}`]?.identifiers?.amazon;
299
+
300
+ return Array.isArray(amazon) && amazon[0] ? String(amazon[0]).trim().toUpperCase() : '';
301
+ } catch {
302
+ return '';
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Google Books occasionally lists a non-ISBN "OTHER" industry identifier that
308
+ * matches the shape of an ASIN. Best-effort only — most volumes won't have one.
309
+ */
310
+ async function lookupAsinFromGoogleBooks(isbn, fetchImpl = fetch) {
311
+ if (!isbn) return '';
312
+
313
+ try {
314
+ const url = `https://www.googleapis.com/books/v1/volumes?q=isbn:${encodeURIComponent(isbn)}`;
315
+ const res = await fetchImpl(url);
316
+
317
+ if (!res.ok) return '';
318
+
319
+ const data = await res.json();
320
+ const identifiers = data?.items?.[0]?.volumeInfo?.industryIdentifiers ?? [];
321
+
322
+ for (const id of identifiers) {
323
+ if (id?.type !== 'OTHER') continue;
324
+
325
+ const bare = String(id.identifier || '').trim().toUpperCase().replace(/^[A-Z]+:/, '');
326
+
327
+ if (/^[A-Z0-9]{10}$/.test(bare) && bare !== isbn.toUpperCase()) return bare;
328
+ }
329
+
330
+ return '';
331
+ } catch {
332
+ return '';
333
+ }
334
+ }
335
+
336
+ function hmac(key, data) {
337
+ return createHmac('sha256', key).update(data, 'utf8').digest();
338
+ }
339
+
340
+ function sha256Hex(data) {
341
+ return createHash('sha256').update(data, 'utf8').digest('hex');
342
+ }
343
+
344
+ function amazonPaApiSignature({ secretKey, region, service, dateStamp, stringToSign }) {
345
+ const kDate = hmac(`AWS4${secretKey}`, dateStamp);
346
+ const kRegion = hmac(kDate, region);
347
+ const kService = hmac(kRegion, service);
348
+ const kSigning = hmac(kService, 'aws4_request');
349
+
350
+ return hmac(kSigning, stringToSign).toString('hex');
351
+ }
352
+
353
+ /**
354
+ * Official Amazon Product Advertising API v5 GetItems call, signed with SigV4.
355
+ * Only attempted when accessKey/secretKey/partnerTag are configured.
356
+ */
357
+ async function lookupAsinFromAmazonPa(isbn, config, fetchImpl = fetch) {
358
+ if (!isbn || !config?.accessKey || !config?.secretKey || !config?.partnerTag) return '';
359
+
360
+ const host = config.host || 'webservices.amazon.com';
361
+ const region = config.region || 'us-east-1';
362
+ const marketplace = config.marketplace || 'www.amazon.com';
363
+ const service = 'ProductAdvertisingAPI';
364
+ const target = 'com.amazon.paapi5.v1.ProductAdvertisingAPIv1.GetItems';
365
+ const uri = '/paapi5/getitems';
366
+
367
+ const payload = JSON.stringify({
368
+ ItemIds: [isbn],
369
+ ItemIdType: 'ISBN',
370
+ Resources: ['ItemInfo.Title'],
371
+ PartnerTag: config.partnerTag,
372
+ PartnerType: 'Associates',
373
+ Marketplace: marketplace,
374
+ });
375
+
376
+ const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
377
+ const dateStamp = amzDate.slice(0, 8);
378
+ const canonicalHeaders =
379
+ `content-encoding:amz-1.0\ncontent-type:application/json; charset=utf-8\nhost:${host}\n` +
380
+ `x-amz-date:${amzDate}\nx-amz-target:${target}\n`;
381
+ const signedHeaders = 'content-encoding;content-type;host;x-amz-date;x-amz-target';
382
+ const canonicalRequest = `POST\n${uri}\n\n${canonicalHeaders}\n${signedHeaders}\n${sha256Hex(payload)}`;
383
+ const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
384
+ const stringToSign =
385
+ `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${sha256Hex(canonicalRequest)}`;
386
+ const signature = amazonPaApiSignature({ secretKey: config.secretKey, region, service, dateStamp, stringToSign });
387
+ const authorization =
388
+ `AWS4-HMAC-SHA256 Credential=${config.accessKey}/${credentialScope}, ` +
389
+ `SignedHeaders=${signedHeaders}, Signature=${signature}`;
390
+
391
+ try {
392
+ const res = await fetchImpl(`https://${host}${uri}`, {
393
+ method: 'POST',
394
+ headers: {
395
+ 'content-encoding': 'amz-1.0',
396
+ 'content-type': 'application/json; charset=utf-8',
397
+ host,
398
+ 'x-amz-date': amzDate,
399
+ 'x-amz-target': target,
400
+ authorization,
401
+ },
402
+ body: payload,
403
+ });
404
+
405
+ if (!res.ok) return '';
406
+
407
+ const data = await res.json();
408
+ const asin = data?.ItemsResult?.Items?.[0]?.ASIN;
409
+
410
+ return asin ? String(asin).trim().toUpperCase() : '';
411
+ } catch {
412
+ return '';
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Look up an ASIN for an ISBN, trying free sources first (Open Library, then
418
+ * Google Books) and falling back to the Amazon Product Advertising API only
419
+ * when it's configured. Returns '' when no source has a match.
420
+ */
421
+ async function lookupAsin(isbn, amazonPaConfig, cache, fetchImpl = fetch) {
422
+ if (!isbn) return '';
423
+
424
+ if (cache?.has(isbn)) return cache.get(isbn);
425
+
426
+ let asin = await lookupAsinFromOpenLibrary(isbn, fetchImpl);
427
+
428
+ if (!asin) {
429
+ await sleep(150);
430
+ asin = await lookupAsinFromGoogleBooks(isbn, fetchImpl);
431
+ }
432
+
433
+ if (!asin && amazonPaConfig) {
434
+ await sleep(150);
435
+ asin = await lookupAsinFromAmazonPa(isbn, amazonPaConfig, fetchImpl);
436
+ }
437
+
438
+ if (cache) cache.set(isbn, asin);
439
+
440
+ return asin;
441
+ }
442
+
443
+ // -------------------- Cover art (opt-in) --------------------
444
+
445
+ const IMAGE_EXT_BY_CONTENT_TYPE = {
446
+ 'image/jpeg': '.jpg',
447
+ 'image/jpg': '.jpg',
448
+ 'image/png': '.png',
449
+ 'image/webp': '.webp',
450
+ 'image/gif': '.gif',
451
+ };
452
+
453
+ function extFromContentType(contentType) {
454
+ const bare = String(contentType || '').split(';')[0].trim().toLowerCase();
455
+
456
+ return IMAGE_EXT_BY_CONTENT_TYPE[bare] || '.jpg';
457
+ }
458
+
459
+ /**
460
+ * Convert a project-relative asset path (e.g. "assets/images/books/king-stephen/it.jpg")
461
+ * into the Hugo-served URL path, mirroring pick-image's convention of stripping the
462
+ * `assets/` prefix.
463
+ */
464
+ function coverUrlFromAssetPath(assetPath) {
465
+ const rel = assetPath.split(path.sep).join('/').replace(/^assets\//, '');
466
+
467
+ return `/${rel}`;
468
+ }
469
+
470
+ /**
471
+ * Open Library's cover API serves the image directly at a predictable URL keyed by
472
+ * ISBN. `default=false` makes it 404 instead of returning a placeholder when there's
473
+ * no cover, so a non-OK response reliably means "no cover here."
474
+ */
475
+ async function fetchOpenLibraryCover(isbn, fetchImpl = fetch) {
476
+ if (!isbn) return null;
477
+
478
+ try {
479
+ const url = `https://covers.openlibrary.org/b/isbn/${encodeURIComponent(isbn)}-L.jpg?default=false`;
480
+ const res = await fetchImpl(url);
481
+
482
+ if (!res.ok) return null;
483
+
484
+ const contentType = res.headers?.get?.('content-type') || 'image/jpeg';
485
+
486
+ if (!contentType.startsWith('image/')) return null;
487
+
488
+ const buffer = Buffer.from(await res.arrayBuffer());
489
+
490
+ if (buffer.length === 0) return null;
491
+
492
+ return { buffer, contentType };
493
+ } catch {
494
+ return null;
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Fallback cover source: Google Books' volume thumbnail, used when Open Library has none.
500
+ */
501
+ async function fetchGoogleBooksCover(isbn, fetchImpl = fetch) {
502
+ if (!isbn) return null;
503
+
504
+ try {
505
+ const url = `https://www.googleapis.com/books/v1/volumes?q=isbn:${encodeURIComponent(isbn)}`;
506
+ const res = await fetchImpl(url);
507
+
508
+ if (!res.ok) return null;
509
+
510
+ const data = await res.json();
511
+ const imageLinks = data?.items?.[0]?.volumeInfo?.imageLinks;
512
+ const thumbnail = imageLinks?.thumbnail || imageLinks?.smallThumbnail;
513
+
514
+ if (!thumbnail) return null;
515
+
516
+ const imgRes = await fetchImpl(thumbnail.replace(/^http:/, 'https:'));
517
+
518
+ if (!imgRes.ok) return null;
519
+
520
+ const contentType = imgRes.headers?.get?.('content-type') || 'image/jpeg';
521
+
522
+ if (!contentType.startsWith('image/')) return null;
523
+
524
+ const buffer = Buffer.from(await imgRes.arrayBuffer());
525
+
526
+ if (buffer.length === 0) return null;
527
+
528
+ return { buffer, contentType };
529
+ } catch {
530
+ return null;
531
+ }
532
+ }
533
+
534
+ /**
535
+ * Look up cover art for an ISBN, trying Open Library first and Google Books second.
536
+ * Returns null when neither source has one.
537
+ */
538
+ async function fetchBookCover(isbn, fetchImpl = fetch) {
539
+ if (!isbn) return null;
540
+
541
+ const openLibrary = await fetchOpenLibraryCover(isbn, fetchImpl);
542
+
543
+ if (openLibrary) return openLibrary;
544
+
545
+ await sleep(150);
546
+ return fetchGoogleBooksCover(isbn, fetchImpl);
547
+ }
548
+
549
+ /**
550
+ * Write cover image bytes to `<coversDir>/<authorDir>/<slug><ext>` (relative to
551
+ * `projectRoot`) and return the Hugo URL path to store in front matter.
552
+ */
553
+ async function saveCover(coversDir, authorDir, slug, cover, projectRoot = PROJECT_ROOT) {
554
+ const ext = extFromContentType(cover.contentType);
555
+ const relPath = path.join(coversDir, authorDir, `${slug}${ext}`);
556
+ const absPath = path.join(projectRoot, relPath);
557
+
558
+ await fs.mkdir(path.dirname(absPath), { recursive: true });
559
+ await fs.writeFile(absPath, cover.buffer);
560
+
561
+ return coverUrlFromAssetPath(relPath);
562
+ }
563
+
221
564
  /**
222
565
  * Use "Author l-f" (e.g. "Baldacci, David") → folder "baldacci-david"
223
566
  */
@@ -357,6 +700,109 @@ function updateRatingInFrontMatter(md, newRating) {
357
700
  return md.replace(/^rating:.*$/m, `rating: ${newRating}`);
358
701
  }
359
702
 
703
+ /**
704
+ * Extract the existing `reference: { isbn, asin }` values from a file's front matter.
705
+ * Returns { isbn: '', asin: '' } when the block (or either field) is absent.
706
+ */
707
+ function parseExistingReference(md) {
708
+ const m = md.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
709
+
710
+ if (!m) return { isbn: '', asin: '' };
711
+
712
+ const lines = m[1].split('\n');
713
+ let isbn = '';
714
+ let asin = '';
715
+
716
+ for (let i = 0; i < lines.length; i++) {
717
+ if (lines[i].trim() !== 'reference:') continue;
718
+
719
+ for (let j = i + 1; j < lines.length; j++) {
720
+ const line = lines[j];
721
+
722
+ if (/^[A-Za-z0-9_-]+:/.test(line)) break; // new top-level key
723
+
724
+ const isbnMatch = line.match(/^\s*isbn:\s*"?([^"\n]*)"?\s*$/);
725
+
726
+ if (isbnMatch) isbn = isbnMatch[1].trim();
727
+
728
+ const asinMatch = line.match(/^\s*asin:\s*"?([^"\n]*)"?\s*$/);
729
+
730
+ if (asinMatch) asin = asinMatch[1].trim();
731
+ }
732
+ break;
733
+ }
734
+
735
+ return { isbn, asin };
736
+ }
737
+
738
+ /**
739
+ * Insert or replace the `reference:` block in front matter, leaving everything
740
+ * else untouched. Inserts a new block just before the closing `---` when none exists.
741
+ */
742
+ function upsertReferenceInFrontMatter(md, { isbn, asin }) {
743
+ const block = [
744
+ 'reference:',
745
+ ` isbn: "${escapeYAMLString(isbn)}"`,
746
+ ` asin: "${escapeYAMLString(asin)}"`,
747
+ ];
748
+ const lines = md.split('\n');
749
+ const refIdx = lines.findIndex((l) => /^reference:\s*$/.test(l));
750
+
751
+ if (refIdx !== -1) {
752
+ let endIdx = refIdx + 1;
753
+
754
+ while (endIdx < lines.length && /^\s/.test(lines[endIdx])) {
755
+ endIdx++;
756
+ }
757
+ return [...lines.slice(0, refIdx), ...block, ...lines.slice(endIdx)].join('\n');
758
+ }
759
+
760
+ const closingIdx = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
761
+
762
+ if (closingIdx === -1) {
763
+ console.warn(' WARNING: could not locate front matter to insert \'reference:\' block — file left unchanged');
764
+ return md;
765
+ }
766
+
767
+ return [...lines.slice(0, closingIdx), ...block, ...lines.slice(closingIdx)].join('\n');
768
+ }
769
+
770
+ /**
771
+ * Extract the existing scalar `cover:` value from a file's front matter.
772
+ * Returns '' when the field is absent.
773
+ */
774
+ function parseExistingCover(md) {
775
+ const m = md.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
776
+
777
+ if (!m) return '';
778
+
779
+ const mm = m[1].match(/^cover:\s*"?([^"\n]*)"?\s*$/m);
780
+
781
+ return mm ? mm[1].trim() : '';
782
+ }
783
+
784
+ /**
785
+ * Insert or replace the scalar `cover:` field in front matter, leaving everything
786
+ * else untouched. Inserts a new line just before the closing `---` when absent.
787
+ */
788
+ function upsertCoverInFrontMatter(md, coverPath) {
789
+ const line = `cover: "${escapeYAMLString(coverPath)}"`;
790
+
791
+ if (/^cover:.*$/m.test(md)) {
792
+ return md.replace(/^cover:.*$/m, line);
793
+ }
794
+
795
+ const lines = md.split('\n');
796
+ const closingIdx = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
797
+
798
+ if (closingIdx === -1) {
799
+ console.warn(' WARNING: could not locate front matter to insert \'cover:\' field — file left unchanged');
800
+ return md;
801
+ }
802
+
803
+ return [...lines.slice(0, closingIdx), line, ...lines.slice(closingIdx)].join('\n');
804
+ }
805
+
360
806
  // -------------------- Existing-file index --------------------
361
807
 
362
808
  /**
@@ -485,7 +931,7 @@ async function removeIfEmpty(dirPath) {
485
931
 
486
932
  // -------------------- Writing front matter --------------------
487
933
 
488
- function toFrontMatter({ title, author, rating, finished, links }) {
934
+ function toFrontMatter({ title, author, rating, finished, links, reference, cover }) {
489
935
  const lines = [];
490
936
 
491
937
  lines.push('---');
@@ -499,6 +945,13 @@ function toFrontMatter({ title, author, rating, finished, links }) {
499
945
  lines.push(` amazon: "${escapeYAMLString(links.amazon)}"`);
500
946
  lines.push(` openlibrary: "${escapeYAMLString(links.openlibrary)}"`);
501
947
  lines.push(` goodreads: "${escapeYAMLString(links.goodreads)}"`);
948
+
949
+ lines.push('reference:');
950
+ lines.push(` isbn: "${escapeYAMLString(reference?.isbn ?? '')}"`);
951
+ lines.push(` asin: "${escapeYAMLString(reference?.asin ?? '')}"`);
952
+
953
+ lines.push(`cover: "${escapeYAMLString(cover ?? '')}"`);
954
+
502
955
  lines.push('---');
503
956
  return lines.join('\n');
504
957
  }
@@ -517,11 +970,13 @@ async function main() {
517
970
  process.exit(1);
518
971
  }
519
972
 
520
- const csvPath = process.argv[2];
973
+ const argv = process.argv.slice(2);
974
+ const withCovers = argv.includes('--covers');
975
+ const csvPath = argv.find((a) => a && !a.startsWith('--'));
521
976
 
522
977
  if (!csvPath) usageAndExit();
523
978
 
524
- const csvText = await fs.readFile(csvPath, 'utf8');
979
+ const csvText = stripBOM(await fs.readFile(csvPath, 'utf8'));
525
980
  const rows = parseCSV(csvText);
526
981
 
527
982
  if (rows.length < 2) {
@@ -662,11 +1117,17 @@ async function main() {
662
1117
  let renamedFiles = 0;
663
1118
  let skippedUnchanged = 0;
664
1119
  let ratingsUpdated = 0;
1120
+ let referencesUpdated = 0;
1121
+ let coversFetched = 0;
1122
+ let reReadsFlagged = 0;
1123
+
1124
+ const asinCache = new Map();
665
1125
 
666
1126
  for (const b of bookList) {
667
1127
  const authorDir = authorDirFromAuthorLF(b.authorLF);
668
1128
  const filename = `${slugify(b.title)}.md`;
669
1129
  const outPath = path.join(OUTPUT_ROOT, authorDir, filename);
1130
+ const isbnBest = b.isbn13 || b.isbn10;
670
1131
 
671
1132
  const existingPath = await findExistingFile(b, outPath, index);
672
1133
 
@@ -687,7 +1148,44 @@ async function main() {
687
1148
  // entered by hand there instead, so leave it alone rather than clobbering it with 0.
688
1149
  const hasRatingChange = b.rating > 0 && existingRating !== null && b.rating !== existingRating;
689
1150
 
690
- if (!titleChanged && !hasNewDates && !hasRatingChange) {
1151
+ // Never clobber an isbn/asin already present in the file — only fill blanks.
1152
+ const { isbn: existingIsbn, asin: existingAsin } = parseExistingReference(existingContent);
1153
+ const finalIsbn = existingIsbn || isbnBest || '';
1154
+ let finalAsin = existingAsin;
1155
+
1156
+ if (!finalAsin && finalIsbn) {
1157
+ finalAsin = await lookupAsin(finalIsbn, AMAZON_PA_CONFIG, asinCache);
1158
+ }
1159
+
1160
+ const hasReferenceChange = finalIsbn !== existingIsbn || finalAsin !== existingAsin;
1161
+
1162
+ // Only fetch/backfill cover art when explicitly requested, and only into a blank field.
1163
+ const existingCover = parseExistingCover(existingContent);
1164
+ let finalCover = existingCover;
1165
+
1166
+ if (withCovers && !finalCover && finalIsbn) {
1167
+ const coverResult = await fetchBookCover(finalIsbn);
1168
+
1169
+ if (coverResult) {
1170
+ finalCover = await saveCover(COVERS_DIR, authorDir, slugify(b.title), coverResult);
1171
+ }
1172
+ }
1173
+
1174
+ const hasCoverChange = finalCover !== existingCover;
1175
+
1176
+ // Independent of whether anything else changed — surfaced every run until resolved.
1177
+ if (needsReReadDates(b.readCount, finishedMerged.length)) {
1178
+ reReadsFlagged++;
1179
+
1180
+ const rel = path.relative(OUTPUT_ROOT, existingPath);
1181
+
1182
+ console.log(
1183
+ ` NOTE: ${rel} shows ${b.readCount}x read on Goodreads but only ${finishedMerged.length} ` +
1184
+ 'date(s) on file — Goodreads only exports the latest read date, so add earlier ones to \'finished\' by hand.',
1185
+ );
1186
+ }
1187
+
1188
+ if (!titleChanged && !hasNewDates && !hasRatingChange && !hasReferenceChange && !hasCoverChange) {
691
1189
  skippedUnchanged++;
692
1190
  continue;
693
1191
  }
@@ -708,6 +1206,24 @@ async function main() {
708
1206
  console.log(` RATING UPDATED: ${rel} (${existingRating} -> ${b.rating})`);
709
1207
  }
710
1208
 
1209
+ if (hasReferenceChange) {
1210
+ patched = upsertReferenceInFrontMatter(patched, { isbn: finalIsbn, asin: finalAsin });
1211
+ referencesUpdated++;
1212
+
1213
+ const rel = path.relative(OUTPUT_ROOT, existingPath);
1214
+
1215
+ console.log(` REFERENCE UPDATED: ${rel} (isbn: "${finalIsbn}", asin: "${finalAsin}")`);
1216
+ }
1217
+
1218
+ if (hasCoverChange) {
1219
+ patched = upsertCoverInFrontMatter(patched, finalCover);
1220
+ coversFetched++;
1221
+
1222
+ const rel = path.relative(OUTPUT_ROOT, existingPath);
1223
+
1224
+ console.log(` COVER ADDED: ${rel} (${finalCover})`);
1225
+ }
1226
+
711
1227
  if (titleChanged) {
712
1228
  patched = updateTitleInFrontMatter(patched, b.title);
713
1229
  }
@@ -736,7 +1252,15 @@ async function main() {
736
1252
  createdNew++;
737
1253
 
738
1254
  const finishedMerged = uniqSortedDates(b.finished);
739
- const isbnBest = b.isbn13 || b.isbn10;
1255
+
1256
+ if (needsReReadDates(b.readCount, finishedMerged.length)) {
1257
+ reReadsFlagged++;
1258
+
1259
+ console.log(
1260
+ ` NOTE: "${b.title}" shows ${b.readCount}x read on Goodreads but only ${finishedMerged.length} ` +
1261
+ 'date(s) on file — Goodreads only exports the latest read date, so add earlier ones to \'finished\' by hand.',
1262
+ );
1263
+ }
740
1264
 
741
1265
  const links = {
742
1266
  amazon: amazonSearchLink({ isbn13: b.isbn13, isbn10: b.isbn10, title: b.title, author: b.author }),
@@ -744,12 +1268,32 @@ async function main() {
744
1268
  goodreads: goodreadsBookUrl(b.bookId),
745
1269
  };
746
1270
 
1271
+ const reference = {
1272
+ isbn: isbnBest,
1273
+ asin: isbnBest ? await lookupAsin(isbnBest, AMAZON_PA_CONFIG, asinCache) : '',
1274
+ };
1275
+
1276
+ let cover = '';
1277
+
1278
+ if (withCovers && isbnBest) {
1279
+ const coverResult = await fetchBookCover(isbnBest);
1280
+
1281
+ if (coverResult) {
1282
+ cover = await saveCover(COVERS_DIR, authorDir, slugify(b.title), coverResult);
1283
+ coversFetched++;
1284
+
1285
+ console.log(` COVER SAVED: ${cover}`);
1286
+ }
1287
+ }
1288
+
747
1289
  const fm = toFrontMatter({
748
1290
  title: b.title,
749
1291
  author: b.author,
750
1292
  rating: b.rating,
751
1293
  finished: finishedMerged,
752
1294
  links,
1295
+ reference,
1296
+ cover,
753
1297
  });
754
1298
 
755
1299
  const bodyToWrite = b.review ? `\n\n${b.review}\n` : '\n';
@@ -785,7 +1329,10 @@ async function main() {
785
1329
  ` Existing files updated: ${mergedExisting}\n` +
786
1330
  ` Files renamed: ${renamedFiles}\n` +
787
1331
  ` Ratings updated: ${ratingsUpdated}\n` +
1332
+ ` References updated: ${referencesUpdated}\n` +
1333
+ ` Covers fetched: ${coversFetched}${withCovers ? '' : ' (pass --covers to fetch cover art)'}\n` +
788
1334
  ` Existing files skipped: ${skippedUnchanged}\n` +
1335
+ ` Rereads needing dates: ${reReadsFlagged}${reReadsFlagged ? ' (see NOTE lines above)' : ''}\n` +
789
1336
  ` Content output: ${OUTPUT_ROOT}\n`,
790
1337
  );
791
1338
  }
@@ -810,6 +1357,8 @@ export {
810
1357
  uniqSortedDates,
811
1358
  authorDirFromAuthorLF,
812
1359
  keyFor,
1360
+ needsReReadDates,
1361
+ stripBOM,
813
1362
  goodreadsBookUrl,
814
1363
  openLibraryIsbnUrl,
815
1364
  amazonSearchLink,
@@ -818,9 +1367,23 @@ export {
818
1367
  replaceFinishedBlock,
819
1368
  updateTitleInFrontMatter,
820
1369
  updateRatingInFrontMatter,
1370
+ parseExistingReference,
1371
+ upsertReferenceInFrontMatter,
1372
+ parseExistingCover,
1373
+ upsertCoverInFrontMatter,
821
1374
  extractGoodreadsId,
822
1375
  extractFileIsbns,
823
1376
  toFrontMatter,
824
1377
  buildExistingIndex,
825
1378
  findExistingFile,
1379
+ lookupAsinFromOpenLibrary,
1380
+ lookupAsinFromGoogleBooks,
1381
+ lookupAsinFromAmazonPa,
1382
+ lookupAsin,
1383
+ extFromContentType,
1384
+ coverUrlFromAssetPath,
1385
+ fetchOpenLibraryCover,
1386
+ fetchGoogleBooksCover,
1387
+ fetchBookCover,
1388
+ saveCover,
826
1389
  };