@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.mjs
CHANGED
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
* - Existing files are matched by Goodreads Book ID first, then ISBN, then title slug.
|
|
13
13
|
* - When a match is found at a different path (title changed in Goodreads), the file is
|
|
14
14
|
* renamed to the new slug and the title in front matter is updated automatically.
|
|
15
|
-
* - For an existing book file, finished dates are merged
|
|
15
|
+
* - For an existing book file, finished dates are merged and the rating is updated when the
|
|
16
|
+
* Goodreads rating differs from the file's rating — but only when Goodreads has a rating
|
|
17
|
+
* (> 0). A Goodreads rating of 0 (unrated) never overwrites a rating already in the file,
|
|
18
|
+
* since you may have rated the book only in the markdown. Other fields are left untouched
|
|
16
19
|
* unless a rename occurs (in which case the title is also updated).
|
|
17
20
|
*
|
|
18
21
|
* Usage:
|
|
@@ -22,42 +25,44 @@
|
|
|
22
25
|
* - This script uses global fetch (Node 18+).
|
|
23
26
|
*/
|
|
24
27
|
|
|
25
|
-
import fs from
|
|
26
|
-
import { existsSync, readFileSync } from
|
|
27
|
-
import path from
|
|
28
|
+
import fs from 'node:fs/promises';
|
|
29
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
28
31
|
|
|
29
32
|
// -------------------- Config --------------------
|
|
30
33
|
|
|
31
34
|
const PROJECT_ROOT = process.cwd();
|
|
32
35
|
|
|
33
36
|
let _userConfig = {};
|
|
37
|
+
|
|
34
38
|
try {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
const cfgPath = path.join(PROJECT_ROOT, '.hugo-tools.json');
|
|
40
|
+
|
|
41
|
+
if (existsSync(cfgPath)) {
|
|
42
|
+
_userConfig = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
43
|
+
}
|
|
39
44
|
} catch {} // eslint-disable-line no-empty
|
|
40
45
|
|
|
41
|
-
const OUTPUT_ROOT = path.join(PROJECT_ROOT, _userConfig.booksDir ??
|
|
46
|
+
const OUTPUT_ROOT = path.join(PROJECT_ROOT, _userConfig.booksDir ?? 'content/books');
|
|
42
47
|
|
|
43
48
|
// -------------------- Utilities --------------------
|
|
44
49
|
|
|
45
50
|
function usageAndExit() {
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
console.error('Usage: node scripts/book.mjs path/to/goodreads.csv');
|
|
52
|
+
process.exit(1);
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
function slugify(s) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
return (
|
|
57
|
+
(s || '')
|
|
58
|
+
.trim()
|
|
59
|
+
.toLowerCase()
|
|
60
|
+
.normalize('NFKD')
|
|
61
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
62
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
63
|
+
.replace(/[\s_-]+/g, '-')
|
|
64
|
+
.replace(/^-+|-+$/g, '') || 'unknown'
|
|
65
|
+
);
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
/**
|
|
@@ -65,47 +70,48 @@ function slugify(s) {
|
|
|
65
70
|
* Good enough for Goodreads exports.
|
|
66
71
|
*/
|
|
67
72
|
function parseCSV(text) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
73
|
+
const rows = [];
|
|
74
|
+
let row = [];
|
|
75
|
+
let field = '';
|
|
76
|
+
let inQuotes = false;
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < text.length; i++) {
|
|
79
|
+
const c = text[i];
|
|
80
|
+
const next = text[i + 1];
|
|
81
|
+
|
|
82
|
+
if (inQuotes) {
|
|
83
|
+
if (c === '"' && next === '"') {
|
|
84
|
+
field += '"';
|
|
85
|
+
i++;
|
|
86
|
+
} else if (c === '"') {
|
|
87
|
+
inQuotes = false;
|
|
88
|
+
} else {
|
|
89
|
+
field += c;
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
if (c === '"') inQuotes = true;
|
|
93
|
+
else if (c === ',') {
|
|
94
|
+
row.push(field);
|
|
95
|
+
field = '';
|
|
96
|
+
} else if (c === '\r') {
|
|
97
|
+
// ignore
|
|
98
|
+
} else if (c === '\n') {
|
|
99
|
+
row.push(field);
|
|
100
|
+
rows.push(row);
|
|
101
|
+
row = [];
|
|
102
|
+
field = '';
|
|
103
|
+
} else {
|
|
104
|
+
field += c;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (field.length > 0 || row.length > 0) {
|
|
110
|
+
row.push(field);
|
|
111
|
+
rows.push(row);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return rows;
|
|
109
115
|
}
|
|
110
116
|
|
|
111
117
|
/**
|
|
@@ -114,105 +120,123 @@ function parseCSV(text) {
|
|
|
114
120
|
* Normalize to digits/X only.
|
|
115
121
|
*/
|
|
116
122
|
function cleanIsbn(s) {
|
|
117
|
-
|
|
118
|
-
|
|
123
|
+
const raw = String(s || '').trim();
|
|
124
|
+
|
|
125
|
+
if (!raw) return '';
|
|
119
126
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
127
|
+
// strip leading '=' and surrounding quotes
|
|
128
|
+
const stripped = raw.replace(/^=+/, '').replace(/^"+|"+$/g, '');
|
|
129
|
+
|
|
130
|
+
return stripped.replace(/[^0-9Xx]/g, '').toUpperCase();
|
|
123
131
|
}
|
|
124
132
|
|
|
125
133
|
function toISODate(dateStr) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
134
|
+
const s = (dateStr || '').trim();
|
|
135
|
+
|
|
136
|
+
if (!s) return null;
|
|
137
|
+
|
|
138
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
|
|
139
|
+
|
|
140
|
+
// YYYY/MM/DD (your export uses this)
|
|
141
|
+
let m = s.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/);
|
|
142
|
+
|
|
143
|
+
if (m) {
|
|
144
|
+
const year = Number(m[1]);
|
|
145
|
+
const month = Number(m[2]);
|
|
146
|
+
const day = Number(m[3]);
|
|
147
|
+
|
|
148
|
+
if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
|
|
149
|
+
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// M/D/YYYY
|
|
154
|
+
m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2}|\d{4})$/);
|
|
155
|
+
|
|
156
|
+
if (m) {
|
|
157
|
+
const month = Number(m[1]);
|
|
158
|
+
const day = Number(m[2]);
|
|
159
|
+
let year = Number(m[3]);
|
|
160
|
+
|
|
161
|
+
if (year < 100) year += 2000;
|
|
162
|
+
|
|
163
|
+
if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
|
|
164
|
+
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// last resort
|
|
169
|
+
const d = new Date(s);
|
|
170
|
+
|
|
171
|
+
if (!Number.isNaN(d.getTime())) {
|
|
172
|
+
const yyyy = d.getFullYear();
|
|
173
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
174
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
175
|
+
|
|
176
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return null;
|
|
164
180
|
}
|
|
165
181
|
|
|
166
182
|
function clampRating(n) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
183
|
+
if (!Number.isFinite(n)) return 0;
|
|
184
|
+
|
|
185
|
+
if (n < 0) return 0;
|
|
186
|
+
|
|
187
|
+
if (n > 5) return 5;
|
|
188
|
+
|
|
189
|
+
return Math.trunc(n);
|
|
171
190
|
}
|
|
172
191
|
|
|
173
192
|
function escapeYAMLString(s) {
|
|
174
|
-
|
|
193
|
+
return String(s ?? '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
175
194
|
}
|
|
176
195
|
|
|
177
196
|
function uniqSortedDates(dates) {
|
|
178
|
-
|
|
179
|
-
|
|
197
|
+
const set = new Set((dates || []).map((d) => String(d).trim()).filter(Boolean));
|
|
198
|
+
|
|
199
|
+
return Array.from(set).sort(); // YYYY-MM-DD sorts lexicographically
|
|
180
200
|
}
|
|
181
201
|
|
|
182
202
|
function keyFor(title, author) {
|
|
183
|
-
|
|
203
|
+
return `${title.trim().toLowerCase()}|${author.trim().toLowerCase()}`;
|
|
184
204
|
}
|
|
185
205
|
|
|
186
206
|
function goodreadsBookUrl(bookId) {
|
|
187
|
-
|
|
207
|
+
return bookId ? `https://www.goodreads.com/book/show/${bookId}` : '';
|
|
188
208
|
}
|
|
189
209
|
|
|
190
210
|
function openLibraryIsbnUrl(isbn) {
|
|
191
|
-
|
|
211
|
+
return isbn ? `https://openlibrary.org/search?isbn=${encodeURIComponent(isbn)}` : '';
|
|
192
212
|
}
|
|
193
213
|
|
|
194
214
|
function amazonSearchLink({ isbn13, isbn10, title, author }) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
215
|
+
// Search by ISBN13 (most specific) > ISBN10 > title+author — search pages never 404.
|
|
216
|
+
const query = isbn13 || isbn10 || `${title} ${author}`;
|
|
217
|
+
|
|
218
|
+
return `https://www.amazon.com/s?k=${encodeURIComponent(query)}`;
|
|
198
219
|
}
|
|
199
220
|
|
|
200
221
|
/**
|
|
201
222
|
* Use "Author l-f" (e.g. "Baldacci, David") → folder "baldacci-david"
|
|
202
223
|
*/
|
|
203
224
|
function authorDirFromAuthorLF(authorLF) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
225
|
+
const a = (authorLF || '').trim();
|
|
226
|
+
|
|
227
|
+
if (!a) return 'unknown';
|
|
228
|
+
|
|
229
|
+
if (a.includes(',')) {
|
|
230
|
+
const [last, first] = a.split(',', 2).map((x) => (x || '').trim());
|
|
231
|
+
const lastSlug = slugify(last);
|
|
232
|
+
const firstSlug = slugify(first);
|
|
233
|
+
|
|
234
|
+
if (lastSlug && firstSlug && firstSlug !== 'unknown') return `${lastSlug}-${firstSlug}`;
|
|
235
|
+
|
|
236
|
+
return lastSlug || firstSlug || 'unknown';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return slugify(a);
|
|
216
240
|
}
|
|
217
241
|
|
|
218
242
|
/**
|
|
@@ -223,47 +247,65 @@ function authorDirFromAuthorLF(authorLF) {
|
|
|
223
247
|
* - "YYYY-MM-DD"
|
|
224
248
|
* - "YYYY-MM-DD"
|
|
225
249
|
* - finished: "YYYY-MM-DD" (legacy scalar; included as one date)
|
|
250
|
+
*
|
|
251
|
+
* Also extracts the existing `rating:` value (or null if absent/unparseable).
|
|
226
252
|
*/
|
|
227
253
|
function parseExistingMarkdown(md) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
254
|
+
const m = md.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
|
|
255
|
+
|
|
256
|
+
if (!m) return { finished: [], rating: null };
|
|
257
|
+
|
|
258
|
+
const fm = m[1];
|
|
259
|
+
const lines = fm.split('\n');
|
|
260
|
+
const finished = [];
|
|
261
|
+
|
|
262
|
+
// list style
|
|
263
|
+
for (let i = 0; i < lines.length; i++) {
|
|
264
|
+
if (lines[i].trim() === 'finished:') {
|
|
265
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
266
|
+
const line = lines[j];
|
|
267
|
+
|
|
268
|
+
if (/^[A-Za-z0-9_-]+:/.test(line)) break; // new top-level key
|
|
269
|
+
|
|
270
|
+
const mm = line.match(/^\s*-\s*"?(\d{4}-\d{2}-\d{2})"?\s*$/);
|
|
271
|
+
|
|
272
|
+
if (mm) finished.push(mm[1]);
|
|
273
|
+
}
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// scalar legacy style
|
|
279
|
+
for (const line of lines) {
|
|
280
|
+
const mm = line.match(/^finished:\s*"?(\d{4}-\d{2}-\d{2})"?\s*$/);
|
|
281
|
+
|
|
282
|
+
if (mm) {
|
|
283
|
+
finished.push(mm[1]);
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let rating = null;
|
|
289
|
+
|
|
290
|
+
for (const line of lines) {
|
|
291
|
+
const mm = line.match(/^rating:\s*(\d+)\s*$/);
|
|
292
|
+
|
|
293
|
+
if (mm) {
|
|
294
|
+
rating = Number(mm[1]);
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return { finished: uniqSortedDates(finished), rating };
|
|
258
300
|
}
|
|
259
301
|
|
|
260
302
|
async function fileExists(p) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
303
|
+
try {
|
|
304
|
+
await fs.access(p);
|
|
305
|
+
return true;
|
|
306
|
+
} catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
267
309
|
}
|
|
268
310
|
|
|
269
311
|
// -------------------- Patch finished block in existing file --------------------
|
|
@@ -273,37 +315,46 @@ async function fileExists(p) {
|
|
|
273
315
|
* Leaves all other front matter (title, author, rating, links, body) untouched.
|
|
274
316
|
*/
|
|
275
317
|
function replaceFinishedBlock(md, newFinished) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
318
|
+
const block = ['finished:', ...newFinished.map((d) => ` - "${d}"`)];
|
|
319
|
+
const lines = md.split('\n');
|
|
320
|
+
|
|
321
|
+
// Find the "finished:" line
|
|
322
|
+
const finishedIdx = lines.findIndex((l) => /^finished:\s*$/.test(l));
|
|
323
|
+
|
|
324
|
+
if (finishedIdx !== -1) {
|
|
325
|
+
// List form: "finished:\n - ...\n - ..."
|
|
326
|
+
// Find where the list ends (first line that doesn't start with whitespace, after the key)
|
|
327
|
+
let endIdx = finishedIdx + 1;
|
|
328
|
+
|
|
329
|
+
while (endIdx < lines.length && /^\s/.test(lines[endIdx])) {
|
|
330
|
+
endIdx++;
|
|
331
|
+
}
|
|
332
|
+
return [...lines.slice(0, finishedIdx), ...block, ...lines.slice(endIdx)].join('\n');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Scalar form: 'finished: "YYYY-MM-DD"' or 'finished: YYYY-MM-DD'
|
|
336
|
+
const scalarIdx = lines.findIndex((l) => /^finished:\s+"?\d{4}-\d{2}-\d{2}"?\s*$/.test(l));
|
|
337
|
+
|
|
338
|
+
if (scalarIdx !== -1) {
|
|
339
|
+
return [...lines.slice(0, scalarIdx), ...block, ...lines.slice(scalarIdx + 1)].join('\n');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
console.warn(' WARNING: could not locate \'finished:\' block — file left unchanged');
|
|
343
|
+
return md;
|
|
300
344
|
}
|
|
301
345
|
|
|
302
346
|
/**
|
|
303
347
|
* Update the title field in front matter, leaving everything else untouched.
|
|
304
348
|
*/
|
|
305
349
|
function updateTitleInFrontMatter(md, newTitle) {
|
|
306
|
-
|
|
350
|
+
return md.replace(/^title:.*$/m, `title: "${escapeYAMLString(newTitle)}"`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Update the rating field in front matter, leaving everything else untouched.
|
|
355
|
+
*/
|
|
356
|
+
function updateRatingInFrontMatter(md, newRating) {
|
|
357
|
+
return md.replace(/^rating:.*$/m, `rating: ${newRating}`);
|
|
307
358
|
}
|
|
308
359
|
|
|
309
360
|
// -------------------- Existing-file index --------------------
|
|
@@ -313,8 +364,9 @@ function updateTitleInFrontMatter(md, newTitle) {
|
|
|
313
364
|
* Matches: goodreads: "https://www.goodreads.com/book/show/12345678"
|
|
314
365
|
*/
|
|
315
366
|
function extractGoodreadsId(md) {
|
|
316
|
-
|
|
317
|
-
|
|
367
|
+
const m = md.match(/goodreads:\s*"https:\/\/www\.goodreads\.com\/book\/show\/(\d+)"/);
|
|
368
|
+
|
|
369
|
+
return m ? m[1] : null;
|
|
318
370
|
}
|
|
319
371
|
|
|
320
372
|
/**
|
|
@@ -322,17 +374,19 @@ function extractGoodreadsId(md) {
|
|
|
322
374
|
* Checks the openlibrary URL (reliable) and the amazon URL (only when it looks like a bare ISBN).
|
|
323
375
|
*/
|
|
324
376
|
function extractFileIsbns(md) {
|
|
325
|
-
|
|
377
|
+
const isbns = new Set();
|
|
378
|
+
|
|
379
|
+
// openlibrary: "https://openlibrary.org/search?isbn=9780062694430"
|
|
380
|
+
const ol = md.match(/openlibrary:\s*"https:\/\/openlibrary\.org\/search\?isbn=([0-9X]+)"/i);
|
|
381
|
+
|
|
382
|
+
if (ol) isbns.add(ol[1].toUpperCase());
|
|
326
383
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if (ol) isbns.add(ol[1].toUpperCase());
|
|
384
|
+
// amazon: "https://www.amazon.com/s?k=9780062694430" — only when the query is a bare ISBN
|
|
385
|
+
const am = md.match(/amazon:\s*"https:\/\/www\.amazon\.com\/s\?k=([0-9X]{10,13})"/i);
|
|
330
386
|
|
|
331
|
-
|
|
332
|
-
const am = md.match(/amazon:\s*"https:\/\/www\.amazon\.com\/s\?k=([0-9X]{10,13})"/i);
|
|
333
|
-
if (am) isbns.add(am[1].toUpperCase());
|
|
387
|
+
if (am) isbns.add(am[1].toUpperCase());
|
|
334
388
|
|
|
335
|
-
|
|
389
|
+
return Array.from(isbns);
|
|
336
390
|
}
|
|
337
391
|
|
|
338
392
|
/**
|
|
@@ -340,50 +394,56 @@ function extractFileIsbns(md) {
|
|
|
340
394
|
* These are used to match incoming CSV rows even when the title (and thus slug) has changed.
|
|
341
395
|
*/
|
|
342
396
|
async function buildExistingIndex(outputRoot) {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
397
|
+
const byGoodreadsId = new Map(); // bookId → absolute filePath
|
|
398
|
+
const byIsbn = new Map(); // isbn (normalized) → absolute filePath
|
|
399
|
+
|
|
400
|
+
let dirEntries;
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
dirEntries = await fs.readdir(outputRoot, { withFileTypes: true });
|
|
404
|
+
} catch {
|
|
405
|
+
return { byGoodreadsId, byIsbn };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
for (const entry of dirEntries) {
|
|
409
|
+
if (!entry.isDirectory()) continue;
|
|
410
|
+
|
|
411
|
+
const dirPath = path.join(outputRoot, entry.name);
|
|
412
|
+
|
|
413
|
+
let fileEntries;
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
fileEntries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
417
|
+
} catch {
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
for (const fileEntry of fileEntries) {
|
|
422
|
+
if (!fileEntry.isFile() || !fileEntry.name.endsWith('.md')) continue;
|
|
423
|
+
|
|
424
|
+
const filePath = path.join(dirPath, fileEntry.name);
|
|
425
|
+
|
|
426
|
+
let content;
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
content = await fs.readFile(filePath, 'utf8');
|
|
430
|
+
} catch {
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const gid = extractGoodreadsId(content);
|
|
435
|
+
|
|
436
|
+
if (gid && !byGoodreadsId.has(gid)) {
|
|
437
|
+
byGoodreadsId.set(gid, filePath);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
for (const isbn of extractFileIsbns(content)) {
|
|
441
|
+
if (!byIsbn.has(isbn)) byIsbn.set(isbn, filePath);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return { byGoodreadsId, byIsbn };
|
|
387
447
|
}
|
|
388
448
|
|
|
389
449
|
/**
|
|
@@ -391,335 +451,376 @@ async function buildExistingIndex(outputRoot) {
|
|
|
391
451
|
* Returns the absolute path if found, or null.
|
|
392
452
|
*/
|
|
393
453
|
async function findExistingFile(b, outPath, index) {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
454
|
+
if (b.bookId && index.byGoodreadsId.has(b.bookId)) {
|
|
455
|
+
return index.byGoodreadsId.get(b.bookId);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (b.isbn13 && index.byIsbn.has(b.isbn13)) {
|
|
459
|
+
return index.byIsbn.get(b.isbn13);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (b.isbn10 && index.byIsbn.has(b.isbn10)) {
|
|
463
|
+
return index.byIsbn.get(b.isbn10);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (await fileExists(outPath)) {
|
|
467
|
+
return outPath;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return null;
|
|
407
471
|
}
|
|
408
472
|
|
|
409
473
|
/**
|
|
410
474
|
* Remove a directory if it is empty (best-effort; ignores errors).
|
|
411
475
|
*/
|
|
412
476
|
async function removeIfEmpty(dirPath) {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
477
|
+
try {
|
|
478
|
+
const entries = await fs.readdir(dirPath);
|
|
479
|
+
|
|
480
|
+
if (entries.length === 0) await fs.rmdir(dirPath);
|
|
481
|
+
} catch {
|
|
482
|
+
// ignore
|
|
483
|
+
}
|
|
419
484
|
}
|
|
420
485
|
|
|
421
486
|
// -------------------- Writing front matter --------------------
|
|
422
487
|
|
|
423
488
|
function toFrontMatter({ title, author, rating, finished, links }) {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
489
|
+
const lines = [];
|
|
490
|
+
|
|
491
|
+
lines.push('---');
|
|
492
|
+
lines.push(`title: "${escapeYAMLString(title)}"`);
|
|
493
|
+
lines.push(`author: "${escapeYAMLString(author)}"`);
|
|
494
|
+
lines.push(`rating: ${rating}`);
|
|
495
|
+
lines.push('finished:');
|
|
496
|
+
for (const d of finished) lines.push(` - "${escapeYAMLString(d)}"`);
|
|
497
|
+
|
|
498
|
+
lines.push('links:');
|
|
499
|
+
lines.push(` amazon: "${escapeYAMLString(links.amazon)}"`);
|
|
500
|
+
lines.push(` openlibrary: "${escapeYAMLString(links.openlibrary)}"`);
|
|
501
|
+
lines.push(` goodreads: "${escapeYAMLString(links.goodreads)}"`);
|
|
502
|
+
lines.push('---');
|
|
503
|
+
return lines.join('\n');
|
|
438
504
|
}
|
|
439
505
|
|
|
440
506
|
// -------------------- Main --------------------
|
|
441
507
|
|
|
442
508
|
const HUGO_CONFIGS = [
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
509
|
+
'hugo.toml', 'hugo.yaml', 'hugo.yml', 'hugo.json',
|
|
510
|
+
'config.toml', 'config.yaml', 'config.yml', 'config.json',
|
|
511
|
+
'config',
|
|
446
512
|
];
|
|
447
513
|
|
|
448
514
|
async function main() {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
515
|
+
if (!HUGO_CONFIGS.some((name) => existsSync(path.join(PROJECT_ROOT, name)))) {
|
|
516
|
+
process.stderr.write('Error: no Hugo config file found. Run this command from the root of your Hugo site.\n');
|
|
517
|
+
process.exit(1);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const csvPath = process.argv[2];
|
|
521
|
+
|
|
522
|
+
if (!csvPath) usageAndExit();
|
|
523
|
+
|
|
524
|
+
const csvText = await fs.readFile(csvPath, 'utf8');
|
|
525
|
+
const rows = parseCSV(csvText);
|
|
526
|
+
|
|
527
|
+
if (rows.length < 2) {
|
|
528
|
+
console.error('CSV appears empty or missing header row.');
|
|
529
|
+
process.exit(1);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const header = rows[0].map((h) => (h ?? '').trim());
|
|
533
|
+
const idx = (name) => header.indexOf(name);
|
|
534
|
+
|
|
535
|
+
// Columns from your sample
|
|
536
|
+
const iBookId = idx('Book Id');
|
|
537
|
+
const iTitle = idx('Title');
|
|
538
|
+
const iAuthor = idx('Author');
|
|
539
|
+
const iAuthorLF = idx('Author l-f');
|
|
540
|
+
const iIsbn = idx('ISBN');
|
|
541
|
+
const iIsbn13 = idx('ISBN13');
|
|
542
|
+
const iRating = idx('My Rating');
|
|
543
|
+
const iDateRead = idx('Date Read');
|
|
544
|
+
const iDateAdded = idx('Date Added');
|
|
545
|
+
const iShelf = idx('Exclusive Shelf');
|
|
546
|
+
const iReview = idx('My Review');
|
|
547
|
+
const iReadCount = idx('Read Count');
|
|
548
|
+
|
|
549
|
+
const required = [
|
|
550
|
+
['Book Id', iBookId],
|
|
551
|
+
['Title', iTitle],
|
|
552
|
+
['Author', iAuthor],
|
|
553
|
+
['Author l-f', iAuthorLF],
|
|
554
|
+
['ISBN', iIsbn],
|
|
555
|
+
['ISBN13', iIsbn13],
|
|
556
|
+
['My Rating', iRating],
|
|
557
|
+
['Date Read', iDateRead],
|
|
558
|
+
['Date Added', iDateAdded],
|
|
559
|
+
['Exclusive Shelf', iShelf],
|
|
560
|
+
['My Review', iReview],
|
|
561
|
+
['Read Count', iReadCount],
|
|
562
|
+
];
|
|
563
|
+
const missing = required.filter(([, i]) => i === -1).map(([n]) => n);
|
|
564
|
+
|
|
565
|
+
if (missing.length) {
|
|
566
|
+
console.error(`CSV is missing expected columns: ${missing.join(', ')}`);
|
|
567
|
+
process.exit(1);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Aggregate by Title+Author, collecting finished dates from CSV rows
|
|
571
|
+
const books = new Map();
|
|
572
|
+
|
|
573
|
+
let seenRows = 0;
|
|
574
|
+
let importedRows = 0;
|
|
575
|
+
|
|
576
|
+
for (let r = 1; r < rows.length; r++) {
|
|
577
|
+
seenRows++;
|
|
578
|
+
const row = rows[r];
|
|
579
|
+
|
|
580
|
+
const bookId = String(row[iBookId] || '').trim();
|
|
581
|
+
const title = String(row[iTitle] || '').trim();
|
|
582
|
+
const author = String(row[iAuthor] || '').trim();
|
|
583
|
+
const authorLF = String(row[iAuthorLF] || '').trim();
|
|
584
|
+
|
|
585
|
+
if (!title || !author) continue;
|
|
586
|
+
|
|
587
|
+
// Only import read shelf so Date Added fallback doesn't imply "finished" for to-read/currently-reading
|
|
588
|
+
const shelf = String(row[iShelf] || '').trim().toLowerCase();
|
|
589
|
+
|
|
590
|
+
if (shelf !== 'read') continue;
|
|
591
|
+
|
|
592
|
+
importedRows++;
|
|
593
|
+
|
|
594
|
+
const rating = clampRating(Number(String(row[iRating] || '').trim() || 0));
|
|
595
|
+
const review = String(row[iReview] || '').trim();
|
|
596
|
+
const readCount = Number(String(row[iReadCount] || '').trim() || 0);
|
|
597
|
+
|
|
598
|
+
const isbn10 = cleanIsbn(row[iIsbn]);
|
|
599
|
+
const isbn13 = cleanIsbn(row[iIsbn13]);
|
|
600
|
+
|
|
601
|
+
const dateReadISO = toISODate(String(row[iDateRead] || ''));
|
|
602
|
+
const dateAddedISO = toISODate(String(row[iDateAdded] || ''));
|
|
603
|
+
const chosen = dateReadISO || dateAddedISO || null;
|
|
604
|
+
|
|
605
|
+
const k = keyFor(title, author);
|
|
606
|
+
|
|
607
|
+
if (!books.has(k)) {
|
|
608
|
+
books.set(k, {
|
|
609
|
+
bookId,
|
|
610
|
+
title,
|
|
611
|
+
author,
|
|
612
|
+
authorLF,
|
|
613
|
+
rating,
|
|
614
|
+
isbn10,
|
|
615
|
+
isbn13,
|
|
616
|
+
readCount,
|
|
617
|
+
finished: [],
|
|
618
|
+
review: review || '',
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const b = books.get(k);
|
|
623
|
+
|
|
624
|
+
// Keep "best" metadata across duplicate rows
|
|
625
|
+
if (rating > b.rating) b.rating = rating;
|
|
626
|
+
|
|
627
|
+
if (!b.review && review) b.review = review;
|
|
628
|
+
|
|
629
|
+
if (!b.isbn13 && isbn13) b.isbn13 = isbn13;
|
|
630
|
+
|
|
631
|
+
if (!b.isbn10 && isbn10) b.isbn10 = isbn10;
|
|
632
|
+
|
|
633
|
+
if (!b.bookId && bookId) b.bookId = bookId;
|
|
634
|
+
|
|
635
|
+
if (chosen) b.finished.push(chosen);
|
|
636
|
+
|
|
637
|
+
if (Number.isFinite(readCount) && readCount > (b.readCount || 0)) b.readCount = readCount;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const bookList = Array.from(books.values());
|
|
641
|
+
|
|
642
|
+
console.log(
|
|
643
|
+
`Parsed rows: ${seenRows}, importing read-shelf rows: ${importedRows}, unique books: ${bookList.length}`,
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
// Build index of existing files for smart matching (by Goodreads ID and ISBN)
|
|
647
|
+
console.log('Indexing existing book files...');
|
|
648
|
+
const index = await buildExistingIndex(OUTPUT_ROOT);
|
|
649
|
+
|
|
650
|
+
console.log(
|
|
651
|
+
` Indexed ${index.byGoodreadsId.size} Goodreads IDs, ${index.byIsbn.size} ISBNs`,
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
// ---- Write markdown ----
|
|
655
|
+
// - New files: write everything from CSV data.
|
|
656
|
+
// - Existing files matched by Goodreads ID or ISBN: if the title/slug changed, rename
|
|
657
|
+
// the file and update the title in front matter; always merge new finished dates.
|
|
658
|
+
// - Existing files with no changes: skip.
|
|
659
|
+
let written = 0;
|
|
660
|
+
let createdNew = 0;
|
|
661
|
+
let mergedExisting = 0;
|
|
662
|
+
let renamedFiles = 0;
|
|
663
|
+
let skippedUnchanged = 0;
|
|
664
|
+
let ratingsUpdated = 0;
|
|
665
|
+
|
|
666
|
+
for (const b of bookList) {
|
|
667
|
+
const authorDir = authorDirFromAuthorLF(b.authorLF);
|
|
668
|
+
const filename = `${slugify(b.title)}.md`;
|
|
669
|
+
const outPath = path.join(OUTPUT_ROOT, authorDir, filename);
|
|
670
|
+
|
|
671
|
+
const existingPath = await findExistingFile(b, outPath, index);
|
|
672
|
+
|
|
673
|
+
if (existingPath) {
|
|
674
|
+
const titleChanged = existingPath !== outPath;
|
|
675
|
+
|
|
676
|
+
// Read the existing file
|
|
677
|
+
const existingContent = await fs.readFile(existingPath, 'utf8');
|
|
678
|
+
const { finished: existingFinished, rating: existingRating } = parseExistingMarkdown(existingContent);
|
|
679
|
+
const finishedMerged = uniqSortedDates([...existingFinished, ...b.finished]);
|
|
680
|
+
|
|
681
|
+
const hasNewDates =
|
|
682
|
+
finishedMerged.length !== existingFinished.length ||
|
|
604
683
|
finishedMerged.some((d, i) => d !== existingFinished[i]);
|
|
605
684
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
+
// Only let Goodreads override the file's rating when Goodreads actually has one (> 0).
|
|
686
|
+
// A rating of 0 usually means "not rated on Goodreads" — the file may carry a rating
|
|
687
|
+
// entered by hand there instead, so leave it alone rather than clobbering it with 0.
|
|
688
|
+
const hasRatingChange = b.rating > 0 && existingRating !== null && b.rating !== existingRating;
|
|
689
|
+
|
|
690
|
+
if (!titleChanged && !hasNewDates && !hasRatingChange) {
|
|
691
|
+
skippedUnchanged++;
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Apply patches
|
|
696
|
+
let patched = existingContent;
|
|
697
|
+
|
|
698
|
+
if (hasNewDates) {
|
|
699
|
+
patched = replaceFinishedBlock(patched, finishedMerged);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (hasRatingChange) {
|
|
703
|
+
patched = updateRatingInFrontMatter(patched, b.rating);
|
|
704
|
+
ratingsUpdated++;
|
|
705
|
+
|
|
706
|
+
const rel = path.relative(OUTPUT_ROOT, existingPath);
|
|
707
|
+
|
|
708
|
+
console.log(` RATING UPDATED: ${rel} (${existingRating} -> ${b.rating})`);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (titleChanged) {
|
|
712
|
+
patched = updateTitleInFrontMatter(patched, b.title);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (titleChanged) {
|
|
716
|
+
// Write to new path, remove old path, clean up empty author dir
|
|
717
|
+
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
718
|
+
await fs.writeFile(outPath, patched, 'utf8');
|
|
719
|
+
await fs.unlink(existingPath);
|
|
720
|
+
await removeIfEmpty(path.dirname(existingPath));
|
|
721
|
+
|
|
722
|
+
const relOld = path.relative(OUTPUT_ROOT, existingPath);
|
|
723
|
+
const relNew = path.relative(OUTPUT_ROOT, outPath);
|
|
724
|
+
|
|
725
|
+
console.log(` RENAMED: ${relOld}`);
|
|
726
|
+
console.log(` --> ${relNew}`);
|
|
727
|
+
renamedFiles++;
|
|
728
|
+
} else {
|
|
729
|
+
await fs.writeFile(existingPath, patched, 'utf8');
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
written++;
|
|
733
|
+
mergedExisting++;
|
|
734
|
+
} else {
|
|
735
|
+
// New file — write full content from CSV data
|
|
736
|
+
createdNew++;
|
|
737
|
+
|
|
738
|
+
const finishedMerged = uniqSortedDates(b.finished);
|
|
739
|
+
const isbnBest = b.isbn13 || b.isbn10;
|
|
740
|
+
|
|
741
|
+
const links = {
|
|
742
|
+
amazon: amazonSearchLink({ isbn13: b.isbn13, isbn10: b.isbn10, title: b.title, author: b.author }),
|
|
743
|
+
openlibrary: openLibraryIsbnUrl(isbnBest),
|
|
744
|
+
goodreads: goodreadsBookUrl(b.bookId),
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
const fm = toFrontMatter({
|
|
748
|
+
title: b.title,
|
|
749
|
+
author: b.author,
|
|
750
|
+
rating: b.rating,
|
|
751
|
+
finished: finishedMerged,
|
|
752
|
+
links,
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
const bodyToWrite = b.review ? `\n\n${b.review}\n` : '\n';
|
|
756
|
+
|
|
757
|
+
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
758
|
+
await fs.writeFile(outPath, fm + bodyToWrite, 'utf8');
|
|
759
|
+
written++;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (written > 0) {
|
|
764
|
+
const libraryPath = path.join(PROJECT_ROOT, 'content', 'library.md');
|
|
765
|
+
|
|
766
|
+
try {
|
|
767
|
+
const libraryContent = await fs.readFile(libraryPath, 'utf8');
|
|
768
|
+
const now = new Date().toISOString().replace(/\.\d{3}Z$/, '+00:00');
|
|
769
|
+
const updated = libraryContent.replace(
|
|
770
|
+
/^date:.*$/m,
|
|
771
|
+
`date: ${now}`,
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
await fs.writeFile(libraryPath, updated, 'utf8');
|
|
775
|
+
console.log(` Updated library.md date to ${now}`);
|
|
776
|
+
} catch (err) {
|
|
777
|
+
console.warn(` WARNING: could not update library.md: ${err.message}`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
console.log(
|
|
782
|
+
'Done.\n' +
|
|
685
783
|
` Books written: ${written}\n` +
|
|
686
784
|
` New files created: ${createdNew}\n` +
|
|
687
785
|
` Existing files updated: ${mergedExisting}\n` +
|
|
688
786
|
` Files renamed: ${renamedFiles}\n` +
|
|
787
|
+
` Ratings updated: ${ratingsUpdated}\n` +
|
|
689
788
|
` Existing files skipped: ${skippedUnchanged}\n` +
|
|
690
|
-
` Content output: ${OUTPUT_ROOT}\n
|
|
691
|
-
|
|
789
|
+
` Content output: ${OUTPUT_ROOT}\n`,
|
|
790
|
+
);
|
|
692
791
|
}
|
|
693
792
|
|
|
694
793
|
// Only run main() when executed directly as a script (not imported by tests)
|
|
695
794
|
const isMain =
|
|
696
|
-
|
|
795
|
+
path.resolve(new URL(import.meta.url).pathname) === path.resolve(process.argv[1] ?? '');
|
|
796
|
+
|
|
697
797
|
if (isMain) {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
798
|
+
main().catch((err) => {
|
|
799
|
+
console.error(err);
|
|
800
|
+
process.exit(1);
|
|
801
|
+
});
|
|
702
802
|
}
|
|
703
803
|
|
|
704
804
|
export {
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
805
|
+
slugify,
|
|
806
|
+
cleanIsbn,
|
|
807
|
+
toISODate,
|
|
808
|
+
clampRating,
|
|
809
|
+
escapeYAMLString,
|
|
810
|
+
uniqSortedDates,
|
|
811
|
+
authorDirFromAuthorLF,
|
|
812
|
+
keyFor,
|
|
813
|
+
goodreadsBookUrl,
|
|
814
|
+
openLibraryIsbnUrl,
|
|
815
|
+
amazonSearchLink,
|
|
816
|
+
parseCSV,
|
|
817
|
+
parseExistingMarkdown,
|
|
818
|
+
replaceFinishedBlock,
|
|
819
|
+
updateTitleInFrontMatter,
|
|
820
|
+
updateRatingInFrontMatter,
|
|
821
|
+
extractGoodreadsId,
|
|
822
|
+
extractFileIsbns,
|
|
823
|
+
toFrontMatter,
|
|
824
|
+
buildExistingIndex,
|
|
825
|
+
findExistingFile,
|
|
725
826
|
};
|