@chriswiegman/hugo-tools 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,834 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ import {
8
+ slugify,
9
+ cleanIsbn,
10
+ toISODate,
11
+ clampRating,
12
+ escapeYAMLString,
13
+ uniqSortedDates,
14
+ authorDirFromAuthorLF,
15
+ keyFor,
16
+ goodreadsBookUrl,
17
+ openLibraryIsbnUrl,
18
+ amazonSearchLink,
19
+ parseCSV,
20
+ parseExistingMarkdown,
21
+ replaceFinishedBlock,
22
+ updateTitleInFrontMatter,
23
+ extractGoodreadsId,
24
+ extractFileIsbns,
25
+ toFrontMatter,
26
+ buildExistingIndex,
27
+ findExistingFile,
28
+ } from "./book.mjs";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Helpers
32
+ // ---------------------------------------------------------------------------
33
+
34
+ async function withTempDir(fn) {
35
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "book-test-"));
36
+ try {
37
+ return await fn(dir);
38
+ } finally {
39
+ await fs.rm(dir, { recursive: true, force: true });
40
+ }
41
+ }
42
+
43
+ function sampleMarkdown({
44
+ title = "Test Book",
45
+ author = "John Doe",
46
+ rating = 4,
47
+ finished = ["2023-01-15"],
48
+ goodreadsId = "12345",
49
+ isbn13 = "9781234567890",
50
+ } = {}) {
51
+ return [
52
+ "---",
53
+ `title: "${title}"`,
54
+ `author: "${author}"`,
55
+ `rating: ${rating}`,
56
+ "finished:",
57
+ ...finished.map((d) => ` - "${d}"`),
58
+ "links:",
59
+ ` amazon: "https://www.amazon.com/s?k=${isbn13}"`,
60
+ ` openlibrary: "https://openlibrary.org/search?isbn=${isbn13}"`,
61
+ ` goodreads: "https://www.goodreads.com/book/show/${goodreadsId}"`,
62
+ "---",
63
+ "",
64
+ ].join("\n");
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // slugify
69
+ // ---------------------------------------------------------------------------
70
+
71
+ test("slugify: basic lowercase and hyphenation", () => {
72
+ assert.equal(slugify("Hello World"), "hello-world");
73
+ });
74
+
75
+ test("slugify: collapses multiple spaces and hyphens", () => {
76
+ assert.equal(slugify("Hello World--Test"), "hello-world-test");
77
+ });
78
+
79
+ test("slugify: removes special characters", () => {
80
+ assert.equal(slugify("It's a Test!"), "its-a-test");
81
+ });
82
+
83
+ test("slugify: strips em-dashes and mdash-like punctuation", () => {
84
+ assert.equal(slugify("Title\u2014Subtitle"), "titlesubtitle");
85
+ });
86
+
87
+ test("slugify: normalizes unicode accents", () => {
88
+ assert.equal(slugify("Café Résumé"), "cafe-resume");
89
+ });
90
+
91
+ test("slugify: returns 'unknown' for empty string", () => {
92
+ assert.equal(slugify(""), "unknown");
93
+ });
94
+
95
+ test("slugify: returns 'unknown' for null", () => {
96
+ assert.equal(slugify(null), "unknown");
97
+ });
98
+
99
+ test("slugify: trims leading and trailing hyphens", () => {
100
+ assert.equal(slugify(" ---hello--- "), "hello");
101
+ });
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // cleanIsbn
105
+ // ---------------------------------------------------------------------------
106
+
107
+ test("cleanIsbn: Goodreads ISBN13 format =\"...\"", () => {
108
+ assert.equal(cleanIsbn('="9781234567890"'), "9781234567890");
109
+ });
110
+
111
+ test("cleanIsbn: Goodreads ISBN10 format =\"...\"", () => {
112
+ assert.equal(cleanIsbn('="1234567890"'), "1234567890");
113
+ });
114
+
115
+ test("cleanIsbn: empty Goodreads field =\"\"", () => {
116
+ assert.equal(cleanIsbn('=""'), "");
117
+ });
118
+
119
+ test("cleanIsbn: plain ISBN string passed through", () => {
120
+ assert.equal(cleanIsbn("9780062694430"), "9780062694430");
121
+ });
122
+
123
+ test("cleanIsbn: normalizes to uppercase (X check digit)", () => {
124
+ assert.equal(cleanIsbn("019x"), "019X");
125
+ });
126
+
127
+ test("cleanIsbn: null returns empty string", () => {
128
+ assert.equal(cleanIsbn(null), "");
129
+ });
130
+
131
+ test("cleanIsbn: undefined returns empty string", () => {
132
+ assert.equal(cleanIsbn(undefined), "");
133
+ });
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // toISODate
137
+ // ---------------------------------------------------------------------------
138
+
139
+ test("toISODate: already ISO YYYY-MM-DD passthrough", () => {
140
+ assert.equal(toISODate("2023-09-10"), "2023-09-10");
141
+ });
142
+
143
+ test("toISODate: YYYY/MM/DD format", () => {
144
+ assert.equal(toISODate("2023/9/10"), "2023-09-10");
145
+ });
146
+
147
+ test("toISODate: YYYY/M/D zero-pads month and day", () => {
148
+ assert.equal(toISODate("2024/1/5"), "2024-01-05");
149
+ });
150
+
151
+ test("toISODate: M/D/YYYY format", () => {
152
+ assert.equal(toISODate("3/20/2024"), "2024-03-20");
153
+ });
154
+
155
+ test("toISODate: M/D/YY treats 2-digit year as 20xx", () => {
156
+ assert.equal(toISODate("1/5/23"), "2023-01-05");
157
+ });
158
+
159
+ test("toISODate: empty string returns null", () => {
160
+ assert.equal(toISODate(""), null);
161
+ });
162
+
163
+ test("toISODate: null returns null", () => {
164
+ assert.equal(toISODate(null), null);
165
+ });
166
+
167
+ test("toISODate: invalid string returns null", () => {
168
+ assert.equal(toISODate("not-a-date"), null);
169
+ });
170
+
171
+ test("toISODate: invalid month 13 in YYYY/MM/DD returns null", () => {
172
+ assert.equal(toISODate("2023/13/01"), null);
173
+ });
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // clampRating
177
+ // ---------------------------------------------------------------------------
178
+
179
+ test("clampRating: 0 stays 0", () => assert.equal(clampRating(0), 0));
180
+ test("clampRating: 3 stays 3", () => assert.equal(clampRating(3), 3));
181
+ test("clampRating: 5 stays 5", () => assert.equal(clampRating(5), 5));
182
+ test("clampRating: -1 clamps to 0", () => assert.equal(clampRating(-1), 0));
183
+ test("clampRating: 6 clamps to 5", () => assert.equal(clampRating(6), 5));
184
+ test("clampRating: NaN returns 0", () => assert.equal(clampRating(NaN), 0));
185
+ test("clampRating: 3.9 truncates to 3", () => assert.equal(clampRating(3.9), 3));
186
+ test("clampRating: Infinity is non-finite, returns 0", () => assert.equal(clampRating(Infinity), 0));
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // escapeYAMLString
190
+ // ---------------------------------------------------------------------------
191
+
192
+ test("escapeYAMLString: escapes double quotes", () => {
193
+ assert.equal(escapeYAMLString('say "hello"'), 'say \\"hello\\"');
194
+ });
195
+
196
+ test("escapeYAMLString: escapes backslashes", () => {
197
+ assert.equal(escapeYAMLString("a\\b"), "a\\\\b");
198
+ });
199
+
200
+ test("escapeYAMLString: empty string", () => {
201
+ assert.equal(escapeYAMLString(""), "");
202
+ });
203
+
204
+ test("escapeYAMLString: null becomes empty string", () => {
205
+ assert.equal(escapeYAMLString(null), "");
206
+ });
207
+
208
+ test("escapeYAMLString: plain string unchanged", () => {
209
+ assert.equal(escapeYAMLString("Hello World"), "Hello World");
210
+ });
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // uniqSortedDates
214
+ // ---------------------------------------------------------------------------
215
+
216
+ test("uniqSortedDates: deduplicates identical dates", () => {
217
+ assert.deepEqual(
218
+ uniqSortedDates(["2023-01-15", "2023-01-15", "2024-03-20"]),
219
+ ["2023-01-15", "2024-03-20"]
220
+ );
221
+ });
222
+
223
+ test("uniqSortedDates: sorts chronologically", () => {
224
+ assert.deepEqual(
225
+ uniqSortedDates(["2024-03-20", "2023-01-15"]),
226
+ ["2023-01-15", "2024-03-20"]
227
+ );
228
+ });
229
+
230
+ test("uniqSortedDates: filters empty/blank entries", () => {
231
+ assert.deepEqual(uniqSortedDates(["2023-01-15", "", " "]), ["2023-01-15"]);
232
+ });
233
+
234
+ test("uniqSortedDates: empty array returns empty array", () => {
235
+ assert.deepEqual(uniqSortedDates([]), []);
236
+ });
237
+
238
+ test("uniqSortedDates: null returns empty array", () => {
239
+ assert.deepEqual(uniqSortedDates(null), []);
240
+ });
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // authorDirFromAuthorLF
244
+ // ---------------------------------------------------------------------------
245
+
246
+ test("authorDirFromAuthorLF: 'Last, First' becomes 'last-first'", () => {
247
+ assert.equal(authorDirFromAuthorLF("Baldacci, David"), "baldacci-david");
248
+ });
249
+
250
+ test("authorDirFromAuthorLF: single name without comma", () => {
251
+ assert.equal(authorDirFromAuthorLF("Homer"), "homer");
252
+ });
253
+
254
+ test("authorDirFromAuthorLF: empty returns 'unknown'", () => {
255
+ assert.equal(authorDirFromAuthorLF(""), "unknown");
256
+ });
257
+
258
+ test("authorDirFromAuthorLF: null returns 'unknown'", () => {
259
+ assert.equal(authorDirFromAuthorLF(null), "unknown");
260
+ });
261
+
262
+ test("authorDirFromAuthorLF: handles special chars in name", () => {
263
+ assert.equal(authorDirFromAuthorLF("O'Brien, Tim"), "obrien-tim");
264
+ });
265
+
266
+ test("authorDirFromAuthorLF: handles suffix-only after comma", () => {
267
+ assert.equal(authorDirFromAuthorLF("King, Jr."), "king-jr");
268
+ });
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // keyFor
272
+ // ---------------------------------------------------------------------------
273
+
274
+ test("keyFor: combines lowercased title and author with pipe", () => {
275
+ assert.equal(keyFor("Test Book", "John Doe"), "test book|john doe");
276
+ });
277
+
278
+ test("keyFor: trims whitespace before combining", () => {
279
+ assert.equal(keyFor(" Test Book ", " John Doe "), "test book|john doe");
280
+ });
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // goodreadsBookUrl / openLibraryIsbnUrl / amazonSearchLink
284
+ // ---------------------------------------------------------------------------
285
+
286
+ test("goodreadsBookUrl: returns correct URL for a book ID", () => {
287
+ assert.equal(
288
+ goodreadsBookUrl("12345"),
289
+ "https://www.goodreads.com/book/show/12345"
290
+ );
291
+ });
292
+
293
+ test("goodreadsBookUrl: empty ID returns empty string", () => {
294
+ assert.equal(goodreadsBookUrl(""), "");
295
+ assert.equal(goodreadsBookUrl(null), "");
296
+ });
297
+
298
+ test("openLibraryIsbnUrl: returns correct search URL", () => {
299
+ assert.equal(
300
+ openLibraryIsbnUrl("9781234567890"),
301
+ "https://openlibrary.org/search?isbn=9781234567890"
302
+ );
303
+ });
304
+
305
+ test("openLibraryIsbnUrl: empty ISBN returns empty string", () => {
306
+ assert.equal(openLibraryIsbnUrl(""), "");
307
+ });
308
+
309
+ test("amazonSearchLink: prefers ISBN13", () => {
310
+ const url = amazonSearchLink({
311
+ isbn13: "9781234567890",
312
+ isbn10: "1234567890",
313
+ title: "Test",
314
+ author: "Doe",
315
+ });
316
+ assert.ok(url.includes("9781234567890"), `expected ISBN13 in URL, got: ${url}`);
317
+ });
318
+
319
+ test("amazonSearchLink: falls back to ISBN10 when no ISBN13", () => {
320
+ const url = amazonSearchLink({
321
+ isbn13: "",
322
+ isbn10: "1234567890",
323
+ title: "Test",
324
+ author: "Doe",
325
+ });
326
+ assert.ok(url.includes("1234567890"), `expected ISBN10 in URL, got: ${url}`);
327
+ });
328
+
329
+ test("amazonSearchLink: falls back to title+author when no ISBNs", () => {
330
+ const url = amazonSearchLink({ isbn13: "", isbn10: "", title: "My Book", author: "Jane" });
331
+ // encodeURIComponent uses %20, not +
332
+ assert.ok(url.includes("My%20Book"), `expected title in URL, got: ${url}`);
333
+ });
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // parseCSV
337
+ // ---------------------------------------------------------------------------
338
+
339
+ test("parseCSV: parses simple two-row CSV", () => {
340
+ const csv = "col1,col2,col3\nval1,val2,val3\n";
341
+ const rows = parseCSV(csv);
342
+ assert.deepEqual(rows[0], ["col1", "col2", "col3"]);
343
+ assert.deepEqual(rows[1], ["val1", "val2", "val3"]);
344
+ });
345
+
346
+ test("parseCSV: handles quoted fields containing commas", () => {
347
+ const csv = 'a,"b,c",d\n';
348
+ const rows = parseCSV(csv);
349
+ assert.deepEqual(rows[0], ["a", "b,c", "d"]);
350
+ });
351
+
352
+ test("parseCSV: handles escaped double quotes inside quoted fields", () => {
353
+ const csv = 'a,"say ""hello""",b\n';
354
+ const rows = parseCSV(csv);
355
+ assert.deepEqual(rows[0], ["a", 'say "hello"', "b"]);
356
+ });
357
+
358
+ test("parseCSV: handles Goodreads ISBN format =\"...\"", () => {
359
+ // The CSV parser sees ="9781234567890": the = is a literal char, then "..." is a quoted segment.
360
+ // Result: =9781234567890 (quotes stripped by parser). cleanIsbn then strips the leading =.
361
+ const csv = 'Title,ISBN\n"Test Book",="9781234567890"\n';
362
+ const rows = parseCSV(csv);
363
+ assert.equal(rows[1][1], "=9781234567890");
364
+ assert.equal(cleanIsbn(rows[1][1]), "9781234567890");
365
+ });
366
+
367
+ test("parseCSV: handles CRLF line endings", () => {
368
+ const csv = "a,b\r\nc,d\r\n";
369
+ const rows = parseCSV(csv);
370
+ assert.deepEqual(rows[0], ["a", "b"]);
371
+ assert.deepEqual(rows[1], ["c", "d"]);
372
+ });
373
+
374
+ test("parseCSV: no trailing newline still parses last row", () => {
375
+ const csv = "a,b\nc,d";
376
+ const rows = parseCSV(csv);
377
+ assert.equal(rows.length, 2);
378
+ assert.deepEqual(rows[1], ["c", "d"]);
379
+ });
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // parseExistingMarkdown
383
+ // ---------------------------------------------------------------------------
384
+
385
+ test("parseExistingMarkdown: parses list-style finished block", () => {
386
+ const md = sampleMarkdown({ finished: ["2023-01-15", "2024-07-22"] });
387
+ const { finished } = parseExistingMarkdown(md);
388
+ assert.deepEqual(finished, ["2023-01-15", "2024-07-22"]);
389
+ });
390
+
391
+ test("parseExistingMarkdown: parses scalar-style finished field", () => {
392
+ const md = [
393
+ "---",
394
+ 'title: "Test Book"',
395
+ "rating: 4",
396
+ 'finished: "2023-01-15"',
397
+ "---",
398
+ "",
399
+ ].join("\n");
400
+ const { finished } = parseExistingMarkdown(md);
401
+ assert.deepEqual(finished, ["2023-01-15"]);
402
+ });
403
+
404
+ test("parseExistingMarkdown: deduplicates dates from list", () => {
405
+ const md = sampleMarkdown({ finished: ["2023-01-15", "2023-01-15"] });
406
+ const { finished } = parseExistingMarkdown(md);
407
+ assert.deepEqual(finished, ["2023-01-15"]);
408
+ });
409
+
410
+ test("parseExistingMarkdown: returns empty array when no finished field", () => {
411
+ const md = ["---", 'title: "Test"', "rating: 3", "---", ""].join("\n");
412
+ const { finished } = parseExistingMarkdown(md);
413
+ assert.deepEqual(finished, []);
414
+ });
415
+
416
+ test("parseExistingMarkdown: returns empty array for invalid front matter", () => {
417
+ const { finished } = parseExistingMarkdown("no front matter here");
418
+ assert.deepEqual(finished, []);
419
+ });
420
+
421
+ // ---------------------------------------------------------------------------
422
+ // replaceFinishedBlock
423
+ // ---------------------------------------------------------------------------
424
+
425
+ test("replaceFinishedBlock: replaces list-style block with new dates", () => {
426
+ const md = sampleMarkdown({ finished: ["2023-01-15"] });
427
+ const result = replaceFinishedBlock(md, ["2023-01-15", "2024-07-22"]);
428
+ assert.ok(result.includes(' - "2023-01-15"'));
429
+ assert.ok(result.includes(' - "2024-07-22"'));
430
+ });
431
+
432
+ test("replaceFinishedBlock: replaces scalar-style finished with list", () => {
433
+ const md = [
434
+ "---",
435
+ 'title: "Test Book"',
436
+ "rating: 4",
437
+ 'finished: "2023-01-15"',
438
+ "links:",
439
+ ' amazon: "https://www.amazon.com/s?k=test"',
440
+ "---",
441
+ "",
442
+ ].join("\n");
443
+ const result = replaceFinishedBlock(md, ["2023-01-15", "2024-07-22"]);
444
+ assert.ok(result.includes("finished:"));
445
+ assert.ok(result.includes(' - "2023-01-15"'));
446
+ assert.ok(result.includes(' - "2024-07-22"'));
447
+ assert.ok(!result.includes('finished: "2023-01-15"'));
448
+ });
449
+
450
+ test("replaceFinishedBlock: preserves other front matter fields", () => {
451
+ const md = sampleMarkdown({ finished: ["2023-01-15"] });
452
+ const result = replaceFinishedBlock(md, ["2023-01-15", "2024-07-22"]);
453
+ assert.ok(result.includes('title: "Test Book"'));
454
+ assert.ok(result.includes("rating: 4"));
455
+ assert.ok(result.includes("openlibrary:"));
456
+ });
457
+
458
+ test("replaceFinishedBlock: single new date produces single list item", () => {
459
+ const md = sampleMarkdown({ finished: ["2023-01-15", "2024-07-22"] });
460
+ const result = replaceFinishedBlock(md, ["2023-01-15"]);
461
+ assert.ok(result.includes(' - "2023-01-15"'));
462
+ assert.ok(!result.includes("2024-07-22"));
463
+ });
464
+
465
+ // ---------------------------------------------------------------------------
466
+ // updateTitleInFrontMatter
467
+ // ---------------------------------------------------------------------------
468
+
469
+ test("updateTitleInFrontMatter: replaces title line", () => {
470
+ const md = sampleMarkdown({ title: "Old Title" });
471
+ const result = updateTitleInFrontMatter(md, "New Title");
472
+ assert.ok(result.includes('title: "New Title"'));
473
+ assert.ok(!result.includes('title: "Old Title"'));
474
+ });
475
+
476
+ test("updateTitleInFrontMatter: escapes special chars in new title", () => {
477
+ const md = sampleMarkdown({ title: "Old Title" });
478
+ const result = updateTitleInFrontMatter(md, 'Title with "quotes"');
479
+ assert.ok(result.includes('title: "Title with \\"quotes\\""'));
480
+ });
481
+
482
+ test("updateTitleInFrontMatter: preserves all other front matter", () => {
483
+ const md = sampleMarkdown({ title: "Old", rating: 3 });
484
+ const result = updateTitleInFrontMatter(md, "New");
485
+ assert.ok(result.includes("rating: 3"));
486
+ assert.ok(result.includes("goodreads:"));
487
+ });
488
+
489
+ // ---------------------------------------------------------------------------
490
+ // extractGoodreadsId
491
+ // ---------------------------------------------------------------------------
492
+
493
+ test("extractGoodreadsId: extracts numeric ID from goodreads URL", () => {
494
+ const md = sampleMarkdown({ goodreadsId: "61150728" });
495
+ assert.equal(extractGoodreadsId(md), "61150728");
496
+ });
497
+
498
+ test("extractGoodreadsId: returns null when not present", () => {
499
+ const md = ['---', 'title: "Test"', '---', ''].join("\n");
500
+ assert.equal(extractGoodreadsId(md), null);
501
+ });
502
+
503
+ // ---------------------------------------------------------------------------
504
+ // extractFileIsbns
505
+ // ---------------------------------------------------------------------------
506
+
507
+ test("extractFileIsbns: extracts ISBN from openlibrary URL", () => {
508
+ const md = sampleMarkdown({ isbn13: "9781234567890" });
509
+ const isbns = extractFileIsbns(md);
510
+ assert.ok(isbns.includes("9781234567890"), `expected ISBN in result: ${isbns}`);
511
+ });
512
+
513
+ test("extractFileIsbns: extracts bare ISBN from amazon search URL", () => {
514
+ const md = sampleMarkdown({ isbn13: "9781234567890" });
515
+ const isbns = extractFileIsbns(md);
516
+ assert.ok(isbns.includes("9781234567890"));
517
+ });
518
+
519
+ test("extractFileIsbns: returns empty array when no ISBNs in URLs", () => {
520
+ const md = [
521
+ "---",
522
+ 'title: "Test"',
523
+ "links:",
524
+ ' amazon: "https://www.amazon.com/s?k=Some+Book+Title"',
525
+ ' openlibrary: ""',
526
+ "---",
527
+ "",
528
+ ].join("\n");
529
+ const isbns = extractFileIsbns(md);
530
+ assert.deepEqual(isbns, []);
531
+ });
532
+
533
+ // ---------------------------------------------------------------------------
534
+ // toFrontMatter
535
+ // ---------------------------------------------------------------------------
536
+
537
+ test("toFrontMatter: produces correct YAML structure", () => {
538
+ const fm = toFrontMatter({
539
+ title: "Test Book",
540
+ author: "John Doe",
541
+ rating: 4,
542
+ finished: ["2023-01-15"],
543
+ links: {
544
+ amazon: "https://www.amazon.com/s?k=9781234567890",
545
+ openlibrary: "https://openlibrary.org/search?isbn=9781234567890",
546
+ goodreads: "https://www.goodreads.com/book/show/12345",
547
+ },
548
+ });
549
+
550
+ assert.ok(fm.startsWith("---\n"));
551
+ assert.ok(fm.endsWith("\n---"));
552
+ assert.ok(fm.includes('title: "Test Book"'));
553
+ assert.ok(fm.includes('author: "John Doe"'));
554
+ assert.ok(fm.includes("rating: 4"));
555
+ assert.ok(fm.includes("finished:"));
556
+ assert.ok(fm.includes(' - "2023-01-15"'));
557
+ assert.ok(fm.includes("links:"));
558
+ assert.ok(fm.includes("amazon:"));
559
+ assert.ok(fm.includes("openlibrary:"));
560
+ assert.ok(fm.includes("goodreads:"));
561
+ });
562
+
563
+ test("toFrontMatter: escapes special chars in title and author", () => {
564
+ const fm = toFrontMatter({
565
+ title: 'Book "One"',
566
+ author: 'Author "A"',
567
+ rating: 0,
568
+ finished: [],
569
+ links: { amazon: "", openlibrary: "", goodreads: "" },
570
+ });
571
+ assert.ok(fm.includes('title: "Book \\"One\\""'));
572
+ assert.ok(fm.includes('author: "Author \\"A\\""'));
573
+ });
574
+
575
+ // ---------------------------------------------------------------------------
576
+ // buildExistingIndex (async)
577
+ // ---------------------------------------------------------------------------
578
+
579
+ test("buildExistingIndex: indexes files by Goodreads ID and ISBN", async () => {
580
+ await withTempDir(async (tmpDir) => {
581
+ const authorDir = path.join(tmpDir, "doe-john");
582
+ await fs.mkdir(authorDir);
583
+ const mdPath = path.join(authorDir, "test-book.md");
584
+ await fs.writeFile(mdPath, sampleMarkdown({ goodreadsId: "12345", isbn13: "9781234567890" }));
585
+
586
+ const index = await buildExistingIndex(tmpDir);
587
+
588
+ assert.equal(index.byGoodreadsId.get("12345"), mdPath);
589
+ assert.equal(index.byIsbn.get("9781234567890"), mdPath);
590
+ });
591
+ });
592
+
593
+ test("buildExistingIndex: returns empty index for missing directory", async () => {
594
+ const index = await buildExistingIndex("/nonexistent/path/xyz");
595
+ assert.equal(index.byGoodreadsId.size, 0);
596
+ assert.equal(index.byIsbn.size, 0);
597
+ });
598
+
599
+ test("buildExistingIndex: indexes multiple books across author dirs", async () => {
600
+ await withTempDir(async (tmpDir) => {
601
+ const dir1 = path.join(tmpDir, "doe-john");
602
+ const dir2 = path.join(tmpDir, "smith-jane");
603
+ await fs.mkdir(dir1);
604
+ await fs.mkdir(dir2);
605
+
606
+ await fs.writeFile(
607
+ path.join(dir1, "book-a.md"),
608
+ sampleMarkdown({ goodreadsId: "111", isbn13: "9780000000001" })
609
+ );
610
+ await fs.writeFile(
611
+ path.join(dir2, "book-b.md"),
612
+ sampleMarkdown({ goodreadsId: "222", isbn13: "9780000000002" })
613
+ );
614
+
615
+ const index = await buildExistingIndex(tmpDir);
616
+
617
+ assert.equal(index.byGoodreadsId.size, 2);
618
+ assert.equal(index.byIsbn.size, 2);
619
+ assert.ok(index.byGoodreadsId.has("111"));
620
+ assert.ok(index.byGoodreadsId.has("222"));
621
+ });
622
+ });
623
+
624
+ test("buildExistingIndex: ignores non-.md files", async () => {
625
+ await withTempDir(async (tmpDir) => {
626
+ const dir = path.join(tmpDir, "doe-john");
627
+ await fs.mkdir(dir);
628
+ await fs.writeFile(path.join(dir, "notes.txt"), "not a book");
629
+ await fs.writeFile(
630
+ path.join(dir, "book.md"),
631
+ sampleMarkdown({ goodreadsId: "999" })
632
+ );
633
+
634
+ const index = await buildExistingIndex(tmpDir);
635
+ assert.equal(index.byGoodreadsId.size, 1);
636
+ });
637
+ });
638
+
639
+ // ---------------------------------------------------------------------------
640
+ // findExistingFile (async)
641
+ // ---------------------------------------------------------------------------
642
+
643
+ test("findExistingFile: finds by Goodreads ID in index", async () => {
644
+ await withTempDir(async (tmpDir) => {
645
+ const filePath = path.join(tmpDir, "some-dir", "old-title.md");
646
+ const index = {
647
+ byGoodreadsId: new Map([["99999", filePath]]),
648
+ byIsbn: new Map(),
649
+ };
650
+ const b = { bookId: "99999", isbn13: "", isbn10: "" };
651
+ const outPath = path.join(tmpDir, "some-dir", "new-title.md");
652
+
653
+ const result = await findExistingFile(b, outPath, index);
654
+ assert.equal(result, filePath);
655
+ });
656
+ });
657
+
658
+ test("findExistingFile: finds by ISBN13 when no Goodreads ID match", async () => {
659
+ await withTempDir(async (tmpDir) => {
660
+ const filePath = path.join(tmpDir, "author", "old-title.md");
661
+ const index = {
662
+ byGoodreadsId: new Map(),
663
+ byIsbn: new Map([["9781234567890", filePath]]),
664
+ };
665
+ const b = { bookId: "unknown", isbn13: "9781234567890", isbn10: "" };
666
+ const outPath = path.join(tmpDir, "author", "new-title.md");
667
+
668
+ const result = await findExistingFile(b, outPath, index);
669
+ assert.equal(result, filePath);
670
+ });
671
+ });
672
+
673
+ test("findExistingFile: finds by ISBN10 when no ISBN13 match", async () => {
674
+ await withTempDir(async (tmpDir) => {
675
+ const filePath = path.join(tmpDir, "author", "old-title.md");
676
+ const index = {
677
+ byGoodreadsId: new Map(),
678
+ byIsbn: new Map([["1234567890", filePath]]),
679
+ };
680
+ const b = { bookId: "", isbn13: "", isbn10: "1234567890" };
681
+ const outPath = path.join(tmpDir, "author", "new-title.md");
682
+
683
+ const result = await findExistingFile(b, outPath, index);
684
+ assert.equal(result, filePath);
685
+ });
686
+ });
687
+
688
+ test("findExistingFile: falls back to outPath when file exists there", async () => {
689
+ await withTempDir(async (tmpDir) => {
690
+ const outPath = path.join(tmpDir, "test.md");
691
+ await fs.writeFile(outPath, "content");
692
+ const index = { byGoodreadsId: new Map(), byIsbn: new Map() };
693
+ const b = { bookId: "", isbn13: "", isbn10: "" };
694
+
695
+ const result = await findExistingFile(b, outPath, index);
696
+ assert.equal(result, outPath);
697
+ });
698
+ });
699
+
700
+ test("findExistingFile: returns null when no match found anywhere", async () => {
701
+ await withTempDir(async (tmpDir) => {
702
+ const outPath = path.join(tmpDir, "nonexistent.md");
703
+ const index = { byGoodreadsId: new Map(), byIsbn: new Map() };
704
+ const b = { bookId: "xyz", isbn13: "0000000000000", isbn10: "0000000000" };
705
+
706
+ const result = await findExistingFile(b, outPath, index);
707
+ assert.equal(result, null);
708
+ });
709
+ });
710
+
711
+ // ---------------------------------------------------------------------------
712
+ // Integration: CSV parsing pipeline
713
+ // ---------------------------------------------------------------------------
714
+
715
+ const SAMPLE_CSV = [
716
+ "Book Id,Title,Author,Author l-f,ISBN,ISBN13,My Rating,Date Read,Date Added,Exclusive Shelf,My Review,Read Count",
717
+ '12345,"Test Book","John Doe","Doe, John",="1234567890",="9781234567890",4,2023/1/15,2023/1/1,read,"Great read",1',
718
+ '67890,"Another Book","Jane Smith","Smith, Jane",="",="9780987654321",3,2024/3/20,2024/3/1,read,"",1',
719
+ '11111,"Unread Book","Bob Brown","Brown, Bob",="",="",0,,2024/1/1,to-read,"",0',
720
+ '99999,"Second Read","John Doe","Doe, John",="",="9781111111111",5,2023/6/1,2023/5/1,read,"",2',
721
+ '12345,"Test Book","John Doe","Doe, John",="1234567890",="9781234567890",4,2024/2/10,2023/1/1,read,"Great read",2',
722
+ ].join("\n");
723
+
724
+ test("integration: CSV parses read-shelf books and skips to-read", () => {
725
+ const rows = parseCSV(SAMPLE_CSV);
726
+ const header = rows[0];
727
+ const shelfIdx = header.indexOf("Exclusive Shelf");
728
+ const readRows = rows.slice(1).filter((r) => r[shelfIdx] === "read");
729
+ assert.equal(readRows.length, 4); // 11111 (to-read) skipped
730
+ });
731
+
732
+ test("integration: ISBN cleanup pipeline from Goodreads format", () => {
733
+ const rows = parseCSV(SAMPLE_CSV);
734
+ const header = rows[0];
735
+ const isbnIdx = header.indexOf("ISBN");
736
+ const isbn13Idx = header.indexOf("ISBN13");
737
+
738
+ const firstBook = rows[1];
739
+ assert.equal(cleanIsbn(firstBook[isbnIdx]), "1234567890");
740
+ assert.equal(cleanIsbn(firstBook[isbn13Idx]), "9781234567890");
741
+
742
+ const secondBook = rows[2];
743
+ assert.equal(cleanIsbn(secondBook[isbnIdx]), "");
744
+ assert.equal(cleanIsbn(secondBook[isbn13Idx]), "9780987654321");
745
+ });
746
+
747
+ test("integration: date parsing from YYYY/MM/DD CSV format", () => {
748
+ const rows = parseCSV(SAMPLE_CSV);
749
+ const header = rows[0];
750
+ const dateReadIdx = header.indexOf("Date Read");
751
+
752
+ assert.equal(toISODate(rows[1][dateReadIdx]), "2023-01-15");
753
+ assert.equal(toISODate(rows[2][dateReadIdx]), "2024-03-20");
754
+ });
755
+
756
+ test("integration: author directory derived from Author l-f", () => {
757
+ const rows = parseCSV(SAMPLE_CSV);
758
+ const header = rows[0];
759
+ const authorLFIdx = header.indexOf("Author l-f");
760
+
761
+ assert.equal(authorDirFromAuthorLF(rows[1][authorLFIdx]), "doe-john");
762
+ assert.equal(authorDirFromAuthorLF(rows[2][authorLFIdx]), "smith-jane");
763
+ });
764
+
765
+ test("integration: full round-trip — write and re-read a book file", async () => {
766
+ await withTempDir(async (tmpDir) => {
767
+ const authorDir = path.join(tmpDir, "doe-john");
768
+ await fs.mkdir(authorDir);
769
+
770
+ const fm = toFrontMatter({
771
+ title: "Test Book",
772
+ author: "John Doe",
773
+ rating: 4,
774
+ finished: ["2023-01-15"],
775
+ links: {
776
+ amazon: "https://www.amazon.com/s?k=9781234567890",
777
+ openlibrary: "https://openlibrary.org/search?isbn=9781234567890",
778
+ goodreads: "https://www.goodreads.com/book/show/12345",
779
+ },
780
+ });
781
+ const filePath = path.join(authorDir, "test-book.md");
782
+ await fs.writeFile(filePath, fm + "\n");
783
+
784
+ // Simulate a re-import: merge a new finished date
785
+ const content = await fs.readFile(filePath, "utf8");
786
+ const { finished } = parseExistingMarkdown(content);
787
+ assert.deepEqual(finished, ["2023-01-15"]);
788
+
789
+ const merged = uniqSortedDates([...finished, "2024-02-10"]);
790
+ const patched = replaceFinishedBlock(content, merged);
791
+ await fs.writeFile(filePath, patched, "utf8");
792
+
793
+ const updated = await fs.readFile(filePath, "utf8");
794
+ const { finished: updatedDates } = parseExistingMarkdown(updated);
795
+ assert.deepEqual(updatedDates, ["2023-01-15", "2024-02-10"]);
796
+ });
797
+ });
798
+
799
+ test("integration: title-change rename via buildExistingIndex", async () => {
800
+ await withTempDir(async (tmpDir) => {
801
+ // Write a file under the old title slug
802
+ const authorDir = path.join(tmpDir, "doe-john");
803
+ await fs.mkdir(authorDir);
804
+ const oldPath = path.join(authorDir, "old-title.md");
805
+ await fs.writeFile(
806
+ oldPath,
807
+ sampleMarkdown({ title: "Old Title", goodreadsId: "12345", isbn13: "9781234567890" })
808
+ );
809
+
810
+ // Build index — should find the file by Goodreads ID
811
+ const index = await buildExistingIndex(tmpDir);
812
+ assert.equal(index.byGoodreadsId.get("12345"), oldPath);
813
+
814
+ // Simulate incoming CSV entry with new title
815
+ const b = { bookId: "12345", isbn13: "9781234567890", isbn10: "" };
816
+ const newPath = path.join(authorDir, "new-title.md");
817
+ const found = await findExistingFile(b, newPath, index);
818
+
819
+ // Should find the old path, revealing a title change is needed
820
+ assert.equal(found, oldPath);
821
+ assert.notEqual(found, newPath);
822
+
823
+ // Apply the rename
824
+ const existingContent = await fs.readFile(oldPath, "utf8");
825
+ const patched = updateTitleInFrontMatter(existingContent, "New Title");
826
+ await fs.writeFile(newPath, patched, "utf8");
827
+ await fs.unlink(oldPath);
828
+
829
+ // Verify
830
+ assert.ok(!(await fs.access(oldPath).then(() => true).catch(() => false)));
831
+ const newContent = await fs.readFile(newPath, "utf8");
832
+ assert.ok(newContent.includes('title: "New Title"'));
833
+ });
834
+ });