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