@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/CHANGELOG.md +18 -0
- package/README.md +12 -1
- package/package.json +6 -5
- package/src/add-tags.js +103 -100
- package/src/book.mjs +643 -542
- package/src/book.test.mjs +660 -566
- package/src/config-init.js +9 -9
- package/src/config.js +22 -20
- package/src/draft.js +44 -42
- package/src/draft.test.mjs +32 -27
- package/src/extract-tags.js +184 -173
- package/src/extract-tags.test.mjs +56 -43
- package/src/pick-image.js +111 -97
- package/src/pick-image.test.mjs +57 -46
- package/src/publish.js +363 -317
- package/src/publish.test.mjs +242 -201
- package/src/vscode.js +68 -65
package/src/book.test.mjs
CHANGED
|
@@ -1,711 +1,792 @@
|
|
|
1
|
-
import assert from
|
|
2
|
-
import { test } from
|
|
3
|
-
import fs from
|
|
4
|
-
import os from
|
|
5
|
-
import path from
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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(
|
|
72
|
-
|
|
73
|
+
test('slugify: basic lowercase and hyphenation', () => {
|
|
74
|
+
assert.equal(slugify('Hello World'), 'hello-world');
|
|
73
75
|
});
|
|
74
76
|
|
|
75
|
-
test(
|
|
76
|
-
|
|
77
|
+
test('slugify: collapses multiple spaces and hyphens', () => {
|
|
78
|
+
assert.equal(slugify('Hello World--Test'), 'hello-world-test');
|
|
77
79
|
});
|
|
78
80
|
|
|
79
|
-
test(
|
|
80
|
-
|
|
81
|
+
test('slugify: removes special characters', () => {
|
|
82
|
+
assert.equal(slugify('It\'s a Test!'), 'its-a-test');
|
|
81
83
|
});
|
|
82
84
|
|
|
83
|
-
test(
|
|
84
|
-
|
|
85
|
+
test('slugify: strips em-dashes and mdash-like punctuation', () => {
|
|
86
|
+
assert.equal(slugify('Title\u2014Subtitle'), 'titlesubtitle');
|
|
85
87
|
});
|
|
86
88
|
|
|
87
|
-
test(
|
|
88
|
-
|
|
89
|
+
test('slugify: normalizes unicode accents', () => {
|
|
90
|
+
assert.equal(slugify('Café Résumé'), 'cafe-resume');
|
|
89
91
|
});
|
|
90
92
|
|
|
91
|
-
test(
|
|
92
|
-
|
|
93
|
+
test('slugify: returns \'unknown\' for empty string', () => {
|
|
94
|
+
assert.equal(slugify(''), 'unknown');
|
|
93
95
|
});
|
|
94
96
|
|
|
95
|
-
test(
|
|
96
|
-
|
|
97
|
+
test('slugify: returns \'unknown\' for null', () => {
|
|
98
|
+
assert.equal(slugify(null), 'unknown');
|
|
97
99
|
});
|
|
98
100
|
|
|
99
|
-
test(
|
|
100
|
-
|
|
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(
|
|
108
|
-
|
|
109
|
+
test('cleanIsbn: Goodreads ISBN13 format ="..."', () => {
|
|
110
|
+
assert.equal(cleanIsbn('="9781234567890"'), '9781234567890');
|
|
109
111
|
});
|
|
110
112
|
|
|
111
|
-
test(
|
|
112
|
-
|
|
113
|
+
test('cleanIsbn: Goodreads ISBN10 format ="..."', () => {
|
|
114
|
+
assert.equal(cleanIsbn('="1234567890"'), '1234567890');
|
|
113
115
|
});
|
|
114
116
|
|
|
115
|
-
test(
|
|
116
|
-
|
|
117
|
+
test('cleanIsbn: empty Goodreads field =""', () => {
|
|
118
|
+
assert.equal(cleanIsbn('=""'), '');
|
|
117
119
|
});
|
|
118
120
|
|
|
119
|
-
test(
|
|
120
|
-
|
|
121
|
+
test('cleanIsbn: plain ISBN string passed through', () => {
|
|
122
|
+
assert.equal(cleanIsbn('9780062694430'), '9780062694430');
|
|
121
123
|
});
|
|
122
124
|
|
|
123
|
-
test(
|
|
124
|
-
|
|
125
|
+
test('cleanIsbn: normalizes to uppercase (X check digit)', () => {
|
|
126
|
+
assert.equal(cleanIsbn('019x'), '019X');
|
|
125
127
|
});
|
|
126
128
|
|
|
127
|
-
test(
|
|
128
|
-
|
|
129
|
+
test('cleanIsbn: null returns empty string', () => {
|
|
130
|
+
assert.equal(cleanIsbn(null), '');
|
|
129
131
|
});
|
|
130
132
|
|
|
131
|
-
test(
|
|
132
|
-
|
|
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(
|
|
140
|
-
|
|
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(
|
|
144
|
-
|
|
145
|
+
test('toISODate: YYYY/MM/DD format', () => {
|
|
146
|
+
assert.equal(toISODate('2023/9/10'), '2023-09-10');
|
|
145
147
|
});
|
|
146
148
|
|
|
147
|
-
test(
|
|
148
|
-
|
|
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(
|
|
152
|
-
|
|
153
|
+
test('toISODate: M/D/YYYY format', () => {
|
|
154
|
+
assert.equal(toISODate('3/20/2024'), '2024-03-20');
|
|
153
155
|
});
|
|
154
156
|
|
|
155
|
-
test(
|
|
156
|
-
|
|
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(
|
|
160
|
-
|
|
161
|
+
test('toISODate: empty string returns null', () => {
|
|
162
|
+
assert.equal(toISODate(''), null);
|
|
161
163
|
});
|
|
162
164
|
|
|
163
|
-
test(
|
|
164
|
-
|
|
165
|
+
test('toISODate: null returns null', () => {
|
|
166
|
+
assert.equal(toISODate(null), null);
|
|
165
167
|
});
|
|
166
168
|
|
|
167
|
-
test(
|
|
168
|
-
|
|
169
|
+
test('toISODate: invalid string returns null', () => {
|
|
170
|
+
assert.equal(toISODate('not-a-date'), null);
|
|
169
171
|
});
|
|
170
172
|
|
|
171
|
-
test(
|
|
172
|
-
|
|
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(
|
|
180
|
-
test(
|
|
181
|
-
test(
|
|
182
|
-
test(
|
|
183
|
-
test(
|
|
184
|
-
test(
|
|
185
|
-
test(
|
|
186
|
-
test(
|
|
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(
|
|
193
|
-
|
|
194
|
+
test('escapeYAMLString: escapes double quotes', () => {
|
|
195
|
+
assert.equal(escapeYAMLString('say "hello"'), 'say \\"hello\\"');
|
|
194
196
|
});
|
|
195
197
|
|
|
196
|
-
test(
|
|
197
|
-
|
|
198
|
+
test('escapeYAMLString: escapes backslashes', () => {
|
|
199
|
+
assert.equal(escapeYAMLString('a\\b'), 'a\\\\b');
|
|
198
200
|
});
|
|
199
201
|
|
|
200
|
-
test(
|
|
201
|
-
|
|
202
|
+
test('escapeYAMLString: empty string', () => {
|
|
203
|
+
assert.equal(escapeYAMLString(''), '');
|
|
202
204
|
});
|
|
203
205
|
|
|
204
|
-
test(
|
|
205
|
-
|
|
206
|
+
test('escapeYAMLString: null becomes empty string', () => {
|
|
207
|
+
assert.equal(escapeYAMLString(null), '');
|
|
206
208
|
});
|
|
207
209
|
|
|
208
|
-
test(
|
|
209
|
-
|
|
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(
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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(
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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(
|
|
231
|
-
|
|
232
|
+
test('uniqSortedDates: filters empty/blank entries', () => {
|
|
233
|
+
assert.deepEqual(uniqSortedDates(['2023-01-15', '', ' ']), ['2023-01-15']);
|
|
232
234
|
});
|
|
233
235
|
|
|
234
|
-
test(
|
|
235
|
-
|
|
236
|
+
test('uniqSortedDates: empty array returns empty array', () => {
|
|
237
|
+
assert.deepEqual(uniqSortedDates([]), []);
|
|
236
238
|
});
|
|
237
239
|
|
|
238
|
-
test(
|
|
239
|
-
|
|
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(
|
|
247
|
-
|
|
248
|
+
test('authorDirFromAuthorLF: \'Last, First\' becomes \'last-first\'', () => {
|
|
249
|
+
assert.equal(authorDirFromAuthorLF('Baldacci, David'), 'baldacci-david');
|
|
248
250
|
});
|
|
249
251
|
|
|
250
|
-
test(
|
|
251
|
-
|
|
252
|
+
test('authorDirFromAuthorLF: single name without comma', () => {
|
|
253
|
+
assert.equal(authorDirFromAuthorLF('Homer'), 'homer');
|
|
252
254
|
});
|
|
253
255
|
|
|
254
|
-
test(
|
|
255
|
-
|
|
256
|
+
test('authorDirFromAuthorLF: empty returns \'unknown\'', () => {
|
|
257
|
+
assert.equal(authorDirFromAuthorLF(''), 'unknown');
|
|
256
258
|
});
|
|
257
259
|
|
|
258
|
-
test(
|
|
259
|
-
|
|
260
|
+
test('authorDirFromAuthorLF: null returns \'unknown\'', () => {
|
|
261
|
+
assert.equal(authorDirFromAuthorLF(null), 'unknown');
|
|
260
262
|
});
|
|
261
263
|
|
|
262
|
-
test(
|
|
263
|
-
|
|
264
|
+
test('authorDirFromAuthorLF: handles special chars in name', () => {
|
|
265
|
+
assert.equal(authorDirFromAuthorLF('O\'Brien, Tim'), 'obrien-tim');
|
|
264
266
|
});
|
|
265
267
|
|
|
266
|
-
test(
|
|
267
|
-
|
|
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(
|
|
275
|
-
|
|
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(
|
|
279
|
-
|
|
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(
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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(
|
|
294
|
-
|
|
295
|
-
|
|
295
|
+
test('goodreadsBookUrl: empty ID returns empty string', () => {
|
|
296
|
+
assert.equal(goodreadsBookUrl(''), '');
|
|
297
|
+
assert.equal(goodreadsBookUrl(null), '');
|
|
296
298
|
});
|
|
297
299
|
|
|
298
|
-
test(
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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(
|
|
306
|
-
|
|
307
|
+
test('openLibraryIsbnUrl: empty ISBN returns empty string', () => {
|
|
308
|
+
assert.equal(openLibraryIsbnUrl(''), '');
|
|
307
309
|
});
|
|
308
310
|
|
|
309
|
-
test(
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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(
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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(
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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(
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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(
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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(
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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(
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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(
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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(
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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(
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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(
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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(
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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(
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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(
|
|
417
|
-
|
|
418
|
-
|
|
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(
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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(
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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(
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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(
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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(
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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(
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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(
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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(
|
|
494
|
-
|
|
495
|
-
|
|
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(
|
|
499
|
-
|
|
500
|
-
|
|
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(
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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(
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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(
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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(
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
});
|
|
562
|
-
|
|
563
|
-
test(
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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(
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
657
|
+
const index = await buildExistingIndex(tmpDir);
|
|
587
658
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
659
|
+
assert.equal(index.byGoodreadsId.get('12345'), mdPath);
|
|
660
|
+
assert.equal(index.byIsbn.get('9781234567890'), mdPath);
|
|
661
|
+
});
|
|
591
662
|
});
|
|
592
663
|
|
|
593
|
-
test(
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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(
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
688
|
+
const index = await buildExistingIndex(tmpDir);
|
|
616
689
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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(
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
635
|
-
|
|
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(
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
});
|
|
730
|
+
assert.equal(result, filePath);
|
|
731
|
+
});
|
|
656
732
|
});
|
|
657
733
|
|
|
658
|
-
test(
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
});
|
|
746
|
+
assert.equal(result, filePath);
|
|
747
|
+
});
|
|
671
748
|
});
|
|
672
749
|
|
|
673
|
-
test(
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
760
|
+
const result = await findExistingFile(b, outPath, index);
|
|
761
|
+
|
|
762
|
+
assert.equal(result, filePath);
|
|
763
|
+
});
|
|
686
764
|
});
|
|
687
765
|
|
|
688
|
-
test(
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
});
|
|
776
|
+
assert.equal(result, outPath);
|
|
777
|
+
});
|
|
698
778
|
});
|
|
699
779
|
|
|
700
|
-
test(
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
707
|
-
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
].join(
|
|
723
|
-
|
|
724
|
-
test(
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
});
|