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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anthony Liddle
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # library-reads
2
+
3
+ I read mostly through Libby. I also pick up physical books from the library and from Powell's, and now and then I'm reading something a friend lent me. This package turns a Libby export, plus a small hand-edited extras file, into a typed array of my recent reads, enriched with covers and metadata from Open Library. The output is meant to drop straight onto a "Lately" page: what I've been reading, in the order I read it.
4
+
5
+ It's built around how I actually read, not around a service. If that happens to match how you read, you're welcome to it.
6
+
7
+ ## How It Works
8
+
9
+ There are three sources. The bulk comes from a Libby timeline export, a CSV of every loan, which for me is mostly audiobooks. The second is a hand-edited `extras.yaml`: the book I'm reading right now, the physical one I checked out at the branch, the paperback from Powell's, the one a friend pressed into my hands. The third is Open Library, which fills in covers, page counts, publish years, and subjects.
10
+
11
+ Libby and extras are merged by ISBN. When the same book appears in both, the extras entry wins on status and the dates are kept from both sides. So a Libby `borrowed` and an extras `finished` for the same ISBN become a single `finished` entry that still remembers when I borrowed it. Everything else passes through untouched.
12
+
13
+ Enrichment is cached hard. The package talks to Open Library politely, one request per second with an identifying User-Agent, and writes what it learns to a cache file you commit to git. After the first build, unchanged books cost nothing.
14
+
15
+ Each entry carries a `matchQuality` field (`'exact'`, `'fuzzy'`, or `'unmatched'`) so you can programmatically flag the entries that need a human eye, rather than grepping the warnings array for them.
16
+
17
+ The one thing Libby genuinely cannot tell you is what you're reading right now; its export only lists loans you've already returned. That gap is the main reason `extras.yaml` exists.
18
+
19
+ ## Quick Start
20
+
21
+ ```bash
22
+ pnpm add library-reads
23
+ ```
24
+
25
+ ```typescript
26
+ import { getReads } from 'library-reads';
27
+
28
+ const result = await getReads({
29
+ libby: { path: './libbytimeline-all-loans.csv' },
30
+ extras: { path: './extras.yaml' },
31
+ cache: { path: './library-reads-cache.json' },
32
+ userAgent: 'my-site/1.0 (me@example.com)',
33
+ });
34
+
35
+ // result.entries: ReadEntry[], sorted by sortDate descending
36
+ // result.warnings: string[]
37
+ // result.lastEntryDate: string | undefined
38
+ ```
39
+
40
+ `userAgent` is required. Open Library asks callers to identify themselves, and it's easy to forget, so the type makes you pass it.
41
+
42
+ Each entry comes back shaped roughly like this:
43
+
44
+ ```typescript
45
+ {
46
+ title: 'The Overstory',
47
+ author: 'Richard Powers',
48
+ isbn: '9780393635522',
49
+ status: 'finished',
50
+ format: 'physical',
51
+ startedAt: '2026-04-01',
52
+ finishedAt: '2026-04-28',
53
+ sortDate: '2026-04-28',
54
+ coverUrl: 'https://covers.openlibrary.org/b/id/8758252-L.jpg',
55
+ pageCount: 502,
56
+ source: 'library',
57
+ provenance: 'extras',
58
+ }
59
+ ```
60
+
61
+ A word of warning about the first build: it takes a few minutes. Open Library is rate-limited to one request a second, and most books need two requests, so a few dozen entries adds up. Every build after that is effectively instant, because the cache is committed and the network is only touched for books it hasn't seen. Don't be alarmed the first time; do be glad every time after.
62
+
63
+ ## The `extras.yaml` Shape
64
+
65
+ This file is hand-edited, and the package validates it strictly. A small example:
66
+
67
+ ```yaml
68
+ # The book I'm reading right now, from Powell's last weekend.
69
+ - title: 'The MANIAC'
70
+ author: 'Benjamín Labatut'
71
+ isbn: '9780593654477'
72
+ status: reading
73
+ format: physical
74
+ source: "Powell's"
75
+ startedAt: '2026-05-20'
76
+
77
+ # A Libby audiobook I actually finished. Same ISBN as the Libby row,
78
+ # so this promotes that loan from 'borrowed' to 'finished' on merge.
79
+ - isbn: '9781501977831'
80
+ status: finished
81
+ finishedAt: '2026-05-22'
82
+ notes: 'Finished on a long walk. Murderbot stays good.'
83
+
84
+ # A privately-tracked read. It stays out of the output unless I ask
85
+ # for it with includePrivate, so I can keep a few things off the page.
86
+ - title: 'The Body Keeps the Score'
87
+ author: 'Bessel van der Kolk'
88
+ isbn: '9780143127741'
89
+ status: finished
90
+ finishedAt: '2026-01-15'
91
+ private: true
92
+ ```
93
+
94
+ Every entry needs a `status`, and either an `isbn` or both a `title` and an `author` (the package needs at least one way to identify the book). The rest is optional:
95
+
96
+ - `format`: one of `audiobook`, `ebook`, `physical`.
97
+ - `source`: free text for where the book came from. `'library'`, `"Powell's"`, `'borrowed from Joel'`.
98
+ - `startedAt`, `finishedAt`, `borrowedAt`: ISO dates (`YYYY-MM-DD`).
99
+ - `notes`: a line for yourself.
100
+ - `olid`: an Open Library ID, to pin a specific edition when the automatic match is wrong.
101
+ - `private`: when `true`, the entry is excluded from output unless you pass `includePrivate`.
102
+
103
+ `status` is one of `borrowed`, `reading`, `finished`, or `abandoned`. The full field reference is the types file; this is just the part you'll touch by hand.
104
+
105
+ ## What This Doesn't Do
106
+
107
+ This is the part I'd want to read first, so it's the part I'll be most honest about.
108
+
109
+ **It doesn't integrate with Goodreads or StoryGraph.** I don't use either, so the package is built around Libby and a hand-edited file, because that's how I actually read. If you log your reading to Goodreads or StoryGraph, you already have good options; there are scrapers and RSS-based packages aimed squarely at you. This isn't one of them, and that's a deliberate choice rather than a missing feature. The case study goes into why.
110
+
111
+ **Open Library doesn't have every book.** This bites harder than you'd expect. Libby's exports carry the ISBN of the exact edition you borrowed, which for audiobooks is usually a 979-prefix ISBN, and Open Library indexes mostly print editions. In practice, roughly two-thirds of a typical Libby export gets a 404 on exact-ISBN lookup. The package handles this without falling over: it remembers the misses so it stops asking, then falls back to a title-and-author search. Those fallbacks are marked `matchQuality === 'fuzzy'` on the entry, so you can surface them for a quick check instead of trusting every one blindly, because a fuzzy search can land on the wrong edition. When one does, the `olid` field in `extras.yaml` pins the right edition.
112
+
113
+ **Repeat borrows show up as repeat entries.** If you've borrowed The Great Gatsby three times, you'll see it three times. That's on purpose. This is a timeline of a reading life, including the re-reads and the books I keep meaning to finish, not a deduplicated bibliography. "Lately" is about what's been in my life, and sometimes the same book has been in it more than once.
114
+
115
+ **Currently-reading lives only in extras.** Libby's "all loans" export contains loans you've already returned; it has no idea what you have out right now. That's a limit of the source, not a bug in the package, and it's why the one thing I always keep current in `extras.yaml` is whatever I'm in the middle of.
116
+
117
+ **It doesn't handle your library card.** The package reads a CSV; it doesn't talk to Libby's API. Getting the export is a manual download for now (in the Libby app: Timeline, then Actions, then Export Timeline). Automating that would mean storing patron credentials and leaning on unsanctioned OverDrive endpoints, and I'd rather not.
118
+
119
+ In short:
120
+
121
+ - No Goodreads or StoryGraph.
122
+ - Not every book is in Open Library.
123
+ - Re-reads are not deduplicated.
124
+ - Currently-reading is manual.
125
+ - No login; the CSV is a manual export.
126
+
127
+ If you hit something outside all of that, open an issue. And before you do, the escape hatches (`olid` to pin an edition, `bust` to refetch specific keys, `cache.ignoreReads` to bypass the cache for a run) cover most of the "this one match is wrong" cases on their own.
128
+
129
+ ## Cache
130
+
131
+ The cache file is committed to git, not ignored. That's intentional. Builds stay deterministic across clones; a fresh checkout needs no network for books it has already seen; and what Open Library told you becomes part of the project's reproducible state rather than a disposable temp file.
132
+
133
+ Three options give you surgical control when you need it:
134
+
135
+ - `cache.maxAgeDays`: refetch any entry older than this many days. Defaults to 180.
136
+ - `cache.bust`: an array of cache keys to force-refetch this run, for when you know a specific record changed.
137
+ - `cache.ignoreReads`: skip reading the cache this run, but still write it, for when you want fresh data without throwing away the file.
138
+
139
+ Open Library 404s are cached too, so the books it doesn't have stop being re-fetched on every build. The cache is for everything I learned, not just the things I learned successfully.
140
+
141
+ ## Why This Exists
142
+
143
+ I've gone to the library my whole life, most weeks, on the standing rule that you can take home as many books as you can carry. As life got busier the format shifted more toward audiobooks through Libby, but the habit never changed. This package is the engine behind a "Lately" feature on my site, a small, honest record of what I've been reading. Libraries give a lot away for free and ask for almost nothing back; a page that quietly says "here's what that gift looked like this month" felt like the right way to say thank you. The longer story is in the [case study](https://anthonyliddle.dev) (link to be updated once it's published).
144
+
145
+ ## License
146
+
147
+ MIT, Anthony Liddle, 2026.
@@ -0,0 +1,400 @@
1
+ //#region src/types.d.ts
2
+ /**
3
+ * The state of a book in the reader's life.
4
+ *
5
+ * `borrowed` means it came into the reader's life (a Libby loan, a library
6
+ * pickup) but no read-through is asserted. Libby imports default to this.
7
+ *
8
+ * `reading`, `finished`, and `abandoned` are reader-asserted states that
9
+ * typically come from a manual entry in extras, or override a Libby borrow
10
+ * for the same book.
11
+ */
12
+ type ReadStatus = 'borrowed' | 'reading' | 'finished' | 'abandoned';
13
+ /**
14
+ * The physical or digital form of the book. Optional because Libby's export
15
+ * does not expose this field reliably; Open Library enrichment may populate
16
+ * it when known, and manual entries in extras can declare it explicitly.
17
+ */
18
+ type ReadFormat = 'audiobook' | 'ebook' | 'physical';
19
+ /**
20
+ * Pipeline provenance: which parser produced this entry. This is a structural
21
+ * value about the data path, not the user-facing origin of the book. For the
22
+ * latter, see the free-form `source` field on ReadEntry.
23
+ */
24
+ type ReadSource = 'libby' | 'extras';
25
+ /**
26
+ * A single book in the reader's recent activity. Dates are ISO date strings
27
+ * (YYYY-MM-DD), not Date objects, so the output is trivially serializable
28
+ * and free of timezone surprises.
29
+ */
30
+ interface ReadEntry {
31
+ title: string;
32
+ author: string;
33
+ isbn?: string;
34
+ /** Open Library ID, when known. */
35
+ olid?: string;
36
+ status: ReadStatus;
37
+ format?: ReadFormat;
38
+ /** ISO date (YYYY-MM-DD). */
39
+ startedAt?: string;
40
+ /** ISO date (YYYY-MM-DD). */
41
+ finishedAt?: string;
42
+ /** ISO date (YYYY-MM-DD). For Libby entries, the borrow date. */
43
+ borrowedAt?: string;
44
+ coverUrl?: string;
45
+ pageCount?: number;
46
+ publishYear?: number;
47
+ subjects?: string[];
48
+ /**
49
+ * How this entry's enrichment data was matched against Open Library:
50
+ *
51
+ * - `exact`: matched by ISBN or OLID.
52
+ * - `fuzzy`: matched by title+author search, either because the entry had no
53
+ * ISBN/OLID to begin with, or because the ISBN lookup returned 404 and we
54
+ * fell back to search. Fuzzy matches are right most of the time but worth
55
+ * verifying; consider providing an `olid` override in extras.yaml when wrong.
56
+ * - `unmatched`: no Open Library match at all. Cover and metadata are missing,
57
+ * unless filled in from extras (title/author) or from the Libby fallback cover.
58
+ *
59
+ * Undefined when enrichment was skipped (`skipEnrichment: true`) or the entry
60
+ * pre-dates this field's introduction in a cached entry.
61
+ */
62
+ matchQuality?: 'exact' | 'fuzzy' | 'unmatched';
63
+ /**
64
+ * The canonical date for sorting and "lately" calculations, derived from
65
+ * status. ISO date string (YYYY-MM-DD). The orchestrator computes this.
66
+ *
67
+ * Derivation:
68
+ * - finished, abandoned -> finishedAt (preferred) or startedAt
69
+ * - reading -> startedAt
70
+ * - borrowed -> borrowedAt
71
+ *
72
+ * If none of the relevant dates exist, sortDate is the empty string and
73
+ * the entry will sort to the end. This shouldn't happen for valid entries
74
+ * but the field is required so consumers don't have to optional-chain.
75
+ */
76
+ sortDate: string;
77
+ /**
78
+ * Which parser produced this entry. Pipeline provenance, used for debugging
79
+ * and merge logic. NOT for display; render `source` instead when you want to
80
+ * show the reader where a book came from.
81
+ */
82
+ provenance: ReadSource;
83
+ /** When true, excluded from output unless the consumer opts in. Set from extras. */
84
+ private?: boolean;
85
+ /**
86
+ * Free-form origin of this read, written by the user or defaulted by the
87
+ * orchestrator. Examples: 'library', "Powell's", 'Audible', 'borrowed from
88
+ * Joel'. This is the field a consumer page should render to the reader.
89
+ *
90
+ * Distinct from `provenance`, which records which parser produced the entry
91
+ * (a structural, internal value used for debugging and merge logic, not for
92
+ * display).
93
+ */
94
+ source?: string;
95
+ /** Library system name, when known (e.g. from Libby). */
96
+ library?: string;
97
+ publisher?: string;
98
+ notes?: string;
99
+ }
100
+ /**
101
+ * The wrapped result returned by getReads. Wraps the entries array so we can
102
+ * surface freshness signals and soft warnings (Open Library match fallbacks,
103
+ * etc.) without throwing them away.
104
+ */
105
+ interface ReadResult {
106
+ /** Entries sorted descending by the most recent date available on each entry. */
107
+ entries: ReadEntry[];
108
+ /** ISO date of the most recent activity, useful for staleness UI. */
109
+ lastEntryDate?: string;
110
+ /** Soft failures and notable events from the build (fallback matches, missing covers, etc.). */
111
+ warnings: string[];
112
+ }
113
+ /**
114
+ * The raw shape of a single row from a Libby timeline CSV export, after
115
+ * parsing but before normalization into a ReadEntry. Fields appear exactly
116
+ * as they do in the source CSV, with dates normalized to ISO YYYY-MM-DD.
117
+ * Empty-string fields in the source surface as undefined here.
118
+ *
119
+ * Field names match the CSV columns: cover, title, author, publisher, isbn,
120
+ * timestamp, activity, library, details.
121
+ */
122
+ interface RawLibbyEntry {
123
+ /** OverDrive CDN URL for the cover. May be used as fallback if Open Library has no cover. */
124
+ cover?: string;
125
+ title: string;
126
+ author?: string;
127
+ publisher?: string;
128
+ /** ISBN as it appeared in the CSV. May be ISBN-10 or ISBN-13; not normalized here. */
129
+ isbn?: string;
130
+ /** Borrow date, normalized from the source timestamp to ISO YYYY-MM-DD. */
131
+ borrowedAt: string;
132
+ /** The activity column value. In all-loans exports this is always "Borrowed". */
133
+ activity: string;
134
+ /** Library system name, e.g. "Washington County Cooperative Library Services". */
135
+ library?: string;
136
+ /** Trimmed details column (e.g. "21 days"). Empty/whitespace-only values become undefined. */
137
+ details?: string;
138
+ }
139
+ /**
140
+ * The raw shape of a single entry from a hand-edited extras file (YAML or JSON),
141
+ * after parsing but before normalization into a ReadEntry. Fields appear exactly
142
+ * as the user wrote them, with status and format validated against their enums
143
+ * and dates validated against ISO YYYY-MM-DD.
144
+ *
145
+ * An entry must have either `isbn` OR both `title` and `author`. Entries
146
+ * missing both identifier paths are rejected with a warning during parse.
147
+ */
148
+ interface RawExtrasEntry {
149
+ isbn?: string;
150
+ /** Open Library ID, for manual override of fuzzy enrichment matches. */
151
+ olid?: string;
152
+ title?: string;
153
+ author?: string;
154
+ status: ReadStatus;
155
+ format?: ReadFormat;
156
+ /** Free-form origin of this read: 'library', "Powell's", 'Audible', etc. */
157
+ source?: string;
158
+ /** ISO date YYYY-MM-DD. */
159
+ startedAt?: string;
160
+ /** ISO date YYYY-MM-DD. */
161
+ finishedAt?: string;
162
+ /** ISO date YYYY-MM-DD. */
163
+ borrowedAt?: string;
164
+ notes?: string;
165
+ /** If true, the orchestrator will exclude this entry from output by default. */
166
+ private?: boolean;
167
+ }
168
+ //#endregion
169
+ //#region src/enrich.d.ts
170
+ /** Distilled fields extracted from Open Library; the shape we cache and merge into ReadEntry. */
171
+ interface EnrichmentData {
172
+ coverUrl?: string;
173
+ pageCount?: number;
174
+ publishYear?: number;
175
+ subjects?: string[];
176
+ /** The work OLID, normalized (e.g. 'OL12345W'). */
177
+ olid?: string;
178
+ format?: ReadFormat;
179
+ }
180
+ /** One row in the cache file. */
181
+ interface CacheEntry {
182
+ /** ISO date string (YYYY-MM-DD) of when this entry was fetched. */
183
+ fetchedAt: string;
184
+ /** The lookup key we used: 'isbn:{isbn}', 'olid:{olid}', or 'title-author:{hash}'. */
185
+ lookupKey: string;
186
+ data: EnrichmentData;
187
+ /**
188
+ * True when the lookup definitively returned 404 (Open Library doesn't
189
+ * have this book). Distinguishes "we tried and it isn't there" from
190
+ * "we got a sparse but valid record." Negative cache entries are
191
+ * refetched on the same maxAgeDays schedule as positive ones; busting
192
+ * a key forces a refetch regardless.
193
+ */
194
+ notFound?: boolean;
195
+ /**
196
+ * How this entry was matched ('exact' | 'fuzzy' | 'unmatched'). Written on
197
+ * every cache entry created from now on; optional because entries written
198
+ * before this field existed lack it (a cache hit on such an entry yields an
199
+ * undefined matchQuality, and we do not refetch just to populate it).
200
+ */
201
+ matchQuality?: 'exact' | 'fuzzy' | 'unmatched';
202
+ }
203
+ /** The full cache file shape. Keys are the lookupKey values. */
204
+ type Cache = Record<string, CacheEntry>;
205
+ interface EditionPreferences {
206
+ /**
207
+ * Preferred language codes in order of preference (Open Library uses 3-letter codes:
208
+ * 'eng', 'spa', 'fre', 'jpn', etc.). Tried sequentially; the picker uses the first
209
+ * language that has at least one matching edition. Default: ['eng'].
210
+ *
211
+ * Example: a Spanish-speaking user might pass ['spa', 'eng'] to prefer Spanish
212
+ * editions but fall back to English when no Spanish edition exists.
213
+ */
214
+ languages?: string[];
215
+ /**
216
+ * Prefer editions that have BOTH a cover image AND a page count. Default: true.
217
+ * Set false if you want the original publication edition regardless of completeness.
218
+ */
219
+ preferComplete?: boolean;
220
+ /**
221
+ * Prefer the most recent edition among otherwise-equal candidates. Default: true.
222
+ * Set false to prefer the original publication (oldest edition).
223
+ */
224
+ preferRecent?: boolean;
225
+ }
226
+ interface EnrichOptions {
227
+ /** Required: Open Library asks for identifying requests. Format: 'package-name/version (email)'. */
228
+ userAgent: string;
229
+ /** The cache to read from and write to. Mutated by the function. */
230
+ cache: Cache;
231
+ /**
232
+ * Refetch entries whose fetchedAt is older than this many days. Default 180.
233
+ * Open Library data is mostly static but does get corrected occasionally.
234
+ */
235
+ maxAgeDays?: number;
236
+ /** Force-refetch these lookup keys this run, even if cached. Use for surgical corrections. */
237
+ bust?: string[];
238
+ /** Disable cache reads (still writes for next build). Useful for debugging enrichment. */
239
+ ignoreCache?: boolean;
240
+ /** Injectable fetch for testability. Defaults to globalThis.fetch. */
241
+ fetchImpl?: typeof globalThis.fetch;
242
+ /** Minimum ms between requests. Default 1000 (1 req/sec). */
243
+ rateLimitMs?: number;
244
+ /**
245
+ * Shared rate-limiter state. When provided, sequential `enrich` calls
246
+ * coordinate so that the 1 req/sec budget is honored across the entire
247
+ * batch, not just within each call. The orchestrator creates one and
248
+ * passes the same reference to every call.
249
+ *
250
+ * Shape: `{ nextAllowedAt: number }` where nextAllowedAt is a millisecond
251
+ * timestamp. Both reads and writes happen during a single enrich call.
252
+ *
253
+ * When omitted, each enrich call uses its own per-call state (correct in
254
+ * isolation but unsafe in batches).
255
+ */
256
+ rateLimiterState?: {
257
+ nextAllowedAt: number;
258
+ };
259
+ /**
260
+ * Preferences applied when picking a representative edition from a work's editions list.
261
+ * Defaults are Anglocentric and lean toward complete + recent editions; override via this
262
+ * option to prefer a different language, original publications, etc.
263
+ *
264
+ * Note: the cache stores the picked edition's data, not the raw responses. If you change
265
+ * these preferences between builds, cached entries reflect the OLD preferences until you
266
+ * bust them. The package does not auto-detect preference changes; pass
267
+ * `bust: Object.keys(cache)` to refetch everything and see the new picks take effect.
268
+ */
269
+ editionPreferences?: EditionPreferences;
270
+ }
271
+ interface EnrichResult {
272
+ /** The enriched entry. May equal the input if enrichment had no path or all fields failed. */
273
+ entry: ReadEntry;
274
+ /** Soft failures (404 from Open Library, fuzzy match fallback, missing fields, etc.). */
275
+ warnings: string[];
276
+ /**
277
+ * How the entry was matched: 'exact' (ISBN/OLID), 'fuzzy' (title+author
278
+ * search, primary or fallback), or 'unmatched' (no match at all). Undefined
279
+ * when a transport failure left the outcome unknown, or when a cache hit came
280
+ * from an entry written before this field existed. The orchestrator copies a
281
+ * defined value onto the returned entry's `matchQuality`.
282
+ */
283
+ matchQuality?: 'exact' | 'fuzzy' | 'unmatched';
284
+ }
285
+ /**
286
+ * Enrich a single ReadEntry with metadata from Open Library, using and updating the
287
+ * provided cache. See the module docs and EnrichOptions for the lookup strategy.
288
+ *
289
+ * User-provided title and author always win; Open Library fills only missing fields.
290
+ * The cache is mutated in place and is only written when every request in the lookup
291
+ * sequence succeeded (a 2xx, parseable response). Transport and HTTP failures push a
292
+ * warning, leave the cache untouched (so the next build retries), and return whatever
293
+ * partial enrichment was gathered.
294
+ */
295
+ declare function enrich(entry: ReadEntry, options: EnrichOptions): Promise<EnrichResult>;
296
+ //#endregion
297
+ //#region src/extras.d.ts
298
+ /**
299
+ * Result of parsing an extras file. Entries is the successfully-parsed and
300
+ * validated entries; warnings is a list of soft failures collected during
301
+ * parsing (malformed entries, unknown fields, bad dates).
302
+ */
303
+ interface ParseExtrasResult {
304
+ entries: RawExtrasEntry[];
305
+ warnings: string[];
306
+ }
307
+ /**
308
+ * Parse the text content of an extras file into raw entries.
309
+ *
310
+ * The file must be a list (YAML sequence or JSON array) of entry objects.
311
+ * Each entry is validated against the schema; entries that fail validation
312
+ * are skipped with a warning rather than throwing. A completely malformed
313
+ * file (invalid YAML/JSON, root not a list) returns empty entries with one
314
+ * warning describing the failure.
315
+ *
316
+ * @param content the raw text of the extras file
317
+ * @param format 'yaml' or 'json'
318
+ * @returns a ParseExtrasResult with successfully-validated entries and warnings
319
+ */
320
+ declare function parseExtras(content: string, format: 'yaml' | 'json'): ParseExtrasResult;
321
+ //#endregion
322
+ //#region src/libby.d.ts
323
+ /**
324
+ * Result of parsing a Libby CSV. Entries is the successfully-parsed rows;
325
+ * warnings is a list of soft failures (malformed rows, unparseable dates)
326
+ * collected during parsing. Parsing does NOT throw on a single bad row;
327
+ * one weird entry should not kill the whole build.
328
+ */
329
+ interface ParseLibbyResult {
330
+ entries: RawLibbyEntry[];
331
+ warnings: string[];
332
+ }
333
+ /**
334
+ * Parse the text of a Libby timeline CSV export into raw entries.
335
+ *
336
+ * Dates are normalized to ISO YYYY-MM-DD. Empty-string fields become undefined.
337
+ * Trailing whitespace and blank lines are tolerated. Rows missing a title or
338
+ * with an unparseable date are skipped with a warning rather than throwing.
339
+ *
340
+ * @param csv the raw text content of a libbytimeline-all-loans.csv file
341
+ * @returns a ParseLibbyResult with successfully-parsed entries and any warnings
342
+ */
343
+ declare function parseLibbyCsv(csv: string): ParseLibbyResult;
344
+ //#endregion
345
+ //#region src/orchestrator.d.ts
346
+ interface LibbyInput {
347
+ path?: string;
348
+ content?: string;
349
+ fetch?: () => Promise<string>;
350
+ }
351
+ interface ExtrasInput {
352
+ path?: string;
353
+ content?: string;
354
+ /** Required when using `content`; inferred from the path extension when using `path`. */
355
+ format?: 'yaml' | 'json';
356
+ fetch?: () => Promise<{
357
+ content: string;
358
+ format: 'yaml' | 'json';
359
+ }>;
360
+ }
361
+ interface CacheConfig {
362
+ path: string;
363
+ maxAgeDays?: number;
364
+ bust?: string[];
365
+ ignoreReads?: boolean;
366
+ }
367
+ interface GetReadsOptions {
368
+ libby?: LibbyInput;
369
+ extras?: ExtrasInput;
370
+ cache?: CacheConfig;
371
+ /** Required: Open Library asks for identifying requests. */
372
+ userAgent: string;
373
+ /** Skip Open Library enrichment entirely. Default false. */
374
+ skipEnrichment?: boolean;
375
+ /** Include entries marked `private: true`. Default false. */
376
+ includePrivate?: boolean;
377
+ /** Cap on returned entries after sort. Default unbounded. */
378
+ limit?: number;
379
+ /** Edition picker preferences passed through to the enricher. */
380
+ editionPreferences?: EditionPreferences;
381
+ /** Override fetch (testability). Passed through to the enricher. */
382
+ fetchImpl?: typeof globalThis.fetch;
383
+ /** Min ms between Open Library requests. Default 1000. */
384
+ rateLimitMs?: number;
385
+ }
386
+ /**
387
+ * The package's main entry point. Reads Libby and/or extras input, parses,
388
+ * normalizes, enriches via Open Library, and returns a sorted, deduplicated,
389
+ * privacy-filtered array of ReadEntry.
390
+ *
391
+ * At least one of `libby` or `extras` must be provided. Calling with neither
392
+ * returns an empty result with a warning.
393
+ *
394
+ * @param options the source inputs, cache config, and behavior flags
395
+ * @returns ReadResult with entries (sorted desc by sortDate), warnings, lastEntryDate
396
+ */
397
+ declare function getReads(options: GetReadsOptions): Promise<ReadResult>;
398
+ //#endregion
399
+ export { type Cache, type CacheConfig, type CacheEntry, type EditionPreferences, type EnrichOptions, type EnrichResult, type EnrichmentData, type ExtrasInput, type GetReadsOptions, type LibbyInput, type ParseExtrasResult, type ParseLibbyResult, RawExtrasEntry, RawLibbyEntry, ReadEntry, ReadFormat, ReadResult, ReadSource, ReadStatus, enrich, getReads, parseExtras, parseLibbyCsv };
400
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/enrich.ts","../src/extras.ts","../src/libby.ts","../src/orchestrator.ts"],"mappings":"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"}