keepset 0.1.0 → 0.3.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.
@@ -0,0 +1,811 @@
1
+ import { H as NativeParseError, R as ArchiveError, U as RecipeError, V as KeepsetError, j as getTarget, k as countTarget, o as getOwn, t as getDataset } from "../bundled--fOitQlP.js";
2
+ import { encode } from "twindex";
3
+ import { createHash } from "node:crypto";
4
+ import { gunzipSync } from "node:zlib";
5
+ import { unzipSync } from "fflate";
6
+ //#region src/parsers/native.ts
7
+ /**
8
+ * Collects node ids in order of first appearance, so a parser that learns its nodes from its edges emits them deterministically.
9
+ */
10
+ var NodeOrder = class {
11
+ #ids = /* @__PURE__ */ new Set();
12
+ /**
13
+ * Records an id; a repeat keeps its first position.
14
+ * @param id - The node id.
15
+ */
16
+ add(id) {
17
+ this.#ids.add(id);
18
+ }
19
+ /** @returns Whether the id has been recorded. */
20
+ has(id) {
21
+ return this.#ids.has(id);
22
+ }
23
+ /** @returns The nodes, in first-appearance order. */
24
+ nodes() {
25
+ return [...this.#ids].map((id) => ({ id }));
26
+ }
27
+ };
28
+ /**
29
+ * Splits text into lines, accepting both LF and CRLF endings.
30
+ * @param text - The file text.
31
+ * @returns The lines, without their terminators.
32
+ */
33
+ function lines(text) {
34
+ return text.split(/\r?\n/);
35
+ }
36
+ //#endregion
37
+ //#region src/parsers/edge-list.ts
38
+ /** How each fixed delimiter option splits a line into fields. */
39
+ const splitters = {
40
+ whitespace: (line) => line.trim().split(/\s+/),
41
+ tab: (line) => line.split(" "),
42
+ comma: (line) => line.split(","),
43
+ space: (line) => line.split(" ")
44
+ };
45
+ /**
46
+ * Reads one endpoint field, trimmed.
47
+ * @param fields - The line's fields.
48
+ * @param column - The endpoint's column.
49
+ * @param lineNumber - The 1-based line, for the error.
50
+ * @returns The endpoint id.
51
+ * @throws NativeParseError when the column is absent or empty.
52
+ */
53
+ function endpoint(fields, column, lineNumber) {
54
+ const value = fields[column];
55
+ if (value === void 0 || value.trim() === "") throw new NativeParseError("edge-list", lineNumber, `column ${String(column)} holds no node id`);
56
+ return value.trim();
57
+ }
58
+ /**
59
+ * Resolves `detect` to a fixed delimiter from the first data line: tab when it holds a tab, comma when it holds a comma, whitespace otherwise. The rule is a function of the file's bytes, so a recipe that detects still reads the same way every time.
60
+ * @param dataLine - The first line that is neither skipped, blank nor a comment.
61
+ * @returns The fixed delimiter.
62
+ */
63
+ function detect(dataLine) {
64
+ if (dataLine.includes(" ")) return "tab";
65
+ return dataLine.includes(",") ? "comma" : "whitespace";
66
+ }
67
+ /**
68
+ * Parses a delimited edge list: one edge per line, source and target in the configured columns, after `skipLines` header lines, with blank lines and lines whose first non-blank characters are a comment prefix skipped. A `detect` delimiter is fixed from the first data line. Nodes are the endpoints, in order of first appearance.
69
+ * @param text - The file text.
70
+ * @param options - The recipe's edge-list parser.
71
+ * @returns The native graph.
72
+ * @throws NativeParseError naming the line when a data line has too few fields or an empty endpoint, or when the file holds no edges.
73
+ */
74
+ function parseEdgeList(text, options) {
75
+ let split = options.delimiter === "detect" ? void 0 : splitters[options.delimiter];
76
+ const needed = Math.max(options.sourceColumn, options.targetColumn) + 1;
77
+ const order = new NodeOrder();
78
+ const edges = [];
79
+ for (const [index, line] of lines(text).entries()) {
80
+ const lineNumber = index + 1;
81
+ const content = line.trimStart();
82
+ if (index < options.skipLines || content === "" || options.commentPrefixes.some((prefix) => content.startsWith(prefix))) continue;
83
+ split ??= splitters[detect(line)];
84
+ const fields = split(line);
85
+ if (fields.length < needed) throw new NativeParseError("edge-list", lineNumber, `expected at least ${String(needed)} fields, found ${String(fields.length)}`);
86
+ const source = endpoint(fields, options.sourceColumn, lineNumber);
87
+ const target = endpoint(fields, options.targetColumn, lineNumber);
88
+ order.add(source);
89
+ order.add(target);
90
+ edges.push({
91
+ source,
92
+ target
93
+ });
94
+ }
95
+ if (edges.length === 0) throw new NativeParseError("edge-list", lines(text).length, "the file holds no edges");
96
+ return {
97
+ nodes: order.nodes(),
98
+ edges
99
+ };
100
+ }
101
+ //#endregion
102
+ //#region src/parsers/gml.ts
103
+ const KEY = /[A-Za-z_][A-Za-z0-9_]*/y;
104
+ const NUMBER = /[+-]?(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?/y;
105
+ /**
106
+ * Matches a sticky pattern at a position.
107
+ * @param pattern - A sticky regular expression.
108
+ * @param text - The text.
109
+ * @param position - Where the match must start.
110
+ * @returns The matched text, or undefined when the pattern does not match there.
111
+ */
112
+ function matchAt(pattern, text, position) {
113
+ const sticky = new RegExp(pattern.source, "y");
114
+ sticky.lastIndex = position;
115
+ return sticky.exec(text)?.[0];
116
+ }
117
+ /**
118
+ * Splits GML text into tokens: keys, numbers, double-quoted strings (which GML lets span lines and never escapes) and brackets, skipping whitespace and `#` comment lines. One pass over the text, by position.
119
+ * @param text - The file text.
120
+ * @returns The tokens with their 1-based lines.
121
+ */
122
+ function tokenise(text) {
123
+ const tokens = [];
124
+ let line = 1;
125
+ let position = 0;
126
+ while (position < text.length) {
127
+ const char = text.charAt(position);
128
+ if (char === "\n") {
129
+ line++;
130
+ position++;
131
+ } else if (/\s/.test(char)) position++;
132
+ else if (char === "#") {
133
+ const end = text.indexOf("\n", position);
134
+ position = end === -1 ? text.length : end;
135
+ } else if (char === "[" || char === "]") {
136
+ tokens.push({
137
+ kind: char === "[" ? "open" : "close",
138
+ text: char,
139
+ line
140
+ });
141
+ position++;
142
+ } else if (char === "\"") {
143
+ const end = text.indexOf("\"", position + 1);
144
+ if (end === -1) throw new NativeParseError("gml", line, "a string is never closed");
145
+ const body = text.slice(position + 1, end);
146
+ tokens.push({
147
+ kind: "scalar",
148
+ text: body,
149
+ line
150
+ });
151
+ line += body.split("\n").length - 1;
152
+ position = end + 1;
153
+ } else {
154
+ const key = matchAt(KEY, text, position);
155
+ const number = key === void 0 ? matchAt(NUMBER, text, position) : void 0;
156
+ const match = key ?? number;
157
+ if (match === void 0) throw new NativeParseError("gml", line, `unexpected character ${JSON.stringify(char)}`);
158
+ tokens.push({
159
+ kind: key === void 0 ? "scalar" : "key",
160
+ text: match,
161
+ line
162
+ });
163
+ position += match.length;
164
+ }
165
+ }
166
+ return tokens;
167
+ }
168
+ /**
169
+ * Parses tokens into key-value pairs until the end of input (at the top level) or a closing bracket (inside a list).
170
+ * @param tokens - Every token.
171
+ * @param start - The index to start at.
172
+ * @param nested - Whether this list was opened by a bracket and must be closed by one.
173
+ * @returns The pairs and the index after the list.
174
+ */
175
+ function parsePairs(tokens, start, nested) {
176
+ const pairs = [];
177
+ let index = start;
178
+ for (let token = tokens[index]; token !== void 0; token = tokens[index]) {
179
+ if (token.kind === "close") {
180
+ if (!nested) throw new NativeParseError("gml", token.line, "a ']' closes no list");
181
+ return {
182
+ pairs,
183
+ next: index + 1
184
+ };
185
+ }
186
+ if (token.kind !== "key") throw new NativeParseError("gml", token.line, `expected a key, found ${JSON.stringify(token.text)}`);
187
+ const value = tokens[index + 1];
188
+ if (value === void 0) throw new NativeParseError("gml", token.line, `key ${token.text} has no value`);
189
+ if (value.kind === "open") {
190
+ const list = parsePairs(tokens, index + 2, true);
191
+ pairs.push({
192
+ key: token.text,
193
+ value: {
194
+ kind: "list",
195
+ pairs: list.pairs,
196
+ line: value.line
197
+ }
198
+ });
199
+ index = list.next;
200
+ } else if (value.kind === "scalar") {
201
+ pairs.push({
202
+ key: token.text,
203
+ value: {
204
+ kind: "scalar",
205
+ text: value.text,
206
+ line: value.line
207
+ }
208
+ });
209
+ index += 2;
210
+ } else throw new NativeParseError("gml", value.line, `key ${token.text} has no value`);
211
+ }
212
+ const last = tokens.at(-1);
213
+ if (nested) throw new NativeParseError("gml", last === void 0 ? 1 : last.line, "a '[' is never closed");
214
+ return {
215
+ pairs,
216
+ next: index
217
+ };
218
+ }
219
+ /**
220
+ * Reads the one scalar a list holds under a key.
221
+ * @param list - The list.
222
+ * @param key - The key.
223
+ * @param owner - What the list is, for the error.
224
+ * @returns The scalar's text.
225
+ */
226
+ function scalar(list, key, owner) {
227
+ const values = list.pairs.filter((pair) => pair.key === key).map((pair) => pair.value);
228
+ const [value] = values;
229
+ if (values.length !== 1 || value?.kind !== "scalar") throw new NativeParseError("gml", list.line, `a ${owner} must hold exactly one scalar ${key}, found ${String(values.length)}`);
230
+ return value.text;
231
+ }
232
+ /**
233
+ * Parses a GML file (the format of Michael Himsolt's "GML: A portable Graph File Format", Universität Passau) as a graph: the one top-level `graph` list, its `node` lists by `id` in file order and its `edge` lists by `source` and `target` in file order. Ids keep their source text, so `1` and `"1"` name the same node. Every other key (labels, values, graphics, `directed`) is not carried in this version.
234
+ * @param text - The file text.
235
+ * @returns The native graph.
236
+ * @throws NativeParseError naming the line for a malformed file, a file without exactly one graph, or a node or edge without exactly one id, source or target.
237
+ */
238
+ function parseGml(text) {
239
+ const { pairs } = parsePairs(tokenise(text), 0, false);
240
+ const graphs = pairs.filter((pair) => pair.key === "graph").map((pair) => pair.value);
241
+ const [graph] = graphs;
242
+ if (graphs.length !== 1 || graph?.kind !== "list") throw new NativeParseError("gml", 1, `the file must hold exactly one graph list, found ${String(graphs.length)}`);
243
+ const nodes = [];
244
+ const edges = [];
245
+ for (const pair of graph.pairs) {
246
+ if (pair.value.kind !== "list") continue;
247
+ if (pair.key === "node") nodes.push({ id: scalar(pair.value, "id", "node") });
248
+ else if (pair.key === "edge") edges.push({
249
+ source: scalar(pair.value, "source", "edge"),
250
+ target: scalar(pair.value, "target", "edge")
251
+ });
252
+ }
253
+ return {
254
+ nodes,
255
+ edges
256
+ };
257
+ }
258
+ //#endregion
259
+ //#region src/parsers/matrix-market.ts
260
+ /** How many tokens an entry line of each field type holds: the coordinates, then the value's parts (none for a pattern, a real and an imaginary part for a complex value). */
261
+ const tokensPerEntry = /* @__PURE__ */ new Map([
262
+ ["pattern", 2],
263
+ ["real", 3],
264
+ ["integer", 3],
265
+ ["complex", 4]
266
+ ]);
267
+ /** The fields of the size line, in order. */
268
+ const SIZE_FIELDS = [
269
+ "rows",
270
+ "columns",
271
+ "entries"
272
+ ];
273
+ const symmetries = /* @__PURE__ */ new Set([
274
+ "general",
275
+ "symmetric",
276
+ "skew-symmetric",
277
+ "hermitian"
278
+ ]);
279
+ /**
280
+ * Reads a positive integer token.
281
+ * @param token - The token.
282
+ * @param line - The 1-based line, for the error.
283
+ * @param what - What the token is, for the error.
284
+ * @returns The integer.
285
+ */
286
+ function positiveInteger(token, line, what) {
287
+ if (token === void 0 || !/^[1-9]\d*$/.test(token)) throw new NativeParseError("matrix-market", line, `${what} must be a positive integer, found ${JSON.stringify(token)}`);
288
+ return Number(token);
289
+ }
290
+ /**
291
+ * Parses a Matrix Market coordinate file (https://math.nist.gov/MatrixMarket/formats.html) as a graph: each stored entry is one edge from its row to its column, and entry values are not carried. A square matrix's nodes are `1` to `n`, isolated ones included; a rectangular matrix is bipartite, with row nodes `r1` to `rM` and column nodes `c1` to `cN`. A symmetric, skew-symmetric or Hermitian file stores one triangle, and each stored entry stays one edge: the graph's direction is recorded in the entry's properties, never by duplicating edges.
292
+ * @param text - The file text.
293
+ * @returns The native graph.
294
+ * @throws NativeParseError naming the line for a missing or unsupported header, an array-format file, a malformed size line, an entry outside the matrix or with the wrong number of tokens, or an entry count that disagrees with the size line.
295
+ */
296
+ function parseMatrixMarket(text) {
297
+ const all = lines(text);
298
+ const [first] = all;
299
+ if (first === void 0) throw new NativeParseError("matrix-market", 1, "the file is empty");
300
+ const [banner, object, layout, field, symmetry] = first.trim().split(/\s+/).map((token) => token.toLowerCase());
301
+ if (banner !== "%%matrixmarket" || object !== "matrix") throw new NativeParseError("matrix-market", 1, "expected a '%%MatrixMarket matrix' header");
302
+ if (layout !== "coordinate") throw new NativeParseError("matrix-market", 1, `only the coordinate layout is supported, found ${JSON.stringify(layout)}`);
303
+ const perEntry = field === void 0 ? void 0 : tokensPerEntry.get(field);
304
+ if (perEntry === void 0) throw new NativeParseError("matrix-market", 1, `unknown field type ${JSON.stringify(field)}`);
305
+ if (symmetry === void 0 || !symmetries.has(symmetry)) throw new NativeParseError("matrix-market", 1, `unknown symmetry ${JSON.stringify(symmetry)}`);
306
+ let size;
307
+ const edges = [];
308
+ let rowId = (row) => String(row);
309
+ let columnId = (column) => String(column);
310
+ for (const [index, line] of all.entries()) {
311
+ const lineNumber = index + 1;
312
+ const content = line.trim();
313
+ if (index === 0 || content === "" || content.startsWith("%")) continue;
314
+ const tokens = content.split(/\s+/);
315
+ if (size === void 0) {
316
+ if (tokens.length !== SIZE_FIELDS.length) throw new NativeParseError("matrix-market", lineNumber, "the size line must be 'rows columns entries'");
317
+ size = {
318
+ rows: positiveInteger(tokens[0], lineNumber, "rows"),
319
+ columns: positiveInteger(tokens[1], lineNumber, "columns"),
320
+ entries: positiveInteger(tokens[2], lineNumber, "entries")
321
+ };
322
+ if (size.rows !== size.columns) {
323
+ rowId = (row) => `r${String(row)}`;
324
+ columnId = (column) => `c${String(column)}`;
325
+ }
326
+ continue;
327
+ }
328
+ if (tokens.length !== perEntry) throw new NativeParseError("matrix-market", lineNumber, `a ${String(field)} entry has ${String(perEntry)} tokens, found ${String(tokens.length)}`);
329
+ const row = positiveInteger(tokens[0], lineNumber, "the row");
330
+ const column = positiveInteger(tokens[1], lineNumber, "the column");
331
+ if (row > size.rows || column > size.columns) throw new NativeParseError("matrix-market", lineNumber, `entry (${String(row)}, ${String(column)}) lies outside the ${String(size.rows)} by ${String(size.columns)} matrix`);
332
+ edges.push({
333
+ source: rowId(row),
334
+ target: columnId(column)
335
+ });
336
+ }
337
+ if (size === void 0) throw new NativeParseError("matrix-market", all.length, "the file has no size line");
338
+ if (edges.length !== size.entries) throw new NativeParseError("matrix-market", all.length, `the size line declares ${String(size.entries)} entries but the file holds ${String(edges.length)}`);
339
+ const nodes = [];
340
+ for (let row = 1; row <= size.rows; row++) nodes.push({ id: rowId(row) });
341
+ if (size.rows !== size.columns) for (let column = 1; column <= size.columns; column++) nodes.push({ id: columnId(column) });
342
+ return {
343
+ nodes,
344
+ edges
345
+ };
346
+ }
347
+ //#endregion
348
+ //#region src/parsers/pajek.ts
349
+ /** Section keywords, lower-cased, mapped to how their lines are read. `*Partition` and `*Vector` hold node attributes this version does not carry, so their lines are skipped, as are the `*Vertices` lines inside them. */
350
+ const sections = /* @__PURE__ */ new Map([
351
+ ["*vertices", "vertices"],
352
+ ["*arcs", "pairs"],
353
+ ["*edges", "pairs"],
354
+ ["*arcslist", "lists"],
355
+ ["*edgeslist", "lists"],
356
+ ["*matrix", "matrix"],
357
+ ["*partition", "attributes"],
358
+ ["*vector", "attributes"]
359
+ ]);
360
+ /**
361
+ * Reads a vertex number, which Pajek numbers from 1 to the declared vertex count.
362
+ * @param token - The token.
363
+ * @param count - The declared vertex count.
364
+ * @param line - The 1-based line, for the error.
365
+ * @returns The vertex id as text.
366
+ */
367
+ function vertex(token, count, line) {
368
+ if (token === void 0 || !/^[1-9]\d*$/.test(token) || Number(token) > count) throw new NativeParseError("pajek", line, `expected a vertex number from 1 to ${String(count)}, found ${JSON.stringify(token)}`);
369
+ return token;
370
+ }
371
+ /**
372
+ * Parses a Pajek network (`.net`, or the first network of a `.paj` project, in the format the Pajek manual by Batagelj and Mrvar describes) as a graph. Nodes are `1` to `n` from `*Vertices n`, isolated ones included. Edges come from `*Arcs` and `*Edges` pairs, `*Arcslist` and `*Edgeslist` adjacency lists, and `*Matrix` rows (a non-zero cell is an edge; in a two-mode network, `*Vertices n n1`, rows are the first n1 vertices and columns the rest), in file order. Weights, labels and coordinates are not carried in this version.
373
+ * @param text - The file text.
374
+ * @returns The native graph.
375
+ * @throws NativeParseError naming the line for data before `*Vertices`, a vertex number outside the declared range, a malformed matrix row, an unknown section, or a second network in a project file.
376
+ */
377
+ function parsePajek(text) {
378
+ let count;
379
+ let firstMode;
380
+ let section = "preamble";
381
+ let matrixRow = 0;
382
+ let networks = 0;
383
+ const edges = [];
384
+ for (const [index, raw] of lines(text).entries()) {
385
+ const lineNumber = index + 1;
386
+ const content = raw.trim();
387
+ if (content === "" || content.startsWith("%")) continue;
388
+ const tokens = content.split(/\s+/);
389
+ const [keyword] = tokens;
390
+ if (keyword?.startsWith("*") === true) {
391
+ const name = keyword.toLowerCase();
392
+ if (name === "*network") {
393
+ networks++;
394
+ if (networks > 1) throw new NativeParseError("pajek", lineNumber, "a project file holding more than one network is not supported");
395
+ continue;
396
+ }
397
+ const next = sections.get(name);
398
+ if (next === void 0) throw new NativeParseError("pajek", lineNumber, `unknown section ${keyword}`);
399
+ if (next === "vertices" && section !== "attributes") {
400
+ if (count !== void 0) throw new NativeParseError("pajek", lineNumber, "a network declares *Vertices twice");
401
+ const declared = tokens[1];
402
+ if (declared === void 0 || !/^\d+$/.test(declared)) throw new NativeParseError("pajek", lineNumber, "*Vertices must give the vertex count");
403
+ count = Number(declared);
404
+ const mode = tokens[2];
405
+ firstMode = mode === void 0 ? void 0 : Number(vertex(mode, count, lineNumber));
406
+ section = "vertices";
407
+ } else if (next !== "vertices") section = next;
408
+ matrixRow = 0;
409
+ continue;
410
+ }
411
+ if (section === "attributes" || section === "vertices") continue;
412
+ if (count === void 0) throw new NativeParseError("pajek", lineNumber, "data before *Vertices");
413
+ if (section === "preamble") throw new NativeParseError("pajek", lineNumber, "data outside any section");
414
+ if (section === "pairs") edges.push({
415
+ source: vertex(tokens[0], count, lineNumber),
416
+ target: vertex(tokens[1], count, lineNumber)
417
+ });
418
+ else if (section === "lists") {
419
+ const source = vertex(tokens[0], count, lineNumber);
420
+ for (const target of tokens.slice(1)) edges.push({
421
+ source,
422
+ target: vertex(target, count, lineNumber)
423
+ });
424
+ } else {
425
+ const rows = firstMode ?? count;
426
+ const columnOffset = firstMode ?? 0;
427
+ const columns = count - columnOffset;
428
+ matrixRow++;
429
+ if (matrixRow > rows || tokens.length !== columns) throw new NativeParseError("pajek", lineNumber, `expected ${String(rows)} matrix rows of ${String(columns)} cells`);
430
+ for (const [column, cell] of tokens.entries()) {
431
+ if (!/^[+-]?(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?$/.test(cell)) throw new NativeParseError("pajek", lineNumber, `matrix cell ${JSON.stringify(cell)} is not a number`);
432
+ if (Number(cell) !== 0) edges.push({
433
+ source: String(matrixRow),
434
+ target: String(columnOffset + column + 1)
435
+ });
436
+ }
437
+ }
438
+ }
439
+ if (count === void 0) throw new NativeParseError("pajek", lines(text).length, "the file declares no *Vertices");
440
+ const nodes = [];
441
+ for (let id = 1; id <= count; id++) nodes.push({ id: String(id) });
442
+ return {
443
+ nodes,
444
+ edges
445
+ };
446
+ }
447
+ //#endregion
448
+ //#region src/parsers/parse-native.ts
449
+ /** Bytes converted per String.fromCharCode call: spreading a whole file would exceed the engine's argument-count limit, and this many stays well inside every engine's. */
450
+ const LATIN1_CHUNK = 32768;
451
+ /**
452
+ * Decodes a file's bytes in the recipe's encoding, strictly: bytes that are not valid utf-8 fail rather than becoming replacement characters. latin1 maps every byte to the code point of the same value, so it cannot fail.
453
+ * @param bytes - The unpacked file.
454
+ * @param parser - The recipe's parser, which names the encoding.
455
+ * @returns The file text.
456
+ */
457
+ function decode(bytes, parser) {
458
+ if (parser.encoding === "latin1") {
459
+ let text = "";
460
+ for (let start = 0; start < bytes.length; start += LATIN1_CHUNK) text += String.fromCharCode(...bytes.subarray(start, start + LATIN1_CHUNK));
461
+ return text;
462
+ }
463
+ try {
464
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
465
+ } catch (error) {
466
+ throw new NativeParseError(parser.format, 1, `the file is not valid utf-8 (${error instanceof Error ? error.message : String(error)}); a recipe for a latin1 file states encoding latin1`);
467
+ }
468
+ }
469
+ /**
470
+ * Reads a file into the native graph shape with the recipe's parser.
471
+ * @param bytes - The unpacked file.
472
+ * @param parser - The recipe's parser.
473
+ * @returns The native graph.
474
+ * @throws NativeParseError when the bytes are not valid in the stated encoding or the text is not in the stated format.
475
+ */
476
+ function parseNative(bytes, parser) {
477
+ const text = decode(bytes, parser);
478
+ if (parser.format === "edge-list") return parseEdgeList(text, parser);
479
+ if (parser.format === "matrix-market") return parseMatrixMarket(text);
480
+ if (parser.format === "gml") return parseGml(text);
481
+ return parsePajek(text);
482
+ }
483
+ /** The ustar header layout (POSIX.1-1988, pax(1) "ustar Interchange Format"): each field's byte offset and length. */
484
+ const HEADER = {
485
+ name: {
486
+ offset: 0,
487
+ length: 100
488
+ },
489
+ mode: {
490
+ offset: 100,
491
+ length: 8
492
+ },
493
+ size: {
494
+ offset: 124,
495
+ length: 12
496
+ },
497
+ checksum: {
498
+ offset: 148,
499
+ length: 8
500
+ },
501
+ type: {
502
+ offset: 156,
503
+ length: 1
504
+ },
505
+ magic: {
506
+ offset: 257,
507
+ length: 6
508
+ },
509
+ version: {
510
+ offset: 263,
511
+ length: 2
512
+ },
513
+ prefix: {
514
+ offset: 345,
515
+ length: 155
516
+ }
517
+ };
518
+ /** GNU tar's base-256 numeric form: the high bit of a field's first byte flags it, and the remaining bits and bytes are a big-endian number. */
519
+ const BASE256_FLAG = 128;
520
+ const BASE256_FIRST_BITS = 127;
521
+ const BYTE_RADIX = 256;
522
+ /**
523
+ * Reads a NUL-terminated (or field-filling) string from a header field.
524
+ * @param bytes - The block.
525
+ * @param offset - Field start.
526
+ * @param length - Field length.
527
+ * @returns The field text.
528
+ */
529
+ function field(bytes, offset, length) {
530
+ const slice = bytes.subarray(offset, offset + length);
531
+ const end = slice.indexOf(0);
532
+ return new TextDecoder().decode(end === -1 ? slice : slice.subarray(0, end));
533
+ }
534
+ /**
535
+ * Reads a numeric header field: octal text, or for values too large for octal the GNU base-256 form, flagged by the high bit of the first byte.
536
+ * @param bytes - The block.
537
+ * @param offset - Field start.
538
+ * @param length - Field length.
539
+ * @returns The number.
540
+ */
541
+ function numeric(bytes, offset, length) {
542
+ const first = bytes[offset];
543
+ if (first !== void 0 && (first & BASE256_FLAG) !== 0) {
544
+ let value = first & BASE256_FIRST_BITS;
545
+ for (const byte of bytes.subarray(offset + 1, offset + length)) value = value * BYTE_RADIX + byte;
546
+ return value;
547
+ }
548
+ const text = field(bytes, offset, length).trim();
549
+ if (!/^[0-7]*$/.test(text)) throw new ArchiveError(`tar header field is not octal: ${JSON.stringify(text)}`);
550
+ return text === "" ? 0 : Number.parseInt(text, 8);
551
+ }
552
+ /**
553
+ * Reads the `path` record of a pax extended header (POSIX.1-2001): records are `<length> <key>=<value>\n`.
554
+ * @param data - The extended header's data.
555
+ * @returns The path it sets, if it sets one.
556
+ */
557
+ function paxPath(data) {
558
+ const text = new TextDecoder().decode(data);
559
+ let path;
560
+ for (const record of text.split("\n")) {
561
+ const match = /^\d+ path=(.*)$/s.exec(record);
562
+ if (match?.[1] !== void 0) path = match[1];
563
+ }
564
+ return path;
565
+ }
566
+ /**
567
+ * Lists the regular files of a tar archive (ustar, with GNU long names and pax path records), verifying each header's checksum.
568
+ * @param bytes - The archive.
569
+ * @returns Every regular file, with its full path.
570
+ * @throws ArchiveError when a header checksum fails or a member runs past the end of the archive.
571
+ */
572
+ function readTar(bytes) {
573
+ const members = [];
574
+ let longName;
575
+ let offset = 0;
576
+ while (offset + 512 <= bytes.length) {
577
+ const header = bytes.subarray(offset, offset + 512);
578
+ if (header.every((byte) => byte === 0)) break;
579
+ const { checksum } = HEADER;
580
+ let sum = 0;
581
+ for (const [index, byte] of header.entries()) sum += index >= checksum.offset && index < checksum.offset + checksum.length ? 32 : byte;
582
+ if (sum !== numeric(header, checksum.offset, checksum.length)) throw new ArchiveError(`tar header at byte ${String(offset)} fails its checksum`);
583
+ const size = numeric(header, HEADER.size.offset, HEADER.size.length);
584
+ const type = field(header, HEADER.type.offset, HEADER.type.length);
585
+ const dataStart = offset + 512;
586
+ if (dataStart + size > bytes.length) throw new ArchiveError(`tar member at byte ${String(offset)} runs past the end of the archive`);
587
+ const data = bytes.subarray(dataStart, dataStart + size);
588
+ offset = dataStart + Math.ceil(size / 512) * 512;
589
+ if (type === "L") {
590
+ longName = field(data, 0, data.length);
591
+ continue;
592
+ }
593
+ if (type === "x") {
594
+ longName = paxPath(data);
595
+ continue;
596
+ }
597
+ const name = field(header, HEADER.name.offset, HEADER.name.length);
598
+ const prefix = field(header, HEADER.magic.offset, HEADER.magic.length).startsWith("ustar") ? field(header, HEADER.prefix.offset, HEADER.prefix.length) : "";
599
+ const path = longName ?? (prefix === "" ? name : `${prefix}/${name}`);
600
+ longName = void 0;
601
+ if (type === "0" || type === "" || type === "7") members.push({
602
+ path,
603
+ data
604
+ });
605
+ }
606
+ return members;
607
+ }
608
+ //#endregion
609
+ //#region src/node/archive.ts
610
+ /**
611
+ * Whether an archive member is a macOS resource fork (`__MACOSX/` or an AppleDouble `._` file), which carries Finder metadata rather than data and never matches a selector.
612
+ * @param path - The member path.
613
+ * @returns True for a resource fork.
614
+ */
615
+ function isResourceFork(path) {
616
+ return path.startsWith("__MACOSX/") || path.split("/").some((segment) => segment.startsWith("._"));
617
+ }
618
+ /**
619
+ * Whether a member path matches a selector: a path selector matches the whole member path or its trailing segments, an extension selector the end of the member's name.
620
+ * @param path - The member path.
621
+ * @param member - The recipe's selector.
622
+ * @returns True on a match.
623
+ */
624
+ function selects(path, member) {
625
+ if (isResourceFork(path) || path.endsWith("/")) return false;
626
+ if ("path" in member) return path === member.path || path.endsWith(`/${member.path}`);
627
+ return path.toLowerCase().endsWith(member.extension.toLowerCase());
628
+ }
629
+ /**
630
+ * Picks the one member a selector matches.
631
+ * @param paths - Every member path.
632
+ * @param member - The recipe's selector.
633
+ * @returns The matching path.
634
+ * @throws ArchiveError when no member or more than one matches, listing the members.
635
+ */
636
+ function pick(paths, member) {
637
+ const matched = paths.filter((path) => selects(path, member));
638
+ const [only] = matched;
639
+ if (matched.length !== 1 || only === void 0) {
640
+ const wanted = "path" in member ? `member ${member.path}` : `one member ending ${member.extension}`;
641
+ throw new ArchiveError(`expected ${wanted}, found ${String(matched.length)} among: ${paths.join(", ")}`);
642
+ }
643
+ return only;
644
+ }
645
+ /**
646
+ * Unpacks downloaded bytes into the one file the parser reads.
647
+ * @param bytes - The downloaded bytes.
648
+ * @param archive - The recipe's archive layout.
649
+ * @returns The file's bytes, and its member path for a multi-file archive.
650
+ * @throws ArchiveError when the bytes are not a well-formed archive of the stated kind or the selector does not match exactly one member.
651
+ */
652
+ function unpack(bytes, archive) {
653
+ if (archive.kind === "none") return { bytes };
654
+ if (archive.kind === "gzip") return { bytes: gunzip(bytes) };
655
+ if (archive.kind === "zip") {
656
+ const names = [];
657
+ unzip(bytes, (file) => {
658
+ names.push(file.name);
659
+ return false;
660
+ });
661
+ const path = pick(names, archive.member);
662
+ const file = getOwn(unzip(bytes, (candidate) => candidate.name === path), path);
663
+ if (file === void 0) throw new ArchiveError(`zip member ${path} could not be read`);
664
+ return {
665
+ bytes: file,
666
+ member: path
667
+ };
668
+ }
669
+ const members = readTar(archive.kind === "tar" ? bytes : gunzip(bytes));
670
+ const path = pick(members.map((member) => member.path), archive.member);
671
+ const found = members.find((member) => member.path === path);
672
+ if (found === void 0) throw new ArchiveError(`tar member ${path} could not be read`);
673
+ return {
674
+ bytes: found.data,
675
+ member: path
676
+ };
677
+ }
678
+ /**
679
+ * Gunzips bytes, rethrowing zlib's failure in the package's taxonomy.
680
+ * @param bytes - Gzip data.
681
+ * @returns The decompressed bytes.
682
+ */
683
+ function gunzip(bytes) {
684
+ try {
685
+ return gunzipSync(bytes);
686
+ } catch (error) {
687
+ throw new ArchiveError(`not valid gzip data: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
688
+ }
689
+ }
690
+ /**
691
+ * Unzips the entries a filter accepts, rethrowing fflate's failure in the package's taxonomy.
692
+ * @param bytes - Zip data.
693
+ * @param filter - Which entries to inflate.
694
+ * @returns The inflated entries, by name.
695
+ */
696
+ function unzip(bytes, filter) {
697
+ try {
698
+ return unzipSync(bytes, { filter });
699
+ } catch (error) {
700
+ throw new ArchiveError(`not valid zip data: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
701
+ }
702
+ }
703
+ //#endregion
704
+ //#region src/node/fetch-dataset.ts
705
+ /**
706
+ * The URL a recipe downloads a source from. A dead source is fetched from its pinned snapshot in the Wayback Machine's `id_` form, which serves the archived bytes as they were captured rather than wrapped in the archive's page chrome.
707
+ * @param source - The source.
708
+ * @returns The URL to request.
709
+ */
710
+ function downloadUrl(source) {
711
+ if (source.status === "dead") {
712
+ if (source.snapshot === void 0) throw new KeepsetError(`dead source ${source.url} has no snapshot`);
713
+ return `https://web.archive.org/web/${source.snapshot.timestamp}id_/${source.url}`;
714
+ }
715
+ return source.url;
716
+ }
717
+ /**
718
+ * Runs one step, rethrowing any failure as a RecipeError naming the entry and the step, with the original error as its cause.
719
+ * @param entryId - The entry.
720
+ * @param name - The step.
721
+ * @param run - The step's work.
722
+ * @returns What the step returns.
723
+ */
724
+ async function step(entryId, name, run) {
725
+ try {
726
+ return await run();
727
+ } catch (error) {
728
+ if (error instanceof RecipeError) throw error;
729
+ throw new RecipeError(entryId, name, error instanceof Error ? error.message : String(error), { cause: error });
730
+ }
731
+ }
732
+ /**
733
+ * The metadata a fetched result starts from: the entry's id and name, then its recorded properties. The entry schema keeps `id` and `name` out of the properties, so neither can be overwritten.
734
+ * @param entry - The entry.
735
+ * @returns The meta record the crosswalk receives.
736
+ */
737
+ function metaOf(entry) {
738
+ return {
739
+ id: entry.id,
740
+ name: entry.name,
741
+ ...entry.properties
742
+ };
743
+ }
744
+ /**
745
+ * Runs a dataset's recipe: downloads the source (from its pinned snapshot when the source is dead), unpacks the archive, parses the native shape, applies the twindex crosswalk and validates the result against the category target. The native document the crosswalk receives is the parser's output with `meta` built from the entry.
746
+ * @param dataset - A dataset from any catalogue.
747
+ * @param options - The network port, abort signal and clock.
748
+ * @returns The resolved target with its provenance and any count warnings.
749
+ * @throws RecipeError naming the entry and the failing step; nothing partial or default-shaped is ever returned.
750
+ */
751
+ async function fetchEntry(dataset, options = {}) {
752
+ const { entry, crosswalk } = dataset;
753
+ const { fetch: fetchPort = globalThis.fetch, signal, now = () => /* @__PURE__ */ new Date() } = options;
754
+ const { recipe } = entry;
755
+ if (recipe.status === "not-fetchable") throw new RecipeError(entry.id, "recipe", `the entry has no fetchable recipe: ${recipe.reason}`);
756
+ const source = entry.sources[recipe.source];
757
+ if (source === void 0) throw new RecipeError(entry.id, "recipe", `the recipe names source ${String(recipe.source)}, which the entry does not have`);
758
+ const fetchedUrl = downloadUrl(source);
759
+ const retrievedAt = now().toISOString();
760
+ const bytes = await step(entry.id, "download", async () => {
761
+ const response = await fetchPort(fetchedUrl, signal === void 0 ? {} : { signal });
762
+ if (!response.ok) throw new KeepsetError(`${fetchedUrl} answered ${String(response.status)} ${response.statusText}`);
763
+ return new Uint8Array(await response.arrayBuffer());
764
+ });
765
+ const unpacked = await step(entry.id, "extract", () => unpack(bytes, recipe.archive));
766
+ const native = await step(entry.id, "parse", () => parseNative(unpacked.bytes, recipe.parser));
767
+ const encoded = await step(entry.id, "crosswalk", () => encode(crosswalk, {
768
+ meta: metaOf(entry),
769
+ nodes: native.nodes,
770
+ edges: native.edges
771
+ }));
772
+ const data = await step(entry.id, "validate", () => {
773
+ const result = getTarget(entry.category).safeParse(encoded);
774
+ if (!result.success) throw new KeepsetError(`the result is not ${entry.category}: ${result.error.issues.map((issue) => `${issue.path.map(String).join(".") || "(root)"}: ${issue.message}`).join("; ")}`);
775
+ return result.data;
776
+ });
777
+ const counts = countTarget(entry.category, data);
778
+ const warnings = Object.entries(entry.expectedCounts).filter(([key, expected]) => counts.get(key) !== expected).map(([key, expected]) => `${key}: the entry records ${String(expected)}, the fetched result has ${String(counts.get(key))}`);
779
+ return {
780
+ dataset,
781
+ data,
782
+ provenance: {
783
+ entryId: entry.id,
784
+ catalogue: dataset.catalogue,
785
+ category: entry.category,
786
+ source: {
787
+ url: source.url,
788
+ status: source.status,
789
+ ...source.snapshot === void 0 ? {} : { snapshot: source.snapshot }
790
+ },
791
+ fetchedUrl,
792
+ retrievedAt,
793
+ sha256: createHash("sha256").update(bytes).digest("hex"),
794
+ byteLength: bytes.byteLength,
795
+ ...unpacked.member === void 0 ? {} : { member: unpacked.member }
796
+ },
797
+ warnings
798
+ };
799
+ }
800
+ /**
801
+ * Fetches a dataset of the bundled catalogue by id and resolves it to its category's target shape, so `fetchDataset("cora")` yields graph/v1 `{ meta, nodes, edges }` directly. Node-only: it downloads with the platform `fetch` and unpacks with Node's zlib.
802
+ * @param id - The entry id.
803
+ * @param options - The network port, abort signal and clock.
804
+ * @returns The resolved target with its provenance and any count warnings.
805
+ * @throws DatasetNotFoundError when no entry has that id, and RecipeError naming the entry and the failing step when the recipe fails.
806
+ */
807
+ async function fetchDataset(id, options = {}) {
808
+ return fetchEntry(getDataset(id), options);
809
+ }
810
+ //#endregion
811
+ export { downloadUrl, fetchDataset, fetchEntry };