@chriswiegman/hugo-tools 0.2.1 → 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.test.mjs CHANGED
@@ -13,6 +13,8 @@ import {
13
13
  uniqSortedDates,
14
14
  authorDirFromAuthorLF,
15
15
  keyFor,
16
+ needsReReadDates,
17
+ stripBOM,
16
18
  goodreadsBookUrl,
17
19
  openLibraryIsbnUrl,
18
20
  amazonSearchLink,
@@ -20,11 +22,26 @@ import {
20
22
  parseExistingMarkdown,
21
23
  replaceFinishedBlock,
22
24
  updateTitleInFrontMatter,
25
+ updateRatingInFrontMatter,
26
+ parseExistingReference,
27
+ upsertReferenceInFrontMatter,
28
+ parseExistingCover,
29
+ upsertCoverInFrontMatter,
23
30
  extractGoodreadsId,
24
31
  extractFileIsbns,
25
32
  toFrontMatter,
26
33
  buildExistingIndex,
27
34
  findExistingFile,
35
+ lookupAsinFromOpenLibrary,
36
+ lookupAsinFromGoogleBooks,
37
+ lookupAsinFromAmazonPa,
38
+ lookupAsin,
39
+ extFromContentType,
40
+ coverUrlFromAssetPath,
41
+ fetchOpenLibraryCover,
42
+ fetchGoogleBooksCover,
43
+ fetchBookCover,
44
+ saveCover,
28
45
  } from './book.mjs';
29
46
 
30
47
  // ---------------------------------------------------------------------------
@@ -48,8 +65,9 @@ function sampleMarkdown({
48
65
  finished = ['2023-01-15'],
49
66
  goodreadsId = '12345',
50
67
  isbn13 = '9781234567890',
68
+ reference = null,
51
69
  } = {}) {
52
- return [
70
+ const lines = [
53
71
  '---',
54
72
  `title: "${title}"`,
55
73
  `author: "${author}"`,
@@ -60,9 +78,40 @@ function sampleMarkdown({
60
78
  ` amazon: "https://www.amazon.com/s?k=${isbn13}"`,
61
79
  ` openlibrary: "https://openlibrary.org/search?isbn=${isbn13}"`,
62
80
  ` goodreads: "https://www.goodreads.com/book/show/${goodreadsId}"`,
63
- '---',
64
- '',
65
- ].join('\n');
81
+ ];
82
+
83
+ if (reference) {
84
+ lines.push('reference:', ` isbn: "${reference.isbn ?? ''}"`, ` asin: "${reference.asin ?? ''}"`);
85
+ }
86
+
87
+ lines.push('---', '');
88
+ return lines.join('\n');
89
+ }
90
+
91
+ function fakeFetch(map) {
92
+ return async (url) => {
93
+ for (const [match, handler] of map) {
94
+ const matches = typeof match === 'string' ? url.includes(match) : match.test(url);
95
+
96
+ if (matches) return typeof handler === 'function' ? handler(url) : handler;
97
+ }
98
+
99
+ throw new Error(`fakeFetch: no handler for ${url}`);
100
+ };
101
+ }
102
+
103
+ function jsonResponse(body, ok = true) {
104
+ return { ok, json: async () => body };
105
+ }
106
+
107
+ function imageResponse(bytes, contentType = 'image/jpeg', ok = true) {
108
+ const buf = Buffer.from(bytes);
109
+
110
+ return {
111
+ ok,
112
+ headers: { get: () => contentType },
113
+ arrayBuffer: async () => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
114
+ };
66
115
  }
67
116
 
68
117
  // ---------------------------------------------------------------------------
@@ -280,6 +329,65 @@ test('keyFor: trims whitespace before combining', () => {
280
329
  assert.equal(keyFor(' Test Book ', ' John Doe '), 'test book|john doe');
281
330
  });
282
331
 
332
+ // ---------------------------------------------------------------------------
333
+ // needsReReadDates
334
+ // ---------------------------------------------------------------------------
335
+
336
+ test('needsReReadDates: false when read once and one date on file', () => {
337
+ assert.equal(needsReReadDates(1, 1), false);
338
+ });
339
+
340
+ test('needsReReadDates: false when never read (readCount 0)', () => {
341
+ assert.equal(needsReReadDates(0, 0), false);
342
+ });
343
+
344
+ test('needsReReadDates: true when read twice but only one date on file', () => {
345
+ assert.equal(needsReReadDates(2, 1), true);
346
+ });
347
+
348
+ test('needsReReadDates: false once dates on file catch up to read count', () => {
349
+ assert.equal(needsReReadDates(2, 2), false);
350
+ });
351
+
352
+ test('needsReReadDates: false when dates on file exceed read count', () => {
353
+ // e.g. the user manually recorded more reads than Goodreads' counter shows
354
+ assert.equal(needsReReadDates(2, 3), false);
355
+ });
356
+
357
+ test('needsReReadDates: true for a book read many times with a single date on file', () => {
358
+ assert.equal(needsReReadDates(5, 1), true);
359
+ });
360
+
361
+ test('needsReReadDates: false for non-finite readCount', () => {
362
+ assert.equal(needsReReadDates(NaN, 0), false);
363
+ assert.equal(needsReReadDates(undefined, 0), false);
364
+ });
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // stripBOM
368
+ // ---------------------------------------------------------------------------
369
+
370
+ test('stripBOM: removes a leading UTF-8 BOM', () => {
371
+ assert.equal(stripBOM('Book Id,Title'), 'Book Id,Title');
372
+ });
373
+
374
+ test('stripBOM: leaves text without a BOM unchanged', () => {
375
+ assert.equal(stripBOM('Book Id,Title'), 'Book Id,Title');
376
+ });
377
+
378
+ test('stripBOM: only strips a leading BOM, not one mid-string', () => {
379
+ assert.equal(stripBOM('Book Id,Title'), 'Book Id,Title');
380
+ });
381
+
382
+ test('stripBOM: empty string stays empty', () => {
383
+ assert.equal(stripBOM(''), '');
384
+ });
385
+
386
+ test('stripBOM: null/undefined coerce to empty string', () => {
387
+ assert.equal(stripBOM(null), '');
388
+ assert.equal(stripBOM(undefined), '');
389
+ });
390
+
283
391
  // ---------------------------------------------------------------------------
284
392
  // goodreadsBookUrl / openLibraryIsbnUrl / amazonSearchLink
285
393
  // ---------------------------------------------------------------------------
@@ -433,6 +541,26 @@ test('parseExistingMarkdown: returns empty array for invalid front matter', () =
433
541
  assert.deepEqual(finished, []);
434
542
  });
435
543
 
544
+ test('parseExistingMarkdown: extracts rating', () => {
545
+ const md = sampleMarkdown({ rating: 5 });
546
+ const { rating } = parseExistingMarkdown(md);
547
+
548
+ assert.equal(rating, 5);
549
+ });
550
+
551
+ test('parseExistingMarkdown: returns null rating when field is absent', () => {
552
+ const md = ['---', 'title: "Test"', 'finished:', ' - "2023-01-15"', '---', ''].join('\n');
553
+ const { rating } = parseExistingMarkdown(md);
554
+
555
+ assert.equal(rating, null);
556
+ });
557
+
558
+ test('parseExistingMarkdown: returns null rating for invalid front matter', () => {
559
+ const { rating } = parseExistingMarkdown('no front matter here');
560
+
561
+ assert.equal(rating, null);
562
+ });
563
+
436
564
  // ---------------------------------------------------------------------------
437
565
  // replaceFinishedBlock
438
566
  // ---------------------------------------------------------------------------
@@ -508,6 +636,412 @@ test('updateTitleInFrontMatter: preserves all other front matter', () => {
508
636
  assert.ok(result.includes('goodreads:'));
509
637
  });
510
638
 
639
+ // ---------------------------------------------------------------------------
640
+ // updateRatingInFrontMatter
641
+ // ---------------------------------------------------------------------------
642
+
643
+ test('updateRatingInFrontMatter: replaces rating line', () => {
644
+ const md = sampleMarkdown({ rating: 3 });
645
+ const result = updateRatingInFrontMatter(md, 5);
646
+
647
+ assert.ok(result.includes('rating: 5'));
648
+ assert.ok(!result.includes('rating: 3'));
649
+ });
650
+
651
+ test('updateRatingInFrontMatter: preserves all other front matter', () => {
652
+ const md = sampleMarkdown({ title: 'Keep Me', rating: 2 });
653
+ const result = updateRatingInFrontMatter(md, 4);
654
+
655
+ assert.ok(result.includes('title: "Keep Me"'));
656
+ assert.ok(result.includes('goodreads:'));
657
+ });
658
+
659
+ // ---------------------------------------------------------------------------
660
+ // parseExistingReference
661
+ // ---------------------------------------------------------------------------
662
+
663
+ test('parseExistingReference: extracts isbn and asin from reference block', () => {
664
+ const md = sampleMarkdown({ reference: { isbn: '9781234567890', asin: 'B00ABCDEFG' } });
665
+ const result = parseExistingReference(md);
666
+
667
+ assert.deepEqual(result, { isbn: '9781234567890', asin: 'B00ABCDEFG' });
668
+ });
669
+
670
+ test('parseExistingReference: returns blanks when reference block is absent', () => {
671
+ const md = sampleMarkdown();
672
+ const result = parseExistingReference(md);
673
+
674
+ assert.deepEqual(result, { isbn: '', asin: '' });
675
+ });
676
+
677
+ test('parseExistingReference: returns blanks for fields present but empty', () => {
678
+ const md = sampleMarkdown({ reference: { isbn: '', asin: '' } });
679
+ const result = parseExistingReference(md);
680
+
681
+ assert.deepEqual(result, { isbn: '', asin: '' });
682
+ });
683
+
684
+ test('parseExistingReference: returns blanks for invalid front matter', () => {
685
+ assert.deepEqual(parseExistingReference('no front matter here'), { isbn: '', asin: '' });
686
+ });
687
+
688
+ // ---------------------------------------------------------------------------
689
+ // upsertReferenceInFrontMatter
690
+ // ---------------------------------------------------------------------------
691
+
692
+ test('upsertReferenceInFrontMatter: inserts a new block when none exists', () => {
693
+ const md = sampleMarkdown();
694
+ const result = upsertReferenceInFrontMatter(md, { isbn: '9781234567890', asin: 'B00ABCDEFG' });
695
+
696
+ assert.ok(result.includes('reference:'));
697
+ assert.ok(result.includes(' isbn: "9781234567890"'));
698
+ assert.ok(result.includes(' asin: "B00ABCDEFG"'));
699
+ assert.ok(result.includes('title: "Test Book"'));
700
+ });
701
+
702
+ test('upsertReferenceInFrontMatter: replaces an existing block', () => {
703
+ const md = sampleMarkdown({ reference: { isbn: '9781234567890', asin: '' } });
704
+ const result = upsertReferenceInFrontMatter(md, { isbn: '9781234567890', asin: 'B00ABCDEFG' });
705
+
706
+ assert.equal((result.match(/reference:/g) || []).length, 1);
707
+ assert.ok(result.includes(' asin: "B00ABCDEFG"'));
708
+ });
709
+
710
+ test('upsertReferenceInFrontMatter: preserves all other front matter', () => {
711
+ const md = sampleMarkdown({ title: 'Keep Me', rating: 5 });
712
+ const result = upsertReferenceInFrontMatter(md, { isbn: '123', asin: '456' });
713
+
714
+ assert.ok(result.includes('title: "Keep Me"'));
715
+ assert.ok(result.includes('rating: 5'));
716
+ assert.ok(result.includes('goodreads:'));
717
+ });
718
+
719
+ // ---------------------------------------------------------------------------
720
+ // ASIN lookup
721
+ // ---------------------------------------------------------------------------
722
+
723
+ test('lookupAsinFromOpenLibrary: extracts amazon identifier', async () => {
724
+ const fetchImpl = fakeFetch([
725
+ ['openlibrary.org', jsonResponse({
726
+ 'ISBN:9781234567890': { identifiers: { amazon: ['b00abcdefg'] } },
727
+ })],
728
+ ]);
729
+
730
+ assert.equal(await lookupAsinFromOpenLibrary('9781234567890', fetchImpl), 'B00ABCDEFG');
731
+ });
732
+
733
+ test('lookupAsinFromOpenLibrary: returns empty string when no amazon identifier', async () => {
734
+ const fetchImpl = fakeFetch([
735
+ ['openlibrary.org', jsonResponse({ 'ISBN:9781234567890': { identifiers: {} } })],
736
+ ]);
737
+
738
+ assert.equal(await lookupAsinFromOpenLibrary('9781234567890', fetchImpl), '');
739
+ });
740
+
741
+ test('lookupAsinFromOpenLibrary: returns empty string on non-OK response', async () => {
742
+ const fetchImpl = fakeFetch([['openlibrary.org', jsonResponse({}, false)]]);
743
+
744
+ assert.equal(await lookupAsinFromOpenLibrary('9781234567890', fetchImpl), '');
745
+ });
746
+
747
+ test('lookupAsinFromOpenLibrary: returns empty string when fetch throws', async () => {
748
+ const fetchImpl = async () => {
749
+ throw new Error('network down');
750
+ };
751
+
752
+ assert.equal(await lookupAsinFromOpenLibrary('9781234567890', fetchImpl), '');
753
+ });
754
+
755
+ test('lookupAsinFromOpenLibrary: empty isbn returns empty string without fetching', async () => {
756
+ assert.equal(await lookupAsinFromOpenLibrary('', async () => {
757
+ throw new Error('should not be called');
758
+ }), '');
759
+ });
760
+
761
+ test('lookupAsinFromGoogleBooks: extracts ASIN-shaped OTHER identifier', async () => {
762
+ const fetchImpl = fakeFetch([
763
+ ['googleapis.com', jsonResponse({
764
+ items: [{ volumeInfo: { industryIdentifiers: [
765
+ { type: 'ISBN_13', identifier: '9781234567890' },
766
+ { type: 'OTHER', identifier: 'B00ABCDEFG' },
767
+ ] } }],
768
+ })],
769
+ ]);
770
+
771
+ assert.equal(await lookupAsinFromGoogleBooks('9781234567890', fetchImpl), 'B00ABCDEFG');
772
+ });
773
+
774
+ test('lookupAsinFromGoogleBooks: returns empty string when no OTHER identifier matches', async () => {
775
+ const fetchImpl = fakeFetch([
776
+ ['googleapis.com', jsonResponse({
777
+ items: [{ volumeInfo: { industryIdentifiers: [{ type: 'ISBN_10', identifier: '1234567890' }] } }],
778
+ })],
779
+ ]);
780
+
781
+ assert.equal(await lookupAsinFromGoogleBooks('9781234567890', fetchImpl), '');
782
+ });
783
+
784
+ test('lookupAsinFromAmazonPa: returns empty string without config', async () => {
785
+ const fetchImpl = async () => {
786
+ throw new Error('should not be called');
787
+ };
788
+
789
+ assert.equal(await lookupAsinFromAmazonPa('9781234567890', null, fetchImpl), '');
790
+ });
791
+
792
+ test('lookupAsinFromAmazonPa: signs the request and returns the ASIN', async () => {
793
+ let seenAuth = '';
794
+ const fetchImpl = async (url, opts) => {
795
+ seenAuth = opts.headers.authorization;
796
+ return jsonResponse({ ItemsResult: { Items: [{ ASIN: 'b00abcdefg' }] } });
797
+ };
798
+ const config = { accessKey: 'AKIA...', secretKey: 'secret', partnerTag: 'tag-20' };
799
+
800
+ const result = await lookupAsinFromAmazonPa('9781234567890', config, fetchImpl);
801
+
802
+ assert.equal(result, 'B00ABCDEFG');
803
+ assert.ok(seenAuth.startsWith('AWS4-HMAC-SHA256 Credential=AKIA...'));
804
+ });
805
+
806
+ test('lookupAsin: falls back from Open Library to Google Books', async () => {
807
+ const fetchImpl = fakeFetch([
808
+ ['openlibrary.org', jsonResponse({ 'ISBN:9781234567890': { identifiers: {} } })],
809
+ ['googleapis.com', jsonResponse({
810
+ items: [{ volumeInfo: { industryIdentifiers: [{ type: 'OTHER', identifier: 'B00ABCDEFG' }] } }],
811
+ })],
812
+ ]);
813
+
814
+ assert.equal(await lookupAsin('9781234567890', null, new Map(), fetchImpl), 'B00ABCDEFG');
815
+ });
816
+
817
+ test('lookupAsin: caches results per ISBN', async () => {
818
+ let calls = 0;
819
+ const fetchImpl = async () => {
820
+ calls++;
821
+ return jsonResponse({ 'ISBN:9781234567890': { identifiers: { amazon: ['B00ABCDEFG'] } } });
822
+ };
823
+ const cache = new Map();
824
+
825
+ await lookupAsin('9781234567890', null, cache, fetchImpl);
826
+ await lookupAsin('9781234567890', null, cache, fetchImpl);
827
+
828
+ assert.equal(calls, 1);
829
+ });
830
+
831
+ test('lookupAsin: empty isbn returns empty string without fetching', async () => {
832
+ const fetchImpl = async () => {
833
+ throw new Error('should not be called');
834
+ };
835
+
836
+ assert.equal(await lookupAsin('', null, new Map(), fetchImpl), '');
837
+ });
838
+
839
+ // ---------------------------------------------------------------------------
840
+ // Cover art (opt-in)
841
+ // ---------------------------------------------------------------------------
842
+
843
+ test('extFromContentType: maps known image types to extensions', () => {
844
+ assert.equal(extFromContentType('image/jpeg'), '.jpg');
845
+ assert.equal(extFromContentType('image/png'), '.png');
846
+ assert.equal(extFromContentType('image/webp'), '.webp');
847
+ assert.equal(extFromContentType('image/gif'), '.gif');
848
+ });
849
+
850
+ test('extFromContentType: strips charset/parameter suffix', () => {
851
+ assert.equal(extFromContentType('image/jpeg; charset=binary'), '.jpg');
852
+ });
853
+
854
+ test('extFromContentType: defaults to .jpg for unknown or missing types', () => {
855
+ assert.equal(extFromContentType('application/octet-stream'), '.jpg');
856
+ assert.equal(extFromContentType(''), '.jpg');
857
+ assert.equal(extFromContentType(undefined), '.jpg');
858
+ });
859
+
860
+ test('coverUrlFromAssetPath: strips the assets/ prefix and normalizes separators', () => {
861
+ assert.equal(
862
+ coverUrlFromAssetPath(path.join('assets', 'images', 'books', 'king-stephen', 'it.jpg')),
863
+ '/images/books/king-stephen/it.jpg',
864
+ );
865
+ });
866
+
867
+ test('fetchOpenLibraryCover: returns buffer and content type on success', async () => {
868
+ const fetchImpl = fakeFetch([
869
+ ['covers.openlibrary.org', imageResponse([1, 2, 3], 'image/jpeg')],
870
+ ]);
871
+
872
+ const result = await fetchOpenLibraryCover('9781234567890', fetchImpl);
873
+
874
+ assert.ok(result);
875
+ assert.equal(result.contentType, 'image/jpeg');
876
+ assert.deepEqual([...result.buffer], [1, 2, 3]);
877
+ });
878
+
879
+ test('fetchOpenLibraryCover: returns null on non-OK response (no cover)', async () => {
880
+ const fetchImpl = fakeFetch([
881
+ ['covers.openlibrary.org', imageResponse([], 'image/jpeg', false)],
882
+ ]);
883
+
884
+ assert.equal(await fetchOpenLibraryCover('9781234567890', fetchImpl), null);
885
+ });
886
+
887
+ test('fetchOpenLibraryCover: returns null when fetch throws', async () => {
888
+ const fetchImpl = async () => {
889
+ throw new Error('network down');
890
+ };
891
+
892
+ assert.equal(await fetchOpenLibraryCover('9781234567890', fetchImpl), null);
893
+ });
894
+
895
+ test('fetchOpenLibraryCover: empty isbn returns null without fetching', async () => {
896
+ assert.equal(await fetchOpenLibraryCover('', async () => {
897
+ throw new Error('should not be called');
898
+ }), null);
899
+ });
900
+
901
+ test('fetchGoogleBooksCover: fetches the volume thumbnail and returns image bytes', async () => {
902
+ const fetchImpl = fakeFetch([
903
+ ['googleapis.com', jsonResponse({
904
+ items: [{ volumeInfo: { imageLinks: { thumbnail: 'http://books.google.com/thumb.jpg' } } }],
905
+ })],
906
+ ['books.google.com', imageResponse([4, 5, 6], 'image/png')],
907
+ ]);
908
+
909
+ const result = await fetchGoogleBooksCover('9781234567890', fetchImpl);
910
+
911
+ assert.ok(result);
912
+ assert.equal(result.contentType, 'image/png');
913
+ assert.deepEqual([...result.buffer], [4, 5, 6]);
914
+ });
915
+
916
+ test('fetchGoogleBooksCover: returns null when volume has no imageLinks', async () => {
917
+ const fetchImpl = fakeFetch([
918
+ ['googleapis.com', jsonResponse({ items: [{ volumeInfo: {} }] })],
919
+ ]);
920
+
921
+ assert.equal(await fetchGoogleBooksCover('9781234567890', fetchImpl), null);
922
+ });
923
+
924
+ test('fetchGoogleBooksCover: empty isbn returns null without fetching', async () => {
925
+ assert.equal(await fetchGoogleBooksCover('', async () => {
926
+ throw new Error('should not be called');
927
+ }), null);
928
+ });
929
+
930
+ test('fetchBookCover: prefers Open Library over Google Books', async () => {
931
+ const fetchImpl = fakeFetch([
932
+ ['covers.openlibrary.org', imageResponse([1], 'image/jpeg')],
933
+ ['googleapis.com', () => {
934
+ throw new Error('should not be called');
935
+ }],
936
+ ]);
937
+
938
+ const result = await fetchBookCover('9781234567890', fetchImpl);
939
+
940
+ assert.ok(result);
941
+ assert.deepEqual([...result.buffer], [1]);
942
+ });
943
+
944
+ test('fetchBookCover: falls back to Google Books when Open Library has no cover', async () => {
945
+ const fetchImpl = fakeFetch([
946
+ ['covers.openlibrary.org', imageResponse([], 'image/jpeg', false)],
947
+ ['googleapis.com', jsonResponse({
948
+ items: [{ volumeInfo: { imageLinks: { thumbnail: 'http://books.google.com/thumb.jpg' } } }],
949
+ })],
950
+ ['books.google.com', imageResponse([9], 'image/jpeg')],
951
+ ]);
952
+
953
+ const result = await fetchBookCover('9781234567890', fetchImpl);
954
+
955
+ assert.ok(result);
956
+ assert.deepEqual([...result.buffer], [9]);
957
+ });
958
+
959
+ test('fetchBookCover: returns null when neither source has a cover', async () => {
960
+ const fetchImpl = fakeFetch([
961
+ ['covers.openlibrary.org', imageResponse([], 'image/jpeg', false)],
962
+ ['googleapis.com', jsonResponse({ items: [] })],
963
+ ]);
964
+
965
+ assert.equal(await fetchBookCover('9781234567890', fetchImpl), null);
966
+ });
967
+
968
+ test('fetchBookCover: empty isbn returns null without fetching', async () => {
969
+ assert.equal(await fetchBookCover('', async () => {
970
+ throw new Error('should not be called');
971
+ }), null);
972
+ });
973
+
974
+ test('saveCover: writes image bytes under coversDir and returns the Hugo URL path', async () => {
975
+ await withTempDir(async (tmpDir) => {
976
+ const cover = { buffer: Buffer.from([1, 2, 3]), contentType: 'image/png' };
977
+ const url = await saveCover('assets/images/books', 'king-stephen', 'it', cover, tmpDir);
978
+
979
+ assert.equal(url, '/images/books/king-stephen/it.png');
980
+
981
+ const written = await fs.readFile(path.join(tmpDir, 'assets', 'images', 'books', 'king-stephen', 'it.png'));
982
+
983
+ assert.deepEqual([...written], [1, 2, 3]);
984
+ });
985
+ });
986
+
987
+ test('saveCover: creates intermediate directories as needed', async () => {
988
+ await withTempDir(async (tmpDir) => {
989
+ const cover = { buffer: Buffer.from([7]), contentType: 'image/jpeg' };
990
+
991
+ await saveCover('assets/images/books', 'doe-jane', 'a-title', cover, tmpDir);
992
+
993
+ const exists = await fs.access(path.join(tmpDir, 'assets', 'images', 'books', 'doe-jane', 'a-title.jpg'))
994
+ .then(() => true)
995
+ .catch(() => false);
996
+
997
+ assert.ok(exists);
998
+ });
999
+ });
1000
+
1001
+ // ---------------------------------------------------------------------------
1002
+ // parseExistingCover / upsertCoverInFrontMatter
1003
+ // ---------------------------------------------------------------------------
1004
+
1005
+ test('parseExistingCover: extracts the cover value when present', () => {
1006
+ const md = ['---', 'title: "Test"', 'cover: "/images/books/king-stephen/it.jpg"', '---', ''].join('\n');
1007
+
1008
+ assert.equal(parseExistingCover(md), '/images/books/king-stephen/it.jpg');
1009
+ });
1010
+
1011
+ test('parseExistingCover: returns empty string when the field is absent', () => {
1012
+ assert.equal(parseExistingCover(sampleMarkdown()), '');
1013
+ });
1014
+
1015
+ test('parseExistingCover: returns empty string for invalid front matter', () => {
1016
+ assert.equal(parseExistingCover('no front matter here'), '');
1017
+ });
1018
+
1019
+ test('upsertCoverInFrontMatter: inserts the field when absent', () => {
1020
+ const md = sampleMarkdown();
1021
+ const result = upsertCoverInFrontMatter(md, '/images/books/king-stephen/it.jpg');
1022
+
1023
+ assert.ok(result.includes('cover: "/images/books/king-stephen/it.jpg"'));
1024
+ assert.ok(result.includes('title: "Test Book"'));
1025
+ });
1026
+
1027
+ test('upsertCoverInFrontMatter: replaces an existing cover field without duplicating it', () => {
1028
+ const md = ['---', 'title: "Test"', 'cover: "/old.jpg"', '---', ''].join('\n');
1029
+ const result = upsertCoverInFrontMatter(md, '/new.jpg');
1030
+
1031
+ assert.equal((result.match(/^cover:/gm) || []).length, 1);
1032
+ assert.ok(result.includes('cover: "/new.jpg"'));
1033
+ assert.ok(!result.includes('/old.jpg'));
1034
+ });
1035
+
1036
+ test('upsertCoverInFrontMatter: preserves all other front matter', () => {
1037
+ const md = sampleMarkdown({ title: 'Keep Me', rating: 5 });
1038
+ const result = upsertCoverInFrontMatter(md, '/cover.jpg');
1039
+
1040
+ assert.ok(result.includes('title: "Keep Me"'));
1041
+ assert.ok(result.includes('rating: 5'));
1042
+ assert.ok(result.includes('goodreads:'));
1043
+ });
1044
+
511
1045
  // ---------------------------------------------------------------------------
512
1046
  // extractGoodreadsId
513
1047
  // ---------------------------------------------------------------------------
@@ -600,6 +1134,59 @@ test('toFrontMatter: escapes special chars in title and author', () => {
600
1134
  assert.ok(fm.includes('author: "Author \\"A\\""'));
601
1135
  });
602
1136
 
1137
+ test('toFrontMatter: includes reference isbn/asin when provided', () => {
1138
+ const fm = toFrontMatter({
1139
+ title: 'Test Book',
1140
+ author: 'John Doe',
1141
+ rating: 4,
1142
+ finished: [],
1143
+ links: { amazon: '', openlibrary: '', goodreads: '' },
1144
+ reference: { isbn: '9781234567890', asin: 'B00ABCDEFG' },
1145
+ });
1146
+
1147
+ assert.ok(fm.includes('reference:'));
1148
+ assert.ok(fm.includes(' isbn: "9781234567890"'));
1149
+ assert.ok(fm.includes(' asin: "B00ABCDEFG"'));
1150
+ });
1151
+
1152
+ test('toFrontMatter: reference fields default to blank when omitted', () => {
1153
+ const fm = toFrontMatter({
1154
+ title: 'Test Book',
1155
+ author: 'John Doe',
1156
+ rating: 4,
1157
+ finished: [],
1158
+ links: { amazon: '', openlibrary: '', goodreads: '' },
1159
+ });
1160
+
1161
+ assert.ok(fm.includes(' isbn: ""'));
1162
+ assert.ok(fm.includes(' asin: ""'));
1163
+ });
1164
+
1165
+ test('toFrontMatter: includes cover field when provided', () => {
1166
+ const fm = toFrontMatter({
1167
+ title: 'Test Book',
1168
+ author: 'John Doe',
1169
+ rating: 4,
1170
+ finished: [],
1171
+ links: { amazon: '', openlibrary: '', goodreads: '' },
1172
+ cover: '/images/books/doe-john/test-book.jpg',
1173
+ });
1174
+
1175
+ assert.ok(fm.includes('cover: "/images/books/doe-john/test-book.jpg"'));
1176
+ });
1177
+
1178
+ test('toFrontMatter: cover defaults to blank when omitted', () => {
1179
+ const fm = toFrontMatter({
1180
+ title: 'Test Book',
1181
+ author: 'John Doe',
1182
+ rating: 4,
1183
+ finished: [],
1184
+ links: { amazon: '', openlibrary: '', goodreads: '' },
1185
+ });
1186
+
1187
+ assert.ok(fm.includes('cover: ""'));
1188
+ });
1189
+
603
1190
  // ---------------------------------------------------------------------------
604
1191
  // buildExistingIndex (async)
605
1192
  // ---------------------------------------------------------------------------
@@ -885,3 +1472,33 @@ test('integration: title-change rename via buildExistingIndex', async () => {
885
1472
  assert.ok(newContent.includes('title: "New Title"'));
886
1473
  });
887
1474
  });
