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/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.
|
package/dist/index.d.ts
ADDED
|
@@ -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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"}
|