library-reads 0.1.0
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/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/index.d.ts +400 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1090 -0
- package/dist/index.js.map +1 -0
- package/package.json +76 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { parse } from "yaml";
|
|
3
|
+
import { parse as parse$1 } from "csv-parse/sync";
|
|
4
|
+
import { readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { extname } from "node:path";
|
|
6
|
+
//#region src/enrich.ts
|
|
7
|
+
function isString$1(value) {
|
|
8
|
+
return typeof value === "string";
|
|
9
|
+
}
|
|
10
|
+
function isPlainObject$1(value) {
|
|
11
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Map Open Library's free-form `physical_format` to our format enum. Imperfect by
|
|
15
|
+
* design: anything with a value that is not clearly audio or electronic is treated
|
|
16
|
+
* as physical; the user can override via extras. Missing/empty yields undefined.
|
|
17
|
+
*/
|
|
18
|
+
function inferFormat(physicalFormat) {
|
|
19
|
+
if (physicalFormat === void 0 || physicalFormat.trim() === "") return;
|
|
20
|
+
const value = physicalFormat.toLowerCase();
|
|
21
|
+
if (value.includes("audio") || value.includes("mp3") || value.includes("sound recording")) return "audiobook";
|
|
22
|
+
if (value.includes("ebook") || value.includes("electronic") || value.includes("online")) return "ebook";
|
|
23
|
+
return "physical";
|
|
24
|
+
}
|
|
25
|
+
/** Extract a four-digit year from a free-form publish date, or undefined. */
|
|
26
|
+
function extractYear(publishDate) {
|
|
27
|
+
if (publishDate === void 0) return;
|
|
28
|
+
const match = publishDate.match(/\d{4}/);
|
|
29
|
+
return match ? Number(match[0]) : void 0;
|
|
30
|
+
}
|
|
31
|
+
/** Construct the large-cover URL for an Open Library cover ID. */
|
|
32
|
+
function coverUrlFromId(coverId) {
|
|
33
|
+
return `https://covers.openlibrary.org/b/id/${coverId}-L.jpg`;
|
|
34
|
+
}
|
|
35
|
+
/** Strip hyphens and whitespace from an ISBN; leave everything else as-is. */
|
|
36
|
+
function normalizeIsbn(isbn) {
|
|
37
|
+
return isbn.replace(/[-\s]/g, "");
|
|
38
|
+
}
|
|
39
|
+
/** Normalize a string for hashing: lowercase, collapse internal whitespace, trim. */
|
|
40
|
+
function normalizeForHash(value) {
|
|
41
|
+
return value.toLowerCase().replace(/\s+/g, " ").trim();
|
|
42
|
+
}
|
|
43
|
+
/** 16-char sha256 prefix of the normalized 'title|author' key, for compact cache keys. */
|
|
44
|
+
function hashTitleAuthor(title, author) {
|
|
45
|
+
const key = `${normalizeForHash(title)}|${normalizeForHash(author)}`;
|
|
46
|
+
return createHash("sha256").update(key).digest("hex").slice(0, 16);
|
|
47
|
+
}
|
|
48
|
+
/** True when a cache entry is older than maxAgeDays and should be refetched. */
|
|
49
|
+
function shouldRefetch(cacheEntry, maxAgeDays) {
|
|
50
|
+
const fetchedAt = new Date(cacheEntry.fetchedAt).getTime();
|
|
51
|
+
return (Date.now() - fetchedAt) / (1e3 * 60 * 60 * 24) > maxAgeDays;
|
|
52
|
+
}
|
|
53
|
+
/** True when an edition is compatible with a language code (missing languages match anything). */
|
|
54
|
+
function editionMatchesLanguage(edition, code) {
|
|
55
|
+
if (!edition.languages || edition.languages.length === 0) return true;
|
|
56
|
+
return edition.languages.some((language) => language.key === `/languages/${code}`);
|
|
57
|
+
}
|
|
58
|
+
/** True when an edition has both a cover and a page count. */
|
|
59
|
+
function editionIsComplete(edition) {
|
|
60
|
+
return edition.covers !== void 0 && edition.covers.length > 0 && edition.number_of_pages !== void 0;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Pick a representative edition from a work's editions list, applying a sequence of
|
|
64
|
+
* filters that each degrade gracefully: format match (when a format is declared),
|
|
65
|
+
* language preference (first preferred language with any match wins), completeness,
|
|
66
|
+
* then recency. Falls back to the first remaining edition when no year resolves a tie.
|
|
67
|
+
*
|
|
68
|
+
* The caller fills in all preference defaults before calling, so this function never
|
|
69
|
+
* has to reason about unset fields.
|
|
70
|
+
*/
|
|
71
|
+
function pickEdition(editions, preferences, entryFormat) {
|
|
72
|
+
if (editions.length === 0) return;
|
|
73
|
+
let candidates = editions;
|
|
74
|
+
if (entryFormat !== void 0) {
|
|
75
|
+
const matches = candidates.filter((e) => inferFormat(e.physical_format) === entryFormat);
|
|
76
|
+
if (matches.length > 0) candidates = matches;
|
|
77
|
+
}
|
|
78
|
+
for (const code of preferences.languages) {
|
|
79
|
+
const matches = candidates.filter((e) => editionMatchesLanguage(e, code));
|
|
80
|
+
if (matches.length > 0) {
|
|
81
|
+
candidates = matches;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (preferences.preferComplete) {
|
|
86
|
+
const matches = candidates.filter(editionIsComplete);
|
|
87
|
+
if (matches.length > 0) candidates = matches;
|
|
88
|
+
}
|
|
89
|
+
const dated = candidates.map((edition) => ({
|
|
90
|
+
edition,
|
|
91
|
+
year: extractYear(edition.publish_date)
|
|
92
|
+
})).filter((item) => item.year !== void 0).sort((a, b) => a.year - b.year);
|
|
93
|
+
if (dated.length > 0) return preferences.preferRecent ? dated[dated.length - 1].edition : dated[0].edition;
|
|
94
|
+
return candidates[0];
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Apply enrichment data to an entry without overwriting fields the entry already has.
|
|
98
|
+
* User-provided title and author always win; Open Library fills only the gaps.
|
|
99
|
+
*/
|
|
100
|
+
function mergeEnrichment(entry, data) {
|
|
101
|
+
const merged = { ...entry };
|
|
102
|
+
if (merged.coverUrl === void 0 && data.coverUrl !== void 0) merged.coverUrl = data.coverUrl;
|
|
103
|
+
if (merged.pageCount === void 0 && data.pageCount !== void 0) merged.pageCount = data.pageCount;
|
|
104
|
+
if (merged.publishYear === void 0 && data.publishYear !== void 0) merged.publishYear = data.publishYear;
|
|
105
|
+
if (merged.subjects === void 0 && data.subjects !== void 0) merged.subjects = data.subjects;
|
|
106
|
+
if (merged.olid === void 0 && data.olid !== void 0) merged.olid = data.olid;
|
|
107
|
+
if (merged.format === void 0 && data.format !== void 0) merged.format = data.format;
|
|
108
|
+
return merged;
|
|
109
|
+
}
|
|
110
|
+
/** Sleep for the given number of milliseconds. */
|
|
111
|
+
function sleep(ms) {
|
|
112
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
113
|
+
}
|
|
114
|
+
/** Normalize an OLID for lookup keys and endpoint construction: trim and uppercase. */
|
|
115
|
+
function normalizeOlid(olid) {
|
|
116
|
+
return olid.trim().toUpperCase();
|
|
117
|
+
}
|
|
118
|
+
/** Strip the `/works/` (or `/books/`, `/authors/`) prefix from a key, leaving the OLID. */
|
|
119
|
+
function olidFromKey(key) {
|
|
120
|
+
const olid = key.split("/").pop();
|
|
121
|
+
return olid && olid.length > 0 ? olid : void 0;
|
|
122
|
+
}
|
|
123
|
+
/** Narrow an unknown JSON value to the edition fields we read. */
|
|
124
|
+
function asEdition(data) {
|
|
125
|
+
if (!isPlainObject$1(data)) return {};
|
|
126
|
+
const edition = {};
|
|
127
|
+
if (isString$1(data.physical_format)) edition.physical_format = data.physical_format;
|
|
128
|
+
if (Array.isArray(data.covers)) edition.covers = data.covers.filter((cover) => typeof cover === "number");
|
|
129
|
+
if (typeof data.number_of_pages === "number") edition.number_of_pages = data.number_of_pages;
|
|
130
|
+
if (isString$1(data.publish_date)) edition.publish_date = data.publish_date;
|
|
131
|
+
if (Array.isArray(data.languages)) edition.languages = data.languages.filter(isPlainObject$1).map((language) => language.key).filter(isString$1).map((key) => ({ key }));
|
|
132
|
+
if (Array.isArray(data.works)) edition.works = data.works.filter(isPlainObject$1).map((work) => work.key).filter(isString$1).map((key) => ({ key }));
|
|
133
|
+
return edition;
|
|
134
|
+
}
|
|
135
|
+
/** Narrow a `/works/{olid}/editions.json` response to an editions array. */
|
|
136
|
+
function asEditionsList(data) {
|
|
137
|
+
if (isPlainObject$1(data) && Array.isArray(data.entries)) return data.entries.map(asEdition);
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
/** Narrow a work response to its subjects array, if any non-empty strings are present. */
|
|
141
|
+
function asSubjects(data) {
|
|
142
|
+
if (isPlainObject$1(data) && Array.isArray(data.subjects)) {
|
|
143
|
+
const subjects = data.subjects.filter(isString$1);
|
|
144
|
+
return subjects.length > 0 ? subjects : void 0;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Narrow a `/search.json` response to its first doc's relevant fields. */
|
|
148
|
+
function asSearchDoc(data) {
|
|
149
|
+
if (!isPlainObject$1(data) || !Array.isArray(data.docs) || data.docs.length === 0) return;
|
|
150
|
+
const doc = data.docs[0];
|
|
151
|
+
if (!isPlainObject$1(doc)) return;
|
|
152
|
+
const result = {};
|
|
153
|
+
if (isString$1(doc.key)) result.workOlid = olidFromKey(doc.key);
|
|
154
|
+
if (typeof doc.cover_i === "number") result.coverId = doc.cover_i;
|
|
155
|
+
if (Array.isArray(doc.subject)) {
|
|
156
|
+
const subjects = doc.subject.filter(isString$1);
|
|
157
|
+
if (subjects.length > 0) result.subjects = subjects;
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
/** The work OLID linked from an edition's `works` array, if present. */
|
|
162
|
+
function workOlidFromEdition(edition) {
|
|
163
|
+
const key = edition.works?.[0]?.key;
|
|
164
|
+
return key ? olidFromKey(key) : void 0;
|
|
165
|
+
}
|
|
166
|
+
/** Apply an edition's fields to the accumulating enrichment data, filling only gaps. */
|
|
167
|
+
function applyEdition(edition, data) {
|
|
168
|
+
if (data.coverUrl === void 0 && edition.covers && edition.covers.length > 0) data.coverUrl = coverUrlFromId(edition.covers[0]);
|
|
169
|
+
if (data.pageCount === void 0 && edition.number_of_pages !== void 0) data.pageCount = edition.number_of_pages;
|
|
170
|
+
if (data.publishYear === void 0) {
|
|
171
|
+
const year = extractYear(edition.publish_date);
|
|
172
|
+
if (year !== void 0) data.publishYear = year;
|
|
173
|
+
}
|
|
174
|
+
if (data.format === void 0) {
|
|
175
|
+
const format = inferFormat(edition.physical_format);
|
|
176
|
+
if (format !== void 0) data.format = format;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Whether a string is present and non-whitespace. */
|
|
180
|
+
function isNonEmpty(value) {
|
|
181
|
+
return value !== void 0 && value.trim() !== "";
|
|
182
|
+
}
|
|
183
|
+
const OPEN_LIBRARY = "https://openlibrary.org";
|
|
184
|
+
/**
|
|
185
|
+
* Enrich a single ReadEntry with metadata from Open Library, using and updating the
|
|
186
|
+
* provided cache. See the module docs and EnrichOptions for the lookup strategy.
|
|
187
|
+
*
|
|
188
|
+
* User-provided title and author always win; Open Library fills only missing fields.
|
|
189
|
+
* The cache is mutated in place and is only written when every request in the lookup
|
|
190
|
+
* sequence succeeded (a 2xx, parseable response). Transport and HTTP failures push a
|
|
191
|
+
* warning, leave the cache untouched (so the next build retries), and return whatever
|
|
192
|
+
* partial enrichment was gathered.
|
|
193
|
+
*/
|
|
194
|
+
async function enrich(entry, options) {
|
|
195
|
+
const warnings = [];
|
|
196
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
197
|
+
const maxAgeDays = options.maxAgeDays ?? 180;
|
|
198
|
+
const rateLimitMs = options.rateLimitMs ?? 1e3;
|
|
199
|
+
const bust = new Set(options.bust ?? []);
|
|
200
|
+
const preferences = {
|
|
201
|
+
languages: options.editionPreferences?.languages ?? ["eng"],
|
|
202
|
+
preferComplete: options.editionPreferences?.preferComplete ?? true,
|
|
203
|
+
preferRecent: options.editionPreferences?.preferRecent ?? true
|
|
204
|
+
};
|
|
205
|
+
const label = entry.title;
|
|
206
|
+
let lookupKey;
|
|
207
|
+
if (isNonEmpty(entry.olid)) lookupKey = `olid:${normalizeOlid(entry.olid)}`;
|
|
208
|
+
else if (isNonEmpty(entry.isbn)) lookupKey = `isbn:${normalizeIsbn(entry.isbn)}`;
|
|
209
|
+
else if (isNonEmpty(entry.title) && isNonEmpty(entry.author)) lookupKey = `title-author:${hashTitleAuthor(entry.title, entry.author)}`;
|
|
210
|
+
else {
|
|
211
|
+
warnings.push(`Entry '${label}': no enrichment path (missing olid, isbn, and title+author)`);
|
|
212
|
+
return {
|
|
213
|
+
entry,
|
|
214
|
+
warnings,
|
|
215
|
+
matchQuality: "unmatched"
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const hasTitleAndAuthor = isNonEmpty(entry.title) && isNonEmpty(entry.author);
|
|
219
|
+
const usable = (c) => c !== void 0 && !bust.has(c.lookupKey) && !shouldRefetch(c, maxAgeDays);
|
|
220
|
+
if (!options.ignoreCache) {
|
|
221
|
+
const cached = options.cache[lookupKey];
|
|
222
|
+
if (usable(cached)) {
|
|
223
|
+
if (cached.notFound && lookupKey.startsWith("isbn:") && hasTitleAndAuthor) {
|
|
224
|
+
const fallbackKey = `title-author:${hashTitleAuthor(entry.title, entry.author)}`;
|
|
225
|
+
const fallback = options.cache[fallbackKey];
|
|
226
|
+
if (usable(fallback)) return {
|
|
227
|
+
entry: mergeEnrichment(entry, fallback.data),
|
|
228
|
+
warnings,
|
|
229
|
+
matchQuality: fallback.matchQuality
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
entry: mergeEnrichment(entry, cached.data),
|
|
234
|
+
warnings,
|
|
235
|
+
matchQuality: cached.matchQuality
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const rateLimiter = options.rateLimiterState ?? { nextAllowedAt: 0 };
|
|
240
|
+
let anyFailure = false;
|
|
241
|
+
const data = {};
|
|
242
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
243
|
+
/** Cache a successful (possibly sparse) enrichment under a key. */
|
|
244
|
+
const writePositive = (key, quality) => {
|
|
245
|
+
options.cache[key] = {
|
|
246
|
+
fetchedAt: today,
|
|
247
|
+
lookupKey: key,
|
|
248
|
+
data,
|
|
249
|
+
matchQuality: quality
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
/** Cache a definitive 404 ("Open Library has no record") under a key. */
|
|
253
|
+
const writeNotFound = (key) => {
|
|
254
|
+
options.cache[key] = {
|
|
255
|
+
fetchedAt: today,
|
|
256
|
+
lookupKey: key,
|
|
257
|
+
data: {},
|
|
258
|
+
notFound: true,
|
|
259
|
+
matchQuality: "unmatched"
|
|
260
|
+
};
|
|
261
|
+
};
|
|
262
|
+
/**
|
|
263
|
+
* Fetch a JSON endpoint with the shared rate limit. A 404 is a definitive
|
|
264
|
+
* "not found": it returns `{ ok: false, notFound: true }` without a warning
|
|
265
|
+
* or setting anyFailure, because what a 404 means depends on the caller (a
|
|
266
|
+
* primary lookup caches it as a dead end; a secondary lookup just yields no
|
|
267
|
+
* extra data). Transport errors, other non-2xx, and parse failures push a
|
|
268
|
+
* warning and set anyFailure so the positive cache is skipped and the next
|
|
269
|
+
* build retries.
|
|
270
|
+
*/
|
|
271
|
+
const fetchJson = async (url) => {
|
|
272
|
+
const now = Date.now();
|
|
273
|
+
if (now < rateLimiter.nextAllowedAt) await sleep(rateLimiter.nextAllowedAt - now);
|
|
274
|
+
rateLimiter.nextAllowedAt = Date.now() + rateLimitMs;
|
|
275
|
+
let response;
|
|
276
|
+
try {
|
|
277
|
+
response = await fetchImpl(url, { headers: { "User-Agent": options.userAgent } });
|
|
278
|
+
} catch (error) {
|
|
279
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
280
|
+
warnings.push(`Entry '${label}': failed to reach Open Library (${message})`);
|
|
281
|
+
anyFailure = true;
|
|
282
|
+
return {
|
|
283
|
+
ok: false,
|
|
284
|
+
notFound: false
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (response.status === 404) return {
|
|
288
|
+
ok: false,
|
|
289
|
+
notFound: true
|
|
290
|
+
};
|
|
291
|
+
if (!response.ok) {
|
|
292
|
+
warnings.push(`Entry '${label}': Open Library returned ${response.status} for ${url}`);
|
|
293
|
+
anyFailure = true;
|
|
294
|
+
return {
|
|
295
|
+
ok: false,
|
|
296
|
+
notFound: false
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
return {
|
|
301
|
+
ok: true,
|
|
302
|
+
data: await response.json()
|
|
303
|
+
};
|
|
304
|
+
} catch {
|
|
305
|
+
warnings.push(`Entry '${label}': Open Library returned an unparseable response for ${url}`);
|
|
306
|
+
anyFailure = true;
|
|
307
|
+
return {
|
|
308
|
+
ok: false,
|
|
309
|
+
notFound: false
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
/** Fetch a work and apply its subjects. Secondary lookup; a miss is non-fatal. */
|
|
314
|
+
const fetchWork = async (workOlid) => {
|
|
315
|
+
const result = await fetchJson(`${OPEN_LIBRARY}/works/${workOlid}.json`);
|
|
316
|
+
if (!result.ok) return;
|
|
317
|
+
data.olid = workOlid;
|
|
318
|
+
const subjects = asSubjects(result.data);
|
|
319
|
+
if (data.subjects === void 0 && subjects !== void 0) data.subjects = subjects;
|
|
320
|
+
};
|
|
321
|
+
/** Fetch a work's editions list and apply a representative edition. Secondary; a miss is non-fatal. */
|
|
322
|
+
const fetchRepresentativeEdition = async (workOlid) => {
|
|
323
|
+
const result = await fetchJson(`${OPEN_LIBRARY}/works/${workOlid}/editions.json`);
|
|
324
|
+
if (!result.ok) return;
|
|
325
|
+
const picked = pickEdition(asEditionsList(result.data), preferences, entry.format);
|
|
326
|
+
if (picked !== void 0) applyEdition(picked, data);
|
|
327
|
+
};
|
|
328
|
+
/**
|
|
329
|
+
* Run the title+author search, applying any matched doc to `data`. Returns
|
|
330
|
+
* 'found' (a doc matched and was applied), 'empty' (a clean response with no
|
|
331
|
+
* usable doc: the book is not in Open Library), or 'failed' (a transport/HTTP/
|
|
332
|
+
* parse problem already warned; leave the cache untouched for a retry). The
|
|
333
|
+
* caller pushes the verify-this warning, since the wording differs between a
|
|
334
|
+
* primary search and an ISBN-404 fallback.
|
|
335
|
+
*/
|
|
336
|
+
const searchTitleAuthor = async () => {
|
|
337
|
+
const result = await fetchJson(`${OPEN_LIBRARY}/search.json?title=${encodeURIComponent(entry.title)}&author=${encodeURIComponent(entry.author)}&limit=1`);
|
|
338
|
+
if (!result.ok) return result.notFound ? "empty" : "failed";
|
|
339
|
+
const doc = asSearchDoc(result.data);
|
|
340
|
+
if (doc === void 0) return "empty";
|
|
341
|
+
if (doc.subjects !== void 0) data.subjects = doc.subjects;
|
|
342
|
+
if (doc.workOlid !== void 0) {
|
|
343
|
+
data.olid = doc.workOlid;
|
|
344
|
+
await fetchRepresentativeEdition(doc.workOlid);
|
|
345
|
+
}
|
|
346
|
+
if (data.coverUrl === void 0 && doc.coverId !== void 0) data.coverUrl = coverUrlFromId(doc.coverId);
|
|
347
|
+
return "found";
|
|
348
|
+
};
|
|
349
|
+
const noRecord = `Entry '${label}': Open Library has no record for ${lookupKey}`;
|
|
350
|
+
let matchQuality;
|
|
351
|
+
if (isNonEmpty(entry.olid)) {
|
|
352
|
+
const olid = normalizeOlid(entry.olid);
|
|
353
|
+
const result = await fetchJson(olid.endsWith("W") ? `${OPEN_LIBRARY}/works/${olid}.json` : `${OPEN_LIBRARY}/books/${olid}.json`);
|
|
354
|
+
if (result.ok) {
|
|
355
|
+
if (olid.endsWith("W")) {
|
|
356
|
+
data.olid = olid;
|
|
357
|
+
const subjects = asSubjects(result.data);
|
|
358
|
+
if (subjects !== void 0) data.subjects = subjects;
|
|
359
|
+
await fetchRepresentativeEdition(olid);
|
|
360
|
+
} else {
|
|
361
|
+
const edition = asEdition(result.data);
|
|
362
|
+
applyEdition(edition, data);
|
|
363
|
+
const workOlid = workOlidFromEdition(edition);
|
|
364
|
+
if (workOlid !== void 0) await fetchWork(workOlid);
|
|
365
|
+
}
|
|
366
|
+
matchQuality = "exact";
|
|
367
|
+
if (!anyFailure) writePositive(lookupKey, "exact");
|
|
368
|
+
} else if (result.notFound) {
|
|
369
|
+
warnings.push(noRecord);
|
|
370
|
+
writeNotFound(lookupKey);
|
|
371
|
+
matchQuality = "unmatched";
|
|
372
|
+
}
|
|
373
|
+
} else if (isNonEmpty(entry.isbn)) {
|
|
374
|
+
const result = await fetchJson(`${OPEN_LIBRARY}/isbn/${normalizeIsbn(entry.isbn)}.json`);
|
|
375
|
+
if (result.ok) {
|
|
376
|
+
const edition = asEdition(result.data);
|
|
377
|
+
applyEdition(edition, data);
|
|
378
|
+
const workOlid = workOlidFromEdition(edition);
|
|
379
|
+
if (workOlid !== void 0) await fetchWork(workOlid);
|
|
380
|
+
matchQuality = "exact";
|
|
381
|
+
if (!anyFailure) writePositive(lookupKey, "exact");
|
|
382
|
+
} else if (result.notFound) if (hasTitleAndAuthor) {
|
|
383
|
+
const fallbackKey = `title-author:${hashTitleAuthor(entry.title, entry.author)}`;
|
|
384
|
+
const outcome = await searchTitleAuthor();
|
|
385
|
+
if (outcome === "found") {
|
|
386
|
+
warnings.push(`Entry '${label}': ISBN not found in Open Library; fell back to title+author search`);
|
|
387
|
+
matchQuality = "fuzzy";
|
|
388
|
+
if (!anyFailure) writePositive(fallbackKey, "fuzzy");
|
|
389
|
+
writeNotFound(lookupKey);
|
|
390
|
+
} else if (outcome === "empty") {
|
|
391
|
+
warnings.push(noRecord);
|
|
392
|
+
writeNotFound(lookupKey);
|
|
393
|
+
writeNotFound(fallbackKey);
|
|
394
|
+
matchQuality = "unmatched";
|
|
395
|
+
}
|
|
396
|
+
} else {
|
|
397
|
+
warnings.push(noRecord);
|
|
398
|
+
writeNotFound(lookupKey);
|
|
399
|
+
matchQuality = "unmatched";
|
|
400
|
+
}
|
|
401
|
+
} else {
|
|
402
|
+
const outcome = await searchTitleAuthor();
|
|
403
|
+
if (outcome === "found") {
|
|
404
|
+
warnings.push(`Entry '${label}': matched by fuzzy title+author search; verify the result and consider adding an olid override`);
|
|
405
|
+
matchQuality = "fuzzy";
|
|
406
|
+
if (!anyFailure) writePositive(lookupKey, "fuzzy");
|
|
407
|
+
} else if (outcome === "empty") {
|
|
408
|
+
warnings.push(noRecord);
|
|
409
|
+
writeNotFound(lookupKey);
|
|
410
|
+
matchQuality = "unmatched";
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
entry: mergeEnrichment(entry, data),
|
|
415
|
+
warnings,
|
|
416
|
+
matchQuality
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
//#endregion
|
|
420
|
+
//#region src/extras.ts
|
|
421
|
+
const READ_STATUSES = [
|
|
422
|
+
"borrowed",
|
|
423
|
+
"reading",
|
|
424
|
+
"finished",
|
|
425
|
+
"abandoned"
|
|
426
|
+
];
|
|
427
|
+
const READ_FORMATS = [
|
|
428
|
+
"audiobook",
|
|
429
|
+
"ebook",
|
|
430
|
+
"physical"
|
|
431
|
+
];
|
|
432
|
+
/** Fields the schema knows about. Anything else triggers an unknown-field warning. */
|
|
433
|
+
const KNOWN_FIELDS = new Set([
|
|
434
|
+
"isbn",
|
|
435
|
+
"olid",
|
|
436
|
+
"title",
|
|
437
|
+
"author",
|
|
438
|
+
"status",
|
|
439
|
+
"format",
|
|
440
|
+
"source",
|
|
441
|
+
"startedAt",
|
|
442
|
+
"finishedAt",
|
|
443
|
+
"borrowedAt",
|
|
444
|
+
"notes",
|
|
445
|
+
"private"
|
|
446
|
+
]);
|
|
447
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
448
|
+
function isString(value) {
|
|
449
|
+
return typeof value === "string";
|
|
450
|
+
}
|
|
451
|
+
/** A present, non-whitespace string. Whitespace-only counts as empty. */
|
|
452
|
+
function isNonEmptyString(value) {
|
|
453
|
+
return typeof value === "string" && value.trim() !== "";
|
|
454
|
+
}
|
|
455
|
+
function isBoolean(value) {
|
|
456
|
+
return typeof value === "boolean";
|
|
457
|
+
}
|
|
458
|
+
function isPresent(value) {
|
|
459
|
+
return value !== void 0 && value !== null;
|
|
460
|
+
}
|
|
461
|
+
function isPlainObject(value) {
|
|
462
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
463
|
+
}
|
|
464
|
+
function isReadStatus(value) {
|
|
465
|
+
return isString(value) && READ_STATUSES.includes(value);
|
|
466
|
+
}
|
|
467
|
+
function isReadFormat(value) {
|
|
468
|
+
return isString(value) && READ_FORMATS.includes(value);
|
|
469
|
+
}
|
|
470
|
+
/** Render a value for a warning message: strings in single quotes, others as JSON. */
|
|
471
|
+
function show(value) {
|
|
472
|
+
return isString(value) ? `'${value}'` : JSON.stringify(value);
|
|
473
|
+
}
|
|
474
|
+
/** Human-readable label for an entry in warnings: title, else isbn, else position. */
|
|
475
|
+
function entryLabel(item, index) {
|
|
476
|
+
if (isNonEmptyString(item.title)) return `'${item.title}'`;
|
|
477
|
+
if (isNonEmptyString(item.isbn)) return `'${item.isbn}'`;
|
|
478
|
+
return `#${index + 1}`;
|
|
479
|
+
}
|
|
480
|
+
/** Describe the JSON-ish type of a value for the root-not-a-list warning. */
|
|
481
|
+
function describeType(value) {
|
|
482
|
+
if (value === null) return "null";
|
|
483
|
+
if (Array.isArray(value)) return "an array";
|
|
484
|
+
return `a ${typeof value}`;
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Validate a present date value against strict ISO YYYY-MM-DD, rejecting
|
|
488
|
+
* rollover dates like 2026-13-45 and non-dates like 2026-02-30. On failure a
|
|
489
|
+
* warning is pushed and undefined is returned.
|
|
490
|
+
*/
|
|
491
|
+
function validateDate(value, field, label, warnings) {
|
|
492
|
+
if (isString(value) && ISO_DATE.test(value)) {
|
|
493
|
+
const [year, month, day] = value.split("-").map(Number);
|
|
494
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
495
|
+
if (date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day) return value;
|
|
496
|
+
}
|
|
497
|
+
warnings.push(`Entry ${label}: '${field}' must be ISO YYYY-MM-DD, got ${show(value)}`);
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Validate one raw item against the extras schema. Returns a RawExtrasEntry on
|
|
501
|
+
* success, or undefined (with a warning pushed) when the item is rejected.
|
|
502
|
+
* Unknown fields produce warnings but do not reject the entry.
|
|
503
|
+
*/
|
|
504
|
+
function validateEntry(item, index, warnings) {
|
|
505
|
+
if (!isPlainObject(item)) {
|
|
506
|
+
warnings.push(`Entry #${index + 1}: entry must be an object`);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const label = entryLabel(item, index);
|
|
510
|
+
if (!isReadStatus(item.status)) {
|
|
511
|
+
if (isPresent(item.status)) warnings.push(`Entry ${label}: 'status' must be one of ${READ_STATUSES.join(", ")}, got ${show(item.status)}`);
|
|
512
|
+
else warnings.push(`Entry ${label}: missing required field 'status'`);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const status = item.status;
|
|
516
|
+
const hasIsbn = isNonEmptyString(item.isbn);
|
|
517
|
+
const hasTitleAndAuthor = isNonEmptyString(item.title) && isNonEmptyString(item.author);
|
|
518
|
+
if (!hasIsbn && !hasTitleAndAuthor) {
|
|
519
|
+
warnings.push(`Entry ${label}: must have either 'isbn' or both 'title' and 'author'`);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const entry = { status };
|
|
523
|
+
for (const field of [
|
|
524
|
+
"isbn",
|
|
525
|
+
"olid",
|
|
526
|
+
"title",
|
|
527
|
+
"author",
|
|
528
|
+
"notes"
|
|
529
|
+
]) {
|
|
530
|
+
const value = item[field];
|
|
531
|
+
if (!isPresent(value)) continue;
|
|
532
|
+
if (!isNonEmptyString(value)) {
|
|
533
|
+
warnings.push(`Entry ${label}: '${field}' must be a non-empty string`);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
entry[field] = value;
|
|
537
|
+
}
|
|
538
|
+
if (isPresent(item.format)) {
|
|
539
|
+
if (!isReadFormat(item.format)) {
|
|
540
|
+
warnings.push(`Entry ${label}: 'format' must be one of ${READ_FORMATS.join(", ")}, got ${show(item.format)}`);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
entry.format = item.format;
|
|
544
|
+
}
|
|
545
|
+
if (isPresent(item.source)) {
|
|
546
|
+
if (!isNonEmptyString(item.source)) {
|
|
547
|
+
warnings.push(`Entry ${label}: 'source' must be a non-empty string`);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
entry.source = item.source;
|
|
551
|
+
}
|
|
552
|
+
for (const field of [
|
|
553
|
+
"startedAt",
|
|
554
|
+
"finishedAt",
|
|
555
|
+
"borrowedAt"
|
|
556
|
+
]) {
|
|
557
|
+
if (!isPresent(item[field])) continue;
|
|
558
|
+
const validated = validateDate(item[field], field, label, warnings);
|
|
559
|
+
if (validated === void 0) return;
|
|
560
|
+
entry[field] = validated;
|
|
561
|
+
}
|
|
562
|
+
if (isPresent(item.private)) {
|
|
563
|
+
if (!isBoolean(item.private)) {
|
|
564
|
+
warnings.push(`Entry ${label}: 'private' must be a boolean`);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
entry.private = item.private;
|
|
568
|
+
}
|
|
569
|
+
for (const key of Object.keys(item)) if (!KNOWN_FIELDS.has(key)) warnings.push(`Entry ${label}: unknown field '${key}'`);
|
|
570
|
+
return entry;
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Parse the text content of an extras file into raw entries.
|
|
574
|
+
*
|
|
575
|
+
* The file must be a list (YAML sequence or JSON array) of entry objects.
|
|
576
|
+
* Each entry is validated against the schema; entries that fail validation
|
|
577
|
+
* are skipped with a warning rather than throwing. A completely malformed
|
|
578
|
+
* file (invalid YAML/JSON, root not a list) returns empty entries with one
|
|
579
|
+
* warning describing the failure.
|
|
580
|
+
*
|
|
581
|
+
* @param content the raw text of the extras file
|
|
582
|
+
* @param format 'yaml' or 'json'
|
|
583
|
+
* @returns a ParseExtrasResult with successfully-validated entries and warnings
|
|
584
|
+
*/
|
|
585
|
+
function parseExtras(content, format) {
|
|
586
|
+
const warnings = [];
|
|
587
|
+
let parsed;
|
|
588
|
+
try {
|
|
589
|
+
parsed = format === "yaml" ? parse(content) : JSON.parse(content);
|
|
590
|
+
} catch (error) {
|
|
591
|
+
return {
|
|
592
|
+
entries: [],
|
|
593
|
+
warnings: [`Invalid ${format === "yaml" ? "YAML" : "JSON"}: ${error instanceof Error ? error.message : String(error)}`]
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
if (!Array.isArray(parsed)) return {
|
|
597
|
+
entries: [],
|
|
598
|
+
warnings: [`Extras root must be a list of entries, got ${describeType(parsed)}`]
|
|
599
|
+
};
|
|
600
|
+
const entries = [];
|
|
601
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
602
|
+
const entry = validateEntry(parsed[i], i, warnings);
|
|
603
|
+
if (entry !== void 0) entries.push(entry);
|
|
604
|
+
}
|
|
605
|
+
return {
|
|
606
|
+
entries,
|
|
607
|
+
warnings
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
//#endregion
|
|
611
|
+
//#region src/libby.ts
|
|
612
|
+
/** The exact columns a Libby all-loans export is expected to have, in order. */
|
|
613
|
+
const EXPECTED_COLUMNS = [
|
|
614
|
+
"cover",
|
|
615
|
+
"title",
|
|
616
|
+
"author",
|
|
617
|
+
"publisher",
|
|
618
|
+
"isbn",
|
|
619
|
+
"timestamp",
|
|
620
|
+
"activity",
|
|
621
|
+
"library",
|
|
622
|
+
"details"
|
|
623
|
+
];
|
|
624
|
+
/**
|
|
625
|
+
* Pull the first non-empty line of the CSV and split it into trimmed,
|
|
626
|
+
* lower-cased column names. Returns undefined when there is no such line.
|
|
627
|
+
* Header names never contain commas, so a naive split is sufficient.
|
|
628
|
+
*/
|
|
629
|
+
function extractHeaderColumns(csv) {
|
|
630
|
+
for (const line of csv.split(/\r?\n/)) {
|
|
631
|
+
if (line.trim() === "") continue;
|
|
632
|
+
return line.split(",").map((column) => column.trim().toLowerCase());
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
/** Full English month names to their two-digit number, for date normalization. */
|
|
636
|
+
const MONTHS = new Map([
|
|
637
|
+
["January", "01"],
|
|
638
|
+
["February", "02"],
|
|
639
|
+
["March", "03"],
|
|
640
|
+
["April", "04"],
|
|
641
|
+
["May", "05"],
|
|
642
|
+
["June", "06"],
|
|
643
|
+
["July", "07"],
|
|
644
|
+
["August", "08"],
|
|
645
|
+
["September", "09"],
|
|
646
|
+
["October", "10"],
|
|
647
|
+
["November", "11"],
|
|
648
|
+
["December", "12"]
|
|
649
|
+
]);
|
|
650
|
+
/** Trim a field and treat an empty result as missing. */
|
|
651
|
+
function clean(value) {
|
|
652
|
+
if (value === void 0) return;
|
|
653
|
+
const trimmed = value.trim();
|
|
654
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Normalize a Libby timestamp ("Month DD, YYYY HH:MM", US locale) to an ISO
|
|
658
|
+
* date string (YYYY-MM-DD). The time is dropped. Returns undefined when the
|
|
659
|
+
* value is missing or does not match the expected shape.
|
|
660
|
+
*/
|
|
661
|
+
function toIsoDate(timestamp) {
|
|
662
|
+
if (timestamp === void 0) return;
|
|
663
|
+
const match = /^([A-Za-z]+)\s+(\d{1,2}),\s+(\d{4})\b/.exec(timestamp.trim());
|
|
664
|
+
if (match === null) return;
|
|
665
|
+
const [, monthName, day, year] = match;
|
|
666
|
+
if (monthName === void 0 || day === void 0 || year === void 0) return;
|
|
667
|
+
const month = MONTHS.get(monthName);
|
|
668
|
+
if (month === void 0) return;
|
|
669
|
+
return `${year}-${month}-${day.padStart(2, "0")}`;
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Parse the text of a Libby timeline CSV export into raw entries.
|
|
673
|
+
*
|
|
674
|
+
* Dates are normalized to ISO YYYY-MM-DD. Empty-string fields become undefined.
|
|
675
|
+
* Trailing whitespace and blank lines are tolerated. Rows missing a title or
|
|
676
|
+
* with an unparseable date are skipped with a warning rather than throwing.
|
|
677
|
+
*
|
|
678
|
+
* @param csv the raw text content of a libbytimeline-all-loans.csv file
|
|
679
|
+
* @returns a ParseLibbyResult with successfully-parsed entries and any warnings
|
|
680
|
+
*/
|
|
681
|
+
function parseLibbyCsv(csv) {
|
|
682
|
+
const entries = [];
|
|
683
|
+
const warnings = [];
|
|
684
|
+
const header = extractHeaderColumns(csv);
|
|
685
|
+
if (header === void 0) {
|
|
686
|
+
warnings.push(`Header missing: expected [${EXPECTED_COLUMNS.join(", ")}] but the CSV had no header row`);
|
|
687
|
+
return {
|
|
688
|
+
entries,
|
|
689
|
+
warnings
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
if (!(header.length === EXPECTED_COLUMNS.length && header.every((column, index) => column === EXPECTED_COLUMNS[index]))) {
|
|
693
|
+
warnings.push(`Header mismatch: expected [${EXPECTED_COLUMNS.join(", ")}] but found [${header.join(", ")}]`);
|
|
694
|
+
return {
|
|
695
|
+
entries,
|
|
696
|
+
warnings
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
const parsed = parse$1(csv, {
|
|
700
|
+
columns: (record) => record.map((column) => column.trim().toLowerCase()),
|
|
701
|
+
skip_empty_lines: true,
|
|
702
|
+
trim: false,
|
|
703
|
+
relax_column_count: true
|
|
704
|
+
});
|
|
705
|
+
const rows = Array.isArray(parsed) ? parsed : [];
|
|
706
|
+
for (let i = 0; i < rows.length; i++) {
|
|
707
|
+
const row = rows[i];
|
|
708
|
+
if (row === void 0) continue;
|
|
709
|
+
const title = clean(row.title);
|
|
710
|
+
if (title === void 0) {
|
|
711
|
+
warnings.push(`Skipping row ${i + 1}: missing title`);
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
const borrowedAt = toIsoDate(row.timestamp);
|
|
715
|
+
if (borrowedAt === void 0) {
|
|
716
|
+
warnings.push(`Skipping "${title}": unparseable date ${JSON.stringify(row.timestamp ?? "")}`);
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
entries.push({
|
|
720
|
+
cover: clean(row.cover),
|
|
721
|
+
title,
|
|
722
|
+
author: clean(row.author),
|
|
723
|
+
publisher: clean(row.publisher),
|
|
724
|
+
isbn: clean(row.isbn),
|
|
725
|
+
borrowedAt,
|
|
726
|
+
activity: clean(row.activity) ?? "",
|
|
727
|
+
library: clean(row.library),
|
|
728
|
+
details: clean(row.details)
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
return {
|
|
732
|
+
entries,
|
|
733
|
+
warnings
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
//#endregion
|
|
737
|
+
//#region src/orchestrator.ts
|
|
738
|
+
/** Map `.yaml`/`.yml` to yaml, `.json` to json, anything else to undefined. */
|
|
739
|
+
function inferFormatFromPath(path) {
|
|
740
|
+
const ext = extname(path).toLowerCase();
|
|
741
|
+
if (ext === ".yaml" || ext === ".yml") return "yaml";
|
|
742
|
+
if (ext === ".json") return "json";
|
|
743
|
+
}
|
|
744
|
+
/** Resolve a LibbyInput to its raw text content, dispatching on the mode provided. */
|
|
745
|
+
async function resolveLibbyInput(input) {
|
|
746
|
+
if (input.content !== void 0) return {
|
|
747
|
+
content: input.content,
|
|
748
|
+
warnings: []
|
|
749
|
+
};
|
|
750
|
+
if (input.path !== void 0) return {
|
|
751
|
+
content: await readFile(input.path, "utf-8"),
|
|
752
|
+
warnings: []
|
|
753
|
+
};
|
|
754
|
+
if (input.fetch !== void 0) return {
|
|
755
|
+
content: await input.fetch(),
|
|
756
|
+
warnings: []
|
|
757
|
+
};
|
|
758
|
+
return { warnings: ["Libby input provided but none of `path`, `content`, or `fetch` was set"] };
|
|
759
|
+
}
|
|
760
|
+
/** Resolve an ExtrasInput to its raw content and format, dispatching on the mode provided. */
|
|
761
|
+
async function resolveExtrasInput(input) {
|
|
762
|
+
if (input.content !== void 0) {
|
|
763
|
+
if (input.format === void 0) return { warnings: ["Extras `content` requires an explicit `format` ('yaml' or 'json')"] };
|
|
764
|
+
return {
|
|
765
|
+
content: input.content,
|
|
766
|
+
format: input.format,
|
|
767
|
+
warnings: []
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
if (input.path !== void 0) {
|
|
771
|
+
const format = inferFormatFromPath(input.path);
|
|
772
|
+
if (format === void 0) return { warnings: [`Extras path ${input.path} has an unknown extension; expected .yaml, .yml, or .json`] };
|
|
773
|
+
return {
|
|
774
|
+
content: await readFile(input.path, "utf-8"),
|
|
775
|
+
format,
|
|
776
|
+
warnings: []
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
if (input.fetch !== void 0) {
|
|
780
|
+
const { content, format } = await input.fetch();
|
|
781
|
+
return {
|
|
782
|
+
content,
|
|
783
|
+
format,
|
|
784
|
+
warnings: []
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
return { warnings: ["Extras input provided but none of `path`, `content`, or `fetch` was set"] };
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Read and parse the cache file. A missing file is the normal first-build case
|
|
791
|
+
* (empty cache, no warning). Malformed JSON or any other read error yields an
|
|
792
|
+
* empty cache plus a warning, so a bad cache file never blocks a build.
|
|
793
|
+
*/
|
|
794
|
+
async function readCacheFile(path) {
|
|
795
|
+
let raw;
|
|
796
|
+
try {
|
|
797
|
+
raw = await readFile(path, "utf-8");
|
|
798
|
+
} catch (error) {
|
|
799
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return {
|
|
800
|
+
cache: {},
|
|
801
|
+
warnings: []
|
|
802
|
+
};
|
|
803
|
+
return {
|
|
804
|
+
cache: {},
|
|
805
|
+
warnings: [`Cache file ${path} could not be read (${error instanceof Error ? error.message : String(error)}); starting fresh`]
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
try {
|
|
809
|
+
return {
|
|
810
|
+
cache: JSON.parse(raw),
|
|
811
|
+
warnings: []
|
|
812
|
+
};
|
|
813
|
+
} catch {
|
|
814
|
+
return {
|
|
815
|
+
cache: {},
|
|
816
|
+
warnings: [`Cache file ${path} is not valid JSON; starting fresh`]
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Write the cache to disk atomically: write a sibling tmp file, then rename it
|
|
822
|
+
* over the target (atomic on POSIX and modern Windows). On failure the tmp file
|
|
823
|
+
* is cleaned up and the error is re-thrown.
|
|
824
|
+
*/
|
|
825
|
+
async function writeCacheFile(path, cache) {
|
|
826
|
+
const tmpPath = `${path}.tmp`;
|
|
827
|
+
try {
|
|
828
|
+
await writeFile(tmpPath, JSON.stringify(cache, null, 2), "utf-8");
|
|
829
|
+
await rename(tmpPath, path);
|
|
830
|
+
} catch (error) {
|
|
831
|
+
await rm(tmpPath, { force: true });
|
|
832
|
+
throw error;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
/** Convert a raw Libby row into a borrowed ReadEntry. sortDate is filled later. */
|
|
836
|
+
function normalizeLibbyEntry(raw) {
|
|
837
|
+
return {
|
|
838
|
+
title: raw.title,
|
|
839
|
+
author: raw.author ?? "",
|
|
840
|
+
isbn: raw.isbn,
|
|
841
|
+
status: "borrowed",
|
|
842
|
+
borrowedAt: raw.borrowedAt,
|
|
843
|
+
library: raw.library,
|
|
844
|
+
publisher: raw.publisher,
|
|
845
|
+
source: "library",
|
|
846
|
+
sortDate: "",
|
|
847
|
+
provenance: "libby"
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
/** Convert a raw extras entry into a ReadEntry, preserving every user field. sortDate filled later. */
|
|
851
|
+
function normalizeExtrasEntry(raw) {
|
|
852
|
+
return {
|
|
853
|
+
title: raw.title ?? "",
|
|
854
|
+
author: raw.author ?? "",
|
|
855
|
+
isbn: raw.isbn,
|
|
856
|
+
olid: raw.olid,
|
|
857
|
+
status: raw.status,
|
|
858
|
+
format: raw.format,
|
|
859
|
+
startedAt: raw.startedAt,
|
|
860
|
+
finishedAt: raw.finishedAt,
|
|
861
|
+
borrowedAt: raw.borrowedAt,
|
|
862
|
+
source: raw.source,
|
|
863
|
+
notes: raw.notes,
|
|
864
|
+
private: raw.private,
|
|
865
|
+
sortDate: "",
|
|
866
|
+
provenance: "extras"
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
/** Normalize an ISBN for matching: strip hyphens and whitespace, then lowercase. */
|
|
870
|
+
function isbnMatchKey(isbn) {
|
|
871
|
+
if (isbn === void 0 || isbn.trim() === "") return;
|
|
872
|
+
return isbn.replace(/[-\s]/g, "").toLowerCase();
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Merge a Libby entry and an extras entry that share an ISBN into one entry.
|
|
876
|
+
* The extras entry is canonical: its status wins (so a borrow can be promoted
|
|
877
|
+
* to finished), and most fields prefer extras with Libby as fallback. Dates are
|
|
878
|
+
* additive (a union), and Libby's library/publisher are always preserved.
|
|
879
|
+
*/
|
|
880
|
+
function mergePair(libby, extras) {
|
|
881
|
+
return {
|
|
882
|
+
title: extras.title || libby.title,
|
|
883
|
+
author: extras.author || libby.author,
|
|
884
|
+
isbn: extras.isbn ?? libby.isbn,
|
|
885
|
+
olid: extras.olid ?? libby.olid,
|
|
886
|
+
status: extras.status,
|
|
887
|
+
format: extras.format ?? libby.format,
|
|
888
|
+
startedAt: extras.startedAt ?? libby.startedAt,
|
|
889
|
+
finishedAt: extras.finishedAt ?? libby.finishedAt,
|
|
890
|
+
borrowedAt: extras.borrowedAt ?? libby.borrowedAt,
|
|
891
|
+
source: extras.source ?? libby.source,
|
|
892
|
+
notes: extras.notes ?? libby.notes,
|
|
893
|
+
private: extras.private ?? false,
|
|
894
|
+
library: libby.library,
|
|
895
|
+
publisher: libby.publisher,
|
|
896
|
+
sortDate: "",
|
|
897
|
+
provenance: "extras"
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Merge Libby and extras entries by normalized ISBN. Entries sharing an ISBN
|
|
902
|
+
* are merged into one (extras canonical); entries without a counterpart, or
|
|
903
|
+
* without an ISBN at all, pass through unchanged.
|
|
904
|
+
*
|
|
905
|
+
* `libbyCoverByEntry` carries the Libby cover fallback (see getReads). When a
|
|
906
|
+
* merge consumes a Libby entry that had a fallback cover, the association is
|
|
907
|
+
* forwarded to the new merged entry so the fallback survives the merge.
|
|
908
|
+
*/
|
|
909
|
+
function mergeByIsbn(libby, extras, libbyCoverByEntry) {
|
|
910
|
+
const extrasByIsbn = /* @__PURE__ */ new Map();
|
|
911
|
+
for (const entry of extras) {
|
|
912
|
+
const key = isbnMatchKey(entry.isbn);
|
|
913
|
+
if (key !== void 0) extrasByIsbn.set(key, entry);
|
|
914
|
+
}
|
|
915
|
+
const consumed = /* @__PURE__ */ new Set();
|
|
916
|
+
const merged = [];
|
|
917
|
+
for (const entry of libby) {
|
|
918
|
+
const key = isbnMatchKey(entry.isbn);
|
|
919
|
+
const match = key !== void 0 ? extrasByIsbn.get(key) : void 0;
|
|
920
|
+
if (key !== void 0 && match !== void 0) {
|
|
921
|
+
const mergedEntry = mergePair(entry, match);
|
|
922
|
+
const libbyCover = libbyCoverByEntry.get(entry);
|
|
923
|
+
if (libbyCover !== void 0) libbyCoverByEntry.set(mergedEntry, libbyCover);
|
|
924
|
+
merged.push(mergedEntry);
|
|
925
|
+
consumed.add(key);
|
|
926
|
+
} else merged.push(entry);
|
|
927
|
+
}
|
|
928
|
+
for (const entry of extras) {
|
|
929
|
+
const key = isbnMatchKey(entry.isbn);
|
|
930
|
+
if (key !== void 0 && consumed.has(key)) continue;
|
|
931
|
+
merged.push(entry);
|
|
932
|
+
}
|
|
933
|
+
return {
|
|
934
|
+
merged,
|
|
935
|
+
warnings: []
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
/** Derive the canonical sort date for an entry from its status. */
|
|
939
|
+
function computeSortDate(entry) {
|
|
940
|
+
const status = entry.status;
|
|
941
|
+
if (status === "finished" || status === "abandoned") return entry.finishedAt ?? entry.startedAt ?? "";
|
|
942
|
+
if (status === "reading") return entry.startedAt ?? "";
|
|
943
|
+
return entry.borrowedAt ?? "";
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Whether a warning signals a transport/HTTP failure (Open Library failing to
|
|
947
|
+
* answer), as opposed to a definitive 404 ("not found"), a fuzzy match, or a
|
|
948
|
+
* missing enrichment path. A 404 is a clean answer, not unavailability, so it
|
|
949
|
+
* must NOT count toward the rollup. The enricher reports a primary 404 as
|
|
950
|
+
* "...has no record for..." (not matched here), but a secondary 404 can still
|
|
951
|
+
* surface as "returned 404", so the status matcher explicitly excludes 404.
|
|
952
|
+
*/
|
|
953
|
+
function isTransportFailure(warning) {
|
|
954
|
+
if (warning.includes("failed to reach Open Library")) return true;
|
|
955
|
+
if (warning.includes("Open Library returned an unparseable response")) return true;
|
|
956
|
+
const httpMatch = /Open Library returned (\d+) for/.exec(warning);
|
|
957
|
+
return httpMatch !== null && httpMatch[1] !== "404";
|
|
958
|
+
}
|
|
959
|
+
/** Count the warnings that signal a transport/HTTP failure (excludes 404, fuzzy match, no path). */
|
|
960
|
+
function countTransportFailures(warnings) {
|
|
961
|
+
return warnings.filter(isTransportFailure).length;
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* The package's main entry point. Reads Libby and/or extras input, parses,
|
|
965
|
+
* normalizes, enriches via Open Library, and returns a sorted, deduplicated,
|
|
966
|
+
* privacy-filtered array of ReadEntry.
|
|
967
|
+
*
|
|
968
|
+
* At least one of `libby` or `extras` must be provided. Calling with neither
|
|
969
|
+
* returns an empty result with a warning.
|
|
970
|
+
*
|
|
971
|
+
* @param options the source inputs, cache config, and behavior flags
|
|
972
|
+
* @returns ReadResult with entries (sorted desc by sortDate), warnings, lastEntryDate
|
|
973
|
+
*/
|
|
974
|
+
async function getReads(options) {
|
|
975
|
+
const warnings = [];
|
|
976
|
+
if (options.libby === void 0 && options.extras === void 0) {
|
|
977
|
+
warnings.push("No input provided: pass at least one of `libby` or `extras`");
|
|
978
|
+
return {
|
|
979
|
+
entries: [],
|
|
980
|
+
warnings
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
let libbyContent;
|
|
984
|
+
if (options.libby !== void 0) {
|
|
985
|
+
const resolved = await resolveLibbyInput(options.libby);
|
|
986
|
+
libbyContent = resolved.content;
|
|
987
|
+
warnings.push(...resolved.warnings);
|
|
988
|
+
}
|
|
989
|
+
let extrasContent;
|
|
990
|
+
let extrasFormat;
|
|
991
|
+
if (options.extras !== void 0) {
|
|
992
|
+
const resolved = await resolveExtrasInput(options.extras);
|
|
993
|
+
extrasContent = resolved.content;
|
|
994
|
+
extrasFormat = resolved.format;
|
|
995
|
+
warnings.push(...resolved.warnings);
|
|
996
|
+
}
|
|
997
|
+
const libbyRaw = [];
|
|
998
|
+
if (libbyContent !== void 0) {
|
|
999
|
+
const parsed = parseLibbyCsv(libbyContent);
|
|
1000
|
+
libbyRaw.push(...parsed.entries);
|
|
1001
|
+
warnings.push(...parsed.warnings);
|
|
1002
|
+
}
|
|
1003
|
+
const extrasRaw = [];
|
|
1004
|
+
if (extrasContent !== void 0 && extrasFormat !== void 0) {
|
|
1005
|
+
const parsed = parseExtras(extrasContent, extrasFormat);
|
|
1006
|
+
extrasRaw.push(...parsed.entries);
|
|
1007
|
+
warnings.push(...parsed.warnings);
|
|
1008
|
+
}
|
|
1009
|
+
let cache = {};
|
|
1010
|
+
if (options.cache !== void 0 && options.cache.ignoreReads !== true) {
|
|
1011
|
+
const resolved = await readCacheFile(options.cache.path);
|
|
1012
|
+
cache = resolved.cache;
|
|
1013
|
+
warnings.push(...resolved.warnings);
|
|
1014
|
+
}
|
|
1015
|
+
const libbyCoverByEntry = /* @__PURE__ */ new Map();
|
|
1016
|
+
const { merged, warnings: mergeWarnings } = mergeByIsbn(libbyRaw.map((raw) => {
|
|
1017
|
+
const entry = normalizeLibbyEntry(raw);
|
|
1018
|
+
if (raw.cover !== void 0 && raw.cover.trim() !== "") libbyCoverByEntry.set(entry, raw.cover);
|
|
1019
|
+
return entry;
|
|
1020
|
+
}), extrasRaw.map(normalizeExtrasEntry), libbyCoverByEntry);
|
|
1021
|
+
warnings.push(...mergeWarnings);
|
|
1022
|
+
let entries = merged;
|
|
1023
|
+
if (options.skipEnrichment !== true) entries = await enrichAll(merged, cache, options, warnings, libbyCoverByEntry);
|
|
1024
|
+
for (const entry of entries) if (entry.coverUrl === void 0) {
|
|
1025
|
+
const libbyCover = libbyCoverByEntry.get(entry);
|
|
1026
|
+
if (libbyCover !== void 0) entry.coverUrl = libbyCover;
|
|
1027
|
+
}
|
|
1028
|
+
for (const entry of entries) entry.sortDate = computeSortDate(entry);
|
|
1029
|
+
if (options.includePrivate !== true) entries = entries.filter((entry) => entry.private !== true);
|
|
1030
|
+
entries.sort((a, b) => a.sortDate < b.sortDate ? 1 : a.sortDate > b.sortDate ? -1 : 0);
|
|
1031
|
+
if (options.limit !== void 0) entries = entries.slice(0, Math.max(0, options.limit));
|
|
1032
|
+
if (options.cache !== void 0) await writeCacheFile(options.cache.path, cache);
|
|
1033
|
+
const lastEntryDate = entries.length > 0 ? entries[0].sortDate : void 0;
|
|
1034
|
+
return {
|
|
1035
|
+
entries,
|
|
1036
|
+
warnings,
|
|
1037
|
+
lastEntryDate
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Enrich every entry with a shared rate limiter and a fetch wrapper that counts
|
|
1042
|
+
* requests, so the orchestrator can tell which entries actually hit the network
|
|
1043
|
+
* (cache hits and no-path entries never do). Prepends an "Open Library appears
|
|
1044
|
+
* to be unavailable" rollup when more than half of the real attempts failed at
|
|
1045
|
+
* the transport/HTTP layer.
|
|
1046
|
+
*
|
|
1047
|
+
* `enrich` returns a fresh entry object, so the Libby cover fallback association
|
|
1048
|
+
* is forwarded from each input entry to its enriched counterpart, keeping the
|
|
1049
|
+
* post-enrichment fallback pass keyed on the entries that are actually returned.
|
|
1050
|
+
*/
|
|
1051
|
+
async function enrichAll(merged, cache, options, warnings, libbyCoverByEntry) {
|
|
1052
|
+
const rateLimiterState = { nextAllowedAt: 0 };
|
|
1053
|
+
const baseFetch = options.fetchImpl ?? globalThis.fetch;
|
|
1054
|
+
let fetchCount = 0;
|
|
1055
|
+
const countingFetch = (input, init) => {
|
|
1056
|
+
fetchCount++;
|
|
1057
|
+
return baseFetch(input, init);
|
|
1058
|
+
};
|
|
1059
|
+
let attempts = 0;
|
|
1060
|
+
let failedAttempts = 0;
|
|
1061
|
+
const enriched = [];
|
|
1062
|
+
for (const entry of merged) {
|
|
1063
|
+
const before = fetchCount;
|
|
1064
|
+
const result = await enrich(entry, {
|
|
1065
|
+
userAgent: options.userAgent,
|
|
1066
|
+
cache,
|
|
1067
|
+
fetchImpl: countingFetch,
|
|
1068
|
+
rateLimiterState,
|
|
1069
|
+
rateLimitMs: options.rateLimitMs,
|
|
1070
|
+
editionPreferences: options.editionPreferences,
|
|
1071
|
+
maxAgeDays: options.cache?.maxAgeDays,
|
|
1072
|
+
bust: options.cache?.bust
|
|
1073
|
+
});
|
|
1074
|
+
if (result.matchQuality !== void 0) result.entry.matchQuality = result.matchQuality;
|
|
1075
|
+
enriched.push(result.entry);
|
|
1076
|
+
warnings.push(...result.warnings);
|
|
1077
|
+
const libbyCover = libbyCoverByEntry.get(entry);
|
|
1078
|
+
if (libbyCover !== void 0 && result.entry !== entry) libbyCoverByEntry.set(result.entry, libbyCover);
|
|
1079
|
+
if (fetchCount > before) {
|
|
1080
|
+
attempts += 1;
|
|
1081
|
+
if (countTransportFailures(result.warnings) > 0) failedAttempts += 1;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
if (attempts > 0 && failedAttempts * 2 > attempts) warnings.unshift(`Open Library appears to be unavailable (${failedAttempts} of ${attempts} enrichment attempts failed); cached data was used where possible`);
|
|
1085
|
+
return enriched;
|
|
1086
|
+
}
|
|
1087
|
+
//#endregion
|
|
1088
|
+
export { enrich, getReads, parseExtras, parseLibbyCsv };
|
|
1089
|
+
|
|
1090
|
+
//# sourceMappingURL=index.js.map
|