1475
+
1476
+ test('integration: a re-read flagged by needsReReadDates is advisory only — no dates are fabricated', async () => {
1477
+ await withTempDir(async (tmpDir) => {
1478
+ // A book Goodreads says was read twice, but the export (and thus the file) only
1479
+ // ever carries one date — the merge pipeline must not invent a second date.
1480
+ const authorDir = path.join(tmpDir, 'doe-john');
1481
+
1482
+ await fs.mkdir(authorDir);
1483
+ const filePath = path.join(authorDir, 'test-book.md');
1484
+ const original = sampleMarkdown({ finished: ['2023-01-15'] });
1485
+
1486
+ await fs.writeFile(filePath, original);
1487
+
1488
+ const existingContent = await fs.readFile(filePath, 'utf8');
1489
+ const { finished: existingFinished } = parseExistingMarkdown(existingContent);
1490
+ // Goodreads' "Date Read" for this run is identical to what's already on file.
1491
+ const csvFinished = ['2023-01-15'];
1492
+ const finishedMerged = uniqSortedDates([...existingFinished, ...csvFinished]);
1493
+
1494
+ assert.deepEqual(finishedMerged, ['2023-01-15']);
1495
+ assert.equal(needsReReadDates(2, finishedMerged.length), true);
1496
+
1497
+ // No new dates means the file is untouched by the merge itself.
1498
+ const hasNewDates =
1499
+ finishedMerged.length !== existingFinished.length ||
1500
+ finishedMerged.some((d, i) => d !== existingFinished[i]);
1501
+
1502
+ assert.equal(hasNewDates, false);
1503
+ });
1504
+ });