keepset 0.19.0 → 0.21.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/dist/{bundled-8oHQPxcd.js → bundled-NjZZ73vT.js} +740 -50
- package/dist/{categories-tH53cHmu.d.ts → categories-frr8n_SI.d.ts} +40 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node/fetch-dataset.d.ts +1 -1
- package/dist/node/fetch-dataset.js +407 -26
- package/dist-cli/keepset.mjs +1 -1
- package/package.json +1 -1
- package/schemas/entry.schema.json +1 -1
- package/schemas/manifest.schema.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as NativeParseError, F as getOwn, J as ArchiveError, L as countTarget, Q as KeepsetError, et as RecipeError, t as getDataset, z as getTarget } from "../bundled-
|
|
1
|
+
import { $ as NativeParseError, F as getOwn, J as ArchiveError, L as countTarget, Q as KeepsetError, et as RecipeError, t as getDataset, z as getTarget } from "../bundled-NjZZ73vT.js";
|
|
2
2
|
import { RoundTripMismatchError, assertRoundTrip, encode } from "twindex";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
@@ -356,7 +356,7 @@ const DELIMITERS = {
|
|
|
356
356
|
semicolon: ";"
|
|
357
357
|
};
|
|
358
358
|
/** A number as a decimal literal, with an optional sign and exponent. `Number` alone would also accept hex, binary, `Infinity` and the empty string. */
|
|
359
|
-
const DECIMAL = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
359
|
+
const DECIMAL$1 = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
360
360
|
/** The byte order mark some exporters put before the first header. */
|
|
361
361
|
const BYTE_ORDER_MARK = "";
|
|
362
362
|
/**
|
|
@@ -390,8 +390,8 @@ function columnNames(cells, idColumn, line) {
|
|
|
390
390
|
return names;
|
|
391
391
|
}
|
|
392
392
|
/** The code unit of each character the scanner stops at. */
|
|
393
|
-
const QUOTE = "\"".charCodeAt(0);
|
|
394
|
-
const LINE_FEED = "\n".charCodeAt(0);
|
|
393
|
+
const QUOTE$1 = "\"".charCodeAt(0);
|
|
394
|
+
const LINE_FEED$1 = "\n".charCodeAt(0);
|
|
395
395
|
const CARRIAGE_RETURN = "\r".charCodeAt(0);
|
|
396
396
|
/** Scans a table file's text as it arrives in pieces, holding what a record in progress needs between them: the fields and field so far, whether a quoted field is open, and the line the record began on and the line the scan has reached. */
|
|
397
397
|
var RecordScanner = class {
|
|
@@ -429,7 +429,7 @@ var RecordScanner = class {
|
|
|
429
429
|
completed,
|
|
430
430
|
carry: text.slice(index)
|
|
431
431
|
};
|
|
432
|
-
if (text.charCodeAt(index + 1) === QUOTE) {
|
|
432
|
+
if (text.charCodeAt(index + 1) === QUOTE$1) {
|
|
433
433
|
this.field += "\"";
|
|
434
434
|
index += 2;
|
|
435
435
|
} else {
|
|
@@ -439,7 +439,7 @@ var RecordScanner = class {
|
|
|
439
439
|
continue;
|
|
440
440
|
}
|
|
441
441
|
const code = text.charCodeAt(index);
|
|
442
|
-
if (code === QUOTE) {
|
|
442
|
+
if (code === QUOTE$1) {
|
|
443
443
|
if (this.field !== "") throw new NativeParseError("csv-table", this.line, "a quote opens inside an unquoted field");
|
|
444
444
|
this.quoted = true;
|
|
445
445
|
index += 1;
|
|
@@ -447,19 +447,19 @@ var RecordScanner = class {
|
|
|
447
447
|
this.fields.push(this.field);
|
|
448
448
|
this.field = "";
|
|
449
449
|
index += 1;
|
|
450
|
-
} else if (code === LINE_FEED || code === CARRIAGE_RETURN) {
|
|
450
|
+
} else if (code === LINE_FEED$1 || code === CARRIAGE_RETURN) {
|
|
451
451
|
if (code === CARRIAGE_RETURN && index + 1 >= text.length && !final) return {
|
|
452
452
|
completed,
|
|
453
453
|
carry: text.slice(index)
|
|
454
454
|
};
|
|
455
|
-
index += code === CARRIAGE_RETURN && text.charCodeAt(index + 1) === LINE_FEED ? 2 : 1;
|
|
455
|
+
index += code === CARRIAGE_RETURN && text.charCodeAt(index + 1) === LINE_FEED$1 ? 2 : 1;
|
|
456
456
|
this.line += 1;
|
|
457
457
|
completed.push(this.endRecord());
|
|
458
458
|
} else {
|
|
459
459
|
let next = index + 1;
|
|
460
460
|
while (next < text.length) {
|
|
461
461
|
const following = text.charCodeAt(next);
|
|
462
|
-
if (following === QUOTE || following === this.delimiterCode || following === LINE_FEED || following === CARRIAGE_RETURN) break;
|
|
462
|
+
if (following === QUOTE$1 || following === this.delimiterCode || following === LINE_FEED$1 || following === CARRIAGE_RETURN) break;
|
|
463
463
|
next += 1;
|
|
464
464
|
}
|
|
465
465
|
this.field += text.slice(index, next);
|
|
@@ -559,7 +559,7 @@ function* withoutByteOrderMark(text) {
|
|
|
559
559
|
*/
|
|
560
560
|
function numeric$1(cell, factor, column, line) {
|
|
561
561
|
if (cell === "") return null;
|
|
562
|
-
if (!DECIMAL.test(cell)) throw new NativeParseError("csv-table", line, `column ${column} is declared numeric but holds ${JSON.stringify(cell)}`);
|
|
562
|
+
if (!DECIMAL$1.test(cell)) throw new NativeParseError("csv-table", line, `column ${column} is declared numeric but holds ${JSON.stringify(cell)}`);
|
|
563
563
|
return Number(cell) * factor;
|
|
564
564
|
}
|
|
565
565
|
/**
|
|
@@ -933,7 +933,7 @@ const BYTE_RADIX = 256;
|
|
|
933
933
|
* @param length - Field length.
|
|
934
934
|
* @returns The field text.
|
|
935
935
|
*/
|
|
936
|
-
function field(bytes, offset, length) {
|
|
936
|
+
function field$1(bytes, offset, length) {
|
|
937
937
|
const slice = bytes.subarray(offset, offset + length);
|
|
938
938
|
const end = slice.indexOf(0);
|
|
939
939
|
return new TextDecoder().decode(end === -1 ? slice : slice.subarray(0, end));
|
|
@@ -952,7 +952,7 @@ function numeric(bytes, offset, length) {
|
|
|
952
952
|
for (const byte of bytes.subarray(offset + 1, offset + length)) value = value * BYTE_RADIX + byte;
|
|
953
953
|
return value;
|
|
954
954
|
}
|
|
955
|
-
const text = field(bytes, offset, length).trim();
|
|
955
|
+
const text = field$1(bytes, offset, length).trim();
|
|
956
956
|
if (!/^[0-7]*$/.test(text)) throw new ArchiveError(`tar header field is not octal: ${JSON.stringify(text)}`);
|
|
957
957
|
return text === "" ? 0 : Number.parseInt(text, 8);
|
|
958
958
|
}
|
|
@@ -988,21 +988,21 @@ function readTar(bytes) {
|
|
|
988
988
|
for (const [index, byte] of header.entries()) sum += index >= checksum.offset && index < checksum.offset + checksum.length ? 32 : byte;
|
|
989
989
|
if (sum !== numeric(header, checksum.offset, checksum.length)) throw new ArchiveError(`tar header at byte ${String(offset)} fails its checksum`);
|
|
990
990
|
const size = numeric(header, HEADER.size.offset, HEADER.size.length);
|
|
991
|
-
const type = field(header, HEADER.type.offset, HEADER.type.length);
|
|
991
|
+
const type = field$1(header, HEADER.type.offset, HEADER.type.length);
|
|
992
992
|
const dataStart = offset + 512;
|
|
993
993
|
if (dataStart + size > bytes.length) throw new ArchiveError(`tar member at byte ${String(offset)} runs past the end of the archive`);
|
|
994
994
|
const data = bytes.subarray(dataStart, dataStart + size);
|
|
995
995
|
offset = dataStart + Math.ceil(size / 512) * 512;
|
|
996
996
|
if (type === "L") {
|
|
997
|
-
longName = field(data, 0, data.length);
|
|
997
|
+
longName = field$1(data, 0, data.length);
|
|
998
998
|
continue;
|
|
999
999
|
}
|
|
1000
1000
|
if (type === "x") {
|
|
1001
1001
|
longName = paxPath(data);
|
|
1002
1002
|
continue;
|
|
1003
1003
|
}
|
|
1004
|
-
const name = field(header, HEADER.name.offset, HEADER.name.length);
|
|
1005
|
-
const prefix = field(header, HEADER.magic.offset, HEADER.magic.length).startsWith("ustar") ? field(header, HEADER.prefix.offset, HEADER.prefix.length) : "";
|
|
1004
|
+
const name = field$1(header, HEADER.name.offset, HEADER.name.length);
|
|
1005
|
+
const prefix = field$1(header, HEADER.magic.offset, HEADER.magic.length).startsWith("ustar") ? field$1(header, HEADER.prefix.offset, HEADER.prefix.length) : "";
|
|
1006
1006
|
const path = longName ?? (prefix === "" ? name : `${prefix}/${name}`);
|
|
1007
1007
|
longName = void 0;
|
|
1008
1008
|
if (type === "0" || type === "" || type === "7") members.push({
|
|
@@ -1013,6 +1013,255 @@ function readTar(bytes) {
|
|
|
1013
1013
|
return members;
|
|
1014
1014
|
}
|
|
1015
1015
|
//#endregion
|
|
1016
|
+
//#region src/node/xlsx.ts
|
|
1017
|
+
const HEXADECIMAL = 16;
|
|
1018
|
+
const DECIMAL = 10;
|
|
1019
|
+
const LETTERS = 26;
|
|
1020
|
+
const FIRST_LETTER = "A".charCodeAt(0);
|
|
1021
|
+
const SECONDS_PER_DAY = 86400;
|
|
1022
|
+
const MILLISECONDS = 1e3;
|
|
1023
|
+
/** The serial number Excel gives its 29th of February 1900, a day that never existed: serials above it count from 30 December 1899 and serials below it from 31 December 1899. */
|
|
1024
|
+
const LEAP_BUG_SERIAL = 60;
|
|
1025
|
+
/** The day a serial number counts from: 1 January 1904 in a workbook that says so, otherwise 31 December 1899 for the serials before Excel's false 29th of February 1900 and 30 December 1899 for those after it. */
|
|
1026
|
+
const EPOCH_1904 = "1904-01-01T00:00:00Z";
|
|
1027
|
+
const EPOCH_BEFORE_LEAP_BUG = "1899-12-31T00:00:00Z";
|
|
1028
|
+
const EPOCH_AFTER_LEAP_BUG = "1899-12-30T00:00:00Z";
|
|
1029
|
+
const DATE_LENGTH = 10;
|
|
1030
|
+
const DATE_TIME_LENGTH = 19;
|
|
1031
|
+
const SECONDS_PER_MINUTE = 60;
|
|
1032
|
+
const MINUTES_PER_HOUR = 60;
|
|
1033
|
+
/**
|
|
1034
|
+
* Replaces the XML entities and character references of a text.
|
|
1035
|
+
* @param text - Escaped text.
|
|
1036
|
+
* @returns The text.
|
|
1037
|
+
*/
|
|
1038
|
+
function unescape(text) {
|
|
1039
|
+
return text.replaceAll(/&(#x[0-9A-Fa-f]+|#[0-9]+|amp|lt|gt|quot|apos);/g, (_whole, entity) => {
|
|
1040
|
+
if (entity.startsWith("#x")) return String.fromCodePoint(Number.parseInt(entity.slice(2), HEXADECIMAL));
|
|
1041
|
+
if (entity.startsWith("#")) return String.fromCodePoint(Number.parseInt(entity.slice(1), DECIMAL));
|
|
1042
|
+
return {
|
|
1043
|
+
amp: "&",
|
|
1044
|
+
lt: "<",
|
|
1045
|
+
gt: ">",
|
|
1046
|
+
quot: "\"",
|
|
1047
|
+
apos: "'"
|
|
1048
|
+
}[entity] ?? "";
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* The attributes of a start tag's text.
|
|
1053
|
+
* @param tag - The text between `<` and `>`, name included.
|
|
1054
|
+
* @returns The attributes, unescaped.
|
|
1055
|
+
*/
|
|
1056
|
+
function attributesOf(tag) {
|
|
1057
|
+
const attributes = {};
|
|
1058
|
+
for (const [, name, value] of tag.matchAll(/([\w:.-]+)\s*=\s*"([^"]*)"/g)) if (name !== void 0 && value !== void 0) attributes[name] = unescape(value);
|
|
1059
|
+
return attributes;
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* The start tags of one element name in an XML part, in order.
|
|
1063
|
+
* @param xml - The part's text.
|
|
1064
|
+
* @param name - The element name.
|
|
1065
|
+
* @returns The attributes of each start tag.
|
|
1066
|
+
*/
|
|
1067
|
+
function elements(xml, name) {
|
|
1068
|
+
return [...xml.matchAll(new RegExp(`<${name}(?=[\\s/>])([^>]*)>`, "g"))].map((match) => attributesOf(match[1] ?? ""));
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* The strings of a workbook's shared string table, in index order: each item's text runs joined, the phonetic runs of East Asian text left out.
|
|
1072
|
+
* @param xml - The `sharedStrings.xml` text.
|
|
1073
|
+
* @returns The strings.
|
|
1074
|
+
*/
|
|
1075
|
+
function sharedStrings(xml) {
|
|
1076
|
+
return [...xml.matchAll(/<si(?:\s[^>]*)?>([\s\S]*?)<\/si>/g)].map((item) => {
|
|
1077
|
+
return [...(item[1] ?? "").replaceAll(/<rPh[\s\S]*?<\/rPh>/g, "").matchAll(/<t(?:\s[^>]*)?>([\s\S]*?)<\/t>/g)].map((text) => unescape(text[1] ?? "")).join("");
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
/** The built-in number format ids that show a date or a time, as inclusive ranges (ECMA-376, 18.8.30): the short and long dates and times, the locale dates, and the elapsed and minute times. */
|
|
1081
|
+
const BUILT_IN_DATE_FORMATS = [
|
|
1082
|
+
{
|
|
1083
|
+
first: 14,
|
|
1084
|
+
last: 22
|
|
1085
|
+
},
|
|
1086
|
+
{
|
|
1087
|
+
first: 27,
|
|
1088
|
+
last: 36
|
|
1089
|
+
},
|
|
1090
|
+
{
|
|
1091
|
+
first: 45,
|
|
1092
|
+
last: 47
|
|
1093
|
+
},
|
|
1094
|
+
{
|
|
1095
|
+
first: 50,
|
|
1096
|
+
last: 58
|
|
1097
|
+
}
|
|
1098
|
+
];
|
|
1099
|
+
/**
|
|
1100
|
+
* Whether a custom number format shows a date or a time: it names a year, month, day, hour or second once its quoted text, escaped characters and bracketed conditions and colours are removed.
|
|
1101
|
+
* @param code - The format code.
|
|
1102
|
+
* @returns True for a date or time format.
|
|
1103
|
+
*/
|
|
1104
|
+
function isDateFormat(code) {
|
|
1105
|
+
return /[ymdhs]/i.test(code.replaceAll(/"[^"]*"|\\.|\[[^\]]*\]/g, ""));
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Which cell styles show dates, by the style's index.
|
|
1109
|
+
* @param xml - The `styles.xml` text, or undefined when the workbook has none.
|
|
1110
|
+
* @returns The style indexes whose number format is a date or a time.
|
|
1111
|
+
*/
|
|
1112
|
+
function dateStyles(xml) {
|
|
1113
|
+
if (xml === void 0) return /* @__PURE__ */ new Set();
|
|
1114
|
+
const custom = /* @__PURE__ */ new Map();
|
|
1115
|
+
for (const format of elements(xml, "numFmt")) if (format["numFmtId"] !== void 0 && format["formatCode"] !== void 0) custom.set(Number(format["numFmtId"]), format["formatCode"]);
|
|
1116
|
+
const cellFormats = /<cellXfs(?:\s[^>]*)?>([\s\S]*?)<\/cellXfs>/.exec(xml)?.[1] ?? "";
|
|
1117
|
+
const styles = /* @__PURE__ */ new Set();
|
|
1118
|
+
for (const [index, format] of elements(cellFormats, "xf").entries()) {
|
|
1119
|
+
const id = Number(format["numFmtId"] ?? "0");
|
|
1120
|
+
const code = custom.get(id);
|
|
1121
|
+
if (code === void 0 ? BUILT_IN_DATE_FORMATS.some((range) => id >= range.first && id <= range.last) : isDateFormat(code)) styles.add(index);
|
|
1122
|
+
}
|
|
1123
|
+
return styles;
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* A date serial number as ISO 8601 text: a date when it has no time of day, a date and time when it has, and a time alone for a serial below one day in a 1900 workbook.
|
|
1127
|
+
* @param serial - The serial number, with the time of day as its fraction.
|
|
1128
|
+
* @param date1904 - Whether the workbook counts days from 1 January 1904.
|
|
1129
|
+
* @returns The text.
|
|
1130
|
+
* @throws ArchiveError for the serial of the 29th of February 1900, which is not a date.
|
|
1131
|
+
*/
|
|
1132
|
+
function isoOf(serial, date1904) {
|
|
1133
|
+
const whole = Math.floor(serial);
|
|
1134
|
+
const seconds = Math.round((serial - whole) * SECONDS_PER_DAY);
|
|
1135
|
+
if (!date1904 && whole === 0) return [
|
|
1136
|
+
Math.floor(seconds / 3600),
|
|
1137
|
+
Math.floor(seconds / SECONDS_PER_MINUTE) % MINUTES_PER_HOUR,
|
|
1138
|
+
seconds % SECONDS_PER_MINUTE
|
|
1139
|
+
].map((part) => String(part).padStart(2, "0")).join(":");
|
|
1140
|
+
if (!date1904 && whole === LEAP_BUG_SERIAL) throw new ArchiveError("a cell holds the serial of 29 February 1900, which is not a date");
|
|
1141
|
+
return new Date(Date.parse(date1904 ? EPOCH_1904 : whole < LEAP_BUG_SERIAL ? EPOCH_BEFORE_LEAP_BUG : EPOCH_AFTER_LEAP_BUG) + (whole * SECONDS_PER_DAY + seconds) * MILLISECONDS).toISOString().slice(0, seconds === 0 ? DATE_LENGTH : DATE_TIME_LENGTH);
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* The 0-based column of a cell reference such as `AB12`.
|
|
1145
|
+
* @param reference - The reference.
|
|
1146
|
+
* @returns The column index.
|
|
1147
|
+
*/
|
|
1148
|
+
function columnOf(reference) {
|
|
1149
|
+
let index = 0;
|
|
1150
|
+
for (const letter of reference.replaceAll(/[^A-Za-z]/g, "")) index = index * LETTERS + (letter.toUpperCase().charCodeAt(0) - FIRST_LETTER + 1);
|
|
1151
|
+
return index - 1;
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* One CSV field: quoted when it holds a comma, a quote or a line break.
|
|
1155
|
+
* @param text - The cell text.
|
|
1156
|
+
* @returns The field.
|
|
1157
|
+
*/
|
|
1158
|
+
function field(text) {
|
|
1159
|
+
return /[",\r\n]/.test(text) ? `"${text.replaceAll("\"", "\"\"")}"` : text;
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Finds the part that holds a named sheet.
|
|
1163
|
+
* @param workbook - The `workbook.xml` text.
|
|
1164
|
+
* @param rels - The workbook's relationships text.
|
|
1165
|
+
* @param name - The sheet's name.
|
|
1166
|
+
* @returns The part's path inside the archive.
|
|
1167
|
+
* @throws ArchiveError when the workbook has no such sheet or its relationship is missing.
|
|
1168
|
+
*/
|
|
1169
|
+
function sheetPath(workbook, rels, name) {
|
|
1170
|
+
const sheets = elements(workbook, "sheet");
|
|
1171
|
+
const wanted = sheets.find((sheet) => sheet["name"] === name);
|
|
1172
|
+
if (wanted === void 0) throw new ArchiveError(`the workbook has no sheet ${name}; it has ${sheets.map((sheet) => sheet["name"] ?? "").join(", ")}`);
|
|
1173
|
+
const target = elements(rels, "Relationship").find((relation) => relation["Id"] === wanted["r:id"])?.["Target"];
|
|
1174
|
+
if (target === void 0) throw new ArchiveError(`the workbook has no relationship for the sheet ${name}`);
|
|
1175
|
+
return target.startsWith("/") ? target.slice(1) : `xl/${target}`;
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Reads a worksheet's cells into text by row and column.
|
|
1179
|
+
* @param xml - The worksheet's text.
|
|
1180
|
+
* @param strings - The shared strings.
|
|
1181
|
+
* @param dates - The date styles.
|
|
1182
|
+
* @param date1904 - Whether the workbook counts days from 1 January 1904.
|
|
1183
|
+
* @returns The rows by 1-based row number, each a sparse list of the cells' text, and the widest row's column count.
|
|
1184
|
+
* @throws ArchiveError when a cell names a shared string the workbook lacks or a date cell holds no number.
|
|
1185
|
+
*/
|
|
1186
|
+
function readRows(xml, strings, dates, date1904) {
|
|
1187
|
+
const rows = /* @__PURE__ */ new Map();
|
|
1188
|
+
let width = 0;
|
|
1189
|
+
let next = 1;
|
|
1190
|
+
for (const row of xml.matchAll(/<row(?=[\s>])([^>]*?)(?:\/>|>([\s\S]*?)<\/row>)/g)) {
|
|
1191
|
+
const number = Number(attributesOf(row[1] ?? "")["r"] ?? next);
|
|
1192
|
+
next = number + 1;
|
|
1193
|
+
const cells = [];
|
|
1194
|
+
for (const cell of (row[2] ?? "").matchAll(/<c(?=[\s>])([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) {
|
|
1195
|
+
const attributes = attributesOf(cell[1] ?? "");
|
|
1196
|
+
const column = attributes["r"] === void 0 ? cells.length : columnOf(attributes["r"]);
|
|
1197
|
+
const body = cell[2] ?? "";
|
|
1198
|
+
const value = /<v(?:\s[^>]*)?>([\s\S]*?)<\/v>/.exec(body)?.[1];
|
|
1199
|
+
let text;
|
|
1200
|
+
if (attributes["t"] === "inlineStr") text = [...body.matchAll(/<t(?:\s[^>]*)?>([\s\S]*?)<\/t>/g)].map((run) => unescape(run[1] ?? "")).join("");
|
|
1201
|
+
else if (value === void 0) text = void 0;
|
|
1202
|
+
else if (attributes["t"] === "s") {
|
|
1203
|
+
text = strings[Number(value)];
|
|
1204
|
+
if (text === void 0) throw new ArchiveError(`row ${String(number)} names the shared string ${value}, which the workbook does not have`);
|
|
1205
|
+
} else if (attributes["t"] === "b") text = value === "0" ? "FALSE" : "TRUE";
|
|
1206
|
+
else if (attributes["t"] === "str" || attributes["t"] === "e") text = unescape(value);
|
|
1207
|
+
else if (dates.has(Number(attributes["s"] ?? "0"))) {
|
|
1208
|
+
const serial = Number(value);
|
|
1209
|
+
if (!Number.isFinite(serial) || serial < 0) throw new ArchiveError(`row ${String(number)} has a date cell holding ${value}`);
|
|
1210
|
+
text = isoOf(serial, date1904);
|
|
1211
|
+
} else text = unescape(value);
|
|
1212
|
+
if (text !== void 0 && text !== "") {
|
|
1213
|
+
cells[column] = text;
|
|
1214
|
+
width = Math.max(width, column + 1);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
rows.set(number, cells);
|
|
1218
|
+
}
|
|
1219
|
+
return {
|
|
1220
|
+
rows,
|
|
1221
|
+
width
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Writes one sheet of an xlsx workbook as CSV text, so a recipe reads it with the table parser: one line for every row number from the first to the last (a row the sheet leaves out is an empty line, so the lines above the header are the rows an editor shows), every line the width of the sheet's widest row, a date cell as ISO 8601 text, a boolean as TRUE or FALSE, a number as the text the workbook stores, and a formula as its stored result.
|
|
1226
|
+
* @param bytes - The workbook.
|
|
1227
|
+
* @param sheet - The sheet's name.
|
|
1228
|
+
* @returns The CSV, in UTF-8.
|
|
1229
|
+
* @throws ArchiveError when the bytes are not a workbook, the sheet is missing, or a cell cannot be read.
|
|
1230
|
+
*/
|
|
1231
|
+
function sheetToCsv(bytes, sheet) {
|
|
1232
|
+
let parts;
|
|
1233
|
+
try {
|
|
1234
|
+
const wanted = /* @__PURE__ */ new Set([
|
|
1235
|
+
"xl/workbook.xml",
|
|
1236
|
+
"xl/_rels/workbook.xml.rels",
|
|
1237
|
+
"xl/sharedStrings.xml",
|
|
1238
|
+
"xl/styles.xml"
|
|
1239
|
+
]);
|
|
1240
|
+
parts = unzipSync(bytes, { filter: (file) => wanted.has(file.name) });
|
|
1241
|
+
} catch (error) {
|
|
1242
|
+
throw new ArchiveError(`not a valid workbook: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
1243
|
+
}
|
|
1244
|
+
const text = (path) => {
|
|
1245
|
+
const part = getOwn(parts, path);
|
|
1246
|
+
return part === void 0 ? void 0 : new TextDecoder("utf-8").decode(part);
|
|
1247
|
+
};
|
|
1248
|
+
const workbook = text("xl/workbook.xml");
|
|
1249
|
+
const rels = text("xl/_rels/workbook.xml.rels");
|
|
1250
|
+
if (workbook === void 0 || rels === void 0) throw new ArchiveError("the file is not an xlsx workbook: it has no xl/workbook.xml with its relationships");
|
|
1251
|
+
const path = sheetPath(workbook, rels, sheet);
|
|
1252
|
+
const sheetBytes = getOwn(unzipSync(bytes, { filter: (file) => file.name === path }), path);
|
|
1253
|
+
if (sheetBytes === void 0) throw new ArchiveError(`the workbook names the sheet ${sheet} in ${path}, which the file does not have`);
|
|
1254
|
+
const strings = text("xl/sharedStrings.xml");
|
|
1255
|
+
const { rows, width } = readRows(new TextDecoder("utf-8").decode(sheetBytes), strings === void 0 ? [] : sharedStrings(strings), dateStyles(text("xl/styles.xml")), /<workbookPr(?=[\s/>])[^>]*date1904="(?:1|true)"/.test(workbook));
|
|
1256
|
+
const last = Math.max(0, ...rows.keys());
|
|
1257
|
+
const lines = [];
|
|
1258
|
+
for (let number = 1; number <= last; number += 1) {
|
|
1259
|
+
const cells = rows.get(number) ?? [];
|
|
1260
|
+
lines.push(Array.from({ length: width }, (_empty, column) => field(cells[column] ?? "")).join(","));
|
|
1261
|
+
}
|
|
1262
|
+
return new TextEncoder().encode(lines.length === 0 ? "" : `${lines.join("\n")}\n`);
|
|
1263
|
+
}
|
|
1264
|
+
//#endregion
|
|
1016
1265
|
//#region src/node/archive.ts
|
|
1017
1266
|
/**
|
|
1018
1267
|
* 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.
|
|
@@ -1059,6 +1308,10 @@ function pick(paths, member) {
|
|
|
1059
1308
|
function unpack(bytes, archive) {
|
|
1060
1309
|
if (archive.kind === "none") return { bytes };
|
|
1061
1310
|
if (archive.kind === "gzip") return { bytes: gunzip(bytes) };
|
|
1311
|
+
if (archive.kind === "xlsx") return {
|
|
1312
|
+
bytes: sheetToCsv(bytes, archive.sheet),
|
|
1313
|
+
member: archive.sheet
|
|
1314
|
+
};
|
|
1062
1315
|
if (archive.kind === "zip") {
|
|
1063
1316
|
const names = [];
|
|
1064
1317
|
unzip(bytes, (file) => {
|
|
@@ -1120,20 +1373,40 @@ const REDIRECTS = new Set(Object.values({
|
|
|
1120
1373
|
}));
|
|
1121
1374
|
/** What the downloader says it is, so a server operator can tell where the traffic comes from. */
|
|
1122
1375
|
const USER_AGENT = "keepset (+https://github.com/Mearman/keepset)";
|
|
1376
|
+
/** The failure of a connection that sent nothing for the stall time. */
|
|
1377
|
+
var StalledError = class extends KeepsetError {};
|
|
1378
|
+
/**
|
|
1379
|
+
* Whether a failure is the connection's and may pass: a stall, or a socket error such as a reset, a broken pipe or a timeout, which carries an `E` code. A bad URL, a redirect that leads nowhere and an abort are not.
|
|
1380
|
+
* @param error - The failure.
|
|
1381
|
+
* @returns True when asking again may succeed.
|
|
1382
|
+
*/
|
|
1383
|
+
function isConnectionFailure(error) {
|
|
1384
|
+
return error instanceof StalledError || typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" && /^E[A-Z]+$/.test(error.code) && error.code !== "ERR_INVALID_URL";
|
|
1385
|
+
}
|
|
1123
1386
|
/**
|
|
1124
1387
|
* Sends one GET and resolves with the response head, its body still unread.
|
|
1125
1388
|
* @param url - The URL.
|
|
1126
1389
|
* @param signal - Aborts the request.
|
|
1390
|
+
* @param stall - How many milliseconds the connection may send nothing, in the head or the body that follows.
|
|
1127
1391
|
* @returns The response.
|
|
1392
|
+
* @throws KeepsetError when the connection stalls.
|
|
1128
1393
|
*/
|
|
1129
|
-
async function get(url, signal) {
|
|
1394
|
+
async function get(url, signal, stall) {
|
|
1130
1395
|
const send = url.protocol === "https:" ? request$1 : request;
|
|
1131
1396
|
return new Promise((resolve, reject) => {
|
|
1397
|
+
let answered;
|
|
1132
1398
|
const request = send(url, {
|
|
1133
1399
|
method: "GET",
|
|
1134
1400
|
headers: { "user-agent": USER_AGENT },
|
|
1135
1401
|
...signal === void 0 ? {} : { signal }
|
|
1136
|
-
},
|
|
1402
|
+
}, (response) => {
|
|
1403
|
+
answered = response;
|
|
1404
|
+
resolve(response);
|
|
1405
|
+
});
|
|
1406
|
+
request.setTimeout(stall, () => {
|
|
1407
|
+
const dead = new StalledError(`${url.href} sent nothing for ${String(stall)} milliseconds, so the connection was given up as dead`);
|
|
1408
|
+
(answered ?? request).destroy(dead);
|
|
1409
|
+
});
|
|
1137
1410
|
request.on("error", reject);
|
|
1138
1411
|
request.end();
|
|
1139
1412
|
});
|
|
@@ -1154,13 +1427,13 @@ async function readBody(response) {
|
|
|
1154
1427
|
/**
|
|
1155
1428
|
* Requests one URL, following its redirects by recursion.
|
|
1156
1429
|
* @param url - The URL to request.
|
|
1157
|
-
* @param
|
|
1430
|
+
* @param init - The request options.
|
|
1158
1431
|
* @param redirects - How many redirects led here.
|
|
1159
1432
|
* @returns The final response.
|
|
1160
1433
|
*/
|
|
1161
|
-
async function follow(url,
|
|
1434
|
+
async function follow(url, init, redirects) {
|
|
1162
1435
|
if (url.protocol !== "http:" && url.protocol !== "https:") throw new KeepsetError(`${url.href} is not an http or https URL`);
|
|
1163
|
-
const response = await get(url, signal);
|
|
1436
|
+
const response = await get(url, init.signal, init.stallMilliseconds ?? 12e4);
|
|
1164
1437
|
const { statusCode: status, statusMessage } = response;
|
|
1165
1438
|
if (status === void 0 || statusMessage === void 0) throw new KeepsetError(`${url.href} answered without a status line`);
|
|
1166
1439
|
if (!REDIRECTS.has(status)) return new Response(await readBody(response), {
|
|
@@ -1171,17 +1444,36 @@ async function follow(url, signal, redirects) {
|
|
|
1171
1444
|
const { location } = response.headers;
|
|
1172
1445
|
if (location === void 0) throw new KeepsetError(`${url.href} answered ${String(status)} without a location`);
|
|
1173
1446
|
if (redirects === MAX_REDIRECTS) throw new KeepsetError(`${url.href} redirected more than ${String(MAX_REDIRECTS)} times`);
|
|
1174
|
-
return follow(new URL(location, url),
|
|
1447
|
+
return follow(new URL(location, url), init, redirects + 1);
|
|
1175
1448
|
}
|
|
1176
1449
|
/**
|
|
1177
1450
|
* Downloads a URL's bytes exactly as the server sends them, following redirects. Unlike the platform `fetch`, it never undoes a `Content-Encoding`: servers commonly label a `.gz` file `Content-Encoding: gzip`, and a recipe that unpacks gzip needs the file as published, which is also what its SHA-256 is recorded over. No `Accept-Encoding` is sent, so a server that compresses on the fly has no reason to.
|
|
1178
1451
|
* @param url - An http or https URL.
|
|
1179
1452
|
* @param init - The request options.
|
|
1180
1453
|
* @returns A Response holding the raw bytes, with the final status.
|
|
1181
|
-
* @throws KeepsetError when a redirect has no location, leaves http and https, or passes the redirect limit.
|
|
1454
|
+
* @throws KeepsetError when a redirect has no location, leaves http and https, or passes the redirect limit; the connection's failure, after the tries, when it keeps failing or sends nothing for longer than the stall time.
|
|
1182
1455
|
*/
|
|
1183
1456
|
async function downloadRaw(url, init) {
|
|
1184
|
-
return
|
|
1457
|
+
return tryDownload(url, init, 1);
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* One try of a download, and the tries after it while the connection fails.
|
|
1461
|
+
* @param url - The URL.
|
|
1462
|
+
* @param init - The request options.
|
|
1463
|
+
* @param attempt - Which try this is, from 1.
|
|
1464
|
+
* @returns The response.
|
|
1465
|
+
* @throws The last failure when the tries are used up, or at once for a failure that is not the connection's.
|
|
1466
|
+
*/
|
|
1467
|
+
async function tryDownload(url, init, attempt) {
|
|
1468
|
+
try {
|
|
1469
|
+
return await follow(new URL(url), init, 0);
|
|
1470
|
+
} catch (error) {
|
|
1471
|
+
if (attempt >= 3 || init.signal?.aborted === true || !isConnectionFailure(error)) throw error;
|
|
1472
|
+
await new Promise((resolve) => {
|
|
1473
|
+
setTimeout(resolve, (init.retryDelayMilliseconds ?? 3e3) * attempt);
|
|
1474
|
+
});
|
|
1475
|
+
return tryDownload(url, init, attempt + 1);
|
|
1476
|
+
}
|
|
1185
1477
|
}
|
|
1186
1478
|
//#endregion
|
|
1187
1479
|
//#region src/node/paged.ts
|
|
@@ -1228,7 +1520,7 @@ const FeatureListSchema = z.array(z.unknown());
|
|
|
1228
1520
|
async function readPages(layer, held) {
|
|
1229
1521
|
if (held.length >= layer.count) return held;
|
|
1230
1522
|
const url = `${layer.query}?where=1%3D1&outFields=*&orderByFields=${encodeURIComponent(layer.orderBy)}&resultOffset=${String(held.length)}&f=geojson`;
|
|
1231
|
-
const page = await getJson(layer.port, url, layer.signal);
|
|
1523
|
+
const page = await withAttempts(async () => getJson(layer.port, url, layer.signal), layer.signal);
|
|
1232
1524
|
const features = FeatureListSchema.safeParse(typeof page === "object" && page !== null && "features" in page ? page.features : void 0);
|
|
1233
1525
|
if (!features.success || features.data.length === 0) throw new KeepsetError(`${layer.query} gave no features at offset ${String(held.length)}, before the ${String(layer.count)} it reports, so it does not page by offset`);
|
|
1234
1526
|
return readPages(layer, [...held, ...features.data]);
|
|
@@ -1258,6 +1550,95 @@ async function downloadArcgisLayer(layerUrl, orderBy, port, signal) {
|
|
|
1258
1550
|
features: pages
|
|
1259
1551
|
}));
|
|
1260
1552
|
}
|
|
1553
|
+
/** How many times a page is requested before the download fails: a connection can drop or go silent in one request of dozens and succeed in the next, and the pages are read by offset so asking again is exact. */
|
|
1554
|
+
const PAGE_ATTEMPTS = 3;
|
|
1555
|
+
/**
|
|
1556
|
+
* Runs one page read, asking again when it fails, so one bad response of a long download does not lose the rest.
|
|
1557
|
+
* @param read - The page read.
|
|
1558
|
+
* @param signal - Aborts the download, which is not asked again.
|
|
1559
|
+
* @param attempt - Which attempt this is, from 1.
|
|
1560
|
+
* @returns What the read returns.
|
|
1561
|
+
* @throws The last failure, when the attempts are used up or the download was aborted.
|
|
1562
|
+
*/
|
|
1563
|
+
async function withAttempts(read, signal, attempt = 1) {
|
|
1564
|
+
try {
|
|
1565
|
+
return await read();
|
|
1566
|
+
} catch (error) {
|
|
1567
|
+
if (attempt >= PAGE_ATTEMPTS || signal?.aborted === true) throw error;
|
|
1568
|
+
return withAttempts(read, signal, attempt + 1);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
const LINE_FEED = 10;
|
|
1572
|
+
const QUOTE = 34;
|
|
1573
|
+
/**
|
|
1574
|
+
* How many CSV records a response holds: the line feeds outside a quoted field, and one more when the last record has no line feed.
|
|
1575
|
+
* @param bytes - A CSV response.
|
|
1576
|
+
* @returns The number of records, the header included.
|
|
1577
|
+
*/
|
|
1578
|
+
function recordsIn(bytes) {
|
|
1579
|
+
let records = 0;
|
|
1580
|
+
let quoted = false;
|
|
1581
|
+
for (const byte of bytes) if (byte === QUOTE) quoted = !quoted;
|
|
1582
|
+
else if (byte === LINE_FEED && !quoted) records += 1;
|
|
1583
|
+
return bytes.length > 0 && bytes[bytes.length - 1] !== LINE_FEED ? records + 1 : records;
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Reads one response of a Socrata resource endpoint as bytes.
|
|
1587
|
+
* @param port - The network port.
|
|
1588
|
+
* @param url - The request URL.
|
|
1589
|
+
* @param signal - Aborts the request.
|
|
1590
|
+
* @returns The response body.
|
|
1591
|
+
* @throws KeepsetError when the server does not answer successfully.
|
|
1592
|
+
*/
|
|
1593
|
+
async function getBytes(port, url, signal) {
|
|
1594
|
+
const response = await port(url, signal === void 0 ? {} : { signal });
|
|
1595
|
+
if (!response.ok) throw new KeepsetError(`${url} answered ${String(response.status)} ${response.statusText}`);
|
|
1596
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
1597
|
+
}
|
|
1598
|
+
/**
|
|
1599
|
+
* Reads a resource's pages, each starting where the rows held so far end, and checks each holds exactly the rows it should, so a response cut short is an error and not a table with rows missing.
|
|
1600
|
+
* @param read - The resource read.
|
|
1601
|
+
* @param held - The pages read so far, each after the first without its header record.
|
|
1602
|
+
* @param offset - How many rows the pages hold.
|
|
1603
|
+
* @returns Every page.
|
|
1604
|
+
* @throws KeepsetError when a page holds a different number of rows than the count says it should, or its header differs from the first page's.
|
|
1605
|
+
*/
|
|
1606
|
+
async function readResourcePages(read, held, offset) {
|
|
1607
|
+
if (offset >= read.count) return held;
|
|
1608
|
+
const expected = Math.min(read.pageSize, read.count - offset);
|
|
1609
|
+
const page = await withAttempts(async () => {
|
|
1610
|
+
const bytes = await getBytes(read.port, `${read.resource}?%24order=%3Aid&%24limit=${String(read.pageSize)}&%24offset=${String(offset)}`, read.signal);
|
|
1611
|
+
const rows = recordsIn(bytes) - 1;
|
|
1612
|
+
if (rows !== expected) throw new KeepsetError(`${read.resource} gave ${String(rows)} rows at offset ${String(offset)} where ${String(expected)} of its ${String(read.count)} were due, so a response was cut short or the table changed during the download`);
|
|
1613
|
+
return bytes;
|
|
1614
|
+
}, read.signal);
|
|
1615
|
+
const first = held[0];
|
|
1616
|
+
const headerEnd = page.indexOf(LINE_FEED) + 1;
|
|
1617
|
+
if (first !== void 0 && !page.subarray(0, headerEnd).every((byte, index) => byte === first[index])) throw new KeepsetError(`${read.resource} gave a different header at offset ${String(offset)} than at the start`);
|
|
1618
|
+
return readResourcePages(read, [...held, first === void 0 ? page : page.subarray(headerEnd)], offset + expected);
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* Downloads a whole Socrata resource as one CSV: the row count is read first, then pages of `pageSize` rows in the order of the rows' ids, each checked to hold the rows it should, and joined under the first page's header.
|
|
1622
|
+
* @param resource - The resource's URL, such as `https://data.example.org/resource/abcd-1234.csv`, without a query.
|
|
1623
|
+
* @param pageSize - How many rows a page holds.
|
|
1624
|
+
* @param port - The network port.
|
|
1625
|
+
* @param signal - Aborts the download.
|
|
1626
|
+
* @returns The bytes of one CSV, which is what the parser reads and what the provenance hashes.
|
|
1627
|
+
* @throws KeepsetError when the resource does not report a count, or a page is not what it should be.
|
|
1628
|
+
*/
|
|
1629
|
+
async function downloadSocrataResource(resource, pageSize, port, signal) {
|
|
1630
|
+
const reported = /^"count"\r?\n"(\d+)"/.exec(new TextDecoder("utf-8").decode(await getBytes(port, `${resource}?%24select=count(*)`, signal)));
|
|
1631
|
+
if (reported?.[1] === void 0) throw new KeepsetError(`${resource} did not report a row count`);
|
|
1632
|
+
const pages = await readResourcePages({
|
|
1633
|
+
resource,
|
|
1634
|
+
pageSize,
|
|
1635
|
+
count: Number(reported[1]),
|
|
1636
|
+
port,
|
|
1637
|
+
signal
|
|
1638
|
+
}, [], 0);
|
|
1639
|
+
if (pages.length === 0) throw new KeepsetError(`${resource} has no rows`);
|
|
1640
|
+
return Buffer.concat(pages);
|
|
1641
|
+
}
|
|
1261
1642
|
/**
|
|
1262
1643
|
* Downloads a paged source as the one file its pages make, in the way its recipe's paging says.
|
|
1263
1644
|
* @param url - The source's URL.
|
|
@@ -1268,7 +1649,7 @@ async function downloadArcgisLayer(layerUrl, orderBy, port, signal) {
|
|
|
1268
1649
|
* @throws KeepsetError when a page cannot be read or the pages do not add up to what the source reports.
|
|
1269
1650
|
*/
|
|
1270
1651
|
async function downloadPaged(url, paging, port, signal) {
|
|
1271
|
-
return downloadArcgisLayer(url, paging.orderBy, port, signal);
|
|
1652
|
+
return paging.kind === "arcgis-query" ? downloadArcgisLayer(url, paging.orderBy, port, signal) : downloadSocrataResource(url, paging.pageSize, port, signal);
|
|
1272
1653
|
}
|
|
1273
1654
|
//#endregion
|
|
1274
1655
|
//#region src/node/fetch-dataset.ts
|
package/dist-cli/keepset.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { KeepsetError, NAME_SEPARATOR, countTarget, getDataset, listDatasets, li
|
|
|
4
4
|
import { fetchDataset, roundTripDataset } from "keepset/node";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
6
|
//#region package.json
|
|
7
|
-
var version = "0.
|
|
7
|
+
var version = "0.21.0";
|
|
8
8
|
const usage = `Usage: keepset <command> [options]
|
|
9
9
|
|
|
10
10
|
Commands:
|