@chriswiegman/hugo-tools 0.1.2 → 0.2.1

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