@clearmist-labs/comic-archive-handler 1.3.0 → 1.4.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/README.md +1 -0
- package/dist/index.d.mts +33 -16
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +199 -95
- package/dist/index.mjs.map +1 -1
- package/docs/API.md +41 -11
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -90,6 +90,35 @@ function openInputReadStream(input, range) {
|
|
|
90
90
|
return Readable.from(slice);
|
|
91
91
|
}
|
|
92
92
|
//#endregion
|
|
93
|
+
//#region src/internal/streamUtils.ts
|
|
94
|
+
async function streamToBuffer(stream) {
|
|
95
|
+
const chunks = [];
|
|
96
|
+
for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
97
|
+
return Buffer.concat(chunks);
|
|
98
|
+
}
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/internal/collectOutput.ts
|
|
101
|
+
/**
|
|
102
|
+
* Runs `run` against a destination stream. When `output` is given (a path or
|
|
103
|
+
* a Writable), the result streams directly there and this resolves to
|
|
104
|
+
* `undefined`. Otherwise the result is collected into a Buffer for
|
|
105
|
+
* convenience.
|
|
106
|
+
*/
|
|
107
|
+
async function withOutput(output, run) {
|
|
108
|
+
if (typeof output === "string") {
|
|
109
|
+
await run(fs.createWriteStream(output));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (output) {
|
|
113
|
+
await run(output);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const pass = new PassThrough();
|
|
117
|
+
const bufferPromise = streamToBuffer(pass);
|
|
118
|
+
await run(pass);
|
|
119
|
+
return bufferPromise;
|
|
120
|
+
}
|
|
121
|
+
//#endregion
|
|
93
122
|
//#region src/internal/asarHeader.ts
|
|
94
123
|
/**
|
|
95
124
|
* Parses an asar archive's header from a byte reader.
|
|
@@ -138,10 +167,54 @@ function collectFiles(node, prefix, out) {
|
|
|
138
167
|
});
|
|
139
168
|
}
|
|
140
169
|
}
|
|
141
|
-
/**
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
170
|
+
/**
|
|
171
|
+
* Serializes a header object back to the on-disk byte layout `parseAsarHeader`
|
|
172
|
+
* decodes: the exact inverse, byte for byte (outer 8-byte pickle wrapping a
|
|
173
|
+
* uint32 `size`, then a header pickle of [4-byte ignored length][4-byte
|
|
174
|
+
* string byte-length][UTF-8 JSON][zero-padding to a 4-byte boundary]).
|
|
175
|
+
*/
|
|
176
|
+
function serializeAsarHeader(header) {
|
|
177
|
+
const jsonBytes = Buffer.from(JSON.stringify(header), "utf8");
|
|
178
|
+
const stringLength = jsonBytes.length;
|
|
179
|
+
const padding = (4 - stringLength % 4) % 4;
|
|
180
|
+
const payload = Buffer.concat([
|
|
181
|
+
uint32LE(stringLength),
|
|
182
|
+
jsonBytes,
|
|
183
|
+
Buffer.alloc(padding)
|
|
184
|
+
]);
|
|
185
|
+
const headerPickle = Buffer.concat([uint32LE(payload.length), payload]);
|
|
186
|
+
const outerPrefix = Buffer.concat([uint32LE(4), uint32LE(headerPickle.length)]);
|
|
187
|
+
return Buffer.concat([outerPrefix, headerPickle]);
|
|
188
|
+
}
|
|
189
|
+
function uint32LE(value) {
|
|
190
|
+
const buf = Buffer.alloc(4);
|
|
191
|
+
buf.writeUInt32LE(value, 0);
|
|
192
|
+
return buf;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Reads an asar archive's header, applies `mutate` to a shallow clone of it,
|
|
196
|
+
* and re-serializes the whole archive with the patched header followed by
|
|
197
|
+
* the original, byte-for-byte unchanged content region. No repackaging via
|
|
198
|
+
* `@electron/asar`'s `createPackage` needed, since file offsets are already
|
|
199
|
+
* relative to the content region's start and never need adjusting when the
|
|
200
|
+
* header's byte length changes.
|
|
201
|
+
*/
|
|
202
|
+
async function writeAsarHeaderPatch(input, mutate, options = {}) {
|
|
203
|
+
const { header, contentOffset } = await parseAsarHeader((start, end) => readInputRange(input, start, end));
|
|
204
|
+
const clone = { ...header };
|
|
205
|
+
mutate(clone);
|
|
206
|
+
const newHeaderBytes = serializeAsarHeader(clone);
|
|
207
|
+
const contentStream = openInputReadStream(input, {
|
|
208
|
+
start: contentOffset,
|
|
209
|
+
end: await inputSize(input)
|
|
210
|
+
});
|
|
211
|
+
return withOutput(options.output, (destination) => new Promise((resolve, reject) => {
|
|
212
|
+
contentStream.on("error", reject);
|
|
213
|
+
destination.on("error", reject);
|
|
214
|
+
destination.on("finish", resolve);
|
|
215
|
+
destination.write(newHeaderBytes);
|
|
216
|
+
contentStream.pipe(destination);
|
|
217
|
+
}));
|
|
145
218
|
}
|
|
146
219
|
//#endregion
|
|
147
220
|
//#region src/detect.ts
|
|
@@ -273,8 +346,8 @@ function readZip64ExtraField(extra, overflowed) {
|
|
|
273
346
|
throw new ArchiveFormatError("Not a valid zip64 archive: an oversized entry is missing its zip64 extra field.");
|
|
274
347
|
}
|
|
275
348
|
/**
|
|
276
|
-
* Parses a zip's central directory
|
|
277
|
-
* file
|
|
349
|
+
* Parses a zip's central directory, the entry index at the end of the
|
|
350
|
+
* file, into per-entry metadata (name, sizes, compression method, and the
|
|
278
351
|
* offset of its local file header). Reading this index costs a couple of
|
|
279
352
|
* small range reads regardless of archive size; it never reads or
|
|
280
353
|
* decompresses entry data itself, which is why entries can then be read in
|
|
@@ -324,7 +397,7 @@ async function parseZipCentralDirectory(input) {
|
|
|
324
397
|
}
|
|
325
398
|
/**
|
|
326
399
|
* Reads and decompresses one entry's data, using its central directory
|
|
327
|
-
* metadata to locate the bytes directly
|
|
400
|
+
* metadata to locate the bytes directly, with no scan through other entries.
|
|
328
401
|
* The local file header still has to be read first because its name/extra
|
|
329
402
|
* field lengths (which can differ from the central directory's) are what
|
|
330
403
|
* determine where the entry's actual data starts.
|
|
@@ -384,20 +457,13 @@ const zipAdapter = {
|
|
|
384
457
|
}
|
|
385
458
|
};
|
|
386
459
|
//#endregion
|
|
387
|
-
//#region src/internal/streamUtils.ts
|
|
388
|
-
async function streamToBuffer(stream) {
|
|
389
|
-
const chunks = [];
|
|
390
|
-
for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
391
|
-
return Buffer.concat(chunks);
|
|
392
|
-
}
|
|
393
|
-
//#endregion
|
|
394
460
|
//#region src/archive/tar.ts
|
|
395
461
|
/**
|
|
396
462
|
* The tar-stream v3 package is built on `streamx`, not Node's native streams;
|
|
397
463
|
* its `extract()` result is directly async-iterable over per-entry streams, and
|
|
398
464
|
* both directions interop with Node streams via `.pipe()`. Each entry's
|
|
399
|
-
* content is buffered fully before being yielded
|
|
400
|
-
* entry's size (one comic page), not the whole archive
|
|
465
|
+
* content is buffered fully before being yielded, bounded by a single
|
|
466
|
+
* entry's size (one comic page), not the whole archive, since the
|
|
401
467
|
* underlying iterator only advances once the current entry is drained,
|
|
402
468
|
* which doesn't reconcile with this package's lazily-pulled
|
|
403
469
|
* `ArchiveEntry.openReadStream()` contract.
|
|
@@ -535,7 +601,7 @@ function resolveSafeEntryPath(root, entryPath) {
|
|
|
535
601
|
/**
|
|
536
602
|
* Reads bypass @electron/asar's extract API entirely: the header is parsed
|
|
537
603
|
* directly (src/internal/asarHeader.ts) to get each file's byte offset/size,
|
|
538
|
-
* then entries are read via direct byte-range reads against the archive
|
|
604
|
+
* then entries are read via direct byte-range reads against the archive;
|
|
539
605
|
* no extraction step, no temp directory, for reads.
|
|
540
606
|
*
|
|
541
607
|
* Writes still require a staging directory, since @electron/asar's
|
|
@@ -612,8 +678,8 @@ async function ensureBinaryAvailable() {
|
|
|
612
678
|
* going through the `node-7z` wrapper: that package has no way to set the
|
|
613
679
|
* child process's working directory, which is required here to get archive
|
|
614
680
|
* entries stored with paths relative to the staging directory (rather than
|
|
615
|
-
* either leaking absolute host paths
|
|
616
|
-
* implementation testing
|
|
681
|
+
* either leaking absolute host paths or, as discovered during
|
|
682
|
+
* implementation testing, silently operating against this process's actual
|
|
617
683
|
* cwd instead of the intended staging directory).
|
|
618
684
|
*/
|
|
619
685
|
function run7z(binPath, args, options) {
|
|
@@ -741,28 +807,6 @@ function getAdapter(type) {
|
|
|
741
807
|
return adapter;
|
|
742
808
|
}
|
|
743
809
|
//#endregion
|
|
744
|
-
//#region src/internal/collectOutput.ts
|
|
745
|
-
/**
|
|
746
|
-
* Runs `run` against a destination stream. When `output` is given (a path or
|
|
747
|
-
* a Writable), the result streams directly there and this resolves to
|
|
748
|
-
* `undefined`. Otherwise the result is collected into a Buffer for
|
|
749
|
-
* convenience.
|
|
750
|
-
*/
|
|
751
|
-
async function withOutput(output, run) {
|
|
752
|
-
if (typeof output === "string") {
|
|
753
|
-
await run(fs.createWriteStream(output));
|
|
754
|
-
return;
|
|
755
|
-
}
|
|
756
|
-
if (output) {
|
|
757
|
-
await run(output);
|
|
758
|
-
return;
|
|
759
|
-
}
|
|
760
|
-
const pass = new PassThrough();
|
|
761
|
-
const bufferPromise = streamToBuffer(pass);
|
|
762
|
-
await run(pass);
|
|
763
|
-
return bufferPromise;
|
|
764
|
-
}
|
|
765
|
-
//#endregion
|
|
766
810
|
//#region src/images/isImage.ts
|
|
767
811
|
const IMAGE_EXTENSIONS = [
|
|
768
812
|
"jpg",
|
|
@@ -877,18 +921,40 @@ async function extractArchive(input, destDir, options = {}) {
|
|
|
877
921
|
return written;
|
|
878
922
|
}
|
|
879
923
|
//#endregion
|
|
924
|
+
//#region src/removeArchiveEntry.ts
|
|
925
|
+
async function removeArchiveEntry(input, entryPath, options = {}) {
|
|
926
|
+
const adapter = getAdapter(await detectArchiveType(input));
|
|
927
|
+
let found = false;
|
|
928
|
+
for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) if (entry.path === entryPath) {
|
|
929
|
+
found = true;
|
|
930
|
+
break;
|
|
931
|
+
}
|
|
932
|
+
if (!found) throw new MetadataNotFoundError(`No entry named "${entryPath}" was found in the archive.`);
|
|
933
|
+
async function* output() {
|
|
934
|
+
for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) {
|
|
935
|
+
if (entry.path === entryPath) continue;
|
|
936
|
+
yield {
|
|
937
|
+
path: entry.path,
|
|
938
|
+
size: entry.size,
|
|
939
|
+
content: entry.openReadStream()
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
return withOutput(options.output, (destination) => adapter.write(output(), destination, { tempDir: options.tempDir }));
|
|
944
|
+
}
|
|
945
|
+
//#endregion
|
|
880
946
|
//#region src/metadata/schema.ts
|
|
881
947
|
/**
|
|
882
948
|
* Authoritative field-mapping table between the canonical ComicMetadata
|
|
883
949
|
* shape and each external XML schema (ComicInfo.xml v2.1, MetronInfo.xml
|
|
884
|
-
* v1.1
|
|
950
|
+
* v1.1. See `schemas/`). Conversion is intentionally lossy in both
|
|
885
951
|
* directions; a field with no equivalent in the target schema is dropped
|
|
886
952
|
* unless the source and target schema happen to be the same one (in which
|
|
887
953
|
* case `comicInfoExtra`/`metronInfoExtra` round-trips it).
|
|
888
954
|
*
|
|
889
955
|
* | Canonical field | ComicInfo.xml | MetronInfo.xml |
|
|
890
956
|
* |-------------------------|-----------------------------------------------------|------------------------------------------------|
|
|
891
|
-
* | title | Title | (no equivalent
|
|
957
|
+
* | title | Title | (no equivalent; dropped) |
|
|
892
958
|
* | series | Series | Series > Name |
|
|
893
959
|
* | seriesSort | (no equivalent) | Series > SortName |
|
|
894
960
|
* | seriesId / seriesLang | (no equivalent) | Series `id`/`lang` attributes |
|
|
@@ -896,18 +962,18 @@ async function extractArchive(input, destDir, options = {}) {
|
|
|
896
962
|
* | seriesIssueCount | (no equivalent) | Series > IssueCount |
|
|
897
963
|
* | seriesVolumeCount | (no equivalent) | Series > VolumeCount |
|
|
898
964
|
* | seriesAlternativeNames | (no equivalent) | Series > AlternativeNames > AlternativeName[] |
|
|
899
|
-
* | seriesGroup | SeriesGroup | (no equivalent
|
|
965
|
+
* | seriesGroup | SeriesGroup | (no equivalent; dropped) |
|
|
900
966
|
* | volume | Volume | Series > Volume |
|
|
901
967
|
* | number | Number | Number |
|
|
902
968
|
* | alternateNumber | AlternateNumber | AlternativeNumber |
|
|
903
|
-
* | alternateSeries | AlternateSeries | (no equivalent
|
|
904
|
-
* | alternateCount | AlternateCount | (no equivalent
|
|
969
|
+
* | alternateSeries | AlternateSeries | (no equivalent; dropped) |
|
|
970
|
+
* | alternateCount | AlternateCount | (no equivalent; dropped) |
|
|
905
971
|
* | count | Count | (no equivalent) |
|
|
906
972
|
* | pageCount | PageCount | PageCount |
|
|
907
973
|
* | summary | Summary | Summary |
|
|
908
974
|
* | notes | Notes | Notes |
|
|
909
|
-
* | review | Review | (no equivalent
|
|
910
|
-
* | scanInformation | ScanInformation | (no equivalent
|
|
975
|
+
* | review | Review | (no equivalent; dropped) |
|
|
976
|
+
* | scanInformation | ScanInformation | (no equivalent; dropped) |
|
|
911
977
|
* | publisher / publisherId | Publisher | Publisher > Name / `id` attribute |
|
|
912
978
|
* | imprint / imprintId | Imprint | Publisher > Imprint / `id` attribute |
|
|
913
979
|
* | collectionTitle | (no equivalent) | CollectionTitle |
|
|
@@ -925,7 +991,7 @@ async function extractArchive(input, destDir, options = {}) {
|
|
|
925
991
|
* | teams | Teams (comma-separated string) | Teams > Team[] (`id` attribute) |
|
|
926
992
|
* | locations | Locations (comma-separated string) | Locations > Location[] (`id` attribute) |
|
|
927
993
|
* | storyArcs | StoryArc / StoryArcNumber (parallel comma lists) | Arcs > Arc[] (Name + Number + `id`) |
|
|
928
|
-
* | mainCharacterOrTeam | MainCharacterOrTeam | (no equivalent
|
|
994
|
+
* | mainCharacterOrTeam | MainCharacterOrTeam | (no equivalent; dropped) |
|
|
929
995
|
* | stories | (no equivalent) | Stories > Story[] |
|
|
930
996
|
* | reprints | (no equivalent) | Reprints > Reprint[] |
|
|
931
997
|
* | universes | (no equivalent) | Universes > Universe[] |
|
|
@@ -935,9 +1001,9 @@ async function extractArchive(input, destDir, options = {}) {
|
|
|
935
1001
|
* | web | Web (single URL) | URLs > URL[] (`primary` attribute) |
|
|
936
1002
|
* | gtin | GTIN | GTIN > ISBN |
|
|
937
1003
|
* | gtinUpc | (no equivalent) | GTIN > UPC |
|
|
938
|
-
* | blackAndWhite | BlackAndWhite (Yes/No) | (no equivalent
|
|
939
|
-
* | manga | Manga | (no equivalent
|
|
940
|
-
* | pages | Pages > Page[] (Image/Type/DoublePage/... attrs) | (no equivalent
|
|
1004
|
+
* | blackAndWhite | BlackAndWhite (Yes/No) | (no equivalent; ComicInfo-only) |
|
|
1005
|
+
* | manga | Manga | (no equivalent; ComicInfo-only) |
|
|
1006
|
+
* | pages | Pages > Page[] (Image/Type/DoublePage/... attrs) | (no equivalent; ComicInfo-only) |
|
|
941
1007
|
*/
|
|
942
1008
|
const COMIC_INFO_CREDIT_ROLES = [
|
|
943
1009
|
"Writer",
|
|
@@ -1641,7 +1707,7 @@ const SCHEMA_VERSIONS = {
|
|
|
1641
1707
|
/**
|
|
1642
1708
|
* Walks up from this module's location to find the package root (the
|
|
1643
1709
|
* directory containing `schemas/`). This module runs both from `src/`
|
|
1644
|
-
* (tests, ts-node) and from the single-file bundle in `dist
|
|
1710
|
+
* (tests, ts-node) and from the single-file bundle in `dist/`; those sit
|
|
1645
1711
|
* at different depths relative to the package root, so the depth can't be
|
|
1646
1712
|
* hardcoded.
|
|
1647
1713
|
*/
|
|
@@ -1709,6 +1775,7 @@ var metadata_exports = /* @__PURE__ */ __exportAll({
|
|
|
1709
1775
|
metadataToXml: () => metadataToXml,
|
|
1710
1776
|
metronInfoXmlToMetadata: () => metronInfoXmlToMetadata,
|
|
1711
1777
|
readArchiveMetadata: () => readArchiveMetadata,
|
|
1778
|
+
removeComicMetadata: () => removeComicMetadata,
|
|
1712
1779
|
resourceId: () => resourceId,
|
|
1713
1780
|
resourceName: () => resourceName,
|
|
1714
1781
|
splitCommaList: () => splitCommaList,
|
|
@@ -1725,21 +1792,30 @@ function metadataToXml(metadata, schema) {
|
|
|
1725
1792
|
function xmlToMetadata(xml, schema) {
|
|
1726
1793
|
return schema === "ComicInfo" ? comicInfoXmlToMetadata(xml) : metronInfoXmlToMetadata(xml);
|
|
1727
1794
|
}
|
|
1795
|
+
/** The archive's header `comicMetadata` object, if it's an asar archive with one; `{}` otherwise. */
|
|
1796
|
+
async function readAsarComicMetadataHeader(input) {
|
|
1797
|
+
const { header } = await parseAsarHeader((start, end) => readInputRange(input, start, end));
|
|
1798
|
+
return header.comicMetadata ?? {};
|
|
1799
|
+
}
|
|
1728
1800
|
async function hasComicMetadata(input) {
|
|
1729
1801
|
const type = await detectArchiveType(input);
|
|
1730
1802
|
if (type === "asar") {
|
|
1731
|
-
const
|
|
1732
|
-
|
|
1803
|
+
const comicMetadata = await readAsarComicMetadataHeader(input);
|
|
1804
|
+
const bothPresent = Boolean(comicMetadata.ComicInfo && comicMetadata.MetronInfo);
|
|
1805
|
+
if (comicMetadata.ComicInfo) return {
|
|
1733
1806
|
present: true,
|
|
1734
1807
|
schema: "ComicInfo",
|
|
1735
|
-
|
|
1808
|
+
bothPresent
|
|
1736
1809
|
};
|
|
1737
|
-
if (
|
|
1810
|
+
if (comicMetadata.MetronInfo) return {
|
|
1738
1811
|
present: true,
|
|
1739
1812
|
schema: "MetronInfo",
|
|
1740
|
-
|
|
1813
|
+
bothPresent
|
|
1814
|
+
};
|
|
1815
|
+
return {
|
|
1816
|
+
present: false,
|
|
1817
|
+
bothPresent: false
|
|
1741
1818
|
};
|
|
1742
|
-
return { present: false };
|
|
1743
1819
|
}
|
|
1744
1820
|
const adapter = getAdapter(type);
|
|
1745
1821
|
for await (const entry of adapter.listEntries(input)) {
|
|
@@ -1757,10 +1833,35 @@ async function hasComicMetadata(input) {
|
|
|
1757
1833
|
}
|
|
1758
1834
|
return { present: false };
|
|
1759
1835
|
}
|
|
1760
|
-
|
|
1836
|
+
/**
|
|
1837
|
+
* Reads embedded comic metadata. With no `schema`, returns whichever schema
|
|
1838
|
+
* `hasComicMetadata` finds first (asar prefers ComicInfo when both are
|
|
1839
|
+
* present). Pass `schema` to read that specific one regardless of which is
|
|
1840
|
+
* preferred. The only way to read a non-preferred schema back out of an
|
|
1841
|
+
* asar archive that has both, since its metadata isn't addressable by path.
|
|
1842
|
+
*/
|
|
1843
|
+
async function readArchiveMetadata(input, schema) {
|
|
1844
|
+
const type = await detectArchiveType(input);
|
|
1845
|
+
if (type === "asar") {
|
|
1846
|
+
const comicMetadata = await readAsarComicMetadataHeader(input);
|
|
1847
|
+
const resolvedSchema = schema ?? (comicMetadata.ComicInfo ? "ComicInfo" : comicMetadata.MetronInfo ? "MetronInfo" : void 0);
|
|
1848
|
+
const metadata = resolvedSchema && comicMetadata[resolvedSchema];
|
|
1849
|
+
return resolvedSchema && metadata ? {
|
|
1850
|
+
schema: resolvedSchema,
|
|
1851
|
+
metadata
|
|
1852
|
+
} : null;
|
|
1853
|
+
}
|
|
1854
|
+
const adapter = getAdapter(type);
|
|
1855
|
+
if (schema) {
|
|
1856
|
+
const fileName = FILE_NAMES[schema];
|
|
1857
|
+
for await (const entry of adapter.listEntries(input)) if (entry.path.split("/").pop() === fileName) return {
|
|
1858
|
+
schema,
|
|
1859
|
+
metadata: xmlToMetadata(await streamToBuffer(entry.openReadStream()), schema)
|
|
1860
|
+
};
|
|
1861
|
+
return null;
|
|
1862
|
+
}
|
|
1761
1863
|
const found = await hasComicMetadata(input);
|
|
1762
1864
|
if (!found.present || !found.schema || !found.path) return null;
|
|
1763
|
-
const adapter = getAdapter(await detectArchiveType(input));
|
|
1764
1865
|
for await (const entry of adapter.listEntries(input)) if (entry.path === found.path) {
|
|
1765
1866
|
const buffer = await streamToBuffer(entry.openReadStream());
|
|
1766
1867
|
return {
|
|
@@ -1771,7 +1872,14 @@ async function readArchiveMetadata(input) {
|
|
|
1771
1872
|
return null;
|
|
1772
1873
|
}
|
|
1773
1874
|
async function addMetadataToArchive(input, metadata, schema, options = {}) {
|
|
1774
|
-
const
|
|
1875
|
+
const type = await detectArchiveType(input);
|
|
1876
|
+
if (type === "asar") return writeAsarHeaderPatch(input, (header) => {
|
|
1877
|
+
const record = header;
|
|
1878
|
+
record.comicMetadata ??= {};
|
|
1879
|
+
if (!options.overwrite && record.comicMetadata[schema]) throw new ArchiveFormatError(`Archive already contains ${schema} metadata; pass { overwrite: true } to replace it.`);
|
|
1880
|
+
record.comicMetadata[schema] = metadata;
|
|
1881
|
+
}, options);
|
|
1882
|
+
const adapter = getAdapter(type);
|
|
1775
1883
|
const fileName = FILE_NAMES[schema];
|
|
1776
1884
|
const xmlBuffer = Buffer.from(metadataToXml(metadata, schema), "utf8");
|
|
1777
1885
|
async function* entries() {
|
|
@@ -1801,6 +1909,24 @@ async function addMetadataToArchive(input, metadata, schema, options = {}) {
|
|
|
1801
1909
|
}
|
|
1802
1910
|
return withOutput(options.output, (destination) => adapter.write(entries(), destination, { tempDir: options.tempDir }));
|
|
1803
1911
|
}
|
|
1912
|
+
/**
|
|
1913
|
+
* Removes the embedded `{schema}` metadata, if present; an asar header key
|
|
1914
|
+
* removal; or the matching `{schema}.xml` entry for every other format.
|
|
1915
|
+
* Throws `ArchiveFormatError` if the archive has no such metadata; a caller
|
|
1916
|
+
* that wants a no-op instead should check `hasComicMetadata` first.
|
|
1917
|
+
*/
|
|
1918
|
+
async function removeComicMetadata(input, schema, options = {}) {
|
|
1919
|
+
const type = await detectArchiveType(input);
|
|
1920
|
+
if (type === "asar") return writeAsarHeaderPatch(input, (header) => {
|
|
1921
|
+
const comicMetadata = header.comicMetadata;
|
|
1922
|
+
if (!comicMetadata?.[schema]) throw new ArchiveFormatError(`Archive does not contain ${schema} metadata.`);
|
|
1923
|
+
delete comicMetadata[schema];
|
|
1924
|
+
}, options);
|
|
1925
|
+
const adapter = getAdapter(type);
|
|
1926
|
+
const fileName = FILE_NAMES[schema];
|
|
1927
|
+
for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) if (entry.path.split("/").pop() === fileName) return removeArchiveEntry(input, entry.path, options);
|
|
1928
|
+
throw new ArchiveFormatError(`Archive does not contain ${fileName}.`);
|
|
1929
|
+
}
|
|
1804
1930
|
//#endregion
|
|
1805
1931
|
//#region src/images/phash.ts
|
|
1806
1932
|
const HASH_SIZE = 32;
|
|
@@ -1874,7 +2000,7 @@ async function computeArchiveImagePHash(input, entryPath) {
|
|
|
1874
2000
|
//#region src/hashing/asarContent.ts
|
|
1875
2001
|
/**
|
|
1876
2002
|
* Locates the byte range of an asar archive's content region (everything
|
|
1877
|
-
* after the header), so it can be hashed without the header/index
|
|
2003
|
+
* after the header), so it can be hashed without the header/index, meaning
|
|
1878
2004
|
* header-only changes (e.g. re-ordering the file index) never change the
|
|
1879
2005
|
* content hash.
|
|
1880
2006
|
*/
|
|
@@ -1898,7 +2024,7 @@ async function sha256ArchiveEntry(input, entryPath) {
|
|
|
1898
2024
|
throw new MetadataNotFoundError(`No entry named "${entryPath}" was found in the archive.`);
|
|
1899
2025
|
}
|
|
1900
2026
|
/**
|
|
1901
|
-
* SHA256 of the whole archive file
|
|
2027
|
+
* SHA256 of the whole archive file, except for asar, where only the
|
|
1902
2028
|
* content region (bytes after the header) is hashed, so header/index
|
|
1903
2029
|
* reordering never changes the content hash.
|
|
1904
2030
|
*/
|
|
@@ -1999,7 +2125,7 @@ async function readArchiveEntry(input, entryPath) {
|
|
|
1999
2125
|
* Reads every entry's bytes and SHA256 in a single pass over the archive.
|
|
2000
2126
|
* Calling `readArchiveEntry`/`sha256ArchiveEntry` once per entry instead
|
|
2001
2127
|
* means re-running `listEntries` and re-locating the target entry on every
|
|
2002
|
-
* call
|
|
2128
|
+
* call: cheap for zip (backed by real central-directory random access) and
|
|
2003
2129
|
* tar/asar, but for the sequential/CLI-driven formats (rar, 7z, ace) each
|
|
2004
2130
|
* call re-reads the archive from the start, so reading every entry that way
|
|
2005
2131
|
* costs O(n^2). This also does half the I/O of calling both
|
|
@@ -2025,28 +2151,6 @@ async function* readArchiveEntries(input) {
|
|
|
2025
2151
|
}
|
|
2026
2152
|
}
|
|
2027
2153
|
//#endregion
|
|
2028
|
-
//#region src/removeArchiveEntry.ts
|
|
2029
|
-
async function removeArchiveEntry(input, entryPath, options = {}) {
|
|
2030
|
-
const adapter = getAdapter(await detectArchiveType(input));
|
|
2031
|
-
let found = false;
|
|
2032
|
-
for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) if (entry.path === entryPath) {
|
|
2033
|
-
found = true;
|
|
2034
|
-
break;
|
|
2035
|
-
}
|
|
2036
|
-
if (!found) throw new MetadataNotFoundError(`No entry named "${entryPath}" was found in the archive.`);
|
|
2037
|
-
async function* output() {
|
|
2038
|
-
for await (const entry of adapter.listEntries(input, { tempDir: options.tempDir })) {
|
|
2039
|
-
if (entry.path === entryPath) continue;
|
|
2040
|
-
yield {
|
|
2041
|
-
path: entry.path,
|
|
2042
|
-
size: entry.size,
|
|
2043
|
-
content: entry.openReadStream()
|
|
2044
|
-
};
|
|
2045
|
-
}
|
|
2046
|
-
}
|
|
2047
|
-
return withOutput(options.output, (destination) => adapter.write(output(), destination, { tempDir: options.tempDir }));
|
|
2048
|
-
}
|
|
2049
|
-
//#endregion
|
|
2050
2154
|
//#region src/benchmark/timing.ts
|
|
2051
2155
|
/** Runs `run` `iterations` times and returns the average duration in milliseconds. */
|
|
2052
2156
|
async function averageDuration(iterations, run) {
|
|
@@ -2103,19 +2207,19 @@ function topThreeImageFormats(variants) {
|
|
|
2103
2207
|
}).slice(0, 3);
|
|
2104
2208
|
}
|
|
2105
2209
|
function renderRankedSection(title, description, ranked, format) {
|
|
2106
|
-
return `### ${title}\n\n${description}\n\n${ranked.map((variant, index) => `${index + 1}. **${variant.fileName}** (${variant.archiveType}/${variant.imageFormat})
|
|
2210
|
+
return `### ${title}\n\n${description}\n\n${ranked.map((variant, index) => `${index + 1}. **${variant.fileName}** (${variant.archiveType}/${variant.imageFormat}): ${format(variant)}`).join("\n")}\n`;
|
|
2107
2211
|
}
|
|
2108
2212
|
function renderImageFormatRankedSection(title, description, ranked) {
|
|
2109
|
-
return `### ${title}\n\n${description}\n\n${ranked.map((variant, index) => `${index + 1}. **${variant.imageFormat}
|
|
2213
|
+
return `### ${title}\n\n${description}\n\n${ranked.map((variant, index) => `${index + 1}. **${variant.imageFormat}**: ${formatBytes(variant.avgImageSizeBytes)} avg. per page`).join("\n")}\n`;
|
|
2110
2214
|
}
|
|
2111
2215
|
function renderBenchmarkReportMarkdown(result) {
|
|
2112
2216
|
const { variants } = result;
|
|
2113
2217
|
const tableHeader = "| Archive | Image | File | Storage size | Avg. page size | Pages | Avg. creation time | Avg. random read time |\n| --- | --- | --- | --- | --- | --- | --- | --- |";
|
|
2114
2218
|
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");
|
|
2115
|
-
const readSpeed = renderRankedSection("Read speed", "Fastest average random single-file read time
|
|
2116
|
-
const storageSize = renderRankedSection("Storage size", "Smallest resulting archive on disk
|
|
2117
|
-
const transferSize = renderImageFormatRankedSection("Transfer size", "Smallest average individual page
|
|
2118
|
-
const creationSpeed = renderRankedSection("Creation speed", "Fastest average archive creation time
|
|
2219
|
+
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));
|
|
2220
|
+
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));
|
|
2221
|
+
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));
|
|
2222
|
+
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));
|
|
2119
2223
|
return [
|
|
2120
2224
|
"# Archive Benchmark Report",
|
|
2121
2225
|
"",
|
|
@@ -2169,7 +2273,7 @@ function pickRandomSamples(items, count) {
|
|
|
2169
2273
|
}
|
|
2170
2274
|
/**
|
|
2171
2275
|
* The average byte size of one converted image, independent of which
|
|
2172
|
-
* container it ends up packaged in ("transfer size"
|
|
2276
|
+
* container it ends up packaged in ("transfer size": what a client
|
|
2173
2277
|
* actually downloads to fetch a single page).
|
|
2174
2278
|
*/
|
|
2175
2279
|
async function averageConvertedImageSize(preConverted, imageFormat, tempDir) {
|
|
@@ -2300,6 +2404,6 @@ const comicArchiveHandler = {
|
|
|
2300
2404
|
ARCHIVE_TYPE_EXTENSIONS
|
|
2301
2405
|
};
|
|
2302
2406
|
//#endregion
|
|
2303
|
-
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, readArchiveEntries, readArchiveEntry, readArchiveMetadata, removeArchiveEntry, renameArchiveImagesSequentially, renderBenchmarkReportMarkdown, resourceId, resourceName, sha256Archive, sha256ArchiveEntry, splitCommaList, stripNonEssentialFiles, validateMetadataXml, xmlToMetadata };
|
|
2407
|
+
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, readArchiveEntries, readArchiveEntry, readArchiveMetadata, removeArchiveEntry, removeComicMetadata, renameArchiveImagesSequentially, renderBenchmarkReportMarkdown, resourceId, resourceName, sha256Archive, sha256ArchiveEntry, splitCommaList, stripNonEssentialFiles, validateMetadataXml, xmlToMetadata };
|
|
2304
2408
|
|
|
2305
2409
|
//# sourceMappingURL=index.mjs.map
|