@clearmist-labs/comic-archive-handler 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/LICENSE +21 -0
- package/README.md +137 -0
- package/dist/index.d.mts +584 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2157 -0
- package/dist/index.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/docs/API.md +809 -0
- package/package.json +82 -0
- package/schemas/ComicInfo v2.1.xsd +127 -0
- package/schemas/LICENSE-MetronInfo.txt +504 -0
- package/schemas/MetronInfo v1.1.xsd +355 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2157 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
|
+
import { fileTypeFromBuffer, fileTypeFromFile } from "file-type";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import * as fsp from "node:fs/promises";
|
|
6
|
+
import { PassThrough, Readable } from "node:stream";
|
|
7
|
+
import { Unzip, UnzipInflate, Zip, ZipDeflate } from "fflate";
|
|
8
|
+
import * as tarStream from "tar-stream";
|
|
9
|
+
import { createExtractorFromData } from "node-unrar-js";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { createPackage } from "@electron/asar";
|
|
13
|
+
import * as os from "node:os";
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import sevenBin from "7zip-bin-full";
|
|
16
|
+
import sharp from "sharp";
|
|
17
|
+
import { pipeline } from "node:stream/promises";
|
|
18
|
+
import { XMLBuilder, XMLParser } from "fast-xml-parser";
|
|
19
|
+
import { validateXML } from "xmllint-wasm";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
//#region src/errors.ts
|
|
23
|
+
var UnsupportedOperationError = class extends Error {
|
|
24
|
+
constructor(message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "UnsupportedOperationError";
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
var ArchiveFormatError = class extends Error {
|
|
30
|
+
constructor(message) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "ArchiveFormatError";
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var MetadataNotFoundError = class extends Error {
|
|
36
|
+
constructor(message) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "MetadataNotFoundError";
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var FilesystemAccessError = class extends Error {
|
|
42
|
+
constructor(message) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "FilesystemAccessError";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
var SevenZipUnavailableError = class extends Error {
|
|
48
|
+
constructor(message) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = "SevenZipUnavailableError";
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
var NoImagesFoundError = class extends Error {
|
|
54
|
+
constructor(message) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = "NoImagesFoundError";
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/internal/inputSource.ts
|
|
61
|
+
function isPathInput(input) {
|
|
62
|
+
return typeof input === "string";
|
|
63
|
+
}
|
|
64
|
+
async function inputToBuffer(input) {
|
|
65
|
+
return isPathInput(input) ? fsp.readFile(input) : input;
|
|
66
|
+
}
|
|
67
|
+
async function inputSize(input) {
|
|
68
|
+
if (isPathInput(input)) return (await fsp.stat(input)).size;
|
|
69
|
+
return input.length;
|
|
70
|
+
}
|
|
71
|
+
/** Reads the half-open byte range [start, end) from a Buffer or file path. */
|
|
72
|
+
async function readInputRange(input, start, end) {
|
|
73
|
+
if (!isPathInput(input)) return Buffer.from(input.subarray(start, end));
|
|
74
|
+
const fd = await fsp.open(input, "r");
|
|
75
|
+
try {
|
|
76
|
+
const length = Math.max(0, end - start);
|
|
77
|
+
const buf = Buffer.alloc(length);
|
|
78
|
+
const { bytesRead } = await fd.read(buf, 0, length, start);
|
|
79
|
+
return buf.subarray(0, bytesRead);
|
|
80
|
+
} finally {
|
|
81
|
+
await fd.close();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function openInputReadStream(input, range) {
|
|
85
|
+
if (isPathInput(input)) return fs.createReadStream(input, range ? {
|
|
86
|
+
start: range.start,
|
|
87
|
+
end: range.end - 1
|
|
88
|
+
} : void 0);
|
|
89
|
+
const slice = range ? input.subarray(range.start, range.end) : input;
|
|
90
|
+
return Readable.from(slice);
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/internal/asarHeader.ts
|
|
94
|
+
/**
|
|
95
|
+
* Parses an asar archive's header from a byte reader.
|
|
96
|
+
*
|
|
97
|
+
* On-disk layout (Chromium Pickle format, two levels of nesting):
|
|
98
|
+
* [8 bytes: outer pickle wrapping a uint32 `size`]
|
|
99
|
+
* [`size` bytes: header pickle == [4-byte LE payload length][payload]]
|
|
100
|
+
* payload == string pickle == [4-byte LE string byte-length][UTF-8 bytes (+padding, ignored here)]
|
|
101
|
+
* [content region: raw concatenated file bytes, starting at 8 + size]
|
|
102
|
+
*/
|
|
103
|
+
async function parseAsarHeader(readRange) {
|
|
104
|
+
const prefix = await readRange(0, 8);
|
|
105
|
+
if (prefix.length < 8) throw new ArchiveFormatError("File is too small to be a valid asar archive.");
|
|
106
|
+
if (prefix.readUInt32LE(0) !== 4) throw new ArchiveFormatError("Not a valid asar archive (unexpected pickle header).");
|
|
107
|
+
const size = prefix.readUInt32LE(4);
|
|
108
|
+
const headerBuf = await readRange(8, 8 + size);
|
|
109
|
+
if (headerBuf.length < 8) throw new ArchiveFormatError("Not a valid asar archive (truncated header).");
|
|
110
|
+
const stringLength = headerBuf.readUInt32LE(4);
|
|
111
|
+
if (8 + stringLength > headerBuf.length) throw new ArchiveFormatError("Not a valid asar archive (truncated header string).");
|
|
112
|
+
const headerString = headerBuf.subarray(8, 8 + stringLength).toString("utf8");
|
|
113
|
+
let header;
|
|
114
|
+
try {
|
|
115
|
+
header = JSON.parse(headerString);
|
|
116
|
+
} catch {
|
|
117
|
+
throw new ArchiveFormatError("Not a valid asar archive (header is not valid JSON).");
|
|
118
|
+
}
|
|
119
|
+
if (!header || typeof header !== "object" || !header.files) throw new ArchiveFormatError("Not a valid asar archive (header has no files index).");
|
|
120
|
+
const contentOffset = 8 + size;
|
|
121
|
+
const files = [];
|
|
122
|
+
collectFiles(header, "", files);
|
|
123
|
+
return {
|
|
124
|
+
header,
|
|
125
|
+
files,
|
|
126
|
+
contentOffset
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function collectFiles(node, prefix, out) {
|
|
130
|
+
if (!node.files) return;
|
|
131
|
+
for (const [name, child] of Object.entries(node.files)) {
|
|
132
|
+
const entryPath = prefix ? `${prefix}/${name}` : name;
|
|
133
|
+
if (child.files) collectFiles(child, entryPath, out);
|
|
134
|
+
else if (typeof child.offset === "string" && typeof child.size === "number") out.push({
|
|
135
|
+
path: entryPath,
|
|
136
|
+
offset: Number(child.offset),
|
|
137
|
+
size: child.size
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** True if the header's file tree contains an entry at the archive root named `name`. */
|
|
142
|
+
function hasRootFile(header, name) {
|
|
143
|
+
const root = header;
|
|
144
|
+
return Boolean(root?.files?.[name] && !root.files[name]?.files);
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region src/detect.ts
|
|
148
|
+
var detect_exports = /* @__PURE__ */ __exportAll({
|
|
149
|
+
detectArchiveType: () => detectArchiveType,
|
|
150
|
+
is7z: () => is7z,
|
|
151
|
+
isAce: () => isAce,
|
|
152
|
+
isAsar: () => isAsar,
|
|
153
|
+
isRar: () => isRar,
|
|
154
|
+
isTar: () => isTar,
|
|
155
|
+
isZip: () => isZip
|
|
156
|
+
});
|
|
157
|
+
const EXT_TO_TYPE = {
|
|
158
|
+
zip: "zip",
|
|
159
|
+
rar: "rar",
|
|
160
|
+
tar: "tar",
|
|
161
|
+
"7z": "7z",
|
|
162
|
+
ace: "ace"
|
|
163
|
+
};
|
|
164
|
+
async function detectViaFileType(input) {
|
|
165
|
+
const result = isPathInput(input) ? await fileTypeFromFile(input) : await fileTypeFromBuffer(input);
|
|
166
|
+
if (!result) return;
|
|
167
|
+
return EXT_TO_TYPE[result.ext];
|
|
168
|
+
}
|
|
169
|
+
async function looksLikeAsar(input) {
|
|
170
|
+
try {
|
|
171
|
+
const header = await parseAsarHeader((start, end) => readInputRange(input, start, end));
|
|
172
|
+
return Boolean(header.files);
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async function detectArchiveType(input) {
|
|
178
|
+
const viaMagicBytes = await detectViaFileType(input);
|
|
179
|
+
if (viaMagicBytes) return viaMagicBytes;
|
|
180
|
+
if (await looksLikeAsar(input)) return "asar";
|
|
181
|
+
return "unknown";
|
|
182
|
+
}
|
|
183
|
+
async function isZip(input) {
|
|
184
|
+
return await detectArchiveType(input) === "zip";
|
|
185
|
+
}
|
|
186
|
+
async function isRar(input) {
|
|
187
|
+
return await detectArchiveType(input) === "rar";
|
|
188
|
+
}
|
|
189
|
+
async function isTar(input) {
|
|
190
|
+
return await detectArchiveType(input) === "tar";
|
|
191
|
+
}
|
|
192
|
+
async function isAsar(input) {
|
|
193
|
+
return await detectArchiveType(input) === "asar";
|
|
194
|
+
}
|
|
195
|
+
async function is7z(input) {
|
|
196
|
+
return await detectArchiveType(input) === "7z";
|
|
197
|
+
}
|
|
198
|
+
async function isAce(input) {
|
|
199
|
+
return await detectArchiveType(input) === "ace";
|
|
200
|
+
}
|
|
201
|
+
//#endregion
|
|
202
|
+
//#region src/internal/streamUtils.ts
|
|
203
|
+
async function streamToBuffer(stream) {
|
|
204
|
+
const chunks = [];
|
|
205
|
+
for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
206
|
+
return Buffer.concat(chunks);
|
|
207
|
+
}
|
|
208
|
+
/** Bridges a callback-driven push source into an async iterable, in arrival order. */
|
|
209
|
+
var AsyncQueue = class {
|
|
210
|
+
items = [];
|
|
211
|
+
waiter = null;
|
|
212
|
+
done = false;
|
|
213
|
+
error = null;
|
|
214
|
+
push(item) {
|
|
215
|
+
this.items.push(item);
|
|
216
|
+
this.waiter?.();
|
|
217
|
+
}
|
|
218
|
+
finish() {
|
|
219
|
+
this.done = true;
|
|
220
|
+
this.waiter?.();
|
|
221
|
+
}
|
|
222
|
+
fail(err) {
|
|
223
|
+
this.error = err;
|
|
224
|
+
this.done = true;
|
|
225
|
+
this.waiter?.();
|
|
226
|
+
}
|
|
227
|
+
async *[Symbol.asyncIterator]() {
|
|
228
|
+
for (;;) {
|
|
229
|
+
if (this.items.length) {
|
|
230
|
+
yield this.items.shift();
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (this.error) throw this.error;
|
|
234
|
+
if (this.done) return;
|
|
235
|
+
await new Promise((resolve) => {
|
|
236
|
+
this.waiter = resolve;
|
|
237
|
+
});
|
|
238
|
+
this.waiter = null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
//#endregion
|
|
243
|
+
//#region src/archive/zip.ts
|
|
244
|
+
function toEntry(file) {
|
|
245
|
+
return {
|
|
246
|
+
path: file.name,
|
|
247
|
+
size: file.originalSize,
|
|
248
|
+
openReadStream() {
|
|
249
|
+
const pass = new PassThrough();
|
|
250
|
+
file.ondata = (err, data, final) => {
|
|
251
|
+
if (err) {
|
|
252
|
+
pass.destroy(err);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (data.length) pass.write(Buffer.from(data));
|
|
256
|
+
if (final) pass.end();
|
|
257
|
+
};
|
|
258
|
+
file.start();
|
|
259
|
+
return pass;
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
const zipAdapter = {
|
|
264
|
+
type: "zip",
|
|
265
|
+
canWrite: true,
|
|
266
|
+
async *listEntries(input) {
|
|
267
|
+
const source = openInputReadStream(input);
|
|
268
|
+
const queue = new AsyncQueue();
|
|
269
|
+
const unzip = new Unzip();
|
|
270
|
+
unzip.register(UnzipInflate);
|
|
271
|
+
unzip.onfile = (file) => {
|
|
272
|
+
if (file.name.endsWith("/")) return;
|
|
273
|
+
queue.push(toEntry(file));
|
|
274
|
+
};
|
|
275
|
+
source.on("data", (chunk) => unzip.push(new Uint8Array(chunk), false));
|
|
276
|
+
source.on("end", () => {
|
|
277
|
+
unzip.push(/* @__PURE__ */ new Uint8Array(0), true);
|
|
278
|
+
queue.finish();
|
|
279
|
+
});
|
|
280
|
+
source.on("error", (err) => queue.fail(err));
|
|
281
|
+
yield* queue;
|
|
282
|
+
},
|
|
283
|
+
async write(entries, destination) {
|
|
284
|
+
await new Promise((resolve, reject) => {
|
|
285
|
+
const zip = new Zip((err, data, final) => {
|
|
286
|
+
if (err) {
|
|
287
|
+
destination.destroy(err);
|
|
288
|
+
reject(err);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
destination.write(Buffer.from(data));
|
|
292
|
+
if (final) {
|
|
293
|
+
destination.end();
|
|
294
|
+
resolve();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
(async () => {
|
|
298
|
+
for await (const entry of entries) {
|
|
299
|
+
const zipFile = new ZipDeflate(entry.path);
|
|
300
|
+
zip.add(zipFile);
|
|
301
|
+
for await (const chunk of entry.content) zipFile.push(new Uint8Array(chunk), false);
|
|
302
|
+
zipFile.push(/* @__PURE__ */ new Uint8Array(0), true);
|
|
303
|
+
}
|
|
304
|
+
zip.end();
|
|
305
|
+
})().catch(reject);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
//#endregion
|
|
310
|
+
//#region src/archive/tar.ts
|
|
311
|
+
/**
|
|
312
|
+
* tar-stream v3 is built on `streamx`, not Node's native streams; its
|
|
313
|
+
* `extract()` result is directly async-iterable over per-entry streams, and
|
|
314
|
+
* both directions interop with Node streams via `.pipe()`. Each entry's
|
|
315
|
+
* content is buffered fully before being yielded — bounded by a single
|
|
316
|
+
* entry's size (one comic page), not the whole archive — since the
|
|
317
|
+
* underlying iterator only advances once the current entry is drained,
|
|
318
|
+
* which doesn't reconcile with this package's lazily-pulled
|
|
319
|
+
* `ArchiveEntry.openReadStream()` contract.
|
|
320
|
+
*/
|
|
321
|
+
const tarAdapter = {
|
|
322
|
+
type: "tar",
|
|
323
|
+
canWrite: true,
|
|
324
|
+
async *listEntries(input) {
|
|
325
|
+
const source = openInputReadStream(input);
|
|
326
|
+
const extract = tarStream.extract();
|
|
327
|
+
source.pipe(extract);
|
|
328
|
+
for await (const entryStream of extract) {
|
|
329
|
+
const header = entryStream.header;
|
|
330
|
+
const buffer = await streamToBuffer(entryStream);
|
|
331
|
+
if (header.type === "directory") continue;
|
|
332
|
+
yield {
|
|
333
|
+
path: header.name,
|
|
334
|
+
size: buffer.length,
|
|
335
|
+
openReadStream: () => Readable.from(buffer)
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
async write(entries, destination) {
|
|
340
|
+
const pack = tarStream.pack();
|
|
341
|
+
pack.pipe(destination);
|
|
342
|
+
for await (const entry of entries) {
|
|
343
|
+
const buffer = await streamToBuffer(entry.content);
|
|
344
|
+
await new Promise((resolve, reject) => {
|
|
345
|
+
pack.entry({
|
|
346
|
+
name: entry.path,
|
|
347
|
+
size: buffer.length
|
|
348
|
+
}, buffer, (err) => err ? reject(err) : resolve());
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
pack.finalize();
|
|
352
|
+
await new Promise((resolve, reject) => {
|
|
353
|
+
destination.on("finish", resolve);
|
|
354
|
+
destination.on("error", reject);
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
//#endregion
|
|
359
|
+
//#region src/archive/rar.ts
|
|
360
|
+
function toArrayBuffer(buf) {
|
|
361
|
+
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* node-unrar-js cannot extract entries one at a time (a confirmed library
|
|
365
|
+
* limitation) — it decodes the whole archive into memory up front. This
|
|
366
|
+
* adapter still exposes the lazily-pulled ArchiveAdapter shape, but does not
|
|
367
|
+
* get the memory-efficiency win the interface provides for zip/tar/asar.
|
|
368
|
+
*/
|
|
369
|
+
const rarAdapter = {
|
|
370
|
+
type: "rar",
|
|
371
|
+
canWrite: false,
|
|
372
|
+
async *listEntries(input) {
|
|
373
|
+
const buffer = await inputToBuffer(input);
|
|
374
|
+
const extractor = await createExtractorFromData({ data: toArrayBuffer(buffer) });
|
|
375
|
+
const names = [...extractor.getFileList().fileHeaders].filter((header) => !header.flags.directory).map((header) => header.name);
|
|
376
|
+
const extracted = extractor.extract({ files: names });
|
|
377
|
+
for (const file of extracted.files) {
|
|
378
|
+
if (!file.extraction) continue;
|
|
379
|
+
const data = Buffer.from(file.extraction);
|
|
380
|
+
yield {
|
|
381
|
+
path: file.fileHeader.name,
|
|
382
|
+
size: data.length,
|
|
383
|
+
openReadStream: () => Readable.from(data)
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
async write() {
|
|
388
|
+
throw new UnsupportedOperationError("Creating RAR archives is not supported: the UnRAR source license permits decompression only, not building a compatible compressor. Convert to zip, tar, or asar instead.");
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
//#endregion
|
|
392
|
+
//#region src/internal/tempDir.ts
|
|
393
|
+
async function isWritableDir(dir) {
|
|
394
|
+
const probe = path.join(dir, `.cah-write-probe-${process.pid}-${Date.now()}`);
|
|
395
|
+
try {
|
|
396
|
+
await fsp.writeFile(probe, "");
|
|
397
|
+
await fsp.rm(probe, { force: true });
|
|
398
|
+
return true;
|
|
399
|
+
} catch {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Resolves a writable staging directory for operations that require real
|
|
405
|
+
* filesystem access (asar writes, any 7z operation).
|
|
406
|
+
*
|
|
407
|
+
* - If `preferredDir` is given, it must already exist and be writable, or a
|
|
408
|
+
* FilesystemAccessError is thrown naming the path (no silent fallback).
|
|
409
|
+
* - Otherwise, attempts `fs.mkdtemp()` under `os.tmpdir()`.
|
|
410
|
+
* - If that also fails (no writable tmp available, e.g. a locked-down
|
|
411
|
+
* sandbox), throws FilesystemAccessError telling the caller to pass
|
|
412
|
+
* `{ tempDir }` explicitly.
|
|
413
|
+
*
|
|
414
|
+
* Returns a freshly created subdirectory the caller owns and is responsible
|
|
415
|
+
* for cleaning up.
|
|
416
|
+
*/
|
|
417
|
+
async function resolveWritableTempDir(preferredDir) {
|
|
418
|
+
if (preferredDir) {
|
|
419
|
+
if (!await isWritableDir(preferredDir)) throw new FilesystemAccessError(`The provided tempDir "${preferredDir}" is not writable. Pass a writable directory via { tempDir }.`);
|
|
420
|
+
return fsp.mkdtemp(path.join(preferredDir, "cah-"));
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
return await fsp.mkdtemp(path.join(os.tmpdir(), "cah-"));
|
|
424
|
+
} catch (err) {
|
|
425
|
+
throw new FilesystemAccessError(`No writable temporary directory is available (${err.message}). Supply one explicitly via { tempDir: "<writable path>" }.`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
async function cleanupTempDir(dir) {
|
|
429
|
+
await fsp.rm(dir, {
|
|
430
|
+
recursive: true,
|
|
431
|
+
force: true
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
//#endregion
|
|
435
|
+
//#region src/internal/safePath.ts
|
|
436
|
+
/**
|
|
437
|
+
* Resolves an archive entry path against a staging root and verifies the
|
|
438
|
+
* result stays within that root, guarding against zip-slip style path
|
|
439
|
+
* traversal (`../../etc/passwd`, absolute paths, etc.) before anything is
|
|
440
|
+
* written to a real filesystem location.
|
|
441
|
+
*/
|
|
442
|
+
function resolveSafeEntryPath(root, entryPath) {
|
|
443
|
+
const normalizedEntry = entryPath.replace(/\\/g, "/");
|
|
444
|
+
const resolved = path.resolve(root, `.${path.sep}${normalizedEntry}`);
|
|
445
|
+
const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
|
|
446
|
+
if (resolved !== root && !resolved.startsWith(rootWithSep)) throw new ArchiveFormatError(`Refusing to write entry outside staging directory: "${entryPath}"`);
|
|
447
|
+
return resolved;
|
|
448
|
+
}
|
|
449
|
+
//#endregion
|
|
450
|
+
//#region src/archive/asar.ts
|
|
451
|
+
/**
|
|
452
|
+
* Reads bypass @electron/asar's extract API entirely: the header is parsed
|
|
453
|
+
* directly (src/internal/asarHeader.ts) to get each file's byte offset/size,
|
|
454
|
+
* then entries are read via direct byte-range reads against the archive —
|
|
455
|
+
* no extraction step, no temp directory, for reads.
|
|
456
|
+
*
|
|
457
|
+
* Writes still require a staging directory, since @electron/asar's
|
|
458
|
+
* `createPackage(srcDir, destFile)` only accepts a source directory on
|
|
459
|
+
* disk, not streams or buffers.
|
|
460
|
+
*/
|
|
461
|
+
const asarAdapter = {
|
|
462
|
+
type: "asar",
|
|
463
|
+
canWrite: true,
|
|
464
|
+
async *listEntries(input) {
|
|
465
|
+
const { files, contentOffset } = await parseAsarHeader((start, end) => readInputRange(input, start, end));
|
|
466
|
+
for (const file of files) {
|
|
467
|
+
const start = contentOffset + file.offset;
|
|
468
|
+
const end = start + file.size;
|
|
469
|
+
yield {
|
|
470
|
+
path: file.path,
|
|
471
|
+
size: file.size,
|
|
472
|
+
openReadStream: () => openInputReadStream(input, {
|
|
473
|
+
start,
|
|
474
|
+
end
|
|
475
|
+
})
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
async write(entries, destination, options) {
|
|
480
|
+
const tempDir = await resolveWritableTempDir(options?.tempDir);
|
|
481
|
+
const stagingDir = path.join(tempDir, "staging");
|
|
482
|
+
await fsp.mkdir(stagingDir, { recursive: true });
|
|
483
|
+
try {
|
|
484
|
+
for await (const entry of entries) {
|
|
485
|
+
const target = resolveSafeEntryPath(stagingDir, entry.path);
|
|
486
|
+
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
487
|
+
await new Promise((resolve, reject) => {
|
|
488
|
+
const out = fs.createWriteStream(target);
|
|
489
|
+
entry.content.on("error", reject);
|
|
490
|
+
out.on("error", reject);
|
|
491
|
+
out.on("finish", resolve);
|
|
492
|
+
entry.content.pipe(out);
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
const outputFile = path.join(tempDir, "output.asar");
|
|
496
|
+
await createPackage(stagingDir, outputFile);
|
|
497
|
+
await new Promise((resolve, reject) => {
|
|
498
|
+
const readStream = fs.createReadStream(outputFile);
|
|
499
|
+
readStream.on("error", reject);
|
|
500
|
+
destination.on("error", reject);
|
|
501
|
+
destination.on("finish", resolve);
|
|
502
|
+
readStream.pipe(destination);
|
|
503
|
+
});
|
|
504
|
+
} finally {
|
|
505
|
+
await cleanupTempDir(tempDir);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
//#endregion
|
|
510
|
+
//#region src/archive/sevenZip.ts
|
|
511
|
+
async function ensureBinaryAvailable() {
|
|
512
|
+
const binPath = sevenBin.path7zzs;
|
|
513
|
+
if (!fs.existsSync(binPath)) throw new SevenZipUnavailableError(`No 7-Zip binary is available for this platform/architecture (expected at "${binPath}"). 7z read/write is unavailable here; 7z detection via magic bytes still works.`);
|
|
514
|
+
try {
|
|
515
|
+
await fsp.access(binPath, fs.constants.X_OK);
|
|
516
|
+
} catch {
|
|
517
|
+
try {
|
|
518
|
+
await fsp.chmod(binPath, 493);
|
|
519
|
+
await fsp.access(binPath, fs.constants.X_OK);
|
|
520
|
+
} catch {
|
|
521
|
+
throw new SevenZipUnavailableError(`The bundled 7-Zip binary at "${binPath}" is not executable and could not be made executable.`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return binPath;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Spawns the bundled 7zzs (standalone 7-Zip) binary directly rather than
|
|
528
|
+
* going through the `node-7z` wrapper: that package has no way to set the
|
|
529
|
+
* child process's working directory, which is required here to get archive
|
|
530
|
+
* entries stored with paths relative to the staging directory (rather than
|
|
531
|
+
* either leaking absolute host paths, or — as discovered during
|
|
532
|
+
* implementation testing — silently operating against this process's actual
|
|
533
|
+
* cwd instead of the intended staging directory).
|
|
534
|
+
*/
|
|
535
|
+
function run7z(binPath, args, options) {
|
|
536
|
+
return new Promise((resolve, reject) => {
|
|
537
|
+
const child = spawn(binPath, args, {
|
|
538
|
+
cwd: options.cwd,
|
|
539
|
+
windowsHide: true
|
|
540
|
+
});
|
|
541
|
+
let stderr = "";
|
|
542
|
+
child.stderr.on("data", (chunk) => {
|
|
543
|
+
stderr += chunk.toString();
|
|
544
|
+
});
|
|
545
|
+
child.on("error", reject);
|
|
546
|
+
child.on("close", (code) => {
|
|
547
|
+
if (code === 0) resolve();
|
|
548
|
+
else reject(new ArchiveFormatError(`7zzs exited with code ${code}: ${stderr.trim()}`));
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
async function* walkFiles(root, prefix = "") {
|
|
553
|
+
const dirents = await fsp.readdir(path.join(root, prefix), { withFileTypes: true });
|
|
554
|
+
for (const dirent of dirents) {
|
|
555
|
+
const relPath = prefix ? `${prefix}/${dirent.name}` : dirent.name;
|
|
556
|
+
if (dirent.isDirectory()) yield* walkFiles(root, relPath);
|
|
557
|
+
else if (dirent.isFile()) yield {
|
|
558
|
+
absPath: path.join(root, relPath),
|
|
559
|
+
relPath
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Both directions round-trip through a staging directory: the 7z CLI
|
|
565
|
+
* operates on real files, not stdin/stdout, for multi-file archives. Entries
|
|
566
|
+
* are still streamed to/from disk one at a time rather than collected into
|
|
567
|
+
* memory first.
|
|
568
|
+
*/
|
|
569
|
+
const sevenZipAdapter = {
|
|
570
|
+
type: "7z",
|
|
571
|
+
canWrite: true,
|
|
572
|
+
async *listEntries(input, options) {
|
|
573
|
+
const binPath = await ensureBinaryAvailable();
|
|
574
|
+
const tempDir = await resolveWritableTempDir(options?.tempDir);
|
|
575
|
+
try {
|
|
576
|
+
const archivePath = path.join(tempDir, "input.7z");
|
|
577
|
+
await fsp.writeFile(archivePath, await inputToBuffer(input));
|
|
578
|
+
const extractDir = path.join(tempDir, "extracted");
|
|
579
|
+
await fsp.mkdir(extractDir, { recursive: true });
|
|
580
|
+
await run7z(binPath, [
|
|
581
|
+
"x",
|
|
582
|
+
archivePath,
|
|
583
|
+
`-o${extractDir}`,
|
|
584
|
+
"-y"
|
|
585
|
+
], { cwd: tempDir });
|
|
586
|
+
for await (const { absPath, relPath } of walkFiles(extractDir)) yield {
|
|
587
|
+
path: relPath,
|
|
588
|
+
size: (await fsp.stat(absPath)).size,
|
|
589
|
+
openReadStream: () => fs.createReadStream(absPath)
|
|
590
|
+
};
|
|
591
|
+
} finally {
|
|
592
|
+
await cleanupTempDir(tempDir);
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
async write(entries, destination, options) {
|
|
596
|
+
const binPath = await ensureBinaryAvailable();
|
|
597
|
+
const tempDir = await resolveWritableTempDir(options?.tempDir);
|
|
598
|
+
const stagingDir = path.join(tempDir, "staging");
|
|
599
|
+
await fsp.mkdir(stagingDir, { recursive: true });
|
|
600
|
+
try {
|
|
601
|
+
for await (const entry of entries) {
|
|
602
|
+
const target = resolveSafeEntryPath(stagingDir, entry.path);
|
|
603
|
+
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
604
|
+
await new Promise((resolve, reject) => {
|
|
605
|
+
const out = fs.createWriteStream(target);
|
|
606
|
+
entry.content.on("error", reject);
|
|
607
|
+
out.on("error", reject);
|
|
608
|
+
out.on("finish", resolve);
|
|
609
|
+
entry.content.pipe(out);
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
const outputFile = path.join(tempDir, "output.7z");
|
|
613
|
+
await run7z(binPath, [
|
|
614
|
+
"a",
|
|
615
|
+
"-y",
|
|
616
|
+
outputFile,
|
|
617
|
+
"*",
|
|
618
|
+
"-r"
|
|
619
|
+
], { cwd: stagingDir });
|
|
620
|
+
await new Promise((resolve, reject) => {
|
|
621
|
+
const readStream = fs.createReadStream(outputFile);
|
|
622
|
+
readStream.on("error", reject);
|
|
623
|
+
destination.on("error", reject);
|
|
624
|
+
destination.on("finish", resolve);
|
|
625
|
+
readStream.pipe(destination);
|
|
626
|
+
});
|
|
627
|
+
} finally {
|
|
628
|
+
await cleanupTempDir(tempDir);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
//#endregion
|
|
633
|
+
//#region src/archive/ace.ts
|
|
634
|
+
const ACE_UNSUPPORTED_MESSAGE = "ACE (CBA) archives are not supported: ACE is a dead format that hasn't been updated since 2011 and has multiple known security vulnerabilities.";
|
|
635
|
+
//#endregion
|
|
636
|
+
//#region src/archive/index.ts
|
|
637
|
+
const adapters = {
|
|
638
|
+
zip: zipAdapter,
|
|
639
|
+
tar: tarAdapter,
|
|
640
|
+
rar: rarAdapter,
|
|
641
|
+
asar: asarAdapter,
|
|
642
|
+
"7z": sevenZipAdapter,
|
|
643
|
+
ace: {
|
|
644
|
+
type: "ace",
|
|
645
|
+
canWrite: false,
|
|
646
|
+
listEntries() {
|
|
647
|
+
throw new UnsupportedOperationError(ACE_UNSUPPORTED_MESSAGE);
|
|
648
|
+
},
|
|
649
|
+
async write() {
|
|
650
|
+
throw new UnsupportedOperationError(ACE_UNSUPPORTED_MESSAGE);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
function getAdapter(type) {
|
|
655
|
+
const adapter = adapters[type];
|
|
656
|
+
if (!adapter) throw new ArchiveFormatError(`No archive adapter is available for type "${type}".`);
|
|
657
|
+
return adapter;
|
|
658
|
+
}
|
|
659
|
+
//#endregion
|
|
660
|
+
//#region src/internal/collectOutput.ts
|
|
661
|
+
/**
|
|
662
|
+
* Runs `run` against a destination stream. When `output` is given (a path or
|
|
663
|
+
* a Writable), the result streams directly there and this resolves to
|
|
664
|
+
* `undefined`. Otherwise the result is collected into a Buffer for
|
|
665
|
+
* convenience.
|
|
666
|
+
*/
|
|
667
|
+
async function withOutput(output, run) {
|
|
668
|
+
if (typeof output === "string") {
|
|
669
|
+
await run(fs.createWriteStream(output));
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (output) {
|
|
673
|
+
await run(output);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const pass = new PassThrough();
|
|
677
|
+
const bufferPromise = streamToBuffer(pass);
|
|
678
|
+
await run(pass);
|
|
679
|
+
return bufferPromise;
|
|
680
|
+
}
|
|
681
|
+
//#endregion
|
|
682
|
+
//#region src/images/isImage.ts
|
|
683
|
+
const IMAGE_EXTENSIONS = [
|
|
684
|
+
"jpg",
|
|
685
|
+
"jpeg",
|
|
686
|
+
"png",
|
|
687
|
+
"gif",
|
|
688
|
+
"webp",
|
|
689
|
+
"bmp",
|
|
690
|
+
"tiff",
|
|
691
|
+
"tif"
|
|
692
|
+
];
|
|
693
|
+
function getExtension(entryPath) {
|
|
694
|
+
const match = /\.([^./\\]+)$/.exec(entryPath);
|
|
695
|
+
return match ? match[1].toLowerCase() : "";
|
|
696
|
+
}
|
|
697
|
+
function isImagePath(entryPath) {
|
|
698
|
+
return IMAGE_EXTENSIONS.includes(getExtension(entryPath));
|
|
699
|
+
}
|
|
700
|
+
//#endregion
|
|
701
|
+
//#region src/images/convert.ts
|
|
702
|
+
async function convertImageBuffer(image, format, options = {}) {
|
|
703
|
+
let pipeline = sharp(image);
|
|
704
|
+
if (format === "webp") {
|
|
705
|
+
const webp = options.webp ?? {};
|
|
706
|
+
pipeline = pipeline.webp({
|
|
707
|
+
quality: webp.quality ?? 92,
|
|
708
|
+
effort: webp.effort ?? 6,
|
|
709
|
+
smartSubsample: webp.smartSubsample ?? true
|
|
710
|
+
});
|
|
711
|
+
} else if (format === "jpg") {
|
|
712
|
+
const jpeg = options.jpeg ?? {};
|
|
713
|
+
pipeline = pipeline.jpeg({ quality: jpeg.quality ?? 90 });
|
|
714
|
+
} else {
|
|
715
|
+
const png = options.png ?? {};
|
|
716
|
+
pipeline = pipeline.png({
|
|
717
|
+
quality: png.quality,
|
|
718
|
+
compressionLevel: png.compressionLevel,
|
|
719
|
+
palette: png.palette
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
return pipeline.toBuffer();
|
|
723
|
+
}
|
|
724
|
+
function replaceExtension$1(entryPath, format) {
|
|
725
|
+
const newExt = format === "jpg" ? "jpg" : format;
|
|
726
|
+
return `${entryPath.replace(/\.[^./\\]+$/, "")}.${newExt}`;
|
|
727
|
+
}
|
|
728
|
+
async function convertArchiveImages(input, format, options = {}) {
|
|
729
|
+
const adapter = getAdapter(await detectArchiveType(input));
|
|
730
|
+
const { tempDir, output, ...imageOptions } = options;
|
|
731
|
+
async function* entries() {
|
|
732
|
+
for await (const entry of adapter.listEntries(input, { tempDir })) if (isImagePath(entry.path) && getExtension(entry.path) !== (format === "jpg" ? "jpg" : format)) {
|
|
733
|
+
const converted = await convertImageBuffer(await streamToBuffer(entry.openReadStream()), format, imageOptions);
|
|
734
|
+
yield {
|
|
735
|
+
path: replaceExtension$1(entry.path, format),
|
|
736
|
+
size: converted.length,
|
|
737
|
+
content: Readable.from(converted)
|
|
738
|
+
};
|
|
739
|
+
} else yield {
|
|
740
|
+
path: entry.path,
|
|
741
|
+
size: entry.size,
|
|
742
|
+
content: entry.openReadStream()
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
return withOutput(output, (destination) => adapter.write(entries(), destination, { tempDir }));
|
|
746
|
+
}
|
|
747
|
+
//#endregion
|
|
748
|
+
//#region src/convertArchive.ts
|
|
749
|
+
function replaceExtension(entryPath, format) {
|
|
750
|
+
return `${entryPath.replace(/\.[^./\\]+$/, "")}.${format}`;
|
|
751
|
+
}
|
|
752
|
+
async function convertArchive(input, targetType, options = {}) {
|
|
753
|
+
const sourceType = await detectArchiveType(input);
|
|
754
|
+
if (sourceType === "unknown") throw new ArchiveFormatError("Could not determine the source archive type.");
|
|
755
|
+
const sourceAdapter = getAdapter(sourceType);
|
|
756
|
+
const targetAdapter = getAdapter(targetType);
|
|
757
|
+
const image = options.image;
|
|
758
|
+
async function* entries() {
|
|
759
|
+
for await (const entry of sourceAdapter.listEntries(input, { tempDir: options.tempDir })) if (image && isImagePath(entry.path) && getExtension(entry.path) !== (image.format === "jpg" ? "jpg" : image.format)) {
|
|
760
|
+
const converted = await convertImageBuffer(await streamToBuffer(entry.openReadStream()), image.format, image.options);
|
|
761
|
+
yield {
|
|
762
|
+
path: replaceExtension(entry.path, image.format === "jpg" ? "jpg" : image.format),
|
|
763
|
+
size: converted.length,
|
|
764
|
+
content: Readable.from(converted)
|
|
765
|
+
};
|
|
766
|
+
} else yield {
|
|
767
|
+
path: entry.path,
|
|
768
|
+
size: entry.size,
|
|
769
|
+
content: entry.openReadStream()
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
return withOutput(options.output, (destination) => targetAdapter.write(entries(), destination, { tempDir: options.tempDir }));
|
|
773
|
+
}
|
|
774
|
+
//#endregion
|
|
775
|
+
//#region src/extractArchive.ts
|
|
776
|
+
/**
|
|
777
|
+
* Extracts every entry in an archive to real files under `destDir`,
|
|
778
|
+
* preserving relative paths (`destDir` is created if missing). Throws
|
|
779
|
+
* `ArchiveFormatError` for an undetectable/unknown format, or
|
|
780
|
+
* `UnsupportedOperationError` for ACE. Returns the archive-relative entry
|
|
781
|
+
* paths that were written, in archive iteration order.
|
|
782
|
+
*/
|
|
783
|
+
async function extractArchive(input, destDir, options = {}) {
|
|
784
|
+
const adapter = getAdapter(await detectArchiveType(input));
|
|
785
|
+
await fs.promises.mkdir(destDir, { recursive: true });
|
|
786
|
+
const written = [];
|
|
787
|
+
for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) {
|
|
788
|
+
const target = resolveSafeEntryPath(destDir, entry.path);
|
|
789
|
+
await fs.promises.mkdir(path.dirname(target), { recursive: true });
|
|
790
|
+
await pipeline(entry.openReadStream(), fs.createWriteStream(target));
|
|
791
|
+
written.push(entry.path);
|
|
792
|
+
}
|
|
793
|
+
return written;
|
|
794
|
+
}
|
|
795
|
+
//#endregion
|
|
796
|
+
//#region src/metadata/schema.ts
|
|
797
|
+
/**
|
|
798
|
+
* Authoritative field-mapping table between the canonical ComicMetadata
|
|
799
|
+
* shape and each external XML schema (ComicInfo.xml v2.1, MetronInfo.xml
|
|
800
|
+
* v1.1 — see `schemas/`). Conversion is intentionally lossy in both
|
|
801
|
+
* directions; a field with no equivalent in the target schema is dropped
|
|
802
|
+
* unless the source and target schema happen to be the same one (in which
|
|
803
|
+
* case `comicInfoExtra`/`metronInfoExtra` round-trips it).
|
|
804
|
+
*
|
|
805
|
+
* | Canonical field | ComicInfo.xml | MetronInfo.xml |
|
|
806
|
+
* |-------------------------|-----------------------------------------------------|------------------------------------------------|
|
|
807
|
+
* | title | Title | (no equivalent — dropped) |
|
|
808
|
+
* | series | Series | Series > Name |
|
|
809
|
+
* | seriesSort | (no equivalent) | Series > SortName |
|
|
810
|
+
* | seriesId / seriesLang | (no equivalent) | Series `id`/`lang` attributes |
|
|
811
|
+
* | seriesStartYear | (no equivalent) | Series > StartYear |
|
|
812
|
+
* | seriesIssueCount | (no equivalent) | Series > IssueCount |
|
|
813
|
+
* | seriesVolumeCount | (no equivalent) | Series > VolumeCount |
|
|
814
|
+
* | seriesAlternativeNames | (no equivalent) | Series > AlternativeNames > AlternativeName[] |
|
|
815
|
+
* | seriesGroup | SeriesGroup | (no equivalent — dropped) |
|
|
816
|
+
* | volume | Volume | Series > Volume |
|
|
817
|
+
* | number | Number | Number |
|
|
818
|
+
* | alternateNumber | AlternateNumber | AlternativeNumber |
|
|
819
|
+
* | alternateSeries | AlternateSeries | (no equivalent — dropped) |
|
|
820
|
+
* | alternateCount | AlternateCount | (no equivalent — dropped) |
|
|
821
|
+
* | count | Count | (no equivalent) |
|
|
822
|
+
* | pageCount | PageCount | PageCount |
|
|
823
|
+
* | summary | Summary | Summary |
|
|
824
|
+
* | notes | Notes | Notes |
|
|
825
|
+
* | review | Review | (no equivalent — dropped) |
|
|
826
|
+
* | scanInformation | ScanInformation | (no equivalent — dropped) |
|
|
827
|
+
* | publisher / publisherId | Publisher | Publisher > Name / `id` attribute |
|
|
828
|
+
* | imprint / imprintId | Imprint | Publisher > Imprint / `id` attribute |
|
|
829
|
+
* | collectionTitle | (no equivalent) | CollectionTitle |
|
|
830
|
+
* | mangaVolume | (no equivalent) | MangaVolume |
|
|
831
|
+
* | format | Format | Series > Format |
|
|
832
|
+
* | language | LanguageISO | (no equivalent) |
|
|
833
|
+
* | ageRating | AgeRating | AgeRating |
|
|
834
|
+
* | communityRating(Count) | CommunityRating | CommunityRating > AverageRating / RatingCount |
|
|
835
|
+
* | coverDate | Year / Month / Day | CoverDate (YYYY-MM-DD) |
|
|
836
|
+
* | storeDate | (no equivalent) | StoreDate (YYYY-MM-DD) |
|
|
837
|
+
* | lastModified | (no equivalent) | LastModified |
|
|
838
|
+
* | genres | Genre (comma-separated string) | Genres > Genre[] (`id` attribute preserved) |
|
|
839
|
+
* | tags | Tags (comma-separated string) | Tags > Tag[] (`id` attribute preserved) |
|
|
840
|
+
* | characters | Characters (comma-separated string) | Characters > Character[] (`id` attribute) |
|
|
841
|
+
* | teams | Teams (comma-separated string) | Teams > Team[] (`id` attribute) |
|
|
842
|
+
* | locations | Locations (comma-separated string) | Locations > Location[] (`id` attribute) |
|
|
843
|
+
* | storyArcs | StoryArc / StoryArcNumber (parallel comma lists) | Arcs > Arc[] (Name + Number + `id`) |
|
|
844
|
+
* | mainCharacterOrTeam | MainCharacterOrTeam | (no equivalent — dropped) |
|
|
845
|
+
* | stories | (no equivalent) | Stories > Story[] |
|
|
846
|
+
* | reprints | (no equivalent) | Reprints > Reprint[] |
|
|
847
|
+
* | universes | (no equivalent) | Universes > Universe[] |
|
|
848
|
+
* | identifiers | (no equivalent) | IDS > ID[] |
|
|
849
|
+
* | prices | (no equivalent) | Prices > Price[] |
|
|
850
|
+
* | credits | Writer/Penciller/Inker/Colorist/Letterer/CoverArtist/Editor/Translator (comma-separated per role) | Credits > Credit[] (Creator + Roles > Role[], `id` attributes) |
|
|
851
|
+
* | web | Web (single URL) | URLs > URL[] (`primary` attribute) |
|
|
852
|
+
* | gtin | GTIN | GTIN > ISBN |
|
|
853
|
+
* | gtinUpc | (no equivalent) | GTIN > UPC |
|
|
854
|
+
* | blackAndWhite | BlackAndWhite (Yes/No) | (no equivalent — ComicInfo-only) |
|
|
855
|
+
* | manga | Manga | (no equivalent — ComicInfo-only) |
|
|
856
|
+
* | pages | Pages > Page[] (Image/Type/DoublePage/... attrs) | (no equivalent — ComicInfo-only) |
|
|
857
|
+
*/
|
|
858
|
+
const COMIC_INFO_CREDIT_ROLES = [
|
|
859
|
+
"Writer",
|
|
860
|
+
"Penciller",
|
|
861
|
+
"Inker",
|
|
862
|
+
"Colorist",
|
|
863
|
+
"Letterer",
|
|
864
|
+
"CoverArtist",
|
|
865
|
+
"Editor",
|
|
866
|
+
"Translator"
|
|
867
|
+
];
|
|
868
|
+
/** ComicInfo.xml `YesNo` simple type. */
|
|
869
|
+
const COMIC_INFO_YES_NO_VALUES = [
|
|
870
|
+
"Unknown",
|
|
871
|
+
"No",
|
|
872
|
+
"Yes"
|
|
873
|
+
];
|
|
874
|
+
/** ComicInfo.xml `Manga` simple type. */
|
|
875
|
+
const COMIC_INFO_MANGA_VALUES = [
|
|
876
|
+
"Unknown",
|
|
877
|
+
"No",
|
|
878
|
+
"Yes",
|
|
879
|
+
"YesAndRightToLeft"
|
|
880
|
+
];
|
|
881
|
+
/** ComicInfo.xml `AgeRating` simple type. */
|
|
882
|
+
const COMIC_INFO_AGE_RATING_VALUES = [
|
|
883
|
+
"Unknown",
|
|
884
|
+
"Adults Only 18+",
|
|
885
|
+
"Early Childhood",
|
|
886
|
+
"Everyone",
|
|
887
|
+
"Everyone 10+",
|
|
888
|
+
"G",
|
|
889
|
+
"Kids to Adults",
|
|
890
|
+
"M",
|
|
891
|
+
"MA15+",
|
|
892
|
+
"Mature 17+",
|
|
893
|
+
"PG",
|
|
894
|
+
"R18+",
|
|
895
|
+
"Rating Pending",
|
|
896
|
+
"Teen",
|
|
897
|
+
"X18+"
|
|
898
|
+
];
|
|
899
|
+
/** ComicInfo.xml `ComicPageType` simple type. */
|
|
900
|
+
const COMIC_INFO_PAGE_TYPE_VALUES = [
|
|
901
|
+
"FrontCover",
|
|
902
|
+
"InnerCover",
|
|
903
|
+
"Roundup",
|
|
904
|
+
"Story",
|
|
905
|
+
"Advertisement",
|
|
906
|
+
"Editorial",
|
|
907
|
+
"Letters",
|
|
908
|
+
"Preview",
|
|
909
|
+
"BackCover",
|
|
910
|
+
"Other",
|
|
911
|
+
"Deleted"
|
|
912
|
+
];
|
|
913
|
+
/** MetronInfo.xml `formatType` simple type (Series > Format). */
|
|
914
|
+
const METRON_FORMAT_VALUES = [
|
|
915
|
+
"Annual",
|
|
916
|
+
"Digital Chapter",
|
|
917
|
+
"Graphic Novel",
|
|
918
|
+
"Hardcover",
|
|
919
|
+
"Limited Series",
|
|
920
|
+
"Omnibus",
|
|
921
|
+
"One-Shot",
|
|
922
|
+
"Single Issue",
|
|
923
|
+
"Trade Paperback"
|
|
924
|
+
];
|
|
925
|
+
/** MetronInfo.xml `informationSource` simple type (IDS > ID `source` attribute). */
|
|
926
|
+
const METRON_INFORMATION_SOURCE_VALUES = [
|
|
927
|
+
"AniList",
|
|
928
|
+
"Comic Vine",
|
|
929
|
+
"Grand Comics Database",
|
|
930
|
+
"Kitsu",
|
|
931
|
+
"MangaDex",
|
|
932
|
+
"MangaUpdates",
|
|
933
|
+
"Marvel",
|
|
934
|
+
"Metron",
|
|
935
|
+
"MyAnimeList",
|
|
936
|
+
"League of Comic Geeks"
|
|
937
|
+
];
|
|
938
|
+
/** MetronInfo.xml `roleValues` simple type (Credits > Credit > Roles > Role). */
|
|
939
|
+
const METRON_ROLE_VALUES = [
|
|
940
|
+
"Writer",
|
|
941
|
+
"Script",
|
|
942
|
+
"Story",
|
|
943
|
+
"Plot",
|
|
944
|
+
"Interviewer",
|
|
945
|
+
"Artist",
|
|
946
|
+
"Penciller",
|
|
947
|
+
"Breakdowns",
|
|
948
|
+
"Illustrator",
|
|
949
|
+
"Layouts",
|
|
950
|
+
"Inker",
|
|
951
|
+
"Embellisher",
|
|
952
|
+
"Finishes",
|
|
953
|
+
"Ink Assists",
|
|
954
|
+
"Colorist",
|
|
955
|
+
"Color Separations",
|
|
956
|
+
"Color Assists",
|
|
957
|
+
"Color Flats",
|
|
958
|
+
"Digital Art Technician",
|
|
959
|
+
"Gray Tone",
|
|
960
|
+
"Letterer",
|
|
961
|
+
"Cover",
|
|
962
|
+
"Editor",
|
|
963
|
+
"Consulting Editor",
|
|
964
|
+
"Assistant Editor",
|
|
965
|
+
"Associate Editor",
|
|
966
|
+
"Group Editor",
|
|
967
|
+
"Senior Editor",
|
|
968
|
+
"Managing Editor",
|
|
969
|
+
"Collection Editor",
|
|
970
|
+
"Production",
|
|
971
|
+
"Designer",
|
|
972
|
+
"Logo Design",
|
|
973
|
+
"Translator",
|
|
974
|
+
"Supervising Editor",
|
|
975
|
+
"Executive Editor",
|
|
976
|
+
"Editor In Chief",
|
|
977
|
+
"President",
|
|
978
|
+
"Publisher",
|
|
979
|
+
"Chief Creative Officer",
|
|
980
|
+
"Executive Producer",
|
|
981
|
+
"Other"
|
|
982
|
+
];
|
|
983
|
+
/** MetronInfo.xml `ageRatingType` simple type. */
|
|
984
|
+
const METRON_AGE_RATING_VALUES = [
|
|
985
|
+
"Unknown",
|
|
986
|
+
"Everyone",
|
|
987
|
+
"Teen",
|
|
988
|
+
"Teen Plus",
|
|
989
|
+
"Mature",
|
|
990
|
+
"Explicit",
|
|
991
|
+
"Adult"
|
|
992
|
+
];
|
|
993
|
+
function splitCommaList(value) {
|
|
994
|
+
if (!value) return;
|
|
995
|
+
const parts = value.split(",").map((part) => part.trim()).filter(Boolean);
|
|
996
|
+
return parts.length ? parts : void 0;
|
|
997
|
+
}
|
|
998
|
+
function joinCommaList(values) {
|
|
999
|
+
return values && values.length ? values.join(", ") : void 0;
|
|
1000
|
+
}
|
|
1001
|
+
/** Extracts the plain text value from a resource that may carry a MetronInfo `id` attribute. */
|
|
1002
|
+
function resourceName(item) {
|
|
1003
|
+
return typeof item === "string" ? item : item.name;
|
|
1004
|
+
}
|
|
1005
|
+
/** Extracts the MetronInfo `id` attribute from a resource, if any. */
|
|
1006
|
+
function resourceId(item) {
|
|
1007
|
+
return typeof item === "string" ? void 0 : item.id;
|
|
1008
|
+
}
|
|
1009
|
+
function joinResourceNames(values) {
|
|
1010
|
+
return joinCommaList(values?.map(resourceName));
|
|
1011
|
+
}
|
|
1012
|
+
//#endregion
|
|
1013
|
+
//#region src/metadata/comicInfo.ts
|
|
1014
|
+
const PARSE_OPTIONS$1 = {
|
|
1015
|
+
ignoreAttributes: false,
|
|
1016
|
+
attributeNamePrefix: "@_",
|
|
1017
|
+
isArray: (name) => name === "Page"
|
|
1018
|
+
};
|
|
1019
|
+
const BUILD_OPTIONS$1 = {
|
|
1020
|
+
ignoreAttributes: false,
|
|
1021
|
+
attributeNamePrefix: "@_",
|
|
1022
|
+
format: true,
|
|
1023
|
+
suppressEmptyNode: true,
|
|
1024
|
+
suppressBooleanAttributes: false
|
|
1025
|
+
};
|
|
1026
|
+
const KNOWN_KEYS = /* @__PURE__ */ new Set([
|
|
1027
|
+
"Title",
|
|
1028
|
+
"Series",
|
|
1029
|
+
"Number",
|
|
1030
|
+
"Count",
|
|
1031
|
+
"Volume",
|
|
1032
|
+
"AlternateSeries",
|
|
1033
|
+
"AlternateNumber",
|
|
1034
|
+
"AlternateCount",
|
|
1035
|
+
"Summary",
|
|
1036
|
+
"Notes",
|
|
1037
|
+
"Year",
|
|
1038
|
+
"Month",
|
|
1039
|
+
"Day",
|
|
1040
|
+
"Publisher",
|
|
1041
|
+
"Imprint",
|
|
1042
|
+
"Genre",
|
|
1043
|
+
"Tags",
|
|
1044
|
+
"Web",
|
|
1045
|
+
"PageCount",
|
|
1046
|
+
"LanguageISO",
|
|
1047
|
+
"Format",
|
|
1048
|
+
"BlackAndWhite",
|
|
1049
|
+
"Manga",
|
|
1050
|
+
"Characters",
|
|
1051
|
+
"Teams",
|
|
1052
|
+
"Locations",
|
|
1053
|
+
"ScanInformation",
|
|
1054
|
+
"StoryArc",
|
|
1055
|
+
"StoryArcNumber",
|
|
1056
|
+
"SeriesGroup",
|
|
1057
|
+
"AgeRating",
|
|
1058
|
+
"Pages",
|
|
1059
|
+
"CommunityRating",
|
|
1060
|
+
"MainCharacterOrTeam",
|
|
1061
|
+
"Review",
|
|
1062
|
+
"GTIN",
|
|
1063
|
+
...COMIC_INFO_CREDIT_ROLES
|
|
1064
|
+
]);
|
|
1065
|
+
function metadataToComicInfoXml(metadata) {
|
|
1066
|
+
const root = {};
|
|
1067
|
+
if (metadata.title !== void 0) root.Title = metadata.title;
|
|
1068
|
+
if (metadata.series !== void 0) root.Series = metadata.series;
|
|
1069
|
+
if (metadata.number !== void 0) root.Number = metadata.number;
|
|
1070
|
+
if (metadata.count !== void 0) root.Count = metadata.count;
|
|
1071
|
+
if (metadata.volume !== void 0) root.Volume = metadata.volume;
|
|
1072
|
+
if (metadata.alternateSeries !== void 0) root.AlternateSeries = metadata.alternateSeries;
|
|
1073
|
+
if (metadata.alternateNumber !== void 0) root.AlternateNumber = metadata.alternateNumber;
|
|
1074
|
+
if (metadata.alternateCount !== void 0) root.AlternateCount = metadata.alternateCount;
|
|
1075
|
+
if (metadata.summary !== void 0) root.Summary = metadata.summary;
|
|
1076
|
+
if (metadata.notes !== void 0) root.Notes = metadata.notes;
|
|
1077
|
+
if (metadata.coverDate?.year !== void 0) root.Year = metadata.coverDate.year;
|
|
1078
|
+
if (metadata.coverDate?.month !== void 0) root.Month = metadata.coverDate.month;
|
|
1079
|
+
if (metadata.coverDate?.day !== void 0) root.Day = metadata.coverDate.day;
|
|
1080
|
+
for (const role of COMIC_INFO_CREDIT_ROLES) {
|
|
1081
|
+
const joined = joinCommaList((metadata.credits ?? []).filter((credit) => credit.role === role).map((credit) => credit.name));
|
|
1082
|
+
if (joined) root[role] = joined;
|
|
1083
|
+
}
|
|
1084
|
+
if (metadata.publisher !== void 0) root.Publisher = metadata.publisher;
|
|
1085
|
+
if (metadata.imprint !== void 0) root.Imprint = metadata.imprint;
|
|
1086
|
+
const genres = joinResourceNames(metadata.genres);
|
|
1087
|
+
if (genres) root.Genre = genres;
|
|
1088
|
+
const tags = joinResourceNames(metadata.tags);
|
|
1089
|
+
if (tags) root.Tags = tags;
|
|
1090
|
+
const web = metadata.web?.[0];
|
|
1091
|
+
if (web !== void 0) root.Web = typeof web === "string" ? web : web.url;
|
|
1092
|
+
if (metadata.pageCount !== void 0) root.PageCount = metadata.pageCount;
|
|
1093
|
+
if (metadata.language !== void 0) root.LanguageISO = metadata.language;
|
|
1094
|
+
if (metadata.format !== void 0) root.Format = metadata.format;
|
|
1095
|
+
if (metadata.blackAndWhite !== void 0) root.BlackAndWhite = metadata.blackAndWhite ? "Yes" : "No";
|
|
1096
|
+
if (metadata.manga !== void 0) root.Manga = metadata.manga;
|
|
1097
|
+
const characters = joinResourceNames(metadata.characters);
|
|
1098
|
+
if (characters) root.Characters = characters;
|
|
1099
|
+
const teams = joinResourceNames(metadata.teams);
|
|
1100
|
+
if (teams) root.Teams = teams;
|
|
1101
|
+
const locations = joinResourceNames(metadata.locations);
|
|
1102
|
+
if (locations) root.Locations = locations;
|
|
1103
|
+
if (metadata.scanInformation !== void 0) root.ScanInformation = metadata.scanInformation;
|
|
1104
|
+
const storyArcs = joinCommaList(metadata.storyArcs?.map((arc) => arc.name));
|
|
1105
|
+
if (storyArcs) root.StoryArc = storyArcs;
|
|
1106
|
+
if (metadata.storyArcs?.some((arc) => arc.number !== void 0)) root.StoryArcNumber = metadata.storyArcs.map((arc) => arc.number ?? "").join(", ");
|
|
1107
|
+
if (metadata.seriesGroup !== void 0) root.SeriesGroup = metadata.seriesGroup;
|
|
1108
|
+
if (metadata.ageRating !== void 0) root.AgeRating = metadata.ageRating;
|
|
1109
|
+
if (metadata.pages?.length) root.Pages = { Page: metadata.pages.map((page) => {
|
|
1110
|
+
const attrs = { "@_Image": page.index };
|
|
1111
|
+
if (page.type !== void 0) attrs["@_Type"] = page.type;
|
|
1112
|
+
if (page.doublePage !== void 0) attrs["@_DoublePage"] = page.doublePage;
|
|
1113
|
+
if (page.imageSize !== void 0) attrs["@_ImageSize"] = page.imageSize;
|
|
1114
|
+
if (page.imageWidth !== void 0) attrs["@_ImageWidth"] = page.imageWidth;
|
|
1115
|
+
if (page.imageHeight !== void 0) attrs["@_ImageHeight"] = page.imageHeight;
|
|
1116
|
+
if (page.key !== void 0) attrs["@_Key"] = page.key;
|
|
1117
|
+
if (page.bookmark !== void 0) attrs["@_Bookmark"] = page.bookmark;
|
|
1118
|
+
return attrs;
|
|
1119
|
+
}) };
|
|
1120
|
+
if (metadata.communityRating !== void 0) root.CommunityRating = metadata.communityRating;
|
|
1121
|
+
if (metadata.mainCharacterOrTeam !== void 0) root.MainCharacterOrTeam = metadata.mainCharacterOrTeam;
|
|
1122
|
+
if (metadata.review !== void 0) root.Review = metadata.review;
|
|
1123
|
+
if (metadata.gtin !== void 0) root.GTIN = metadata.gtin;
|
|
1124
|
+
if (metadata.comicInfoExtra) Object.assign(root, metadata.comicInfoExtra);
|
|
1125
|
+
return `<?xml version="1.0" encoding="utf-8"?>\n${new XMLBuilder(BUILD_OPTIONS$1).build({ ComicInfo: root })}`;
|
|
1126
|
+
}
|
|
1127
|
+
function comicInfoXmlToMetadata(xml) {
|
|
1128
|
+
const root = new XMLParser(PARSE_OPTIONS$1).parse(xml.toString("utf8")).ComicInfo ?? {};
|
|
1129
|
+
const metadata = {};
|
|
1130
|
+
if (root.Title !== void 0) metadata.title = String(root.Title);
|
|
1131
|
+
if (root.Series !== void 0) metadata.series = String(root.Series);
|
|
1132
|
+
if (root.Number !== void 0) metadata.number = String(root.Number);
|
|
1133
|
+
if (root.Count !== void 0) metadata.count = Number(root.Count);
|
|
1134
|
+
if (root.Volume !== void 0) metadata.volume = String(root.Volume);
|
|
1135
|
+
if (root.AlternateSeries !== void 0) metadata.alternateSeries = String(root.AlternateSeries);
|
|
1136
|
+
if (root.AlternateNumber !== void 0) metadata.alternateNumber = String(root.AlternateNumber);
|
|
1137
|
+
if (root.AlternateCount !== void 0) metadata.alternateCount = Number(root.AlternateCount);
|
|
1138
|
+
if (root.Summary !== void 0) metadata.summary = String(root.Summary);
|
|
1139
|
+
if (root.Notes !== void 0) metadata.notes = String(root.Notes);
|
|
1140
|
+
if (root.Year !== void 0 || root.Month !== void 0 || root.Day !== void 0) metadata.coverDate = {
|
|
1141
|
+
year: root.Year !== void 0 ? Number(root.Year) : void 0,
|
|
1142
|
+
month: root.Month !== void 0 ? Number(root.Month) : void 0,
|
|
1143
|
+
day: root.Day !== void 0 ? Number(root.Day) : void 0
|
|
1144
|
+
};
|
|
1145
|
+
if (root.Publisher !== void 0) metadata.publisher = String(root.Publisher);
|
|
1146
|
+
if (root.Imprint !== void 0) metadata.imprint = String(root.Imprint);
|
|
1147
|
+
if (root.Genre !== void 0) metadata.genres = splitCommaList(String(root.Genre));
|
|
1148
|
+
if (root.Tags !== void 0) metadata.tags = splitCommaList(String(root.Tags));
|
|
1149
|
+
if (root.Web !== void 0) metadata.web = [String(root.Web)];
|
|
1150
|
+
if (root.PageCount !== void 0) metadata.pageCount = Number(root.PageCount);
|
|
1151
|
+
if (root.LanguageISO !== void 0) metadata.language = String(root.LanguageISO);
|
|
1152
|
+
if (root.Format !== void 0) metadata.format = String(root.Format);
|
|
1153
|
+
if (root.BlackAndWhite !== void 0) metadata.blackAndWhite = String(root.BlackAndWhite).toLowerCase() === "yes";
|
|
1154
|
+
if (root.Manga !== void 0) metadata.manga = String(root.Manga);
|
|
1155
|
+
if (root.Characters !== void 0) metadata.characters = splitCommaList(String(root.Characters));
|
|
1156
|
+
if (root.Teams !== void 0) metadata.teams = splitCommaList(String(root.Teams));
|
|
1157
|
+
if (root.Locations !== void 0) metadata.locations = splitCommaList(String(root.Locations));
|
|
1158
|
+
if (root.ScanInformation !== void 0) metadata.scanInformation = String(root.ScanInformation);
|
|
1159
|
+
if (root.StoryArc !== void 0) {
|
|
1160
|
+
const names = splitCommaList(String(root.StoryArc)) ?? [];
|
|
1161
|
+
const numbers = root.StoryArcNumber !== void 0 ? String(root.StoryArcNumber).split(",").map((part) => part.trim()) : [];
|
|
1162
|
+
metadata.storyArcs = names.map((name, i) => ({
|
|
1163
|
+
name,
|
|
1164
|
+
number: numbers[i] ? Number(numbers[i]) : void 0
|
|
1165
|
+
}));
|
|
1166
|
+
}
|
|
1167
|
+
if (root.SeriesGroup !== void 0) metadata.seriesGroup = String(root.SeriesGroup);
|
|
1168
|
+
if (root.AgeRating !== void 0) metadata.ageRating = String(root.AgeRating);
|
|
1169
|
+
if (root.CommunityRating !== void 0) metadata.communityRating = Number(root.CommunityRating);
|
|
1170
|
+
if (root.MainCharacterOrTeam !== void 0) metadata.mainCharacterOrTeam = String(root.MainCharacterOrTeam);
|
|
1171
|
+
if (root.Review !== void 0) metadata.review = String(root.Review);
|
|
1172
|
+
if (root.GTIN !== void 0) metadata.gtin = String(root.GTIN);
|
|
1173
|
+
const credits = [];
|
|
1174
|
+
for (const role of COMIC_INFO_CREDIT_ROLES) {
|
|
1175
|
+
const value = root[role];
|
|
1176
|
+
if (value !== void 0) for (const name of splitCommaList(String(value)) ?? []) credits.push({
|
|
1177
|
+
name,
|
|
1178
|
+
role
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
if (credits.length) metadata.credits = credits;
|
|
1182
|
+
const pagesNode = root.Pages;
|
|
1183
|
+
if (pagesNode?.Page?.length) metadata.pages = pagesNode.Page.map((page) => ({
|
|
1184
|
+
index: Number(page["@_Image"] ?? 0),
|
|
1185
|
+
type: page["@_Type"] !== void 0 ? String(page["@_Type"]) : void 0,
|
|
1186
|
+
doublePage: page["@_DoublePage"] !== void 0 ? String(page["@_DoublePage"]) === "true" : void 0,
|
|
1187
|
+
imageSize: page["@_ImageSize"] !== void 0 ? Number(page["@_ImageSize"]) : void 0,
|
|
1188
|
+
imageWidth: page["@_ImageWidth"] !== void 0 ? Number(page["@_ImageWidth"]) : void 0,
|
|
1189
|
+
imageHeight: page["@_ImageHeight"] !== void 0 ? Number(page["@_ImageHeight"]) : void 0,
|
|
1190
|
+
key: page["@_Key"] !== void 0 ? String(page["@_Key"]) : void 0,
|
|
1191
|
+
bookmark: page["@_Bookmark"] !== void 0 ? String(page["@_Bookmark"]) : void 0
|
|
1192
|
+
}));
|
|
1193
|
+
const extra = {};
|
|
1194
|
+
for (const [key, value] of Object.entries(root)) if (!KNOWN_KEYS.has(key)) extra[key] = value;
|
|
1195
|
+
if (Object.keys(extra).length) metadata.comicInfoExtra = extra;
|
|
1196
|
+
return metadata;
|
|
1197
|
+
}
|
|
1198
|
+
//#endregion
|
|
1199
|
+
//#region src/metadata/metronInfo.ts
|
|
1200
|
+
const REPEATING_ELEMENTS = /* @__PURE__ */ new Set([
|
|
1201
|
+
"ID",
|
|
1202
|
+
"Genre",
|
|
1203
|
+
"Tag",
|
|
1204
|
+
"Character",
|
|
1205
|
+
"Team",
|
|
1206
|
+
"Universe",
|
|
1207
|
+
"Location",
|
|
1208
|
+
"Reprint",
|
|
1209
|
+
"Arc",
|
|
1210
|
+
"Credit",
|
|
1211
|
+
"Role",
|
|
1212
|
+
"URL",
|
|
1213
|
+
"Story",
|
|
1214
|
+
"Price",
|
|
1215
|
+
"AlternativeName"
|
|
1216
|
+
]);
|
|
1217
|
+
const PARSE_OPTIONS = {
|
|
1218
|
+
ignoreAttributes: false,
|
|
1219
|
+
attributeNamePrefix: "@_",
|
|
1220
|
+
isArray: (name) => REPEATING_ELEMENTS.has(name)
|
|
1221
|
+
};
|
|
1222
|
+
const BUILD_OPTIONS = {
|
|
1223
|
+
ignoreAttributes: false,
|
|
1224
|
+
attributeNamePrefix: "@_",
|
|
1225
|
+
format: true,
|
|
1226
|
+
suppressEmptyNode: true,
|
|
1227
|
+
suppressBooleanAttributes: false
|
|
1228
|
+
};
|
|
1229
|
+
const KNOWN_ROOT_KEYS = /* @__PURE__ */ new Set([
|
|
1230
|
+
"IDS",
|
|
1231
|
+
"Publisher",
|
|
1232
|
+
"Series",
|
|
1233
|
+
"MangaVolume",
|
|
1234
|
+
"CollectionTitle",
|
|
1235
|
+
"Number",
|
|
1236
|
+
"AlternativeNumber",
|
|
1237
|
+
"Stories",
|
|
1238
|
+
"Summary",
|
|
1239
|
+
"Prices",
|
|
1240
|
+
"CoverDate",
|
|
1241
|
+
"StoreDate",
|
|
1242
|
+
"PageCount",
|
|
1243
|
+
"Notes",
|
|
1244
|
+
"Genres",
|
|
1245
|
+
"Tags",
|
|
1246
|
+
"Arcs",
|
|
1247
|
+
"Characters",
|
|
1248
|
+
"Teams",
|
|
1249
|
+
"Universes",
|
|
1250
|
+
"Locations",
|
|
1251
|
+
"Reprints",
|
|
1252
|
+
"GTIN",
|
|
1253
|
+
"AgeRating",
|
|
1254
|
+
"CommunityRating",
|
|
1255
|
+
"URLs",
|
|
1256
|
+
"Credits",
|
|
1257
|
+
"LastModified"
|
|
1258
|
+
]);
|
|
1259
|
+
function formatDate(date) {
|
|
1260
|
+
if (!date || date.year === void 0) return;
|
|
1261
|
+
const month = String(date.month ?? 1).padStart(2, "0");
|
|
1262
|
+
const day = String(date.day ?? 1).padStart(2, "0");
|
|
1263
|
+
return `${date.year}-${month}-${day}`;
|
|
1264
|
+
}
|
|
1265
|
+
function parseDate(value) {
|
|
1266
|
+
if (typeof value !== "string") return;
|
|
1267
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
1268
|
+
if (!match) return;
|
|
1269
|
+
return {
|
|
1270
|
+
year: Number(match[1]),
|
|
1271
|
+
month: Number(match[2]),
|
|
1272
|
+
day: Number(match[3])
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
/** Builds a MetronInfo `resourceType` node: plain text, or `{ #text, @_id }` when an id is present. */
|
|
1276
|
+
function buildResourceItem(item) {
|
|
1277
|
+
const id = resourceId(item);
|
|
1278
|
+
return id !== void 0 ? {
|
|
1279
|
+
"#text": resourceName(item),
|
|
1280
|
+
"@_id": id
|
|
1281
|
+
} : resourceName(item);
|
|
1282
|
+
}
|
|
1283
|
+
/** Parses a MetronInfo `resourceType` node back into a plain string or `MetronResource`. */
|
|
1284
|
+
function parseResourceItem(item) {
|
|
1285
|
+
if (item !== null && typeof item === "object") {
|
|
1286
|
+
const obj = item;
|
|
1287
|
+
const id = obj["@_id"];
|
|
1288
|
+
const text = String(obj["#text"] ?? "");
|
|
1289
|
+
return id !== void 0 ? {
|
|
1290
|
+
name: text,
|
|
1291
|
+
id: String(id)
|
|
1292
|
+
} : text;
|
|
1293
|
+
}
|
|
1294
|
+
return String(item);
|
|
1295
|
+
}
|
|
1296
|
+
function buildUrlItem(item) {
|
|
1297
|
+
if (typeof item === "string") return item;
|
|
1298
|
+
return item.primary !== void 0 ? {
|
|
1299
|
+
"#text": item.url,
|
|
1300
|
+
"@_primary": item.primary
|
|
1301
|
+
} : item.url;
|
|
1302
|
+
}
|
|
1303
|
+
function parseUrlItem(item) {
|
|
1304
|
+
if (item !== null && typeof item === "object") {
|
|
1305
|
+
const obj = item;
|
|
1306
|
+
const primary = obj["@_primary"];
|
|
1307
|
+
const text = String(obj["#text"] ?? "");
|
|
1308
|
+
return primary !== void 0 ? {
|
|
1309
|
+
url: text,
|
|
1310
|
+
primary: String(primary) === "true"
|
|
1311
|
+
} : text;
|
|
1312
|
+
}
|
|
1313
|
+
return String(item);
|
|
1314
|
+
}
|
|
1315
|
+
function buildIdentifierItem(id) {
|
|
1316
|
+
return {
|
|
1317
|
+
"#text": id.value,
|
|
1318
|
+
"@_source": id.source,
|
|
1319
|
+
...id.primary !== void 0 ? { "@_primary": id.primary } : {}
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
function parseIdentifierItem(item) {
|
|
1323
|
+
const obj = item ?? {};
|
|
1324
|
+
return {
|
|
1325
|
+
source: String(obj["@_source"] ?? ""),
|
|
1326
|
+
value: String(obj["#text"] ?? ""),
|
|
1327
|
+
primary: obj["@_primary"] !== void 0 ? String(obj["@_primary"]) === "true" : void 0
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
function buildAlternativeNameItem(item) {
|
|
1331
|
+
const attrs = {};
|
|
1332
|
+
if (item.id !== void 0) attrs["@_id"] = item.id;
|
|
1333
|
+
if (item.lang !== void 0) attrs["@_lang"] = item.lang;
|
|
1334
|
+
return Object.keys(attrs).length ? {
|
|
1335
|
+
"#text": item.name,
|
|
1336
|
+
...attrs
|
|
1337
|
+
} : item.name;
|
|
1338
|
+
}
|
|
1339
|
+
function parseAlternativeNameItem(item) {
|
|
1340
|
+
if (item === null || typeof item !== "object") return { name: String(item) };
|
|
1341
|
+
const obj = item;
|
|
1342
|
+
return {
|
|
1343
|
+
name: String(obj["#text"] ?? ""),
|
|
1344
|
+
id: obj["@_id"] !== void 0 ? String(obj["@_id"]) : void 0,
|
|
1345
|
+
lang: obj["@_lang"] !== void 0 ? String(obj["@_lang"]) : void 0
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
function buildUniverseItem(universe) {
|
|
1349
|
+
return {
|
|
1350
|
+
Name: universe.name,
|
|
1351
|
+
...universe.designation !== void 0 ? { Designation: universe.designation } : {},
|
|
1352
|
+
...universe.id !== void 0 ? { "@_id": universe.id } : {}
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
function parseUniverseItem(item) {
|
|
1356
|
+
return {
|
|
1357
|
+
name: String(item.Name ?? ""),
|
|
1358
|
+
designation: item.Designation !== void 0 ? String(item.Designation) : void 0,
|
|
1359
|
+
id: item["@_id"] !== void 0 ? String(item["@_id"]) : void 0
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
function buildArcItem(arc) {
|
|
1363
|
+
return {
|
|
1364
|
+
Name: arc.name,
|
|
1365
|
+
...arc.number !== void 0 ? { Number: arc.number } : {},
|
|
1366
|
+
...arc.id !== void 0 ? { "@_id": arc.id } : {}
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
function parseArcItem(item) {
|
|
1370
|
+
return {
|
|
1371
|
+
name: String(item.Name ?? ""),
|
|
1372
|
+
number: item.Number !== void 0 ? Number(item.Number) : void 0,
|
|
1373
|
+
id: item["@_id"] !== void 0 ? String(item["@_id"]) : void 0
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
function buildPriceItem(price) {
|
|
1377
|
+
return {
|
|
1378
|
+
"#text": price.amount,
|
|
1379
|
+
"@_country": price.country
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
function parsePriceItem(item) {
|
|
1383
|
+
const obj = item ?? {};
|
|
1384
|
+
return {
|
|
1385
|
+
amount: Number(obj["#text"]),
|
|
1386
|
+
country: String(obj["@_country"] ?? "")
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
function metadataToMetronInfoXml(metadata) {
|
|
1390
|
+
const root = {};
|
|
1391
|
+
if (metadata.identifiers?.length) root.IDS = { ID: metadata.identifiers.map(buildIdentifierItem) };
|
|
1392
|
+
if (metadata.publisher !== void 0 || metadata.imprint !== void 0 || metadata.publisherId !== void 0) root.Publisher = {
|
|
1393
|
+
...metadata.publisher !== void 0 ? { Name: metadata.publisher } : {},
|
|
1394
|
+
...metadata.imprint !== void 0 ? { Imprint: buildResourceItem(metadata.imprintId !== void 0 ? {
|
|
1395
|
+
name: metadata.imprint,
|
|
1396
|
+
id: metadata.imprintId
|
|
1397
|
+
} : metadata.imprint) } : {},
|
|
1398
|
+
...metadata.publisherId !== void 0 ? { "@_id": metadata.publisherId } : {}
|
|
1399
|
+
};
|
|
1400
|
+
if (metadata.series !== void 0 || metadata.seriesSort !== void 0 || metadata.volume !== void 0 || metadata.format !== void 0 || metadata.seriesStartYear !== void 0 || metadata.seriesIssueCount !== void 0 || metadata.seriesVolumeCount !== void 0 || metadata.seriesAlternativeNames?.length || metadata.seriesId !== void 0 || metadata.seriesLang !== void 0) root.Series = {
|
|
1401
|
+
...metadata.series !== void 0 ? { Name: metadata.series } : {},
|
|
1402
|
+
...metadata.seriesSort !== void 0 ? { SortName: metadata.seriesSort } : {},
|
|
1403
|
+
...metadata.volume !== void 0 ? { Volume: metadata.volume } : {},
|
|
1404
|
+
...metadata.format !== void 0 ? { Format: metadata.format } : {},
|
|
1405
|
+
...metadata.seriesStartYear !== void 0 ? { StartYear: metadata.seriesStartYear } : {},
|
|
1406
|
+
...metadata.seriesIssueCount !== void 0 ? { IssueCount: metadata.seriesIssueCount } : {},
|
|
1407
|
+
...metadata.seriesVolumeCount !== void 0 ? { VolumeCount: metadata.seriesVolumeCount } : {},
|
|
1408
|
+
...metadata.seriesAlternativeNames?.length ? { AlternativeNames: { AlternativeName: metadata.seriesAlternativeNames.map(buildAlternativeNameItem) } } : {},
|
|
1409
|
+
...metadata.seriesId !== void 0 ? { "@_id": metadata.seriesId } : {},
|
|
1410
|
+
...metadata.seriesLang !== void 0 ? { "@_lang": metadata.seriesLang } : {}
|
|
1411
|
+
};
|
|
1412
|
+
if (metadata.mangaVolume !== void 0) root.MangaVolume = metadata.mangaVolume;
|
|
1413
|
+
if (metadata.collectionTitle !== void 0) root.CollectionTitle = metadata.collectionTitle;
|
|
1414
|
+
if (metadata.number !== void 0) root.Number = metadata.number;
|
|
1415
|
+
if (metadata.alternateNumber !== void 0) root.AlternativeNumber = metadata.alternateNumber;
|
|
1416
|
+
if (metadata.stories?.length) root.Stories = { Story: metadata.stories.map(buildResourceItem) };
|
|
1417
|
+
if (metadata.summary !== void 0) root.Summary = metadata.summary;
|
|
1418
|
+
if (metadata.prices?.length) root.Prices = { Price: metadata.prices.map(buildPriceItem) };
|
|
1419
|
+
const coverDate = formatDate(metadata.coverDate);
|
|
1420
|
+
if (coverDate) root.CoverDate = coverDate;
|
|
1421
|
+
const storeDate = formatDate(metadata.storeDate);
|
|
1422
|
+
if (storeDate) root.StoreDate = storeDate;
|
|
1423
|
+
if (metadata.pageCount !== void 0) root.PageCount = metadata.pageCount;
|
|
1424
|
+
if (metadata.notes !== void 0) root.Notes = metadata.notes;
|
|
1425
|
+
if (metadata.genres?.length) root.Genres = { Genre: metadata.genres.map(buildResourceItem) };
|
|
1426
|
+
if (metadata.tags?.length) root.Tags = { Tag: metadata.tags.map(buildResourceItem) };
|
|
1427
|
+
if (metadata.storyArcs?.length) root.Arcs = { Arc: metadata.storyArcs.map(buildArcItem) };
|
|
1428
|
+
if (metadata.characters?.length) root.Characters = { Character: metadata.characters.map(buildResourceItem) };
|
|
1429
|
+
if (metadata.teams?.length) root.Teams = { Team: metadata.teams.map(buildResourceItem) };
|
|
1430
|
+
if (metadata.universes?.length) root.Universes = { Universe: metadata.universes.map(buildUniverseItem) };
|
|
1431
|
+
if (metadata.locations?.length) root.Locations = { Location: metadata.locations.map(buildResourceItem) };
|
|
1432
|
+
if (metadata.reprints?.length) root.Reprints = { Reprint: metadata.reprints.map(buildResourceItem) };
|
|
1433
|
+
if (metadata.gtin !== void 0 || metadata.gtinUpc !== void 0) root.GTIN = {
|
|
1434
|
+
...metadata.gtin !== void 0 ? { ISBN: metadata.gtin } : {},
|
|
1435
|
+
...metadata.gtinUpc !== void 0 ? { UPC: metadata.gtinUpc } : {}
|
|
1436
|
+
};
|
|
1437
|
+
if (metadata.ageRating !== void 0) root.AgeRating = metadata.ageRating;
|
|
1438
|
+
if (metadata.communityRating !== void 0) root.CommunityRating = {
|
|
1439
|
+
AverageRating: metadata.communityRating,
|
|
1440
|
+
...metadata.communityRatingCount !== void 0 ? { RatingCount: metadata.communityRatingCount } : {}
|
|
1441
|
+
};
|
|
1442
|
+
if (metadata.web?.length) root.URLs = { URL: metadata.web.map(buildUrlItem) };
|
|
1443
|
+
if (metadata.credits?.length) root.Credits = { Credit: metadata.credits.map((credit) => ({
|
|
1444
|
+
Creator: buildResourceItem(credit.creatorId !== void 0 ? {
|
|
1445
|
+
name: credit.name,
|
|
1446
|
+
id: credit.creatorId
|
|
1447
|
+
} : credit.name),
|
|
1448
|
+
Roles: { Role: [buildResourceItem(credit.roleId !== void 0 ? {
|
|
1449
|
+
name: credit.role,
|
|
1450
|
+
id: credit.roleId
|
|
1451
|
+
} : credit.role)] }
|
|
1452
|
+
})) };
|
|
1453
|
+
if (metadata.lastModified !== void 0) root.LastModified = metadata.lastModified;
|
|
1454
|
+
if (metadata.metronInfoExtra) Object.assign(root, metadata.metronInfoExtra);
|
|
1455
|
+
return `<?xml version="1.0" encoding="utf-8"?>\n${new XMLBuilder(BUILD_OPTIONS).build({ MetronInfo: root })}`;
|
|
1456
|
+
}
|
|
1457
|
+
function metronInfoXmlToMetadata(xml) {
|
|
1458
|
+
const root = new XMLParser(PARSE_OPTIONS).parse(xml.toString("utf8")).MetronInfo ?? {};
|
|
1459
|
+
const metadata = {};
|
|
1460
|
+
const ids = root.IDS;
|
|
1461
|
+
if (ids?.ID?.length) metadata.identifiers = ids.ID.map(parseIdentifierItem);
|
|
1462
|
+
const publisher = root.Publisher;
|
|
1463
|
+
if (publisher?.Name !== void 0) metadata.publisher = String(publisher.Name);
|
|
1464
|
+
if (publisher?.Imprint !== void 0) {
|
|
1465
|
+
const imprint = parseResourceItem(publisher.Imprint);
|
|
1466
|
+
metadata.imprint = resourceName(imprint);
|
|
1467
|
+
metadata.imprintId = resourceId(imprint);
|
|
1468
|
+
}
|
|
1469
|
+
if (publisher?.["@_id"] !== void 0) metadata.publisherId = String(publisher["@_id"]);
|
|
1470
|
+
const series = root.Series;
|
|
1471
|
+
if (series?.Name !== void 0) metadata.series = String(series.Name);
|
|
1472
|
+
if (series?.SortName !== void 0) metadata.seriesSort = String(series.SortName);
|
|
1473
|
+
if (series?.Volume !== void 0) metadata.volume = String(series.Volume);
|
|
1474
|
+
if (series?.Format !== void 0) metadata.format = String(series.Format);
|
|
1475
|
+
if (series?.StartYear !== void 0) metadata.seriesStartYear = Number(series.StartYear);
|
|
1476
|
+
if (series?.IssueCount !== void 0) metadata.seriesIssueCount = Number(series.IssueCount);
|
|
1477
|
+
if (series?.VolumeCount !== void 0) metadata.seriesVolumeCount = Number(series.VolumeCount);
|
|
1478
|
+
if (series?.AlternativeNames?.AlternativeName?.length) metadata.seriesAlternativeNames = series.AlternativeNames.AlternativeName.map(parseAlternativeNameItem);
|
|
1479
|
+
if (series?.["@_id"] !== void 0) metadata.seriesId = String(series["@_id"]);
|
|
1480
|
+
if (series?.["@_lang"] !== void 0) metadata.seriesLang = String(series["@_lang"]);
|
|
1481
|
+
if (root.MangaVolume !== void 0) metadata.mangaVolume = String(root.MangaVolume);
|
|
1482
|
+
if (root.CollectionTitle !== void 0) metadata.collectionTitle = String(root.CollectionTitle);
|
|
1483
|
+
if (root.Number !== void 0) metadata.number = String(root.Number);
|
|
1484
|
+
if (root.AlternativeNumber !== void 0) metadata.alternateNumber = String(root.AlternativeNumber);
|
|
1485
|
+
const stories = root.Stories;
|
|
1486
|
+
if (stories?.Story?.length) metadata.stories = stories.Story.map(parseResourceItem);
|
|
1487
|
+
if (root.Summary !== void 0) metadata.summary = String(root.Summary);
|
|
1488
|
+
const prices = root.Prices;
|
|
1489
|
+
if (prices?.Price?.length) metadata.prices = prices.Price.map(parsePriceItem);
|
|
1490
|
+
metadata.coverDate = parseDate(root.CoverDate);
|
|
1491
|
+
metadata.storeDate = parseDate(root.StoreDate);
|
|
1492
|
+
if (root.PageCount !== void 0) metadata.pageCount = Number(root.PageCount);
|
|
1493
|
+
if (root.Notes !== void 0) metadata.notes = String(root.Notes);
|
|
1494
|
+
const genres = root.Genres;
|
|
1495
|
+
if (genres?.Genre?.length) metadata.genres = genres.Genre.map(parseResourceItem);
|
|
1496
|
+
const tags = root.Tags;
|
|
1497
|
+
if (tags?.Tag?.length) metadata.tags = tags.Tag.map(parseResourceItem);
|
|
1498
|
+
const arcs = root.Arcs;
|
|
1499
|
+
if (arcs?.Arc?.length) metadata.storyArcs = arcs.Arc.map(parseArcItem);
|
|
1500
|
+
const characters = root.Characters;
|
|
1501
|
+
if (characters?.Character?.length) metadata.characters = characters.Character.map(parseResourceItem);
|
|
1502
|
+
const teams = root.Teams;
|
|
1503
|
+
if (teams?.Team?.length) metadata.teams = teams.Team.map(parseResourceItem);
|
|
1504
|
+
const universes = root.Universes;
|
|
1505
|
+
if (universes?.Universe?.length) metadata.universes = universes.Universe.map(parseUniverseItem);
|
|
1506
|
+
const locations = root.Locations;
|
|
1507
|
+
if (locations?.Location?.length) metadata.locations = locations.Location.map(parseResourceItem);
|
|
1508
|
+
const reprints = root.Reprints;
|
|
1509
|
+
if (reprints?.Reprint?.length) metadata.reprints = reprints.Reprint.map(parseResourceItem);
|
|
1510
|
+
const gtin = root.GTIN;
|
|
1511
|
+
if (gtin?.ISBN !== void 0) metadata.gtin = String(gtin.ISBN);
|
|
1512
|
+
if (gtin?.UPC !== void 0) metadata.gtinUpc = String(gtin.UPC);
|
|
1513
|
+
if (root.AgeRating !== void 0) metadata.ageRating = String(root.AgeRating);
|
|
1514
|
+
const communityRating = root.CommunityRating;
|
|
1515
|
+
if (communityRating?.AverageRating !== void 0) metadata.communityRating = Number(communityRating.AverageRating);
|
|
1516
|
+
if (communityRating?.RatingCount !== void 0) metadata.communityRatingCount = Number(communityRating.RatingCount);
|
|
1517
|
+
const urls = root.URLs;
|
|
1518
|
+
if (urls?.URL?.length) metadata.web = urls.URL.map(parseUrlItem);
|
|
1519
|
+
const credits = root.Credits;
|
|
1520
|
+
if (credits?.Credit?.length) {
|
|
1521
|
+
const list = [];
|
|
1522
|
+
for (const credit of credits.Credit) {
|
|
1523
|
+
const creator = parseResourceItem(credit.Creator ?? "");
|
|
1524
|
+
const name = resourceName(creator);
|
|
1525
|
+
const creatorId = resourceId(creator);
|
|
1526
|
+
const roleItems = credit.Roles?.Role?.length ? credit.Roles.Role : ["Unknown"];
|
|
1527
|
+
for (const roleItem of roleItems) {
|
|
1528
|
+
const role = parseResourceItem(roleItem);
|
|
1529
|
+
list.push({
|
|
1530
|
+
name,
|
|
1531
|
+
role: resourceName(role),
|
|
1532
|
+
creatorId,
|
|
1533
|
+
roleId: resourceId(role)
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
metadata.credits = list;
|
|
1538
|
+
}
|
|
1539
|
+
if (root.LastModified !== void 0) metadata.lastModified = String(root.LastModified);
|
|
1540
|
+
const extra = {};
|
|
1541
|
+
for (const [key, value] of Object.entries(root)) if (!KNOWN_ROOT_KEYS.has(key)) extra[key] = value;
|
|
1542
|
+
if (Object.keys(extra).length) metadata.metronInfoExtra = extra;
|
|
1543
|
+
return metadata;
|
|
1544
|
+
}
|
|
1545
|
+
//#endregion
|
|
1546
|
+
//#region src/metadata/schemaVersions.ts
|
|
1547
|
+
/**
|
|
1548
|
+
* Update these when the XSDs in `schemas/` are replaced with a newer
|
|
1549
|
+
* release. Every place that needs to read a bundled schema file (tests,
|
|
1550
|
+
* dev tooling, runtime validation) derives its filename from here instead
|
|
1551
|
+
* of hardcoding a version number.
|
|
1552
|
+
*/
|
|
1553
|
+
const SCHEMA_VERSIONS = {
|
|
1554
|
+
ComicInfo: "2.1",
|
|
1555
|
+
MetronInfo: "1.1"
|
|
1556
|
+
};
|
|
1557
|
+
/**
|
|
1558
|
+
* Walks up from this module's location to find the package root (the
|
|
1559
|
+
* directory containing `schemas/`). This module runs both from `src/`
|
|
1560
|
+
* (tests, ts-node) and from the single-file bundle in `dist/` — those sit
|
|
1561
|
+
* at different depths relative to the package root, so the depth can't be
|
|
1562
|
+
* hardcoded.
|
|
1563
|
+
*/
|
|
1564
|
+
function findPackageRoot() {
|
|
1565
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
1566
|
+
for (let i = 0; i < 5; i++) {
|
|
1567
|
+
if (existsSync(join(dir, "schemas"))) return dir;
|
|
1568
|
+
dir = join(dir, "..");
|
|
1569
|
+
}
|
|
1570
|
+
throw new Error("Could not locate the \"schemas\" directory relative to the package root.");
|
|
1571
|
+
}
|
|
1572
|
+
/** Absolute path to the bundled XSD for `schema`, e.g. ".../schemas/ComicInfo v2.1.xsd". */
|
|
1573
|
+
function schemaFilePath(schema) {
|
|
1574
|
+
return join(findPackageRoot(), "schemas", `${schema} v${SCHEMA_VERSIONS[schema]}.xsd`);
|
|
1575
|
+
}
|
|
1576
|
+
//#endregion
|
|
1577
|
+
//#region src/metadata/validate.ts
|
|
1578
|
+
const schemaCache = /* @__PURE__ */ new Map();
|
|
1579
|
+
function loadSchema(schema) {
|
|
1580
|
+
const cached = schemaCache.get(schema);
|
|
1581
|
+
if (cached !== void 0) return cached;
|
|
1582
|
+
const xsd = readFileSync(schemaFilePath(schema), "utf8").replace(/<xs:assert\b[^>]*\/>\n?/g, "");
|
|
1583
|
+
schemaCache.set(schema, xsd);
|
|
1584
|
+
return xsd;
|
|
1585
|
+
}
|
|
1586
|
+
/**
|
|
1587
|
+
* Validates an XML document against the bundled ComicInfo.xml or
|
|
1588
|
+
* MetronInfo.xml XSD. Returns `{ valid: true, issues: [] }` when the
|
|
1589
|
+
* document conforms, or `{ valid: false, issues }` with one entry per
|
|
1590
|
+
* schema violation (element/attribute name and line number, where
|
|
1591
|
+
* available) otherwise.
|
|
1592
|
+
*/
|
|
1593
|
+
async function validateMetadataXml(xml, schema) {
|
|
1594
|
+
const result = await validateXML({
|
|
1595
|
+
xml: xml.toString(),
|
|
1596
|
+
schema: loadSchema(schema)
|
|
1597
|
+
});
|
|
1598
|
+
return {
|
|
1599
|
+
valid: result.valid,
|
|
1600
|
+
issues: result.errors.map((error) => ({
|
|
1601
|
+
message: error.message,
|
|
1602
|
+
line: error.loc?.lineNumber
|
|
1603
|
+
}))
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
//#endregion
|
|
1607
|
+
//#region src/metadata/index.ts
|
|
1608
|
+
var metadata_exports = /* @__PURE__ */ __exportAll({
|
|
1609
|
+
COMIC_INFO_AGE_RATING_VALUES: () => COMIC_INFO_AGE_RATING_VALUES,
|
|
1610
|
+
COMIC_INFO_CREDIT_ROLES: () => COMIC_INFO_CREDIT_ROLES,
|
|
1611
|
+
COMIC_INFO_MANGA_VALUES: () => COMIC_INFO_MANGA_VALUES,
|
|
1612
|
+
COMIC_INFO_PAGE_TYPE_VALUES: () => COMIC_INFO_PAGE_TYPE_VALUES,
|
|
1613
|
+
COMIC_INFO_YES_NO_VALUES: () => COMIC_INFO_YES_NO_VALUES,
|
|
1614
|
+
METRON_AGE_RATING_VALUES: () => METRON_AGE_RATING_VALUES,
|
|
1615
|
+
METRON_FORMAT_VALUES: () => METRON_FORMAT_VALUES,
|
|
1616
|
+
METRON_INFORMATION_SOURCE_VALUES: () => METRON_INFORMATION_SOURCE_VALUES,
|
|
1617
|
+
METRON_ROLE_VALUES: () => METRON_ROLE_VALUES,
|
|
1618
|
+
addMetadataToArchive: () => addMetadataToArchive,
|
|
1619
|
+
comicInfoXmlToMetadata: () => comicInfoXmlToMetadata,
|
|
1620
|
+
hasComicMetadata: () => hasComicMetadata,
|
|
1621
|
+
joinCommaList: () => joinCommaList,
|
|
1622
|
+
joinResourceNames: () => joinResourceNames,
|
|
1623
|
+
metadataToComicInfoXml: () => metadataToComicInfoXml,
|
|
1624
|
+
metadataToMetronInfoXml: () => metadataToMetronInfoXml,
|
|
1625
|
+
metadataToXml: () => metadataToXml,
|
|
1626
|
+
metronInfoXmlToMetadata: () => metronInfoXmlToMetadata,
|
|
1627
|
+
readArchiveMetadata: () => readArchiveMetadata,
|
|
1628
|
+
resourceId: () => resourceId,
|
|
1629
|
+
resourceName: () => resourceName,
|
|
1630
|
+
splitCommaList: () => splitCommaList,
|
|
1631
|
+
validateMetadataXml: () => validateMetadataXml,
|
|
1632
|
+
xmlToMetadata: () => xmlToMetadata
|
|
1633
|
+
});
|
|
1634
|
+
const FILE_NAMES = {
|
|
1635
|
+
ComicInfo: "ComicInfo.xml",
|
|
1636
|
+
MetronInfo: "MetronInfo.xml"
|
|
1637
|
+
};
|
|
1638
|
+
function metadataToXml(metadata, schema) {
|
|
1639
|
+
return schema === "ComicInfo" ? metadataToComicInfoXml(metadata) : metadataToMetronInfoXml(metadata);
|
|
1640
|
+
}
|
|
1641
|
+
function xmlToMetadata(xml, schema) {
|
|
1642
|
+
return schema === "ComicInfo" ? comicInfoXmlToMetadata(xml) : metronInfoXmlToMetadata(xml);
|
|
1643
|
+
}
|
|
1644
|
+
async function hasComicMetadata(input) {
|
|
1645
|
+
const type = await detectArchiveType(input);
|
|
1646
|
+
if (type === "asar") {
|
|
1647
|
+
const { header } = await parseAsarHeader((start, end) => readInputRange(input, start, end));
|
|
1648
|
+
if (hasRootFile(header, "ComicInfo.xml")) return {
|
|
1649
|
+
present: true,
|
|
1650
|
+
schema: "ComicInfo",
|
|
1651
|
+
path: "ComicInfo.xml"
|
|
1652
|
+
};
|
|
1653
|
+
if (hasRootFile(header, "MetronInfo.xml")) return {
|
|
1654
|
+
present: true,
|
|
1655
|
+
schema: "MetronInfo",
|
|
1656
|
+
path: "MetronInfo.xml"
|
|
1657
|
+
};
|
|
1658
|
+
return { present: false };
|
|
1659
|
+
}
|
|
1660
|
+
const adapter = getAdapter(type);
|
|
1661
|
+
for await (const entry of adapter.listEntries(input)) {
|
|
1662
|
+
const base = entry.path.split("/").pop();
|
|
1663
|
+
if (base === "ComicInfo.xml") return {
|
|
1664
|
+
present: true,
|
|
1665
|
+
schema: "ComicInfo",
|
|
1666
|
+
path: entry.path
|
|
1667
|
+
};
|
|
1668
|
+
if (base === "MetronInfo.xml") return {
|
|
1669
|
+
present: true,
|
|
1670
|
+
schema: "MetronInfo",
|
|
1671
|
+
path: entry.path
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
return { present: false };
|
|
1675
|
+
}
|
|
1676
|
+
async function readArchiveMetadata(input) {
|
|
1677
|
+
const found = await hasComicMetadata(input);
|
|
1678
|
+
if (!found.present || !found.schema || !found.path) return null;
|
|
1679
|
+
const adapter = getAdapter(await detectArchiveType(input));
|
|
1680
|
+
for await (const entry of adapter.listEntries(input)) if (entry.path === found.path) {
|
|
1681
|
+
const buffer = await streamToBuffer(entry.openReadStream());
|
|
1682
|
+
return {
|
|
1683
|
+
schema: found.schema,
|
|
1684
|
+
metadata: xmlToMetadata(buffer, found.schema)
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
return null;
|
|
1688
|
+
}
|
|
1689
|
+
async function addMetadataToArchive(input, metadata, schema, options = {}) {
|
|
1690
|
+
const adapter = getAdapter(await detectArchiveType(input));
|
|
1691
|
+
const fileName = FILE_NAMES[schema];
|
|
1692
|
+
const xmlBuffer = Buffer.from(metadataToXml(metadata, schema), "utf8");
|
|
1693
|
+
async function* entries() {
|
|
1694
|
+
let replaced = false;
|
|
1695
|
+
for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) {
|
|
1696
|
+
if (entry.path.split("/").pop() === fileName) {
|
|
1697
|
+
if (!options.overwrite) throw new ArchiveFormatError(`Archive already contains ${fileName}; pass { overwrite: true } to replace it.`);
|
|
1698
|
+
replaced = true;
|
|
1699
|
+
yield {
|
|
1700
|
+
path: entry.path,
|
|
1701
|
+
size: xmlBuffer.length,
|
|
1702
|
+
content: Readable.from(xmlBuffer)
|
|
1703
|
+
};
|
|
1704
|
+
continue;
|
|
1705
|
+
}
|
|
1706
|
+
yield {
|
|
1707
|
+
path: entry.path,
|
|
1708
|
+
size: entry.size,
|
|
1709
|
+
content: entry.openReadStream()
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
if (!replaced) yield {
|
|
1713
|
+
path: fileName,
|
|
1714
|
+
size: xmlBuffer.length,
|
|
1715
|
+
content: Readable.from(xmlBuffer)
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
return withOutput(options.output, (destination) => adapter.write(entries(), destination, { tempDir: options.tempDir }));
|
|
1719
|
+
}
|
|
1720
|
+
//#endregion
|
|
1721
|
+
//#region src/images/phash.ts
|
|
1722
|
+
const HASH_SIZE = 32;
|
|
1723
|
+
const BLOCK_SIZE = 8;
|
|
1724
|
+
function dct1d(vector) {
|
|
1725
|
+
const n = vector.length;
|
|
1726
|
+
const result = Array.from({ length: n }, () => 0);
|
|
1727
|
+
for (let k = 0; k < n; k++) {
|
|
1728
|
+
let sum = 0;
|
|
1729
|
+
for (let i = 0; i < n; i++) sum += vector[i] * Math.cos(Math.PI / n * (i + .5) * k);
|
|
1730
|
+
result[k] = sum;
|
|
1731
|
+
}
|
|
1732
|
+
return result;
|
|
1733
|
+
}
|
|
1734
|
+
function dct2d(matrix) {
|
|
1735
|
+
const size = matrix.length;
|
|
1736
|
+
const rowsTransformed = matrix.map(dct1d);
|
|
1737
|
+
const result = Array.from({ length: size }, () => Array.from({ length: size }, () => 0));
|
|
1738
|
+
for (let col = 0; col < size; col++) {
|
|
1739
|
+
const transformed = dct1d(rowsTransformed.map((row) => row[col]));
|
|
1740
|
+
for (let row = 0; row < size; row++) result[row][col] = transformed[row];
|
|
1741
|
+
}
|
|
1742
|
+
return result;
|
|
1743
|
+
}
|
|
1744
|
+
function median(values) {
|
|
1745
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
1746
|
+
const mid = Math.floor(sorted.length / 2);
|
|
1747
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
1748
|
+
}
|
|
1749
|
+
/**
|
|
1750
|
+
* Standard 8x8 DCT perceptual hash: resize to 32x32 greyscale, run a 2D
|
|
1751
|
+
* DCT-II, threshold the top-left 8x8 block against the median of that block
|
|
1752
|
+
* (excluding the DC term at [0][0] from the median calculation, though it is
|
|
1753
|
+
* still included positionally as bit 0). Returned as a bigint since JS
|
|
1754
|
+
* `Number` cannot losslessly represent all 64-bit patterns.
|
|
1755
|
+
*/
|
|
1756
|
+
async function computeImagePHash(image) {
|
|
1757
|
+
const pixels = await sharp(image).resize(HASH_SIZE, HASH_SIZE, { fit: "fill" }).greyscale().raw().toBuffer();
|
|
1758
|
+
const matrix = [];
|
|
1759
|
+
for (let y = 0; y < HASH_SIZE; y++) {
|
|
1760
|
+
const row = [];
|
|
1761
|
+
for (let x = 0; x < HASH_SIZE; x++) row.push(pixels[y * HASH_SIZE + x]);
|
|
1762
|
+
matrix.push(row);
|
|
1763
|
+
}
|
|
1764
|
+
const dct = dct2d(matrix);
|
|
1765
|
+
const block = [];
|
|
1766
|
+
for (let y = 0; y < BLOCK_SIZE; y++) for (let x = 0; x < BLOCK_SIZE; x++) block.push(dct[y][x]);
|
|
1767
|
+
const threshold = median(block.slice(1));
|
|
1768
|
+
let hash = 0n;
|
|
1769
|
+
for (const value of block) hash = hash << 1n | (value > threshold ? 1n : 0n);
|
|
1770
|
+
return hash;
|
|
1771
|
+
}
|
|
1772
|
+
function phashToHex(hash) {
|
|
1773
|
+
return hash.toString(16).padStart(16, "0");
|
|
1774
|
+
}
|
|
1775
|
+
function hammingDistance(a, b) {
|
|
1776
|
+
let diff = a ^ b;
|
|
1777
|
+
let count = 0;
|
|
1778
|
+
while (diff > 0n) {
|
|
1779
|
+
count += Number(diff & 1n);
|
|
1780
|
+
diff >>= 1n;
|
|
1781
|
+
}
|
|
1782
|
+
return count;
|
|
1783
|
+
}
|
|
1784
|
+
async function computeArchiveImagePHash(input, entryPath) {
|
|
1785
|
+
const adapter = getAdapter(await detectArchiveType(input));
|
|
1786
|
+
for await (const entry of adapter.listEntries(input)) if (entry.path === entryPath) return computeImagePHash(await streamToBuffer(entry.openReadStream()));
|
|
1787
|
+
throw new MetadataNotFoundError(`No entry named "${entryPath}" was found in the archive.`);
|
|
1788
|
+
}
|
|
1789
|
+
//#endregion
|
|
1790
|
+
//#region src/hashing/asarContent.ts
|
|
1791
|
+
/**
|
|
1792
|
+
* Locates the byte range of an asar archive's content region (everything
|
|
1793
|
+
* after the header), so it can be hashed without the header/index — meaning
|
|
1794
|
+
* header-only changes (e.g. re-ordering the file index) never change the
|
|
1795
|
+
* content hash.
|
|
1796
|
+
*/
|
|
1797
|
+
async function locateAsarContentRegion(input, totalSize) {
|
|
1798
|
+
const { contentOffset } = await parseAsarHeader((start, end) => readInputRange(input, start, end));
|
|
1799
|
+
return {
|
|
1800
|
+
start: contentOffset,
|
|
1801
|
+
end: totalSize
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
//#endregion
|
|
1805
|
+
//#region src/hashing/sha256.ts
|
|
1806
|
+
async function hashStream(stream) {
|
|
1807
|
+
const hash = createHash("sha256");
|
|
1808
|
+
for await (const chunk of stream) hash.update(chunk);
|
|
1809
|
+
return hash.digest("hex");
|
|
1810
|
+
}
|
|
1811
|
+
async function sha256ArchiveEntry(input, entryPath) {
|
|
1812
|
+
const adapter = getAdapter(await detectArchiveType(input));
|
|
1813
|
+
for await (const entry of adapter.listEntries(input)) if (entry.path === entryPath) return hashStream(entry.openReadStream());
|
|
1814
|
+
throw new MetadataNotFoundError(`No entry named "${entryPath}" was found in the archive.`);
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* SHA256 of the whole archive file — except for asar, where only the
|
|
1818
|
+
* content region (bytes after the header) is hashed, so header/index
|
|
1819
|
+
* reordering never changes the content hash.
|
|
1820
|
+
*/
|
|
1821
|
+
async function sha256Archive(input) {
|
|
1822
|
+
if (await detectArchiveType(input) === "asar") {
|
|
1823
|
+
const { start, end } = await locateAsarContentRegion(input, await inputSize(input));
|
|
1824
|
+
return hashStream(openInputReadStream(input, {
|
|
1825
|
+
start,
|
|
1826
|
+
end
|
|
1827
|
+
}));
|
|
1828
|
+
}
|
|
1829
|
+
return hashStream(openInputReadStream(input));
|
|
1830
|
+
}
|
|
1831
|
+
//#endregion
|
|
1832
|
+
//#region src/internal/naturalSort.ts
|
|
1833
|
+
function naturalCompare(a, b) {
|
|
1834
|
+
return a.localeCompare(b, void 0, {
|
|
1835
|
+
numeric: true,
|
|
1836
|
+
sensitivity: "base"
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
//#endregion
|
|
1840
|
+
//#region src/rename.ts
|
|
1841
|
+
/**
|
|
1842
|
+
* Renames image entries to the standard `P#####` pattern in natural sort
|
|
1843
|
+
* order of their current names, leaving non-image entries (like
|
|
1844
|
+
* ComicInfo.xml/MetronInfo.xml) untouched.
|
|
1845
|
+
*
|
|
1846
|
+
* This requires two passes over the archive: `listEntries` is called once to
|
|
1847
|
+
* collect image paths (metadata only — no content is read, so no
|
|
1848
|
+
* decompression work is wasted) to compute the sort-order rename map, then
|
|
1849
|
+
* called again to stream entries out under their new names. Streaming
|
|
1850
|
+
* adapters (zip in particular) can't be "rewound" mid-read, so the second
|
|
1851
|
+
* pass re-invokes `listEntries` from the start rather than reusing entry
|
|
1852
|
+
* objects collected in the first pass.
|
|
1853
|
+
*/
|
|
1854
|
+
async function renameArchiveImagesSequentially(input, options = {}) {
|
|
1855
|
+
const adapter = getAdapter(await detectArchiveType(input));
|
|
1856
|
+
const pad = options.pad ?? 5;
|
|
1857
|
+
const start = options.start ?? 1;
|
|
1858
|
+
const imagePaths = [];
|
|
1859
|
+
for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) if (isImagePath(entry.path)) imagePaths.push(entry.path);
|
|
1860
|
+
imagePaths.sort(naturalCompare);
|
|
1861
|
+
const renameMap = /* @__PURE__ */ new Map();
|
|
1862
|
+
imagePaths.forEach((entryPath, index) => {
|
|
1863
|
+
const ext = getExtension(entryPath);
|
|
1864
|
+
renameMap.set(entryPath, `P${String(start + index).padStart(pad, "0")}.${ext}`);
|
|
1865
|
+
});
|
|
1866
|
+
async function* output() {
|
|
1867
|
+
for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) yield {
|
|
1868
|
+
path: renameMap.get(entry.path) ?? entry.path,
|
|
1869
|
+
size: entry.size,
|
|
1870
|
+
content: entry.openReadStream()
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
return withOutput(options.output, (destination) => adapter.write(output(), destination, { tempDir: options.tempDir }));
|
|
1874
|
+
}
|
|
1875
|
+
//#endregion
|
|
1876
|
+
//#region src/strip.ts
|
|
1877
|
+
async function stripNonEssentialFiles(input, options = {}) {
|
|
1878
|
+
const adapter = getAdapter(await detectArchiveType(input));
|
|
1879
|
+
const keepExtensions = new Set([
|
|
1880
|
+
...IMAGE_EXTENSIONS,
|
|
1881
|
+
"xml",
|
|
1882
|
+
...options.extraKeepExtensions ?? []
|
|
1883
|
+
].map((ext) => ext.toLowerCase()));
|
|
1884
|
+
async function* output() {
|
|
1885
|
+
for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) {
|
|
1886
|
+
if (!keepExtensions.has(getExtension(entry.path))) continue;
|
|
1887
|
+
yield {
|
|
1888
|
+
path: entry.path,
|
|
1889
|
+
size: entry.size,
|
|
1890
|
+
content: entry.openReadStream()
|
|
1891
|
+
};
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
return withOutput(options.output, (destination) => adapter.write(output(), destination, { tempDir: options.tempDir }));
|
|
1895
|
+
}
|
|
1896
|
+
//#endregion
|
|
1897
|
+
//#region src/listFiles.ts
|
|
1898
|
+
async function listArchiveFiles(input) {
|
|
1899
|
+
const adapter = getAdapter(await detectArchiveType(input));
|
|
1900
|
+
const paths = [];
|
|
1901
|
+
for await (const entry of adapter.listEntries(input)) paths.push(entry.path);
|
|
1902
|
+
return paths;
|
|
1903
|
+
}
|
|
1904
|
+
//#endregion
|
|
1905
|
+
//#region src/benchmark/timing.ts
|
|
1906
|
+
/** Runs `run` `iterations` times and returns the average duration in milliseconds. */
|
|
1907
|
+
async function averageDuration(iterations, run) {
|
|
1908
|
+
if (iterations <= 0) return 0;
|
|
1909
|
+
let total = 0;
|
|
1910
|
+
for (let i = 0; i < iterations; i++) {
|
|
1911
|
+
const start = performance.now();
|
|
1912
|
+
await run();
|
|
1913
|
+
total += performance.now() - start;
|
|
1914
|
+
}
|
|
1915
|
+
return total / iterations;
|
|
1916
|
+
}
|
|
1917
|
+
/** Times `run` once per item and returns the average duration in milliseconds. */
|
|
1918
|
+
async function averageDurationOverItems(items, run) {
|
|
1919
|
+
if (items.length === 0) return 0;
|
|
1920
|
+
let total = 0;
|
|
1921
|
+
for (const item of items) {
|
|
1922
|
+
const start = performance.now();
|
|
1923
|
+
await run(item);
|
|
1924
|
+
total += performance.now() - start;
|
|
1925
|
+
}
|
|
1926
|
+
return total / items.length;
|
|
1927
|
+
}
|
|
1928
|
+
//#endregion
|
|
1929
|
+
//#region src/benchmark/report.ts
|
|
1930
|
+
function formatBytes(bytes) {
|
|
1931
|
+
if (bytes < 1024) return `${Math.round(bytes)} B`;
|
|
1932
|
+
const units = [
|
|
1933
|
+
"KB",
|
|
1934
|
+
"MB",
|
|
1935
|
+
"GB"
|
|
1936
|
+
];
|
|
1937
|
+
let value = bytes / 1024;
|
|
1938
|
+
let unitIndex = 0;
|
|
1939
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
1940
|
+
value /= 1024;
|
|
1941
|
+
unitIndex += 1;
|
|
1942
|
+
}
|
|
1943
|
+
return `${value.toFixed(2)} ${units[unitIndex]}`;
|
|
1944
|
+
}
|
|
1945
|
+
function formatMs(ms) {
|
|
1946
|
+
return `${ms.toFixed(2)} ms`;
|
|
1947
|
+
}
|
|
1948
|
+
function topThree(variants, key) {
|
|
1949
|
+
return [...variants].sort((a, b) => key(a) - key(b)).slice(0, 3);
|
|
1950
|
+
}
|
|
1951
|
+
/** Ranks by image format rather than by variant: transfer size depends only on image format, not container choice. */
|
|
1952
|
+
function topThreeImageFormats(variants) {
|
|
1953
|
+
const seenFormats = /* @__PURE__ */ new Set();
|
|
1954
|
+
return [...variants].sort((a, b) => a.avgImageSizeBytes - b.avgImageSizeBytes).filter((variant) => {
|
|
1955
|
+
if (seenFormats.has(variant.imageFormat)) return false;
|
|
1956
|
+
seenFormats.add(variant.imageFormat);
|
|
1957
|
+
return true;
|
|
1958
|
+
}).slice(0, 3);
|
|
1959
|
+
}
|
|
1960
|
+
function renderRankedSection(title, description, ranked, format) {
|
|
1961
|
+
return `### ${title}\n\n${description}\n\n${ranked.map((variant, index) => `${index + 1}. **${variant.fileName}** (${variant.archiveType}/${variant.imageFormat}) — ${format(variant)}`).join("\n")}\n`;
|
|
1962
|
+
}
|
|
1963
|
+
function renderImageFormatRankedSection(title, description, ranked) {
|
|
1964
|
+
return `### ${title}\n\n${description}\n\n${ranked.map((variant, index) => `${index + 1}. **${variant.imageFormat}** — ${formatBytes(variant.avgImageSizeBytes)} avg. per page`).join("\n")}\n`;
|
|
1965
|
+
}
|
|
1966
|
+
function renderBenchmarkReportMarkdown(result) {
|
|
1967
|
+
const { variants } = result;
|
|
1968
|
+
const tableHeader = "| Archive | Image | File | Storage size | Avg. page size | Pages | Avg. creation time | Avg. random read time |\n| --- | --- | --- | --- | --- | --- | --- | --- |";
|
|
1969
|
+
const tableRows = variants.map((variant) => `| ${variant.archiveType} | ${variant.imageFormat} | \`${variant.fileName}\` | ${formatBytes(variant.fileSizeBytes)} | ${formatBytes(variant.avgImageSizeBytes)} | ${variant.pageCount} | ${formatMs(variant.avgCreationMs)} | ${formatMs(variant.avgSeekMs)} |`).join("\n");
|
|
1970
|
+
const readSpeed = renderRankedSection("Read speed", "Fastest average random single-file read time — best for serving individual pages on demand (e.g. a remote reader).", topThree(variants, (variant) => variant.avgSeekMs), (variant) => formatMs(variant.avgSeekMs));
|
|
1971
|
+
const storageSize = renderRankedSection("Storage size", "Smallest resulting archive on disk — best when total storage footprint is the priority.", topThree(variants, (variant) => variant.fileSizeBytes), (variant) => formatBytes(variant.fileSizeBytes));
|
|
1972
|
+
const transferSize = renderImageFormatRankedSection("Transfer size", "Smallest average individual page — best when the cost of sending a single page over the network is the priority (e.g. a client fetching one page at a time). Depends only on image format, not container choice.", topThreeImageFormats(variants));
|
|
1973
|
+
const creationSpeed = renderRankedSection("Creation speed", "Fastest average archive creation time — best when generating or converting archives on the fly.", topThree(variants, (variant) => variant.avgCreationMs), (variant) => formatMs(variant.avgCreationMs));
|
|
1974
|
+
return [
|
|
1975
|
+
"# Archive Benchmark Report",
|
|
1976
|
+
"",
|
|
1977
|
+
`- Source: \`${result.sourcePath}\``,
|
|
1978
|
+
`- Generated: ${result.generatedAt}`,
|
|
1979
|
+
`- Variants: ${variants.length}`,
|
|
1980
|
+
"",
|
|
1981
|
+
"## Results",
|
|
1982
|
+
"",
|
|
1983
|
+
tableHeader,
|
|
1984
|
+
tableRows,
|
|
1985
|
+
"",
|
|
1986
|
+
"## Summary",
|
|
1987
|
+
"",
|
|
1988
|
+
readSpeed,
|
|
1989
|
+
storageSize,
|
|
1990
|
+
transferSize,
|
|
1991
|
+
creationSpeed
|
|
1992
|
+
].join("\n");
|
|
1993
|
+
}
|
|
1994
|
+
//#endregion
|
|
1995
|
+
//#region src/benchmark/types.ts
|
|
1996
|
+
const WRITABLE_ARCHIVE_TYPES = [
|
|
1997
|
+
"zip",
|
|
1998
|
+
"tar",
|
|
1999
|
+
"asar",
|
|
2000
|
+
"7z"
|
|
2001
|
+
];
|
|
2002
|
+
const BENCHMARK_IMAGE_FORMATS = [
|
|
2003
|
+
"webp",
|
|
2004
|
+
"png",
|
|
2005
|
+
"jpg"
|
|
2006
|
+
];
|
|
2007
|
+
/** Conventional comic archive extension for each writable container format. */
|
|
2008
|
+
const ARCHIVE_TYPE_EXTENSIONS = {
|
|
2009
|
+
zip: "cbz",
|
|
2010
|
+
tar: "cbt",
|
|
2011
|
+
asar: "cbas",
|
|
2012
|
+
"7z": "cb7"
|
|
2013
|
+
};
|
|
2014
|
+
//#endregion
|
|
2015
|
+
//#region src/benchmark/benchmarkArchive.ts
|
|
2016
|
+
function timestampForDirName(date) {
|
|
2017
|
+
return date.toISOString().replace(/:/g, "-").replace(/\.\d+Z$/, "Z");
|
|
2018
|
+
}
|
|
2019
|
+
function pickRandomSamples(items, count) {
|
|
2020
|
+
if (items.length === 0) return [];
|
|
2021
|
+
const samples = [];
|
|
2022
|
+
for (let i = 0; i < count; i++) samples.push(items[Math.floor(Math.random() * items.length)]);
|
|
2023
|
+
return samples;
|
|
2024
|
+
}
|
|
2025
|
+
/**
|
|
2026
|
+
* The average byte size of one converted image, independent of which
|
|
2027
|
+
* container it ends up packaged in ("transfer size" — what a client
|
|
2028
|
+
* actually downloads to fetch a single page).
|
|
2029
|
+
*/
|
|
2030
|
+
async function averageConvertedImageSize(preConverted, imageFormat, tempDir) {
|
|
2031
|
+
const extractDir = path.join(tempDir, `preconverted-${imageFormat}`);
|
|
2032
|
+
const imagePaths = (await extractArchive(preConverted, extractDir, { tempDir })).filter((entryPath) => isImagePath(entryPath));
|
|
2033
|
+
let totalBytes = 0;
|
|
2034
|
+
for (const entryPath of imagePaths) {
|
|
2035
|
+
const { size } = await fsp.stat(path.join(extractDir, entryPath));
|
|
2036
|
+
totalBytes += size;
|
|
2037
|
+
}
|
|
2038
|
+
return totalBytes / imagePaths.length;
|
|
2039
|
+
}
|
|
2040
|
+
/**
|
|
2041
|
+
* Extracts a comic archive, validates it contains image files, then
|
|
2042
|
+
* generates one archive per (writable container format) x (page image
|
|
2043
|
+
* format) combination, benchmarks each variant's creation speed and random
|
|
2044
|
+
* single-file read speed, and writes a markdown report summarizing the
|
|
2045
|
+
* results.
|
|
2046
|
+
*
|
|
2047
|
+
* Images are re-encoded to each target format once per image format, before
|
|
2048
|
+
* any timing starts; `avgCreationMs` measures only packaging already-encoded
|
|
2049
|
+
* entries into the target container, not the image re-encoding cost.
|
|
2050
|
+
*
|
|
2051
|
+
* Throws `ArchiveFormatError` (undetectable format) or
|
|
2052
|
+
* `UnsupportedOperationError` (ACE) if `filePath` is not extractable,
|
|
2053
|
+
* `NoImagesFoundError` if the archive contains no image files, and
|
|
2054
|
+
* `RangeError` if `options.imageFormats` is empty or names an unsupported
|
|
2055
|
+
* format.
|
|
2056
|
+
*/
|
|
2057
|
+
async function benchmarkArchive(filePath, options = {}) {
|
|
2058
|
+
try {
|
|
2059
|
+
await fsp.access(filePath);
|
|
2060
|
+
} catch {
|
|
2061
|
+
throw new FilesystemAccessError(`No file exists at "${filePath}".`);
|
|
2062
|
+
}
|
|
2063
|
+
const creationIterations = Math.max(1, options.creationIterations ?? 3);
|
|
2064
|
+
const seekSamples = Math.max(1, options.seekSamples ?? 10);
|
|
2065
|
+
const imageFormats = options.imageFormats ?? BENCHMARK_IMAGE_FORMATS;
|
|
2066
|
+
if (imageFormats.length === 0) throw new RangeError("options.imageFormats must include at least one image format.");
|
|
2067
|
+
const unsupportedFormats = imageFormats.filter((format) => !BENCHMARK_IMAGE_FORMATS.includes(format));
|
|
2068
|
+
if (unsupportedFormats.length > 0) throw new RangeError(`Unsupported image format(s): ${unsupportedFormats.join(", ")}. Supported formats: ${BENCHMARK_IMAGE_FORMATS.join(", ")}.`);
|
|
2069
|
+
const tempDir = await resolveWritableTempDir(options.tempDir);
|
|
2070
|
+
try {
|
|
2071
|
+
const generatedAt = /* @__PURE__ */ new Date();
|
|
2072
|
+
const reportsRoot = options.reportsDir ?? path.join(process.cwd(), "reports");
|
|
2073
|
+
const reportDir = path.join(reportsRoot, timestampForDirName(generatedAt));
|
|
2074
|
+
const sourceDir = path.join(reportDir, "source");
|
|
2075
|
+
const archivesDir = path.join(reportDir, "archives");
|
|
2076
|
+
await fsp.mkdir(archivesDir, { recursive: true });
|
|
2077
|
+
if (!(await extractArchive(filePath, sourceDir, { tempDir })).some((entryPath) => isImagePath(entryPath))) throw new NoImagesFoundError(`Archive "${filePath}" does not contain any image files.`);
|
|
2078
|
+
const stripped = await stripNonEssentialFiles(filePath, { tempDir });
|
|
2079
|
+
const variants = [];
|
|
2080
|
+
for (const imageFormat of imageFormats) {
|
|
2081
|
+
const preConverted = await convertArchiveImages(stripped, imageFormat, {
|
|
2082
|
+
...options.image,
|
|
2083
|
+
tempDir
|
|
2084
|
+
});
|
|
2085
|
+
const avgImageSizeBytes = await averageConvertedImageSize(preConverted, imageFormat, tempDir);
|
|
2086
|
+
for (const archiveType of WRITABLE_ARCHIVE_TYPES) {
|
|
2087
|
+
const fileName = `${archiveType}-${imageFormat}.${ARCHIVE_TYPE_EXTENSIONS[archiveType]}`;
|
|
2088
|
+
const outputPath = path.join(archivesDir, fileName);
|
|
2089
|
+
const avgCreationMs = await averageDuration(creationIterations, async () => {
|
|
2090
|
+
await convertArchive(preConverted, archiveType, {
|
|
2091
|
+
tempDir,
|
|
2092
|
+
output: outputPath
|
|
2093
|
+
});
|
|
2094
|
+
});
|
|
2095
|
+
const imageEntries = (await listArchiveFiles(outputPath)).filter((entryPath) => isImagePath(entryPath));
|
|
2096
|
+
const avgSeekMs = await averageDurationOverItems(pickRandomSamples(imageEntries, seekSamples), async (entryPath) => {
|
|
2097
|
+
await sha256ArchiveEntry(outputPath, entryPath);
|
|
2098
|
+
});
|
|
2099
|
+
const { size } = await fsp.stat(outputPath);
|
|
2100
|
+
variants.push({
|
|
2101
|
+
archiveType,
|
|
2102
|
+
imageFormat,
|
|
2103
|
+
fileName,
|
|
2104
|
+
filePath: outputPath,
|
|
2105
|
+
fileSizeBytes: size,
|
|
2106
|
+
pageCount: imageEntries.length,
|
|
2107
|
+
avgImageSizeBytes,
|
|
2108
|
+
avgCreationMs,
|
|
2109
|
+
avgSeekMs
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
const result = {
|
|
2114
|
+
sourcePath: filePath,
|
|
2115
|
+
generatedAt: generatedAt.toISOString(),
|
|
2116
|
+
reportDir,
|
|
2117
|
+
reportPath: path.join(reportDir, "report.md"),
|
|
2118
|
+
sourceDir,
|
|
2119
|
+
archivesDir,
|
|
2120
|
+
variants
|
|
2121
|
+
};
|
|
2122
|
+
await fsp.writeFile(result.reportPath, renderBenchmarkReportMarkdown(result), "utf8");
|
|
2123
|
+
return result;
|
|
2124
|
+
} finally {
|
|
2125
|
+
await cleanupTempDir(tempDir);
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
//#endregion
|
|
2129
|
+
//#region src/index.ts
|
|
2130
|
+
const comicArchiveHandler = {
|
|
2131
|
+
...detect_exports,
|
|
2132
|
+
convertArchive,
|
|
2133
|
+
extractArchive,
|
|
2134
|
+
...metadata_exports,
|
|
2135
|
+
convertImageBuffer,
|
|
2136
|
+
convertArchiveImages,
|
|
2137
|
+
computeImagePHash,
|
|
2138
|
+
phashToHex,
|
|
2139
|
+
computeArchiveImagePHash,
|
|
2140
|
+
hammingDistance,
|
|
2141
|
+
IMAGE_EXTENSIONS,
|
|
2142
|
+
isImagePath,
|
|
2143
|
+
sha256ArchiveEntry,
|
|
2144
|
+
sha256Archive,
|
|
2145
|
+
renameArchiveImagesSequentially,
|
|
2146
|
+
stripNonEssentialFiles,
|
|
2147
|
+
listArchiveFiles,
|
|
2148
|
+
benchmarkArchive,
|
|
2149
|
+
renderBenchmarkReportMarkdown,
|
|
2150
|
+
WRITABLE_ARCHIVE_TYPES,
|
|
2151
|
+
BENCHMARK_IMAGE_FORMATS,
|
|
2152
|
+
ARCHIVE_TYPE_EXTENSIONS
|
|
2153
|
+
};
|
|
2154
|
+
//#endregion
|
|
2155
|
+
export { ARCHIVE_TYPE_EXTENSIONS, ArchiveFormatError, BENCHMARK_IMAGE_FORMATS, COMIC_INFO_AGE_RATING_VALUES, COMIC_INFO_CREDIT_ROLES, COMIC_INFO_MANGA_VALUES, COMIC_INFO_PAGE_TYPE_VALUES, COMIC_INFO_YES_NO_VALUES, FilesystemAccessError, IMAGE_EXTENSIONS, METRON_AGE_RATING_VALUES, METRON_FORMAT_VALUES, METRON_INFORMATION_SOURCE_VALUES, METRON_ROLE_VALUES, MetadataNotFoundError, NoImagesFoundError, SevenZipUnavailableError, UnsupportedOperationError, WRITABLE_ARCHIVE_TYPES, addMetadataToArchive, benchmarkArchive, comicInfoXmlToMetadata, computeArchiveImagePHash, computeImagePHash, convertArchive, convertArchiveImages, convertImageBuffer, comicArchiveHandler as default, detectArchiveType, extractArchive, getExtension, hammingDistance, hasComicMetadata, is7z, isAce, isAsar, isImagePath, isRar, isTar, isZip, joinCommaList, joinResourceNames, listArchiveFiles, metadataToComicInfoXml, metadataToMetronInfoXml, metadataToXml, metronInfoXmlToMetadata, phashToHex, readArchiveMetadata, renameArchiveImagesSequentially, renderBenchmarkReportMarkdown, resourceId, resourceName, sha256Archive, sha256ArchiveEntry, splitCommaList, stripNonEssentialFiles, validateMetadataXml, xmlToMetadata };
|
|
2156
|
+
|
|
2157
|
+
//# sourceMappingURL=index.mjs.map
|