mcp-databnf 1.0.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/CHANGELOG.md +51 -0
- package/LICENSE +21 -0
- package/README.md +335 -0
- package/dist/bnf/client.d.ts +305 -0
- package/dist/bnf/client.js +1279 -0
- package/dist/bnf/client.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2265 -0
- package/dist/index.js.map +1 -0
- package/package.json +73 -0
|
@@ -0,0 +1,1279 @@
|
|
|
1
|
+
// src/version.ts
|
|
2
|
+
var PKG_VERSION = "1.0.0";
|
|
3
|
+
var REPO_URL = "https://github.com/smeet666/mcp-databnf";
|
|
4
|
+
|
|
5
|
+
// src/config.ts
|
|
6
|
+
var LOG_LEVELS = ["silent", "error", "info", "debug"];
|
|
7
|
+
var MIN_ALLOWED_INTERVAL_MS = 3e3;
|
|
8
|
+
var MAX_ALLOWED_INTERVAL_MS = 12e4;
|
|
9
|
+
var DEFAULT_INTERVAL_MS = 3e3;
|
|
10
|
+
var DEFAULT_TIMEOUT_MS = 6e4;
|
|
11
|
+
var DEFAULT_USER_AGENT = `mcp-databnf/${PKG_VERSION} (+${REPO_URL})`;
|
|
12
|
+
function createLogger(level) {
|
|
13
|
+
const rank = LOG_LEVELS.indexOf(level);
|
|
14
|
+
const write = (at, message) => {
|
|
15
|
+
if (rank === 0 || LOG_LEVELS.indexOf(at) > rank) return;
|
|
16
|
+
process.stderr.write(`[mcp-databnf] ${at}: ${message}
|
|
17
|
+
`);
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
debug: (m) => write("debug", m),
|
|
21
|
+
info: (m) => write("info", m),
|
|
22
|
+
// A warning goes out at the error level so it survives the default
|
|
23
|
+
// setting: a caller has to know that rows were dropped.
|
|
24
|
+
warn: (m) => write("error", m),
|
|
25
|
+
error: (m) => write("error", m)
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function readInteger(env, name, fallback, min, max) {
|
|
29
|
+
const raw = env[name];
|
|
30
|
+
if (raw === void 0 || raw.trim() === "") return fallback;
|
|
31
|
+
const value = Number(raw);
|
|
32
|
+
if (!Number.isFinite(value) || !Number.isInteger(value)) {
|
|
33
|
+
process.stderr.write(
|
|
34
|
+
`[mcp-databnf] error: ${name}="${raw}" is not a whole number; using ${fallback}.
|
|
35
|
+
`
|
|
36
|
+
);
|
|
37
|
+
return fallback;
|
|
38
|
+
}
|
|
39
|
+
if (value < min || value > max) {
|
|
40
|
+
process.stderr.write(
|
|
41
|
+
`[mcp-databnf] error: ${name}=${value} is outside ${min}..${max}; using ${fallback}.
|
|
42
|
+
`
|
|
43
|
+
);
|
|
44
|
+
return fallback;
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
function loadConfig(env = process.env) {
|
|
49
|
+
const level = env.BNF_LOG_LEVEL;
|
|
50
|
+
const logLevel = level && LOG_LEVELS.includes(level) ? level : "error";
|
|
51
|
+
if (level && !LOG_LEVELS.includes(level)) {
|
|
52
|
+
process.stderr.write(
|
|
53
|
+
`[mcp-databnf] error: BNF_LOG_LEVEL="${level}" is not one of ${LOG_LEVELS.join(", ")}; using error.
|
|
54
|
+
`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const custom = env.BNF_USER_AGENT?.trim();
|
|
58
|
+
return {
|
|
59
|
+
// A caller who wants to be recognised may say who they are, and the
|
|
60
|
+
// contact address stays attached: the BnF has to be able to reach a human
|
|
61
|
+
// about traffic it did not expect.
|
|
62
|
+
userAgent: custom ? `${custom} ${DEFAULT_USER_AGENT}` : DEFAULT_USER_AGENT,
|
|
63
|
+
minIntervalMs: readInteger(
|
|
64
|
+
env,
|
|
65
|
+
"BNF_MIN_INTERVAL_MS",
|
|
66
|
+
DEFAULT_INTERVAL_MS,
|
|
67
|
+
MIN_ALLOWED_INTERVAL_MS,
|
|
68
|
+
MAX_ALLOWED_INTERVAL_MS
|
|
69
|
+
),
|
|
70
|
+
timeoutMs: readInteger(env, "BNF_TIMEOUT_MS", DEFAULT_TIMEOUT_MS, 1e3, 3e5),
|
|
71
|
+
maxRetries: readInteger(env, "BNF_MAX_RETRIES", 3, 0, 8),
|
|
72
|
+
cacheTtlMs: readInteger(env, "BNF_CACHE_TTL_MS", 9e5, 0, 864e5),
|
|
73
|
+
cacheMaxEntries: readInteger(env, "BNF_CACHE_MAX_ENTRIES", 200, 1, 5e3),
|
|
74
|
+
logLevel
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// src/errors.ts
|
|
79
|
+
var BnfError = class extends Error {
|
|
80
|
+
code;
|
|
81
|
+
details;
|
|
82
|
+
constructor(code, message, details = {}) {
|
|
83
|
+
super(message);
|
|
84
|
+
this.name = "BnfError";
|
|
85
|
+
this.code = code;
|
|
86
|
+
this.details = details;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
var notFound = (message, details) => new BnfError("not_found", message, details);
|
|
90
|
+
var invalidInput = (message, hint) => new BnfError("invalid_input", message, hint ? { hint } : {});
|
|
91
|
+
var rateLimited = (message, details) => new BnfError("rate_limited", message, {
|
|
92
|
+
hint: "Wait a moment and ask again. This says nothing about whether the BnF describes what you asked for.",
|
|
93
|
+
...details
|
|
94
|
+
});
|
|
95
|
+
var parseFailure = (message, details) => new BnfError("parse_failure", message, {
|
|
96
|
+
hint: "data.bnf.fr may have changed how it answers. Please report this at https://github.com/smeet666/mcp-databnf/issues with the arguments you used.",
|
|
97
|
+
...details
|
|
98
|
+
});
|
|
99
|
+
var networkError = (message, details) => new BnfError("network_error", message, details);
|
|
100
|
+
var timeout = (message, details) => new BnfError("timeout", message, details);
|
|
101
|
+
|
|
102
|
+
// src/bnf/cache.ts
|
|
103
|
+
var Cache = class {
|
|
104
|
+
constructor(ttlMs, maxEntries) {
|
|
105
|
+
this.ttlMs = ttlMs;
|
|
106
|
+
this.maxEntries = maxEntries;
|
|
107
|
+
}
|
|
108
|
+
ttlMs;
|
|
109
|
+
maxEntries;
|
|
110
|
+
entries = /* @__PURE__ */ new Map();
|
|
111
|
+
get(key) {
|
|
112
|
+
const entry = this.entries.get(key);
|
|
113
|
+
if (!entry) return void 0;
|
|
114
|
+
if (entry.expiresAt <= Date.now()) {
|
|
115
|
+
this.entries.delete(key);
|
|
116
|
+
return void 0;
|
|
117
|
+
}
|
|
118
|
+
this.entries.delete(key);
|
|
119
|
+
this.entries.set(key, entry);
|
|
120
|
+
return { value: entry.value, retrievedAt: entry.retrievedAt };
|
|
121
|
+
}
|
|
122
|
+
set(key, value, retrievedAt) {
|
|
123
|
+
if (this.ttlMs <= 0) return;
|
|
124
|
+
this.entries.delete(key);
|
|
125
|
+
this.entries.set(key, { value, expiresAt: Date.now() + this.ttlMs, retrievedAt });
|
|
126
|
+
while (this.entries.size > this.maxEntries) {
|
|
127
|
+
const oldest = this.entries.keys().next();
|
|
128
|
+
if (oldest.done) break;
|
|
129
|
+
this.entries.delete(oldest.value);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
get size() {
|
|
133
|
+
return this.entries.size;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// src/bnf/endpoint.ts
|
|
138
|
+
var SPARQL_ENDPOINT = "https://data.bnf.fr/sparql";
|
|
139
|
+
var ALLOWED_HOST = "data.bnf.fr";
|
|
140
|
+
var GALLICA_HOST = "gallica.bnf.fr";
|
|
141
|
+
function assertRequestable(url) {
|
|
142
|
+
let parsed;
|
|
143
|
+
try {
|
|
144
|
+
parsed = new URL(url);
|
|
145
|
+
} catch {
|
|
146
|
+
throw refusal(`mcp-databnf refuses to request "${url}": it is not an address.`);
|
|
147
|
+
}
|
|
148
|
+
const host = parsed.hostname.toLowerCase().replace(/\.$/, "");
|
|
149
|
+
if (host !== ALLOWED_HOST) {
|
|
150
|
+
throw refusal(
|
|
151
|
+
`mcp-databnf requests ${ALLOWED_HOST} and nothing else; "${host}" was refused. Gallica in particular is described from the catalogue and never read.`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
var refusal = (message) => new BnfError("network_error", message, {
|
|
156
|
+
hint: "This server reads the BnF catalogue at data.bnf.fr. It holds no licence to read the digitised contents, so it does not follow an address that leaves that host."
|
|
157
|
+
});
|
|
158
|
+
function isGallicaAddress(address) {
|
|
159
|
+
try {
|
|
160
|
+
const host = new URL(address).hostname.toLowerCase().replace(/\.$/, "");
|
|
161
|
+
return host === GALLICA_HOST || host.endsWith(`.${GALLICA_HOST}`);
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
var PREFIXES = {
|
|
167
|
+
foaf: "http://xmlns.com/foaf/0.1/",
|
|
168
|
+
rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
|
169
|
+
rdfs: "http://www.w3.org/2000/01/rdf-schema#",
|
|
170
|
+
owl: "http://www.w3.org/2002/07/owl#",
|
|
171
|
+
skos: "http://www.w3.org/2004/02/skos/core#",
|
|
172
|
+
dcterms: "http://purl.org/dc/terms/",
|
|
173
|
+
bio: "http://vocab.org/bio/0.1/",
|
|
174
|
+
bnf: "http://data.bnf.fr/ontology/bnf-onto/",
|
|
175
|
+
bibo: "http://purl.org/ontology/bibo/",
|
|
176
|
+
isni: "http://isni.org/ontology#",
|
|
177
|
+
/** RDA group 2 elements, which carry the biographical fields. */
|
|
178
|
+
rdag2: "http://rdvocab.info/ElementsGr2/",
|
|
179
|
+
/** RDA elements, which carry the bibliographic fields. */
|
|
180
|
+
rdae: "http://rdvocab.info/Elements/",
|
|
181
|
+
/** RDA relationships between work, expression, manifestation and item. */
|
|
182
|
+
rdarel: "http://rdvocab.info/RDARelationshipsWEMI/",
|
|
183
|
+
/** The FRBR classes data.bnf.fr types its entities with. */
|
|
184
|
+
rdafrbr: "http://rdvocab.info/uri/schema/FRBRentitiesRDA/"
|
|
185
|
+
};
|
|
186
|
+
var PREFIX_BLOCK = Object.entries(PREFIXES).map(([name, iri]) => `PREFIX ${name}: <${iri}>`).join("\n");
|
|
187
|
+
var publicPageFor = (entityIri) => entityIri.replace(/#.*$/, "").replace(/^http:/, "https:");
|
|
188
|
+
|
|
189
|
+
// src/bnf/http.ts
|
|
190
|
+
var RETRYABLE = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
191
|
+
var PUSH_BACK = /* @__PURE__ */ new Set([429, 503]);
|
|
192
|
+
var LONGEST_WAIT_MS = 3e4;
|
|
193
|
+
var RETRIES_AFTER_SILENCE = 1;
|
|
194
|
+
var MAX_REDIRECTS = 2;
|
|
195
|
+
function parseRetryAfter(value, now = Date.now()) {
|
|
196
|
+
if (!value) return null;
|
|
197
|
+
const trimmed = value.trim();
|
|
198
|
+
const seconds = Number(trimmed);
|
|
199
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1e3);
|
|
200
|
+
const at = Date.parse(trimmed);
|
|
201
|
+
if (Number.isNaN(at)) return null;
|
|
202
|
+
return Math.max(0, at - now);
|
|
203
|
+
}
|
|
204
|
+
function backoffMs(attempt) {
|
|
205
|
+
const base = Math.min(16e3, 1e3 * 2 ** attempt);
|
|
206
|
+
return base + Math.floor(Math.random() * 500);
|
|
207
|
+
}
|
|
208
|
+
function readEngineError(body) {
|
|
209
|
+
const match = /^Virtuoso\s+(\w+)\s+Error\s+([\s\S]*)$/.exec(body.trim());
|
|
210
|
+
if (!match) return null;
|
|
211
|
+
const state = match[1] ?? "";
|
|
212
|
+
const text2 = (match[2] ?? "").split("\n")[0]?.trim() ?? "";
|
|
213
|
+
return { kind: state === "37000" ? "compile" : "runtime", text: text2 };
|
|
214
|
+
}
|
|
215
|
+
async function postOnce(options, signal) {
|
|
216
|
+
assertRequestable(SPARQL_ENDPOINT);
|
|
217
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
218
|
+
const form = new URLSearchParams();
|
|
219
|
+
form.set("query", options.query);
|
|
220
|
+
form.set("format", "application/sparql-results+json");
|
|
221
|
+
let target = SPARQL_ENDPOINT;
|
|
222
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
|
223
|
+
const response = await doFetch(target, {
|
|
224
|
+
method: "POST",
|
|
225
|
+
signal,
|
|
226
|
+
redirect: "manual",
|
|
227
|
+
headers: {
|
|
228
|
+
"user-agent": options.userAgent,
|
|
229
|
+
accept: "application/sparql-results+json",
|
|
230
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
231
|
+
},
|
|
232
|
+
body: form.toString()
|
|
233
|
+
});
|
|
234
|
+
const location = response.headers.get("location");
|
|
235
|
+
if (response.status >= 300 && response.status < 400 && location !== null) {
|
|
236
|
+
const next = new URL(location, target).toString();
|
|
237
|
+
assertRequestable(next);
|
|
238
|
+
target = next;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
status: response.status,
|
|
243
|
+
headers: response.headers,
|
|
244
|
+
body: await response.text(),
|
|
245
|
+
ok: response.ok
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
throw networkError(`data.bnf.fr redirected this request more than ${MAX_REDIRECTS} times.`, {
|
|
249
|
+
url: SPARQL_ENDPOINT
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
async function runQuery(options) {
|
|
253
|
+
const { timeoutMs, maxRetries, limiter, logger } = options;
|
|
254
|
+
let lastError = null;
|
|
255
|
+
let askedWaitMs = 0;
|
|
256
|
+
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
|
257
|
+
if (askedWaitMs > 0) {
|
|
258
|
+
logger.debug(`waiting ${askedWaitMs}ms, as asked`);
|
|
259
|
+
await new Promise((resolve) => setTimeout(resolve, askedWaitMs));
|
|
260
|
+
askedWaitMs = 0;
|
|
261
|
+
}
|
|
262
|
+
await limiter.beforeRequest();
|
|
263
|
+
const controller = new AbortController();
|
|
264
|
+
const deadline = setTimeout(() => controller.abort(), timeoutMs);
|
|
265
|
+
try {
|
|
266
|
+
logger.debug(`POST ${SPARQL_ENDPOINT} (${options.query.length} characters)`);
|
|
267
|
+
const response = await postOnce(options, controller.signal);
|
|
268
|
+
if (response.ok) {
|
|
269
|
+
let parsed;
|
|
270
|
+
try {
|
|
271
|
+
parsed = parseResults(response.body);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
limiter.pushBack();
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
limiter.succeeded();
|
|
277
|
+
return parsed;
|
|
278
|
+
}
|
|
279
|
+
if (RETRYABLE.has(response.status)) limiter.pushBack();
|
|
280
|
+
if (PUSH_BACK.has(response.status)) {
|
|
281
|
+
const asked = parseRetryAfter(response.headers.get("retry-after"));
|
|
282
|
+
if (asked !== null && asked > LONGEST_WAIT_MS) {
|
|
283
|
+
throw rateLimited(
|
|
284
|
+
`data.bnf.fr asked this client to wait ${Math.round(asked / 1e3)} seconds (HTTP ${response.status}).`,
|
|
285
|
+
{ url: SPARQL_ENDPOINT, status: response.status }
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
if (attempt >= maxRetries) {
|
|
289
|
+
throw rateLimited(
|
|
290
|
+
`data.bnf.fr asked this client to slow down (HTTP ${response.status}).`,
|
|
291
|
+
{
|
|
292
|
+
url: SPARQL_ENDPOINT,
|
|
293
|
+
status: response.status
|
|
294
|
+
}
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
askedWaitMs = asked ?? backoffMs(attempt);
|
|
298
|
+
lastError = new Error(`HTTP ${response.status}`);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (RETRYABLE.has(response.status) && attempt < maxRetries) {
|
|
302
|
+
lastError = new Error(`HTTP ${response.status}`);
|
|
303
|
+
askedWaitMs = backoffMs(attempt);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (response.status === 400 || response.status === 422) {
|
|
307
|
+
const engine = readEngineError(response.body);
|
|
308
|
+
throw invalidInput(
|
|
309
|
+
`data.bnf.fr would not run this query${engine?.text ? `: ${engine.text}` : "."}`,
|
|
310
|
+
"Try fewer or plainer words. If this keeps happening with ordinary input, please report it."
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
throw networkError(`data.bnf.fr answered HTTP ${response.status}.`, {
|
|
314
|
+
url: SPARQL_ENDPOINT,
|
|
315
|
+
status: response.status
|
|
316
|
+
});
|
|
317
|
+
} catch (error) {
|
|
318
|
+
clearTimeout(deadline);
|
|
319
|
+
if (error instanceof Error && error.name === "BnfError") throw error;
|
|
320
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
321
|
+
lastError = error;
|
|
322
|
+
if (attempt >= Math.min(maxRetries, RETRIES_AFTER_SILENCE)) {
|
|
323
|
+
throw timeout(
|
|
324
|
+
`No answer from data.bnf.fr within ${timeoutMs}ms. A query spanning the whole catalogue can take longer than the service will spend on it.`,
|
|
325
|
+
{ url: SPARQL_ENDPOINT }
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
askedWaitMs = backoffMs(attempt);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
332
|
+
if (attempt >= maxRetries) {
|
|
333
|
+
throw networkError(`Could not reach data.bnf.fr: ${lastError.message}`, {
|
|
334
|
+
url: SPARQL_ENDPOINT
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
askedWaitMs = backoffMs(attempt);
|
|
338
|
+
continue;
|
|
339
|
+
} finally {
|
|
340
|
+
clearTimeout(deadline);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
throw networkError(
|
|
344
|
+
`Could not reach data.bnf.fr: ${lastError?.message ?? "no attempt was made"}`,
|
|
345
|
+
{
|
|
346
|
+
url: SPARQL_ENDPOINT
|
|
347
|
+
}
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
function parseResults(body) {
|
|
351
|
+
if (body.trim() === "") {
|
|
352
|
+
throw parseFailure(
|
|
353
|
+
"data.bnf.fr returned an empty body, which is what it does when it abandons a query part way through.",
|
|
354
|
+
{ url: SPARQL_ENDPOINT }
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
const engine = readEngineError(body);
|
|
358
|
+
if (engine) {
|
|
359
|
+
if (engine.kind === "compile") {
|
|
360
|
+
throw invalidInput(
|
|
361
|
+
`data.bnf.fr could not read this query: ${engine.text}`,
|
|
362
|
+
"Try fewer or plainer words. If this keeps happening with ordinary input, please report it."
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
throw timeout(`data.bnf.fr started this query and gave it up: ${engine.text}`, {
|
|
366
|
+
url: SPARQL_ENDPOINT
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
let payload;
|
|
370
|
+
try {
|
|
371
|
+
payload = JSON.parse(body);
|
|
372
|
+
} catch {
|
|
373
|
+
throw parseFailure("data.bnf.fr answered with something that is not JSON.", {
|
|
374
|
+
url: SPARQL_ENDPOINT
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
if (typeof payload !== "object" || payload === null || !("results" in payload) || typeof payload.results !== "object" || !Array.isArray(payload.results?.bindings)) {
|
|
378
|
+
throw parseFailure("data.bnf.fr answered with JSON that is not a SPARQL result set.", {
|
|
379
|
+
url: SPARQL_ENDPOINT
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
return payload;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// src/bnf/sparql.ts
|
|
386
|
+
function escapeLiteral(value) {
|
|
387
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "");
|
|
388
|
+
}
|
|
389
|
+
var literal = (value) => `"${escapeLiteral(value)}"`;
|
|
390
|
+
function toSearchWords(input) {
|
|
391
|
+
const cleaned = input.normalize("NFC").replace(/[‘’ʼ]/gu, "'").replace(/[^\p{L}\p{N}'-]+/gu, " ");
|
|
392
|
+
const words = [];
|
|
393
|
+
for (const raw of cleaned.split(" ")) {
|
|
394
|
+
const word = raw.replace(/^['-]+/u, "").replace(/['-]+$/u, "");
|
|
395
|
+
if (word === "") continue;
|
|
396
|
+
words.push(word.length > 60 ? word.slice(0, 60) : word);
|
|
397
|
+
if (words.length >= 12) break;
|
|
398
|
+
}
|
|
399
|
+
return words;
|
|
400
|
+
}
|
|
401
|
+
function containsAllWords(words) {
|
|
402
|
+
if (words.length === 0) {
|
|
403
|
+
throw invalidInput(
|
|
404
|
+
"No word to search for: the text held no letters or digits.",
|
|
405
|
+
"Write the words to look for, such as a surname or a title."
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
const clause = words.map((word) => `'${word.replace(/'/g, "''")}'`).join(" AND ");
|
|
409
|
+
return literal(clause);
|
|
410
|
+
}
|
|
411
|
+
var ARK_ID = /^c[bc][0-9a-z]{6,20}$/;
|
|
412
|
+
var TEMP_WORK_ID = /^[0-9a-f]{32}$/;
|
|
413
|
+
function parseEntityId(input) {
|
|
414
|
+
const trimmed = input.trim();
|
|
415
|
+
if (trimmed === "") {
|
|
416
|
+
throw invalidInput("An identifier is required.");
|
|
417
|
+
}
|
|
418
|
+
const arkMatch = /(?:^|\/)ark:\/12148\/(c[bc][0-9a-z]+)/i.exec(trimmed);
|
|
419
|
+
const bare = arkMatch ? arkMatch[1] : trimmed.replace(/^#/, "").split(/[#?]/)[0];
|
|
420
|
+
const candidate = (bare ?? "").toLowerCase();
|
|
421
|
+
if (ARK_ID.test(candidate)) {
|
|
422
|
+
return {
|
|
423
|
+
kind: "ark",
|
|
424
|
+
id: candidate,
|
|
425
|
+
iri: `http://data.bnf.fr/ark:/12148/${candidate}#about`,
|
|
426
|
+
pageUrl: `https://data.bnf.fr/ark:/12148/${candidate}`
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
const tempMatch = /temp-work\/([0-9a-f]{32})/i.exec(trimmed);
|
|
430
|
+
const digest = (tempMatch?.[1] ?? candidate).toLowerCase();
|
|
431
|
+
if (TEMP_WORK_ID.test(digest)) {
|
|
432
|
+
return {
|
|
433
|
+
kind: "temp-work",
|
|
434
|
+
id: `temp-work/${digest}`,
|
|
435
|
+
iri: `http://data.bnf.fr/temp-work/${digest}/#about`,
|
|
436
|
+
pageUrl: `https://data.bnf.fr/temp-work/${digest}/`
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
throw invalidInput(`"${input}" is not a data.bnf.fr identifier.`, hintFor(trimmed));
|
|
440
|
+
}
|
|
441
|
+
var GALLICA_DOCUMENT = /^(?:bpt6k|btv1b|bd6t|cb32|btv1b)[0-9a-z]+$/i;
|
|
442
|
+
var PAGE_ADDRESS = /data\.bnf\.fr\/(?:[a-z]{2}\/)?(\d{6,12})\/([a-z0-9_]+)/i;
|
|
443
|
+
var GENERAL_HINT = "An identifier looks like 'cb119219976' for an established record, or 'temp-work/22d7f68c1a4bdd081ad7ca791fd3b730' for a provisional one. Take it from a search result rather than building one.";
|
|
444
|
+
function hintFor(input) {
|
|
445
|
+
const page2 = PAGE_ADDRESS.exec(input);
|
|
446
|
+
if (page2) {
|
|
447
|
+
const name = (page2[2] ?? "").replace(/_/g, " ");
|
|
448
|
+
return `That is the readable address of a data.bnf.fr page, and ${page2[1]} in it is the record's FRBNF number rather than the identifier these tools take. Search for "${name}" with search_authors or search_works, and use the identifier the row carries.`;
|
|
449
|
+
}
|
|
450
|
+
const gallicaArk = /ark:\/12148\/([0-9a-z]+)/i.exec(input)?.[1] ?? input;
|
|
451
|
+
if (GALLICA_DOCUMENT.test(gallicaArk)) {
|
|
452
|
+
return "That is a Gallica identifier: it names a digitised document rather than a record in the catalogue, and these tools read the catalogue. Search for the work with search_works, then list_editions will give you the Gallica link alongside the edition it belongs to.";
|
|
453
|
+
}
|
|
454
|
+
return GENERAL_HINT;
|
|
455
|
+
}
|
|
456
|
+
var iriOf = (id) => `<${id.iri}>`;
|
|
457
|
+
function boundedInteger(value, min, max, name) {
|
|
458
|
+
if (!Number.isFinite(value) || !Number.isInteger(value)) {
|
|
459
|
+
throw invalidInput(`'${name}' has to be a whole number.`);
|
|
460
|
+
}
|
|
461
|
+
if (value < min || value > max) {
|
|
462
|
+
throw invalidInput(`'${name}' has to be between ${min} and ${max}.`);
|
|
463
|
+
}
|
|
464
|
+
return value;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// src/bnf/queries.ts
|
|
468
|
+
var TEXT_WINDOW = 400;
|
|
469
|
+
var prefixed = (body) => `${PREFIX_BLOCK}
|
|
470
|
+
${body.trim()}
|
|
471
|
+
`;
|
|
472
|
+
function searchAuthorsQuery(words, limit, offset) {
|
|
473
|
+
const take = boundedInteger(limit, 1, 50, "limit") + 1;
|
|
474
|
+
const skip = boundedInteger(offset, 0, 5e3, "offset");
|
|
475
|
+
return prefixed(`
|
|
476
|
+
SELECT ?person ?name ?label ?birthYear ?deathYear ?role WHERE {
|
|
477
|
+
{
|
|
478
|
+
SELECT DISTINCT ?person WHERE {
|
|
479
|
+
{
|
|
480
|
+
SELECT DISTINCT ?person ?name WHERE {
|
|
481
|
+
?person foaf:name ?name .
|
|
482
|
+
?name bif:contains ${containsAllWords(words)} .
|
|
483
|
+
}
|
|
484
|
+
LIMIT ${TEXT_WINDOW}
|
|
485
|
+
}
|
|
486
|
+
?person a foaf:Person .
|
|
487
|
+
}
|
|
488
|
+
ORDER BY ?person
|
|
489
|
+
LIMIT ${take} OFFSET ${skip}
|
|
490
|
+
}
|
|
491
|
+
OPTIONAL { ?person foaf:name ?name }
|
|
492
|
+
OPTIONAL { ?record foaf:focus ?person ; skos:prefLabel ?label }
|
|
493
|
+
OPTIONAL { ?person bnf:firstYear ?birthYear }
|
|
494
|
+
OPTIONAL { ?person bnf:lastYear ?deathYear }
|
|
495
|
+
OPTIONAL { ?person rdag2:biographicalInformation ?role }
|
|
496
|
+
}`);
|
|
497
|
+
}
|
|
498
|
+
var MOST_TRIPLES = 2e3;
|
|
499
|
+
function authorQuery(id) {
|
|
500
|
+
const person = iriOf(id);
|
|
501
|
+
return prefixed(`
|
|
502
|
+
SELECT ?p ?o ?lang WHERE {
|
|
503
|
+
{
|
|
504
|
+
${person} ?p ?o .
|
|
505
|
+
BIND(LANG(?o) AS ?lang)
|
|
506
|
+
}
|
|
507
|
+
UNION
|
|
508
|
+
{
|
|
509
|
+
?record foaf:focus ${person} ; ?p ?o .
|
|
510
|
+
BIND(LANG(?o) AS ?lang)
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
LIMIT ${MOST_TRIPLES}`);
|
|
514
|
+
}
|
|
515
|
+
function searchWorksQuery(words, limit, offset) {
|
|
516
|
+
const take = boundedInteger(limit, 1, 50, "limit") + 1;
|
|
517
|
+
const skip = boundedInteger(offset, 0, 5e3, "offset");
|
|
518
|
+
return prefixed(`
|
|
519
|
+
SELECT ?work ?title ?date ?status ?creator ?creatorName WHERE {
|
|
520
|
+
{
|
|
521
|
+
SELECT DISTINCT ?work WHERE {
|
|
522
|
+
{
|
|
523
|
+
SELECT DISTINCT ?work ?title WHERE {
|
|
524
|
+
?work dcterms:title ?title .
|
|
525
|
+
?title bif:contains ${containsAllWords(words)} .
|
|
526
|
+
}
|
|
527
|
+
LIMIT ${TEXT_WINDOW}
|
|
528
|
+
}
|
|
529
|
+
?work a rdafrbr:Work .
|
|
530
|
+
}
|
|
531
|
+
ORDER BY ?work
|
|
532
|
+
LIMIT ${take} OFFSET ${skip}
|
|
533
|
+
}
|
|
534
|
+
OPTIONAL { ?work dcterms:title ?title }
|
|
535
|
+
OPTIONAL { ?work dcterms:date ?date }
|
|
536
|
+
OPTIONAL { ?work rdae:statusOfIdentification ?status }
|
|
537
|
+
OPTIONAL {
|
|
538
|
+
?work dcterms:creator ?creator .
|
|
539
|
+
OPTIONAL { ?creator foaf:name ?creatorName }
|
|
540
|
+
}
|
|
541
|
+
}`);
|
|
542
|
+
}
|
|
543
|
+
function workQuery(id) {
|
|
544
|
+
const work = iriOf(id);
|
|
545
|
+
return prefixed(`
|
|
546
|
+
SELECT ?p ?o ?lang ?name WHERE {
|
|
547
|
+
${work} ?p ?o .
|
|
548
|
+
BIND(LANG(?o) AS ?lang)
|
|
549
|
+
OPTIONAL { ?o foaf:name ?name }
|
|
550
|
+
}
|
|
551
|
+
LIMIT ${MOST_TRIPLES}`);
|
|
552
|
+
}
|
|
553
|
+
function editionsQuery(id, limit, offset) {
|
|
554
|
+
const work = iriOf(id);
|
|
555
|
+
const take = boundedInteger(limit, 1, 50, "limit") + 1;
|
|
556
|
+
const skip = boundedInteger(offset, 0, 5e3, "offset");
|
|
557
|
+
return prefixed(`
|
|
558
|
+
SELECT ?edition ?title ?date ?year ?publisher ?place ?editionStatement
|
|
559
|
+
?extent ?isbn ?note ?catalogue ?reproduction ?ocr WHERE {
|
|
560
|
+
{
|
|
561
|
+
SELECT DISTINCT ?edition WHERE {
|
|
562
|
+
?edition rdarel:workManifested ${work} .
|
|
563
|
+
}
|
|
564
|
+
ORDER BY ?edition
|
|
565
|
+
LIMIT ${take} OFFSET ${skip}
|
|
566
|
+
}
|
|
567
|
+
OPTIONAL { ?edition dcterms:title ?title }
|
|
568
|
+
OPTIONAL { ?edition dcterms:date ?date }
|
|
569
|
+
OPTIONAL { ?edition bnf:firstYear ?year }
|
|
570
|
+
OPTIONAL { ?edition rdae:publishersName ?publisher }
|
|
571
|
+
OPTIONAL { ?edition rdae:placeOfPublication ?place }
|
|
572
|
+
OPTIONAL { ?edition rdae:editionStatement ?editionStatement }
|
|
573
|
+
OPTIONAL { ?edition dcterms:description ?extent }
|
|
574
|
+
OPTIONAL { ?edition bnf:isbn ?isbn }
|
|
575
|
+
OPTIONAL { ?edition rdae:note ?note }
|
|
576
|
+
OPTIONAL { ?edition rdfs:seeAlso ?catalogue }
|
|
577
|
+
OPTIONAL { ?edition rdarel:electronicReproduction ?reproduction }
|
|
578
|
+
OPTIONAL { ?edition bnf:OCR ?ocr }
|
|
579
|
+
}`);
|
|
580
|
+
}
|
|
581
|
+
function digitisedForWorkQuery(id, limit) {
|
|
582
|
+
const work = iriOf(id);
|
|
583
|
+
const take = boundedInteger(limit, 1, 200, "limit") + 1;
|
|
584
|
+
return prefixed(`
|
|
585
|
+
SELECT DISTINCT ?rank ?edition ?title ?reproduction ?ocr ?depiction WHERE {
|
|
586
|
+
{
|
|
587
|
+
?edition rdarel:workManifested ${work} .
|
|
588
|
+
OPTIONAL { ?edition dcterms:title ?title }
|
|
589
|
+
{ ?edition rdarel:electronicReproduction ?reproduction }
|
|
590
|
+
UNION
|
|
591
|
+
{ ?edition bnf:OCR ?ocr }
|
|
592
|
+
BIND(0 AS ?rank)
|
|
593
|
+
}
|
|
594
|
+
UNION
|
|
595
|
+
{
|
|
596
|
+
${work} foaf:depiction ?depiction .
|
|
597
|
+
BIND(${work} AS ?edition)
|
|
598
|
+
BIND(1 AS ?rank)
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
ORDER BY ?rank
|
|
602
|
+
LIMIT ${take}`);
|
|
603
|
+
}
|
|
604
|
+
function digitisedForPersonQuery(id, limit) {
|
|
605
|
+
const person = iriOf(id);
|
|
606
|
+
const take = boundedInteger(limit, 1, 200, "limit") + 1;
|
|
607
|
+
return prefixed(`
|
|
608
|
+
SELECT DISTINCT ?rank ?edition ?title ?reproduction ?ocr ?depiction WHERE {
|
|
609
|
+
{
|
|
610
|
+
?work dcterms:creator ${person} .
|
|
611
|
+
?edition rdarel:workManifested ?work .
|
|
612
|
+
OPTIONAL { ?edition dcterms:title ?title }
|
|
613
|
+
{ ?edition rdarel:electronicReproduction ?reproduction }
|
|
614
|
+
UNION
|
|
615
|
+
{ ?edition bnf:OCR ?ocr }
|
|
616
|
+
BIND(0 AS ?rank)
|
|
617
|
+
}
|
|
618
|
+
UNION
|
|
619
|
+
{
|
|
620
|
+
${person} foaf:depiction ?depiction .
|
|
621
|
+
BIND(${person} AS ?edition)
|
|
622
|
+
BIND(1 AS ?rank)
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
ORDER BY ?rank
|
|
626
|
+
LIMIT ${take}`);
|
|
627
|
+
}
|
|
628
|
+
function kindQuery(id) {
|
|
629
|
+
return prefixed(`
|
|
630
|
+
SELECT DISTINCT ?type WHERE {
|
|
631
|
+
${iriOf(id)} a ?type .
|
|
632
|
+
}
|
|
633
|
+
LIMIT 20`);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// src/bnf/parse.ts
|
|
637
|
+
var P = {
|
|
638
|
+
type: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
|
639
|
+
label: "http://www.w3.org/2000/01/rdf-schema#label",
|
|
640
|
+
seeAlso: "http://www.w3.org/2000/01/rdf-schema#seeAlso",
|
|
641
|
+
sameAs: "http://www.w3.org/2002/07/owl#sameAs",
|
|
642
|
+
exactMatch: "http://www.w3.org/2004/02/skos/core#exactMatch",
|
|
643
|
+
closeMatch: "http://www.w3.org/2004/02/skos/core#closeMatch",
|
|
644
|
+
prefLabel: "http://www.w3.org/2004/02/skos/core#prefLabel",
|
|
645
|
+
altLabel: "http://www.w3.org/2004/02/skos/core#altLabel",
|
|
646
|
+
created: "http://purl.org/dc/terms/created",
|
|
647
|
+
modified: "http://purl.org/dc/terms/modified",
|
|
648
|
+
title: "http://purl.org/dc/terms/title",
|
|
649
|
+
date: "http://purl.org/dc/terms/date",
|
|
650
|
+
creator: "http://purl.org/dc/terms/creator",
|
|
651
|
+
language: "http://purl.org/dc/terms/language",
|
|
652
|
+
subject: "http://purl.org/dc/terms/subject",
|
|
653
|
+
name: "http://xmlns.com/foaf/0.1/name",
|
|
654
|
+
givenName: "http://xmlns.com/foaf/0.1/givenName",
|
|
655
|
+
familyName: "http://xmlns.com/foaf/0.1/familyName",
|
|
656
|
+
depiction: "http://xmlns.com/foaf/0.1/depiction",
|
|
657
|
+
birth: "http://vocab.org/bio/0.1/birth",
|
|
658
|
+
death: "http://vocab.org/bio/0.1/death",
|
|
659
|
+
firstYear: "http://data.bnf.fr/ontology/bnf-onto/firstYear",
|
|
660
|
+
lastYear: "http://data.bnf.fr/ontology/bnf-onto/lastYear",
|
|
661
|
+
subjectLabel: "http://data.bnf.fr/ontology/bnf-onto/subject",
|
|
662
|
+
isni: "http://isni.org/ontology#identifierValid",
|
|
663
|
+
placeOfBirth: "http://rdvocab.info/ElementsGr2/placeOfBirth",
|
|
664
|
+
placeOfDeath: "http://rdvocab.info/ElementsGr2/placeOfDeath",
|
|
665
|
+
biographicalInformation: "http://rdvocab.info/ElementsGr2/biographicalInformation",
|
|
666
|
+
languageOfThePerson: "http://rdvocab.info/ElementsGr2/languageOfThePerson",
|
|
667
|
+
countryAssociated: "http://rdvocab.info/ElementsGr2/countryAssociatedWithThePerson",
|
|
668
|
+
fieldOfActivity: "http://rdvocab.info/ElementsGr2/fieldOfActivityOfThePerson",
|
|
669
|
+
formOfWork: "http://rdvocab.info/Elements/formOfWork",
|
|
670
|
+
statusOfIdentification: "http://rdvocab.info/Elements/statusOfIdentification",
|
|
671
|
+
/** The occupation, which RDA numbers rather than names. */
|
|
672
|
+
occupation: "http://rdaregistry.info/Elements/a/#P50113",
|
|
673
|
+
/** Expressions of a work, which are not editions. */
|
|
674
|
+
expressionOfWork: "http://rdaregistry.info/Elements/w/#P10078"
|
|
675
|
+
};
|
|
676
|
+
var ALIGNMENT_HOSTS = [
|
|
677
|
+
{ host: "viaf.org", name: "viaf" },
|
|
678
|
+
{ host: "idref.fr", name: "idref" },
|
|
679
|
+
{ host: "dbpedia.org", name: "dbpedia" },
|
|
680
|
+
{ host: "wikidata.org", name: "wikidata" },
|
|
681
|
+
{ host: "isni.org", name: "isni" },
|
|
682
|
+
{ host: "d-nb.info", name: "gnd" },
|
|
683
|
+
{ host: "id.loc.gov", name: "loc" },
|
|
684
|
+
{ host: "datos.bne.es", name: "bne" },
|
|
685
|
+
{ host: "wikipedia.org", name: "wikipedia" },
|
|
686
|
+
{ host: "musicbrainz.org", name: "musicbrainz" },
|
|
687
|
+
{ host: "francearchives.gouv.fr", name: "francearchives" },
|
|
688
|
+
{ host: "imslp.org", name: "imslp" },
|
|
689
|
+
{ host: "catalogue.bnf.fr", name: "catalogue" }
|
|
690
|
+
];
|
|
691
|
+
var text = (term) => {
|
|
692
|
+
if (!term) return null;
|
|
693
|
+
const value = term.value.trim();
|
|
694
|
+
return value === "" ? null : value;
|
|
695
|
+
};
|
|
696
|
+
var integer = (term) => {
|
|
697
|
+
const raw = text(term);
|
|
698
|
+
if (raw === null) return null;
|
|
699
|
+
const value = Number(raw);
|
|
700
|
+
return Number.isInteger(value) ? value : null;
|
|
701
|
+
};
|
|
702
|
+
function idFromIri(iri) {
|
|
703
|
+
const ark = /ark:\/12148\/(c[bc][0-9a-z]+)/i.exec(iri);
|
|
704
|
+
if (ark?.[1]) return ark[1].toLowerCase();
|
|
705
|
+
const temp = /temp-work\/([0-9a-f]{32})/i.exec(iri);
|
|
706
|
+
if (temp?.[1]) return `temp-work/${temp[1].toLowerCase()}`;
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
709
|
+
var vocabularyTerm = (iri) => decodeURIComponent(iri.replace(/[/#]$/, "").split(/[/#]/).pop() ?? iri);
|
|
710
|
+
function toDigitisedLink(address, role, fromId, fromTitle) {
|
|
711
|
+
if (!address || !isGallicaAddress(address)) return null;
|
|
712
|
+
const segment = /ark:\/12148\/([^/?#]+)/.exec(address)?.[1] ?? null;
|
|
713
|
+
if (!segment) return null;
|
|
714
|
+
const ark = segment.split(".")[0] ?? segment;
|
|
715
|
+
if (ark === "") return null;
|
|
716
|
+
return { ark, url: address, role, fromId, fromTitle };
|
|
717
|
+
}
|
|
718
|
+
function page(rows, limit, skipped) {
|
|
719
|
+
const more = rows.length > limit;
|
|
720
|
+
return skipped > 0 ? { rows: rows.slice(0, limit), hasMore: more, skipped } : { rows: rows.slice(0, limit), hasMore: more };
|
|
721
|
+
}
|
|
722
|
+
function byPredicate(results) {
|
|
723
|
+
const index = /* @__PURE__ */ new Map();
|
|
724
|
+
for (const row of results.results.bindings) {
|
|
725
|
+
const predicate = text(row.p);
|
|
726
|
+
if (predicate === null) continue;
|
|
727
|
+
const bucket = index.get(predicate);
|
|
728
|
+
if (bucket) bucket.push(row);
|
|
729
|
+
else index.set(predicate, [row]);
|
|
730
|
+
}
|
|
731
|
+
return index;
|
|
732
|
+
}
|
|
733
|
+
var firstValue = (index, predicate) => text(index.get(predicate)?.[0]?.o);
|
|
734
|
+
var allValues = (index, predicate) => {
|
|
735
|
+
const seen = /* @__PURE__ */ new Set();
|
|
736
|
+
for (const row of index.get(predicate) ?? []) {
|
|
737
|
+
const value = text(row.o);
|
|
738
|
+
if (value !== null) seen.add(value);
|
|
739
|
+
}
|
|
740
|
+
return [...seen];
|
|
741
|
+
};
|
|
742
|
+
var allTerms = (index, predicate) => allValues(index, predicate).filter((value) => value.startsWith("http")).map(vocabularyTerm);
|
|
743
|
+
var deweyClasses = (index, predicate) => allValues(index, predicate).filter((value) => value.includes("dewey.info/class/")).map(vocabularyTerm);
|
|
744
|
+
var allWords = (index, predicate) => allValues(index, predicate).filter((value) => !value.startsWith("http"));
|
|
745
|
+
function alignments(index, selfId) {
|
|
746
|
+
const found = {};
|
|
747
|
+
for (const predicate of [P.sameAs, P.exactMatch, P.closeMatch, P.seeAlso]) {
|
|
748
|
+
for (const address of allValues(index, predicate)) {
|
|
749
|
+
if (!address.startsWith("http")) continue;
|
|
750
|
+
if (selfId && address.includes(selfId)) continue;
|
|
751
|
+
let host;
|
|
752
|
+
try {
|
|
753
|
+
host = new URL(address).hostname.toLowerCase();
|
|
754
|
+
} catch {
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
const known = ALIGNMENT_HOSTS.find(
|
|
758
|
+
(entry) => host === entry.host || host.endsWith(`.${entry.host}`)
|
|
759
|
+
);
|
|
760
|
+
if (!known) continue;
|
|
761
|
+
(found[known.name] ??= /* @__PURE__ */ new Set()).add(address);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return Object.fromEntries(Object.entries(found).map(([name, set]) => [name, [...set]]));
|
|
765
|
+
}
|
|
766
|
+
function isniAddresses(index) {
|
|
767
|
+
return allValues(index, P.isni).map(
|
|
768
|
+
(value) => `https://isni.org/isni/${value.replace(/\s/g, "")}`
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
function toAuthorSummaries(results, limit) {
|
|
772
|
+
const rows = [];
|
|
773
|
+
const seen = /* @__PURE__ */ new Set();
|
|
774
|
+
let skipped = 0;
|
|
775
|
+
for (const row of results.results.bindings) {
|
|
776
|
+
const iri = text(row.person);
|
|
777
|
+
const id = iri === null ? null : idFromIri(iri);
|
|
778
|
+
if (iri === null || id === null) {
|
|
779
|
+
skipped += 1;
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
if (seen.has(id)) continue;
|
|
783
|
+
seen.add(id);
|
|
784
|
+
rows.push({
|
|
785
|
+
id,
|
|
786
|
+
name: text(row.name),
|
|
787
|
+
label: text(row.label),
|
|
788
|
+
birthYear: integer(row.birthYear),
|
|
789
|
+
deathYear: integer(row.deathYear),
|
|
790
|
+
role: text(row.role),
|
|
791
|
+
sourceUrl: publicPageFor(iri)
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
return page(rows, limit, skipped);
|
|
795
|
+
}
|
|
796
|
+
function toAuthorDetail(results, id, pageUrl) {
|
|
797
|
+
const index = byPredicate(results);
|
|
798
|
+
const types = allValues(index, P.type);
|
|
799
|
+
const otherNames = [];
|
|
800
|
+
const seenLabels = /* @__PURE__ */ new Set();
|
|
801
|
+
for (const row of index.get(P.altLabel) ?? []) {
|
|
802
|
+
const label = text(row.o);
|
|
803
|
+
if (label === null || seenLabels.has(label)) continue;
|
|
804
|
+
seenLabels.add(label);
|
|
805
|
+
otherNames.push({ label, language: text(row.lang) });
|
|
806
|
+
}
|
|
807
|
+
const sameAs = alignments(index, id);
|
|
808
|
+
const isni = isniAddresses(index);
|
|
809
|
+
if (isni.length > 0) sameAs.isni = [.../* @__PURE__ */ new Set([...sameAs.isni ?? [], ...isni])];
|
|
810
|
+
const catalogueUrl = allValues(index, P.seeAlso).find((address) => address.includes("catalogue.bnf.fr")) ?? null;
|
|
811
|
+
const depictions = [];
|
|
812
|
+
for (const address of allValues(index, P.depiction)) {
|
|
813
|
+
const link = toDigitisedLink(address, "depiction", id, null);
|
|
814
|
+
if (link) depictions.push(link);
|
|
815
|
+
}
|
|
816
|
+
return {
|
|
817
|
+
id,
|
|
818
|
+
name: firstValue(index, P.name),
|
|
819
|
+
label: firstValue(index, P.prefLabel),
|
|
820
|
+
givenName: firstValue(index, P.givenName),
|
|
821
|
+
familyName: firstValue(index, P.familyName),
|
|
822
|
+
otherNames,
|
|
823
|
+
birthDate: firstValue(index, P.birth),
|
|
824
|
+
deathDate: firstValue(index, P.death),
|
|
825
|
+
birthYear: integer(index.get(P.firstYear)?.[0]?.o),
|
|
826
|
+
deathYear: integer(index.get(P.lastYear)?.[0]?.o),
|
|
827
|
+
birthPlace: firstValue(index, P.placeOfBirth),
|
|
828
|
+
deathPlace: firstValue(index, P.placeOfDeath),
|
|
829
|
+
biographicalInformation: firstValue(index, P.biographicalInformation),
|
|
830
|
+
occupation: firstValue(index, P.occupation),
|
|
831
|
+
languages: allTerms(index, P.languageOfThePerson),
|
|
832
|
+
countries: allTerms(index, P.countryAssociated),
|
|
833
|
+
fields: allWords(index, P.fieldOfActivity),
|
|
834
|
+
deweyClasses: deweyClasses(index, P.fieldOfActivity),
|
|
835
|
+
sameAs,
|
|
836
|
+
catalogueUrl,
|
|
837
|
+
recordCreated: firstValue(index, P.created),
|
|
838
|
+
recordModified: firstValue(index, P.modified),
|
|
839
|
+
depictions,
|
|
840
|
+
types,
|
|
841
|
+
truncated: results.results.bindings.length >= MOST_TRIPLES,
|
|
842
|
+
sourceUrl: pageUrl
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
function readStatus(statement, iri) {
|
|
846
|
+
if (statement !== null) {
|
|
847
|
+
const said = statement.toLowerCase();
|
|
848
|
+
if (said.includes("provisional")) return "provisional";
|
|
849
|
+
if (said.includes("established")) return "established";
|
|
850
|
+
}
|
|
851
|
+
return iri.includes("/temp-work/") ? "provisional" : "established";
|
|
852
|
+
}
|
|
853
|
+
function toWorkSummaries(results, limit) {
|
|
854
|
+
const order = [];
|
|
855
|
+
const byId = /* @__PURE__ */ new Map();
|
|
856
|
+
let skipped = 0;
|
|
857
|
+
for (const row of results.results.bindings) {
|
|
858
|
+
const iri = text(row.work);
|
|
859
|
+
const id = iri === null ? null : idFromIri(iri);
|
|
860
|
+
if (iri === null || id === null) {
|
|
861
|
+
skipped += 1;
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
let work = byId.get(id);
|
|
865
|
+
if (!work) {
|
|
866
|
+
work = {
|
|
867
|
+
id,
|
|
868
|
+
title: text(row.title),
|
|
869
|
+
date: text(row.date),
|
|
870
|
+
creators: [],
|
|
871
|
+
status: readStatus(text(row.status), iri),
|
|
872
|
+
sourceUrl: publicPageFor(iri)
|
|
873
|
+
};
|
|
874
|
+
byId.set(id, work);
|
|
875
|
+
order.push(id);
|
|
876
|
+
}
|
|
877
|
+
const creatorIri = text(row.creator);
|
|
878
|
+
const creatorId = creatorIri ? idFromIri(creatorIri) : null;
|
|
879
|
+
if (creatorId && !work.creators.some((c) => c.id === creatorId)) {
|
|
880
|
+
work.creators.push({ id: creatorId, name: text(row.creatorName) });
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
return page(
|
|
884
|
+
order.map((id) => byId.get(id)),
|
|
885
|
+
limit,
|
|
886
|
+
skipped
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
function toWorkDetail(results, id, iri, pageUrl) {
|
|
890
|
+
const index = byPredicate(results);
|
|
891
|
+
const types = allValues(index, P.type);
|
|
892
|
+
const truncated = results.results.bindings.length >= MOST_TRIPLES;
|
|
893
|
+
const creators = [];
|
|
894
|
+
for (const row of index.get(P.creator) ?? []) {
|
|
895
|
+
const creatorIri = text(row.o);
|
|
896
|
+
const creatorId = creatorIri ? idFromIri(creatorIri) : null;
|
|
897
|
+
if (!creatorId || creators.some((c) => c.id === creatorId)) continue;
|
|
898
|
+
creators.push({ id: creatorId, name: text(row.name) });
|
|
899
|
+
}
|
|
900
|
+
const depictions = [];
|
|
901
|
+
for (const address of allValues(index, P.depiction)) {
|
|
902
|
+
const link = toDigitisedLink(address, "depiction", id, null);
|
|
903
|
+
if (link) depictions.push(link);
|
|
904
|
+
}
|
|
905
|
+
const statusStatement = firstValue(index, P.statusOfIdentification);
|
|
906
|
+
const catalogueUrl = allValues(index, P.seeAlso).find((address) => address.includes("catalogue.bnf.fr")) ?? null;
|
|
907
|
+
return {
|
|
908
|
+
id,
|
|
909
|
+
title: firstValue(index, P.title),
|
|
910
|
+
label: firstValue(index, P.label),
|
|
911
|
+
date: firstValue(index, P.date),
|
|
912
|
+
firstYear: integer(index.get(P.firstYear)?.[0]?.o),
|
|
913
|
+
creators,
|
|
914
|
+
languages: allTerms(index, P.language),
|
|
915
|
+
forms: allTerms(index, P.formOfWork),
|
|
916
|
+
subjects: allWords(index, P.subjectLabel),
|
|
917
|
+
deweyClasses: deweyClasses(index, P.subject),
|
|
918
|
+
status: readStatus(statusStatement, iri),
|
|
919
|
+
statusStatement,
|
|
920
|
+
// A cut record makes this the ceiling rather than the count, so it is
|
|
921
|
+
// withheld rather than published as a number the record does not support.
|
|
922
|
+
expressionCount: truncated ? null : allValues(index, P.expressionOfWork).length,
|
|
923
|
+
sameAs: alignments(index, id),
|
|
924
|
+
catalogueUrl,
|
|
925
|
+
depictions,
|
|
926
|
+
types,
|
|
927
|
+
truncated,
|
|
928
|
+
sourceUrl: pageUrl
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
function toEditions(results, limit) {
|
|
932
|
+
const order = [];
|
|
933
|
+
const byId = /* @__PURE__ */ new Map();
|
|
934
|
+
let skipped = 0;
|
|
935
|
+
for (const row of results.results.bindings) {
|
|
936
|
+
const iri = text(row.edition);
|
|
937
|
+
const id = iri === null ? null : idFromIri(iri);
|
|
938
|
+
if (iri === null || id === null) {
|
|
939
|
+
skipped += 1;
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
let edition = byId.get(id);
|
|
943
|
+
if (!edition) {
|
|
944
|
+
edition = {
|
|
945
|
+
id,
|
|
946
|
+
title: text(row.title),
|
|
947
|
+
date: text(row.date),
|
|
948
|
+
year: integer(row.year),
|
|
949
|
+
publisher: text(row.publisher),
|
|
950
|
+
place: text(row.place),
|
|
951
|
+
editionStatement: text(row.editionStatement),
|
|
952
|
+
extent: text(row.extent),
|
|
953
|
+
isbn: text(row.isbn),
|
|
954
|
+
note: text(row.note),
|
|
955
|
+
catalogueUrl: text(row.catalogue),
|
|
956
|
+
digitised: [],
|
|
957
|
+
sourceUrl: publicPageFor(iri)
|
|
958
|
+
};
|
|
959
|
+
byId.set(id, edition);
|
|
960
|
+
order.push(id);
|
|
961
|
+
}
|
|
962
|
+
for (const [column, role] of [
|
|
963
|
+
["reproduction", "reproduction"],
|
|
964
|
+
["ocr", "ocr"]
|
|
965
|
+
]) {
|
|
966
|
+
const link = toDigitisedLink(text(row[column]), role, id, edition.title);
|
|
967
|
+
if (link && !edition.digitised.some((existing) => existing.url === link.url)) {
|
|
968
|
+
edition.digitised.push(link);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
return page(
|
|
973
|
+
order.map((id) => byId.get(id)),
|
|
974
|
+
limit,
|
|
975
|
+
skipped
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
function toDigitisedLinks(results, limit) {
|
|
979
|
+
const links = [];
|
|
980
|
+
const seen = /* @__PURE__ */ new Set();
|
|
981
|
+
const rowCount = results.results.bindings.length;
|
|
982
|
+
for (const row of results.results.bindings) {
|
|
983
|
+
const fromIri = text(row.edition);
|
|
984
|
+
const fromId = fromIri ? idFromIri(fromIri) ?? fromIri : "";
|
|
985
|
+
const title = text(row.title);
|
|
986
|
+
for (const [column, role] of [
|
|
987
|
+
["reproduction", "reproduction"],
|
|
988
|
+
["ocr", "ocr"],
|
|
989
|
+
["depiction", "depiction"]
|
|
990
|
+
]) {
|
|
991
|
+
const link = toDigitisedLink(text(row[column]), role, fromId, title);
|
|
992
|
+
if (!link || seen.has(link.url)) continue;
|
|
993
|
+
seen.add(link.url);
|
|
994
|
+
links.push(link);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
return { rows: links.slice(0, limit), hasMore: links.length > limit || rowCount > limit };
|
|
998
|
+
}
|
|
999
|
+
function toTypes(results) {
|
|
1000
|
+
const types = /* @__PURE__ */ new Set();
|
|
1001
|
+
for (const row of results.results.bindings) {
|
|
1002
|
+
const iri = text(row.type);
|
|
1003
|
+
if (iri !== null) types.add(iri);
|
|
1004
|
+
}
|
|
1005
|
+
return [...types];
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// src/bnf/rateLimiter.ts
|
|
1009
|
+
var RateLimiter = class {
|
|
1010
|
+
baseIntervalMs;
|
|
1011
|
+
maxIntervalMs;
|
|
1012
|
+
intervalMs;
|
|
1013
|
+
lastStartedAt = 0;
|
|
1014
|
+
queue = Promise.resolve();
|
|
1015
|
+
calmStreak = 0;
|
|
1016
|
+
constructor(options) {
|
|
1017
|
+
this.baseIntervalMs = options.intervalMs;
|
|
1018
|
+
this.maxIntervalMs = options.maxIntervalMs ?? options.intervalMs * 8;
|
|
1019
|
+
this.intervalMs = options.intervalMs;
|
|
1020
|
+
}
|
|
1021
|
+
/** The spacing in force, which callers report rather than guess. */
|
|
1022
|
+
get currentIntervalMs() {
|
|
1023
|
+
return this.intervalMs;
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Run `task` with nothing else in flight.
|
|
1027
|
+
*
|
|
1028
|
+
* This only serialises. Spacing is claimed per attempt through
|
|
1029
|
+
* `beforeRequest`, because a task that retries makes several requests and
|
|
1030
|
+
* each of them owes the service the same gap.
|
|
1031
|
+
*/
|
|
1032
|
+
schedule(task) {
|
|
1033
|
+
const result = this.queue.then(task, task);
|
|
1034
|
+
this.queue = result.then(
|
|
1035
|
+
() => void 0,
|
|
1036
|
+
() => void 0
|
|
1037
|
+
);
|
|
1038
|
+
return result;
|
|
1039
|
+
}
|
|
1040
|
+
/** Wait until enough time has passed since the previous request began. */
|
|
1041
|
+
async beforeRequest() {
|
|
1042
|
+
const elapsed = Date.now() - this.lastStartedAt;
|
|
1043
|
+
const wait = Math.min(this.intervalMs, this.intervalMs - elapsed);
|
|
1044
|
+
if (wait > 0) await sleep(wait);
|
|
1045
|
+
this.lastStartedAt = Date.now();
|
|
1046
|
+
}
|
|
1047
|
+
/** Called when the service asks for room: double the gap, up to the ceiling. */
|
|
1048
|
+
pushBack() {
|
|
1049
|
+
this.calmStreak = 0;
|
|
1050
|
+
this.intervalMs = Math.min(this.maxIntervalMs, this.intervalMs * 2);
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Called on a clean answer. Recovery takes several in a row, so one lucky
|
|
1054
|
+
* response after a rough patch does not undo the caution that earned it.
|
|
1055
|
+
*/
|
|
1056
|
+
succeeded() {
|
|
1057
|
+
if (this.intervalMs === this.baseIntervalMs) return;
|
|
1058
|
+
this.calmStreak += 1;
|
|
1059
|
+
if (this.calmStreak < 3) return;
|
|
1060
|
+
this.calmStreak = 0;
|
|
1061
|
+
this.intervalMs = Math.max(this.baseIntervalMs, Math.round(this.intervalMs / 2));
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
function sleep(ms) {
|
|
1065
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// src/bnf/client.ts
|
|
1069
|
+
var PERSON_CLASS = "http://xmlns.com/foaf/0.1/Person";
|
|
1070
|
+
var WORK_CLASSES = /* @__PURE__ */ new Set([
|
|
1071
|
+
"http://rdvocab.info/uri/schema/FRBRentitiesRDA/Work",
|
|
1072
|
+
"http://rdaregistry.info/Elements/c/#C10001"
|
|
1073
|
+
]);
|
|
1074
|
+
function withGuarantees(config) {
|
|
1075
|
+
const defaults = loadConfig({});
|
|
1076
|
+
const bounded = (value, fallback, min, max) => {
|
|
1077
|
+
const parsed = Number(value);
|
|
1078
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
1079
|
+
return Math.min(max, Math.max(min, parsed));
|
|
1080
|
+
};
|
|
1081
|
+
const claimed = typeof config.userAgent === "string" ? config.userAgent.trim() : "";
|
|
1082
|
+
const identifier = defaults.userAgent;
|
|
1083
|
+
return {
|
|
1084
|
+
...config,
|
|
1085
|
+
// A caller may say who they are. Appending rather than replacing means the
|
|
1086
|
+
// BnF can always tell which software is calling, and reach someone.
|
|
1087
|
+
userAgent: claimed === "" || claimed.includes(REPO_URL) ? identifier : `${claimed} ${identifier}`,
|
|
1088
|
+
minIntervalMs: bounded(
|
|
1089
|
+
config.minIntervalMs,
|
|
1090
|
+
defaults.minIntervalMs,
|
|
1091
|
+
MIN_ALLOWED_INTERVAL_MS,
|
|
1092
|
+
MAX_ALLOWED_INTERVAL_MS
|
|
1093
|
+
),
|
|
1094
|
+
timeoutMs: bounded(config.timeoutMs, defaults.timeoutMs, 1e3, 3e5),
|
|
1095
|
+
maxRetries: bounded(config.maxRetries, defaults.maxRetries, 0, 8),
|
|
1096
|
+
cacheTtlMs: bounded(config.cacheTtlMs, defaults.cacheTtlMs, 0, 864e5),
|
|
1097
|
+
cacheMaxEntries: bounded(config.cacheMaxEntries, defaults.cacheMaxEntries, 1, 5e3)
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
var BnfClient = class {
|
|
1101
|
+
config;
|
|
1102
|
+
logger;
|
|
1103
|
+
limiter;
|
|
1104
|
+
cache;
|
|
1105
|
+
/** Questions already on the wire, so an identical one waits instead of asking. */
|
|
1106
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
1107
|
+
fetchImpl;
|
|
1108
|
+
constructor(options = {}) {
|
|
1109
|
+
const base = { ...loadConfig(), ...options.config };
|
|
1110
|
+
this.config = withGuarantees(base);
|
|
1111
|
+
this.logger = options.logger ?? createLogger(this.config.logLevel);
|
|
1112
|
+
this.limiter = new RateLimiter({ intervalMs: this.config.minIntervalMs });
|
|
1113
|
+
this.cache = new Cache(this.config.cacheTtlMs, this.config.cacheMaxEntries);
|
|
1114
|
+
this.fetchImpl = options.fetchImpl;
|
|
1115
|
+
}
|
|
1116
|
+
/** The pacing in force, which widens when the endpoint pushes back. */
|
|
1117
|
+
get intervalMs() {
|
|
1118
|
+
return this.limiter.currentIntervalMs;
|
|
1119
|
+
}
|
|
1120
|
+
/** What the BnF sees this client call itself. */
|
|
1121
|
+
get userAgent() {
|
|
1122
|
+
return this.config.userAgent;
|
|
1123
|
+
}
|
|
1124
|
+
/** The deadline one query is given, which callers report rather than guess. */
|
|
1125
|
+
get timeoutMs() {
|
|
1126
|
+
return this.config.timeoutMs;
|
|
1127
|
+
}
|
|
1128
|
+
/** The one address this client sends anything to. */
|
|
1129
|
+
get endpoint() {
|
|
1130
|
+
return SPARQL_ENDPOINT;
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* `cacheKey` is stated rather than taken from the query, because two reads of
|
|
1134
|
+
* one query can produce different values: a list cut to five rows is not the
|
|
1135
|
+
* same value as the same list cut to twenty, and serving one for the other
|
|
1136
|
+
* would answer a size the caller never asked for.
|
|
1137
|
+
*/
|
|
1138
|
+
read(cacheKey, query, parse) {
|
|
1139
|
+
const hit = this.cache.get(cacheKey);
|
|
1140
|
+
if (hit !== void 0) {
|
|
1141
|
+
this.logger.debug(`cache hit ${cacheKey}`);
|
|
1142
|
+
return Promise.resolve({
|
|
1143
|
+
data: hit.value,
|
|
1144
|
+
cached: true,
|
|
1145
|
+
retrievedAt: new Date(hit.retrievedAt).toISOString()
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
const flying = this.inFlight.get(cacheKey);
|
|
1149
|
+
if (flying !== void 0) {
|
|
1150
|
+
this.logger.debug(`joining a request already in flight for ${cacheKey}`);
|
|
1151
|
+
return flying;
|
|
1152
|
+
}
|
|
1153
|
+
const flight = this.fetchAndParse(cacheKey, query, parse);
|
|
1154
|
+
this.inFlight.set(cacheKey, flight);
|
|
1155
|
+
return flight.finally(() => this.inFlight.delete(cacheKey));
|
|
1156
|
+
}
|
|
1157
|
+
async fetchAndParse(cacheKey, query, parse) {
|
|
1158
|
+
const results = await this.limiter.schedule(
|
|
1159
|
+
() => runQuery({
|
|
1160
|
+
query,
|
|
1161
|
+
userAgent: this.config.userAgent,
|
|
1162
|
+
timeoutMs: this.config.timeoutMs,
|
|
1163
|
+
maxRetries: this.config.maxRetries,
|
|
1164
|
+
limiter: this.limiter,
|
|
1165
|
+
logger: this.logger,
|
|
1166
|
+
...this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}
|
|
1167
|
+
})
|
|
1168
|
+
);
|
|
1169
|
+
const retrievedAt = Date.now();
|
|
1170
|
+
const data = parse(results);
|
|
1171
|
+
this.cache.set(cacheKey, data, retrievedAt);
|
|
1172
|
+
return { data, cached: false, retrievedAt: new Date(retrievedAt).toISOString() };
|
|
1173
|
+
}
|
|
1174
|
+
/** Read an identifier a caller wrote, refusing anything this dataset cannot address. */
|
|
1175
|
+
identify(input) {
|
|
1176
|
+
return parseEntityId(input);
|
|
1177
|
+
}
|
|
1178
|
+
/** The words a search will actually look for, which a tool reports back. */
|
|
1179
|
+
words(input) {
|
|
1180
|
+
return toSearchWords(input);
|
|
1181
|
+
}
|
|
1182
|
+
searchAuthors(name, limit, offset) {
|
|
1183
|
+
const words = toSearchWords(name);
|
|
1184
|
+
const query = searchAuthorsQuery(words, limit, offset);
|
|
1185
|
+
return this.read(
|
|
1186
|
+
`authors:${words.join("+")}:${limit}:${offset}`,
|
|
1187
|
+
query,
|
|
1188
|
+
(results) => toAuthorSummaries(results, limit)
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
async getAuthor(id) {
|
|
1192
|
+
const result = await this.read(
|
|
1193
|
+
`author:${id.id}`,
|
|
1194
|
+
authorQuery(id),
|
|
1195
|
+
(results) => toAuthorDetail(results, id.id, id.pageUrl)
|
|
1196
|
+
);
|
|
1197
|
+
if (!result.data.types.includes(PERSON_CLASS)) {
|
|
1198
|
+
await this.refuse(id, result.data.types, "a person", "get_work reads a work.");
|
|
1199
|
+
}
|
|
1200
|
+
return result;
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Say what an address is, when it is not the kind of thing that was asked for.
|
|
1204
|
+
*
|
|
1205
|
+
* A record the catalogue does not describe at all and a record describing
|
|
1206
|
+
* something else are two different answers, and only one of them means the
|
|
1207
|
+
* caller should go looking elsewhere.
|
|
1208
|
+
*/
|
|
1209
|
+
async refuse(id, types, wanted, hint) {
|
|
1210
|
+
const known = types.length > 0 ? types : (await this.read(`types:${id.id}`, kindQuery(id), toTypes)).data;
|
|
1211
|
+
if (known.length === 0) {
|
|
1212
|
+
throw notFound(`data.bnf.fr describes nothing at "${id.id}".`, {
|
|
1213
|
+
hint: "Find the identifier with search_authors or search_works rather than writing one.",
|
|
1214
|
+
url: id.pageUrl
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
throw notFound(
|
|
1218
|
+
`"${id.id}" is described by data.bnf.fr, and it is not ${wanted}: it is typed ${known.join(", ")}.`,
|
|
1219
|
+
{ hint, url: id.pageUrl }
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
searchWorks(title, limit, offset) {
|
|
1223
|
+
const words = toSearchWords(title);
|
|
1224
|
+
const query = searchWorksQuery(words, limit, offset);
|
|
1225
|
+
return this.read(
|
|
1226
|
+
`works:${words.join("+")}:${limit}:${offset}`,
|
|
1227
|
+
query,
|
|
1228
|
+
(results) => toWorkSummaries(results, limit)
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
async getWork(id) {
|
|
1232
|
+
const result = await this.read(
|
|
1233
|
+
`work:${id.id}`,
|
|
1234
|
+
workQuery(id),
|
|
1235
|
+
(results) => toWorkDetail(results, id.id, id.iri, id.pageUrl)
|
|
1236
|
+
);
|
|
1237
|
+
if (!result.data.types.some((type) => WORK_CLASSES.has(type))) {
|
|
1238
|
+
await this.refuse(
|
|
1239
|
+
id,
|
|
1240
|
+
result.data.types,
|
|
1241
|
+
"a work",
|
|
1242
|
+
"get_author reads a person, and list_editions reads the editions of a work."
|
|
1243
|
+
);
|
|
1244
|
+
}
|
|
1245
|
+
return result;
|
|
1246
|
+
}
|
|
1247
|
+
listEditions(id, limit, offset) {
|
|
1248
|
+
const query = editionsQuery(id, limit, offset);
|
|
1249
|
+
return this.read(
|
|
1250
|
+
`editions:${id.id}:${limit}:${offset}`,
|
|
1251
|
+
query,
|
|
1252
|
+
(results) => toEditions(results, limit)
|
|
1253
|
+
);
|
|
1254
|
+
}
|
|
1255
|
+
/** Digitised documents attached to a work and to its editions. */
|
|
1256
|
+
digitisedForWork(id, limit) {
|
|
1257
|
+
return this.read(
|
|
1258
|
+
`digitised-work:${id.id}:${limit}`,
|
|
1259
|
+
digitisedForWorkQuery(id, limit),
|
|
1260
|
+
(results) => toDigitisedLinks(results, limit)
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
/** Digitised documents attached to a person and to the editions of their works. */
|
|
1264
|
+
digitisedForPerson(id, limit) {
|
|
1265
|
+
return this.read(
|
|
1266
|
+
`digitised-person:${id.id}:${limit}`,
|
|
1267
|
+
digitisedForPersonQuery(id, limit),
|
|
1268
|
+
(results) => toDigitisedLinks(results, limit)
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
/** What an address is typed as, which tells a person from a work. */
|
|
1272
|
+
types(id) {
|
|
1273
|
+
return this.read(`types:${id.id}`, kindQuery(id), toTypes);
|
|
1274
|
+
}
|
|
1275
|
+
};
|
|
1276
|
+
export {
|
|
1277
|
+
BnfClient
|
|
1278
|
+
};
|
|
1279
|
+
//# sourceMappingURL=client.js.map
|