@chriswiegman/hugo-tools 0.2.0 → 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/CHANGELOG.md +7 -0
- package/README.md +12 -1
- package/package.json +6 -5
- package/src/add-tags.js +103 -100
- package/src/book.mjs +602 -541
- package/src/book.test.mjs +619 -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,751 @@
|
|
|
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
|
-
} from
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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(
|
|
72
|
-
|
|
72
|
+
test('slugify: basic lowercase and hyphenation', () => {
|
|
73
|
+
assert.equal(slugify('Hello World'), 'hello-world');
|
|
73
74
|
});
|
|
74
75
|
|
|
75
|
-
test(
|
|
76
|
-
|
|
76
|
+
test('slugify: collapses multiple spaces and hyphens', () => {
|
|
77
|
+
assert.equal(slugify('Hello World--Test'), 'hello-world-test');
|
|
77
78
|
});
|
|
78
79
|
|
|
79
|
-
test(
|
|
80
|
-
|
|
80
|
+
test('slugify: removes special characters', () => {
|
|
81
|
+
assert.equal(slugify('It\'s a Test!'), 'its-a-test');
|
|
81
82
|
});
|
|
82
83
|
|
|
83
|
-
test(
|
|
84
|
-
|
|
84
|
+
test('slugify: strips em-dashes and mdash-like punctuation', () => {
|
|
85
|
+
assert.equal(slugify('Title\u2014Subtitle'), 'titlesubtitle');
|
|
85
86
|
});
|
|
86
87
|
|
|
87
|
-
test(
|
|
88
|
-
|
|
88
|
+
test('slugify: normalizes unicode accents', () => {
|
|
89
|
+
assert.equal(slugify('Café Résumé'), 'cafe-resume');
|
|
89
90
|
});
|
|
90
91
|
|
|
91
|
-
test(
|
|
92
|
-
|
|
92
|
+
test('slugify: returns \'unknown\' for empty string', () => {
|
|
93
|
+
assert.equal(slugify(''), 'unknown');
|
|
93
94
|
});
|
|
94
95
|
|
|
95
|
-
test(
|
|
96
|
-
|
|
96
|
+
test('slugify: returns \'unknown\' for null', () => {
|
|
97
|
+
assert.equal(slugify(null), 'unknown');
|
|
97
98
|
});
|
|
98
99
|
|
|
99
|
-
test(
|
|
100
|
-
|
|
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(
|
|
108
|
-
|
|
108
|
+
test('cleanIsbn: Goodreads ISBN13 format ="..."', () => {
|
|
109
|
+
assert.equal(cleanIsbn('="9781234567890"'), '9781234567890');
|
|
109
110
|
});
|
|
110
111
|
|
|
111
|
-
test(
|
|
112
|
-
|
|
112
|
+
test('cleanIsbn: Goodreads ISBN10 format ="..."', () => {
|
|
113
|
+
assert.equal(cleanIsbn('="1234567890"'), '1234567890');
|
|
113
114
|
});
|
|
114
115
|
|
|
115
|
-
test(
|
|
116
|
-
|
|
116
|
+
test('cleanIsbn: empty Goodreads field =""', () => {
|
|
117
|
+
assert.equal(cleanIsbn('=""'), '');
|
|
117
118
|
});
|
|
118
119
|
|
|
119
|
-
test(
|
|
120
|
-
|
|
120
|
+
test('cleanIsbn: plain ISBN string passed through', () => {
|
|
121
|
+
assert.equal(cleanIsbn('9780062694430'), '9780062694430');
|
|
121
122
|
});
|
|
122
123
|
|
|
123
|
-
test(
|
|
124
|
-
|
|
124
|
+
test('cleanIsbn: normalizes to uppercase (X check digit)', () => {
|
|
125
|
+
assert.equal(cleanIsbn('019x'), '019X');
|
|
125
126
|
});
|
|
126
127
|
|
|
127
|
-
test(
|
|
128
|
-
|
|
128
|
+
test('cleanIsbn: null returns empty string', () => {
|
|
129
|
+
assert.equal(cleanIsbn(null), '');
|
|
129
130
|
});
|
|
130
131
|
|
|
131
|
-
test(
|
|
132
|
-
|
|
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(
|
|
140
|
-
|
|
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(
|
|
144
|
-
|
|
144
|
+
test('toISODate: YYYY/MM/DD format', () => {
|
|
145
|
+
assert.equal(toISODate('2023/9/10'), '2023-09-10');
|
|
145
146
|
});
|
|
146
147
|
|
|
147
|
-
test(
|
|
148
|
-
|
|
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(
|
|
152
|
-
|
|
152
|
+
test('toISODate: M/D/YYYY format', () => {
|
|
153
|
+
assert.equal(toISODate('3/20/2024'), '2024-03-20');
|
|
153
154
|
});
|
|
154
155
|
|
|
155
|
-
test(
|
|
156
|
-
|
|
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(
|
|
160
|
-
|
|
160
|
+
test('toISODate: empty string returns null', () => {
|
|
161
|
+
assert.equal(toISODate(''), null);
|
|
161
162
|
});
|
|
162
163
|
|
|
163
|
-
test(
|
|
164
|
-
|
|
164
|
+
test('toISODate: null returns null', () => {
|
|
165
|
+
assert.equal(toISODate(null), null);
|
|
165
166
|
});
|
|
166
167
|
|
|
167
|
-
test(
|
|
168
|
-
|
|
168
|
+
test('toISODate: invalid string returns null', () => {
|
|
169
|
+
assert.equal(toISODate('not-a-date'), null);
|
|
169
170
|
});
|
|
170
171
|
|
|
171
|
-
test(
|
|
172
|
-
|
|
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(
|
|
180
|
-
test(
|
|
181
|
-
test(
|
|
182
|
-
test(
|
|
183
|
-
test(
|
|
184
|
-
test(
|
|
185
|
-
test(
|
|
186
|
-
test(
|
|
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(
|
|
193
|
-
|
|
193
|
+
test('escapeYAMLString: escapes double quotes', () => {
|
|
194
|
+
assert.equal(escapeYAMLString('say "hello"'), 'say \\"hello\\"');
|
|
194
195
|
});
|
|
195
196
|
|
|
196
|
-
test(
|
|
197
|
-
|
|
197
|
+
test('escapeYAMLString: escapes backslashes', () => {
|
|
198
|
+
assert.equal(escapeYAMLString('a\\b'), 'a\\\\b');
|
|
198
199
|
});
|
|
199
200
|
|
|
200
|
-
test(
|
|
201
|
-
|
|
201
|
+
test('escapeYAMLString: empty string', () => {
|
|
202
|
+
assert.equal(escapeYAMLString(''), '');
|
|
202
203
|
});
|
|
203
204
|
|
|
204
|
-
test(
|
|
205
|
-
|
|
205
|
+
test('escapeYAMLString: null becomes empty string', () => {
|
|
206
|
+
assert.equal(escapeYAMLString(null), '');
|
|
206
207
|
});
|
|
207
208
|
|
|
208
|
-
test(
|
|
209
|
-
|
|
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(
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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(
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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(
|
|
231
|
-
|
|
231
|
+
test('uniqSortedDates: filters empty/blank entries', () => {
|
|
232
|
+
assert.deepEqual(uniqSortedDates(['2023-01-15', '', ' ']), ['2023-01-15']);
|
|
232
233
|
});
|
|
233
234
|
|
|
234
|
-
test(
|
|
235
|
-
|
|
235
|
+
test('uniqSortedDates: empty array returns empty array', () => {
|
|
236
|
+
assert.deepEqual(uniqSortedDates([]), []);
|
|
236
237
|
});
|
|
237
238
|
|
|
238
|
-
test(
|
|
239
|
-
|
|
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(
|
|
247
|
-
|
|
247
|
+
test('authorDirFromAuthorLF: \'Last, First\' becomes \'last-first\'', () => {
|
|
248
|
+
assert.equal(authorDirFromAuthorLF('Baldacci, David'), 'baldacci-david');
|
|
248
249
|
});
|
|
249
250
|
|
|
250
|
-
test(
|
|
251
|
-
|
|
251
|
+
test('authorDirFromAuthorLF: single name without comma', () => {
|
|
252
|
+
assert.equal(authorDirFromAuthorLF('Homer'), 'homer');
|
|
252
253
|
});
|
|
253
254
|
|
|
254
|
-
test(
|
|
255
|
-
|
|
255
|
+
test('authorDirFromAuthorLF: empty returns \'unknown\'', () => {
|
|
256
|
+
assert.equal(authorDirFromAuthorLF(''), 'unknown');
|
|
256
257
|
});
|
|
257
258
|
|
|
258
|
-
test(
|
|
259
|
-
|
|
259
|
+
test('authorDirFromAuthorLF: null returns \'unknown\'', () => {
|
|
260
|
+
assert.equal(authorDirFromAuthorLF(null), 'unknown');
|
|
260
261
|
});
|
|
261
262
|
|
|
262
|
-
test(
|
|
263
|
-
|
|
263
|
+
test('authorDirFromAuthorLF: handles special chars in name', () => {
|
|
264
|
+
assert.equal(authorDirFromAuthorLF('O\'Brien, Tim'), 'obrien-tim');
|
|
264
265
|
});
|
|
265
266
|
|
|
266
|
-
test(
|
|
267
|
-
|
|
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(
|
|
275
|
-
|
|
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(
|
|
279
|
-
|
|
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(
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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(
|
|
294
|
-
|
|
295
|
-
|
|
294
|
+
test('goodreadsBookUrl: empty ID returns empty string', () => {
|
|
295
|
+
assert.equal(goodreadsBookUrl(''), '');
|
|
296
|
+
assert.equal(goodreadsBookUrl(null), '');
|
|
296
297
|
});
|
|
297
298
|
|
|
298
|
-
test(
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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(
|
|
306
|
-
|
|
306
|
+
test('openLibraryIsbnUrl: empty ISBN returns empty string', () => {
|
|
307
|
+
assert.equal(openLibraryIsbnUrl(''), '');
|
|
307
308
|
});
|
|
308
309
|
|
|
309
|
-
test(
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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(
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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(
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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(
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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(
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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(
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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(
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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(
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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(
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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(
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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(
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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(
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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(
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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(
|
|
417
|
-
|
|
418
|
-
|
|
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(
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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(
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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(
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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(
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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(
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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(
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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(
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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(
|
|
494
|
-
|
|
495
|
-
|
|
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(
|
|
499
|
-
|
|
500
|
-
|
|
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(
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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(
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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(
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
616
|
+
const index = await buildExistingIndex(tmpDir);
|
|
587
617
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
618
|
+
assert.equal(index.byGoodreadsId.get('12345'), mdPath);
|
|
619
|
+
assert.equal(index.byIsbn.get('9781234567890'), mdPath);
|
|
620
|
+
});
|
|
591
621
|
});
|
|
592
622
|
|
|
593
|
-
test(
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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(
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
647
|
+
const index = await buildExistingIndex(tmpDir);
|
|
616
648
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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(
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
635
|
-
|
|
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(
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
});
|
|
689
|
+
assert.equal(result, filePath);
|
|
690
|
+
});
|
|
656
691
|
});
|
|
657
692
|
|
|
658
|
-
test(
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
703
|
+
const result = await findExistingFile(b, outPath, index);
|
|
704
|
+
|
|
705
|
+
assert.equal(result, filePath);
|
|
706
|
+
});
|
|
671
707
|
});
|
|
672
708
|
|
|
673
|
-
test(
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
});
|
|
721
|
+
assert.equal(result, filePath);
|
|
722
|
+
});
|
|
686
723
|
});
|
|
687
724
|
|
|
688
|
-
test(
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
});
|
|
735
|
+
assert.equal(result, outPath);
|
|
736
|
+
});
|
|
698
737
|
});
|
|
699
738
|
|
|
700
|
-
test(
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
707
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|