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