mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-09-21 13:31:42 +00:00
fix(archive): close silent-drop gaps in attachment detection
Files whose names match no pattern in detect.ts are dropped with no log,
no packages row and no skipped_packages row. Five gaps of that class:
- Numbered volumes were implemented format-by-format (ZIP_NUMBERED,
SEVENZ_NUMBERED) with no RAR equivalent, so Pack.rar.001 vanished.
Replaced both with a single ARCHIVE_NUMBERED pattern over zip|7z|rar
that derives the format from the match, so adding a format can no
longer be forgotten. \d{2,} also picks up hand-renamed .rar.01 sets.
- RAR legacy sets were ordered wrong: singles were always sorted last,
which is right for .zip/.z01 (the bare .zip is the final disk) but
wrong for .rar/.r00 (the bare .rar is volume 1). parts[0] became a
headerless continuation volume, so listing failed and the package was
labelled from the wrong message. Corrected the misleading comment too.
- Trailing/leading whitespace and trailing dots survive TDLib verbatim
and every pattern is $-anchored, so "Pack.zip " was dropped. The
filename is now normalized before matching and for baseName.
- RAR_PART now accepts .partN.exe, the self-extracting first volume;
previously the set was grouped starting at part 2.
- DOCUMENT_EXTENSIONS gained the slicer-project, 3D-model and CAD
formats present in this corpus (lys, chitubox, ctb, fbx, ztl, ... plus
the blend1 autosave sibling). Image formats stay excluded on purpose.
Tests written first; 31 new cases including pattern-order safety
(ZIP_LEGACY must not swallow .7z.001 and ARCHIVE_NUMBERED must not
swallow .z01/.r00) and both legacy part-ordering directions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { detectArchive, isArchiveAttachment } from "./detect.js";
|
import { detectArchive, isArchiveAttachment } from "./detect.js";
|
||||||
|
|
||||||
describe("detectArchive — 7z numbered multipart (pack.7z.001, pack.7z.002, ...)", () => {
|
describe("detectArchive — numbered volumes (pack.EXT.001, pack.EXT.002, ...)", () => {
|
||||||
it("recognizes a 7z multipart part as an archive attachment", () => {
|
it("recognizes a 7z multipart part as an archive attachment", () => {
|
||||||
expect(isArchiveAttachment("Lost Adventures Vol2.7z.001")).toBe(true);
|
expect(isArchiveAttachment("Lost Adventures Vol2.7z.001")).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -12,7 +12,7 @@ describe("detectArchive — 7z numbered multipart (pack.7z.001, pack.7z.002, ...
|
|||||||
baseName: "Lost Adventures Vol2.7z",
|
baseName: "Lost Adventures Vol2.7z",
|
||||||
partNumber: 1,
|
partNumber: 1,
|
||||||
format: "7Z",
|
format: "7Z",
|
||||||
pattern: "SEVENZ_NUMBERED",
|
pattern: "ARCHIVE_NUMBERED",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,4 +36,249 @@ describe("detectArchive — 7z numbered multipart (pack.7z.001, pack.7z.002, ...
|
|||||||
pattern: "SINGLE",
|
pattern: "SINGLE",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("recognizes numbered ZIP volumes", () => {
|
||||||
|
expect(detectArchive("Big Pack.zip.001")).toEqual({
|
||||||
|
baseName: "Big Pack.zip",
|
||||||
|
partNumber: 1,
|
||||||
|
format: "ZIP",
|
||||||
|
pattern: "ARCHIVE_NUMBERED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes numbered RAR volumes (previously dropped silently)", () => {
|
||||||
|
expect(detectArchive("Big Pack.rar.001")).toEqual({
|
||||||
|
baseName: "Big Pack.rar",
|
||||||
|
partNumber: 1,
|
||||||
|
format: "RAR",
|
||||||
|
pattern: "ARCHIVE_NUMBERED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives the format from the archive extension, not a hardcoded value", () => {
|
||||||
|
expect(detectArchive("A.zip.004")?.format).toBe("ZIP");
|
||||||
|
expect(detectArchive("A.RAR.004")?.format).toBe("RAR");
|
||||||
|
expect(detectArchive("A.7z.004")?.format).toBe("7Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts hand-renamed two-digit volumes for every format", () => {
|
||||||
|
expect(detectArchive("Pack.zip.01")).toEqual({
|
||||||
|
baseName: "Pack.zip",
|
||||||
|
partNumber: 1,
|
||||||
|
format: "ZIP",
|
||||||
|
pattern: "ARCHIVE_NUMBERED",
|
||||||
|
});
|
||||||
|
expect(detectArchive("Pack.rar.02")?.partNumber).toBe(2);
|
||||||
|
expect(detectArchive("Pack.7z.03")?.partNumber).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still accepts four-or-more-digit volumes", () => {
|
||||||
|
expect(detectArchive("Pack.7z.0001")).toEqual({
|
||||||
|
baseName: "Pack.7z",
|
||||||
|
partNumber: 1,
|
||||||
|
format: "7Z",
|
||||||
|
pattern: "ARCHIVE_NUMBERED",
|
||||||
|
});
|
||||||
|
expect(detectArchive("Pack.zip.10001")?.partNumber).toBe(10001);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not match a single-digit suffix (too ambiguous with real extensions)", () => {
|
||||||
|
expect(detectArchive("Pack.zip.1")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("detectArchive — pattern ordering safety", () => {
|
||||||
|
it("does not let ZIP_LEGACY swallow a .7z.NNN name", () => {
|
||||||
|
expect(detectArchive("Pack.7z.001")?.pattern).toBe("ARCHIVE_NUMBERED");
|
||||||
|
expect(detectArchive("Pack.7z.001")?.format).toBe("7Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let ARCHIVE_NUMBERED swallow legacy .zNN names", () => {
|
||||||
|
expect(detectArchive("Pack.z01")).toEqual({
|
||||||
|
baseName: "Pack",
|
||||||
|
partNumber: 1,
|
||||||
|
format: "ZIP",
|
||||||
|
pattern: "ZIP_LEGACY",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let ARCHIVE_NUMBERED swallow legacy .rNN names", () => {
|
||||||
|
expect(detectArchive("Pack.r00")).toEqual({
|
||||||
|
baseName: "Pack",
|
||||||
|
partNumber: 0,
|
||||||
|
format: "RAR",
|
||||||
|
pattern: "RAR_LEGACY",
|
||||||
|
});
|
||||||
|
expect(detectArchive("Pack.r01")?.pattern).toBe("RAR_LEGACY");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps .partN.rar on the RAR_PART pattern", () => {
|
||||||
|
expect(detectArchive("Pack.part2.rar")).toEqual({
|
||||||
|
baseName: "Pack",
|
||||||
|
partNumber: 2,
|
||||||
|
format: "RAR",
|
||||||
|
pattern: "RAR_PART",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("detectArchive — filename normalization", () => {
|
||||||
|
it("recognizes a name with a trailing space and reports the trimmed baseName", () => {
|
||||||
|
expect(detectArchive("Pack.zip ")).toEqual({
|
||||||
|
baseName: "Pack",
|
||||||
|
partNumber: -1,
|
||||||
|
format: "ZIP",
|
||||||
|
pattern: "SINGLE",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes a name with a leading space", () => {
|
||||||
|
expect(detectArchive(" Pack.rar")).toEqual({
|
||||||
|
baseName: "Pack",
|
||||||
|
partNumber: -1,
|
||||||
|
format: "RAR",
|
||||||
|
pattern: "SINGLE",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes a multipart name with surrounding whitespace", () => {
|
||||||
|
expect(detectArchive("\tPack.rar.002 \n")).toEqual({
|
||||||
|
baseName: "Pack.rar",
|
||||||
|
partNumber: 2,
|
||||||
|
format: "RAR",
|
||||||
|
pattern: "ARCHIVE_NUMBERED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes a name with a trailing dot", () => {
|
||||||
|
expect(detectArchive("Pack.zip.")).toEqual({
|
||||||
|
baseName: "Pack",
|
||||||
|
partNumber: -1,
|
||||||
|
format: "ZIP",
|
||||||
|
pattern: "SINGLE",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes a name with a trailing dot followed by a space", () => {
|
||||||
|
expect(detectArchive("Pack.part3.rar. ")?.pattern).toBe("RAR_PART");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still returns null for a whitespace-only or empty name", () => {
|
||||||
|
expect(detectArchive(" ")).toBeNull();
|
||||||
|
expect(detectArchive("")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("detectArchive — self-extracting RAR first volume (.partN.exe)", () => {
|
||||||
|
it("recognizes Pack.part1.exe as the first volume of a RAR_PART set", () => {
|
||||||
|
expect(detectArchive("Pack.part1.exe")).toEqual({
|
||||||
|
baseName: "Pack",
|
||||||
|
partNumber: 1,
|
||||||
|
format: "RAR",
|
||||||
|
pattern: "RAR_PART",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups the SFX first volume with its .rar continuation volumes", () => {
|
||||||
|
const sfx = detectArchive("Pack.part1.exe");
|
||||||
|
const cont = detectArchive("Pack.part2.rar");
|
||||||
|
expect(sfx?.baseName).toBe(cont?.baseName);
|
||||||
|
expect(sfx?.format).toBe(cont?.format);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open the door to arbitrary .exe attachments", () => {
|
||||||
|
expect(detectArchive("Installer.exe")).toBeNull();
|
||||||
|
expect(detectArchive("Pack.exe")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("detectArchive — standalone documents", () => {
|
||||||
|
it("keeps recognizing the pre-existing document extensions", () => {
|
||||||
|
expect(detectArchive("Model.stl")).toEqual({
|
||||||
|
baseName: "Model",
|
||||||
|
partNumber: -1,
|
||||||
|
format: "DOCUMENT",
|
||||||
|
pattern: "SINGLE",
|
||||||
|
});
|
||||||
|
expect(detectArchive("Sheet.pdf")?.format).toBe("DOCUMENT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes slicer-project formats", () => {
|
||||||
|
for (const name of [
|
||||||
|
"Bust.lys",
|
||||||
|
"Bust.lyt",
|
||||||
|
"Bust.lymesh",
|
||||||
|
"Bust.chitubox",
|
||||||
|
"Bust.ctp",
|
||||||
|
"Bust.ctb",
|
||||||
|
"Bust.cbddlp",
|
||||||
|
"Bust.photon",
|
||||||
|
"Bust.pwmx",
|
||||||
|
"Bust.pwmo",
|
||||||
|
"Bust.pws",
|
||||||
|
"Bust.sl1",
|
||||||
|
"Bust.goo",
|
||||||
|
"Bust.phz",
|
||||||
|
"Bust.pm3",
|
||||||
|
"Bust.form",
|
||||||
|
]) {
|
||||||
|
expect(detectArchive(name), name).toEqual({
|
||||||
|
baseName: "Bust",
|
||||||
|
partNumber: -1,
|
||||||
|
format: "DOCUMENT",
|
||||||
|
pattern: "SINGLE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes 3D-model and CAD formats", () => {
|
||||||
|
for (const name of [
|
||||||
|
"Bust.fbx",
|
||||||
|
"Bust.ply",
|
||||||
|
"Bust.glb",
|
||||||
|
"Bust.gltf",
|
||||||
|
"Bust.3ds",
|
||||||
|
"Bust.max",
|
||||||
|
"Bust.c4d",
|
||||||
|
"Bust.ztl",
|
||||||
|
"Bust.zpr",
|
||||||
|
"Bust.mtl",
|
||||||
|
"Bust.f3d",
|
||||||
|
"Bust.scad",
|
||||||
|
"Bust.igs",
|
||||||
|
"Bust.iges",
|
||||||
|
"Bust.sldprt",
|
||||||
|
"Bust.skp",
|
||||||
|
"Bust.wrl",
|
||||||
|
]) {
|
||||||
|
expect(detectArchive(name), name).toEqual({
|
||||||
|
baseName: "Bust",
|
||||||
|
partNumber: -1,
|
||||||
|
format: "DOCUMENT",
|
||||||
|
pattern: "SINGLE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes the .blend1 autosave sibling of .blend", () => {
|
||||||
|
expect(detectArchive("Scene.blend")?.format).toBe("DOCUMENT");
|
||||||
|
expect(detectArchive("Scene.blend1")).toEqual({
|
||||||
|
baseName: "Scene",
|
||||||
|
partNumber: -1,
|
||||||
|
format: "DOCUMENT",
|
||||||
|
pattern: "SINGLE",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT recognize image attachments (they must not become packages)", () => {
|
||||||
|
for (const name of ["Preview.jpg", "Preview.jpeg", "Preview.png", "Preview.webp", "Preview.gif"]) {
|
||||||
|
expect(detectArchive(name), name).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unrelated files", () => {
|
||||||
|
expect(detectArchive("notes.txt")).toBeNull();
|
||||||
|
expect(detectArchive("song.mp3")).toBeNull();
|
||||||
|
expect(detectArchive("noextension")).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,25 +4,29 @@ export interface MultipartInfo {
|
|||||||
baseName: string;
|
baseName: string;
|
||||||
partNumber: number;
|
partNumber: number;
|
||||||
format: ArchiveFormat;
|
format: ArchiveFormat;
|
||||||
pattern: "ZIP_NUMBERED" | "ZIP_LEGACY" | "RAR_PART" | "RAR_LEGACY" | "SEVENZ_NUMBERED" | "SINGLE";
|
pattern: "ARCHIVE_NUMBERED" | "ZIP_LEGACY" | "RAR_PART" | "RAR_LEGACY" | "SINGLE";
|
||||||
}
|
}
|
||||||
|
|
||||||
const patterns: {
|
const patterns: {
|
||||||
regex: RegExp;
|
regex: RegExp;
|
||||||
format: ArchiveFormat;
|
/** A fixed format, or one derived from the match for patterns spanning several formats. */
|
||||||
|
format: ArchiveFormat | ((match: RegExpMatchArray) => ArchiveFormat);
|
||||||
pattern: MultipartInfo["pattern"];
|
pattern: MultipartInfo["pattern"];
|
||||||
getBaseName: (match: RegExpMatchArray) => string;
|
getBaseName: (match: RegExpMatchArray) => string;
|
||||||
getPartNumber: (match: RegExpMatchArray) => number;
|
getPartNumber: (match: RegExpMatchArray) => number;
|
||||||
}[] = [
|
}[] = [
|
||||||
// pack.zip.001, pack.zip.002
|
// pack.zip.001, pack.rar.001, pack.7z.001 (numbered volume split — one pattern for
|
||||||
|
// every format, so a new format can never be silently dropped for lack of its own entry).
|
||||||
|
// {2,} digits also picks up hand-renamed sets like pack.rar.01.
|
||||||
{
|
{
|
||||||
regex: /^(.+\.zip)\.(\d{3,})$/i,
|
regex: /^(.+\.(zip|7z|rar))\.(\d{2,})$/i,
|
||||||
format: "ZIP",
|
// The regex only ever captures zip/7z/rar, so uppercasing yields a valid ArchiveFormat.
|
||||||
pattern: "ZIP_NUMBERED",
|
format: (m) => m[2].toUpperCase() as ArchiveFormat,
|
||||||
getBaseName: (m) => m[1],
|
pattern: "ARCHIVE_NUMBERED",
|
||||||
getPartNumber: (m) => parseInt(m[2], 10),
|
getBaseName: (m) => m[1], // includes the archive extension
|
||||||
|
getPartNumber: (m) => parseInt(m[3], 10),
|
||||||
},
|
},
|
||||||
// pack.z01, pack.z02 (legacy split — final part is pack.zip)
|
// pack.z01, pack.z02 (legacy split — pack.zip is the FINAL disk of the set)
|
||||||
{
|
{
|
||||||
regex: /^(.+)\.z(\d{2,})$/i,
|
regex: /^(.+)\.z(\d{2,})$/i,
|
||||||
format: "ZIP",
|
format: "ZIP",
|
||||||
@@ -30,15 +34,16 @@ const patterns: {
|
|||||||
getBaseName: (m) => m[1],
|
getBaseName: (m) => m[1],
|
||||||
getPartNumber: (m) => parseInt(m[2], 10),
|
getPartNumber: (m) => parseInt(m[2], 10),
|
||||||
},
|
},
|
||||||
// pack.part1.rar, pack.part2.rar
|
// pack.part1.rar, pack.part2.rar — .exe covers a self-extracting first volume
|
||||||
|
// (pack.part1.exe + pack.part2.rar + ...), which is still a RAR volume set.
|
||||||
{
|
{
|
||||||
regex: /^(.+)\.part(\d+)\.rar$/i,
|
regex: /^(.+)\.part(\d+)\.(rar|exe)$/i,
|
||||||
format: "RAR",
|
format: "RAR",
|
||||||
pattern: "RAR_PART",
|
pattern: "RAR_PART",
|
||||||
getBaseName: (m) => m[1],
|
getBaseName: (m) => m[1],
|
||||||
getPartNumber: (m) => parseInt(m[2], 10),
|
getPartNumber: (m) => parseInt(m[2], 10),
|
||||||
},
|
},
|
||||||
// pack.r00, pack.r01 (legacy split — final part is pack.rar)
|
// pack.r00, pack.r01 (legacy split — pack.rar is the FIRST volume, .r00 onwards follow it)
|
||||||
{
|
{
|
||||||
regex: /^(.+)\.r(\d{2,})$/i,
|
regex: /^(.+)\.r(\d{2,})$/i,
|
||||||
format: "RAR",
|
format: "RAR",
|
||||||
@@ -46,50 +51,53 @@ const patterns: {
|
|||||||
getBaseName: (m) => m[1],
|
getBaseName: (m) => m[1],
|
||||||
getPartNumber: (m) => parseInt(m[2], 10),
|
getPartNumber: (m) => parseInt(m[2], 10),
|
||||||
},
|
},
|
||||||
// pack.7z.001, pack.7z.002 (native 7z volume split)
|
|
||||||
{
|
|
||||||
regex: /^(.+\.7z)\.(\d{3,})$/i,
|
|
||||||
format: "7Z",
|
|
||||||
pattern: "SEVENZ_NUMBERED",
|
|
||||||
getBaseName: (m) => m[1],
|
|
||||||
getPartNumber: (m) => parseInt(m[2], 10),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Extensions we recognize as fetchable documents (archives + standalone files) */
|
/** Extensions we recognize as fetchable documents (archives + standalone files).
|
||||||
const DOCUMENT_EXTENSIONS = /\.(pdf|stl|obj|3mf|step|stp|blend|gcode|svg|dxf|ai|eps|psd)$/i;
|
* Deliberately excludes image formats — previews posted as uncompressed documents
|
||||||
|
* must go through the photo-matching path, not become packages of their own. */
|
||||||
|
const DOCUMENT_EXTENSIONS =
|
||||||
|
/\.(pdf|stl|obj|3mf|step|stp|blend1|blend|gcode|svg|dxf|ai|eps|psd|lys|lyt|lymesh|chitubox|ctp|ctb|cbddlp|photon|pwmx|pwmo|pws|sl1|goo|phz|pm3|fbx|ply|glb|gltf|3ds|max|c4d|ztl|zpr|mtl|f3d|scad|igs|iges|sldprt|form|skp|wrl)$/i;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect if a filename is an archive and extract multipart info.
|
* Detect if a filename is an archive and extract multipart info.
|
||||||
*/
|
*/
|
||||||
export function detectArchive(fileName: string): MultipartInfo | null {
|
export function detectArchive(fileName: string): MultipartInfo | null {
|
||||||
|
// TDLib hands us `document.file_name` verbatim and every pattern below is `$`-anchored,
|
||||||
|
// so "Pack.zip " or "Pack.zip." would otherwise be dropped without a trace. Trailing dots
|
||||||
|
// are stripped too: no filesystem or archiver can produce a meaningful one (Windows
|
||||||
|
// silently drops them), so a trailing dot is always cosmetic damage from a re-upload,
|
||||||
|
// never part of the real name. The normalized value is used for matching AND baseName.
|
||||||
|
const name = fileName.trim().replace(/[.\s]+$/, "");
|
||||||
|
if (!name) return null;
|
||||||
|
|
||||||
// Check multipart patterns first
|
// Check multipart patterns first
|
||||||
for (const p of patterns) {
|
for (const p of patterns) {
|
||||||
const match = fileName.match(p.regex);
|
const match = name.match(p.regex);
|
||||||
if (match) {
|
if (match) {
|
||||||
return {
|
return {
|
||||||
baseName: p.getBaseName(match),
|
baseName: p.getBaseName(match),
|
||||||
partNumber: p.getPartNumber(match),
|
partNumber: p.getPartNumber(match),
|
||||||
format: p.format,
|
format: typeof p.format === "function" ? p.format(match) : p.format,
|
||||||
pattern: p.pattern,
|
pattern: p.pattern,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single .zip file — could be a standalone or the final part of a ZIP_LEGACY set
|
// Single .zip file — could be a standalone or the final part of a ZIP_LEGACY set
|
||||||
if (/\.zip$/i.test(fileName)) {
|
if (/\.zip$/i.test(name)) {
|
||||||
return {
|
return {
|
||||||
baseName: fileName.replace(/\.zip$/i, ""),
|
baseName: name.replace(/\.zip$/i, ""),
|
||||||
partNumber: -1, // -1 signals "could be single or final legacy part"
|
partNumber: -1, // -1 signals "could be single or final legacy part"
|
||||||
format: "ZIP",
|
format: "ZIP",
|
||||||
pattern: "SINGLE",
|
pattern: "SINGLE",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single .rar file — could be standalone or final part of RAR_LEGACY set
|
// Single .rar file — could be standalone or the FIRST part of a RAR_LEGACY set
|
||||||
if (/\.rar$/i.test(fileName)) {
|
if (/\.rar$/i.test(name)) {
|
||||||
return {
|
return {
|
||||||
baseName: fileName.replace(/\.rar$/i, ""),
|
baseName: name.replace(/\.rar$/i, ""),
|
||||||
partNumber: -1,
|
partNumber: -1,
|
||||||
format: "RAR",
|
format: "RAR",
|
||||||
pattern: "SINGLE",
|
pattern: "SINGLE",
|
||||||
@@ -97,20 +105,19 @@ export function detectArchive(fileName: string): MultipartInfo | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Single .7z file
|
// Single .7z file
|
||||||
if (/\.7z$/i.test(fileName)) {
|
if (/\.7z$/i.test(name)) {
|
||||||
return {
|
return {
|
||||||
baseName: fileName.replace(/\.7z$/i, ""),
|
baseName: name.replace(/\.7z$/i, ""),
|
||||||
partNumber: -1,
|
partNumber: -1,
|
||||||
format: "7Z",
|
format: "7Z",
|
||||||
pattern: "SINGLE",
|
pattern: "SINGLE",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standalone documents (PDFs, STLs, 3D files, etc.)
|
// Standalone documents (PDFs, STLs, 3D files, slicer projects, etc.)
|
||||||
if (DOCUMENT_EXTENSIONS.test(fileName)) {
|
if (DOCUMENT_EXTENSIONS.test(name)) {
|
||||||
const ext = fileName.match(DOCUMENT_EXTENSIONS)![0];
|
|
||||||
return {
|
return {
|
||||||
baseName: fileName.replace(DOCUMENT_EXTENSIONS, ""),
|
baseName: name.replace(DOCUMENT_EXTENSIONS, ""),
|
||||||
partNumber: -1,
|
partNumber: -1,
|
||||||
format: "DOCUMENT",
|
format: "DOCUMENT",
|
||||||
pattern: "SINGLE",
|
pattern: "SINGLE",
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { groupArchiveSets, type TelegramMessage } from "./multipart.js";
|
||||||
|
|
||||||
|
let nextId = 1000n;
|
||||||
|
|
||||||
|
function msg(fileName: string): TelegramMessage {
|
||||||
|
const id = nextId++;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
fileName,
|
||||||
|
fileId: `file-${id}`,
|
||||||
|
fileSize: 1024n,
|
||||||
|
date: new Date("2026-01-01T00:00:00Z"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function names(files: string[]): string[] {
|
||||||
|
const sets = groupArchiveSets(files.map(msg));
|
||||||
|
expect(sets).toHaveLength(1);
|
||||||
|
return sets[0].parts.map((p) => p.fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("groupArchiveSets — legacy split part ordering", () => {
|
||||||
|
it("puts the bare .rar FIRST in a RAR_LEGACY set (it is volume 1)", () => {
|
||||||
|
expect(names(["Pack.r01", "Pack.rar", "Pack.r00"])).toEqual([
|
||||||
|
"Pack.rar",
|
||||||
|
"Pack.r00",
|
||||||
|
"Pack.r01",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the bare .zip LAST in a ZIP_LEGACY set (it is the final disk)", () => {
|
||||||
|
expect(names(["Pack.z02", "Pack.zip", "Pack.z01"])).toEqual([
|
||||||
|
"Pack.z01",
|
||||||
|
"Pack.z02",
|
||||||
|
"Pack.zip",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks both legacy sets as multipart with the right format", () => {
|
||||||
|
const rar = groupArchiveSets([msg("Pack.rar"), msg("Pack.r00")])[0];
|
||||||
|
expect(rar.isMultipart).toBe(true);
|
||||||
|
expect(rar.type).toBe("RAR");
|
||||||
|
|
||||||
|
const zip = groupArchiveSets([msg("Pack.zip"), msg("Pack.z01")])[0];
|
||||||
|
expect(zip.isMultipart).toBe(true);
|
||||||
|
expect(zip.type).toBe("ZIP");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders numbered volume sets by part number", () => {
|
||||||
|
expect(names(["Pack.rar.003", "Pack.rar.001", "Pack.rar.002"])).toEqual([
|
||||||
|
"Pack.rar.001",
|
||||||
|
"Pack.rar.002",
|
||||||
|
"Pack.rar.003",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders .partN sets by part number with an SFX first volume", () => {
|
||||||
|
expect(names(["Pack.part3.rar", "Pack.part1.exe", "Pack.part2.rar"])).toEqual([
|
||||||
|
"Pack.part1.exe",
|
||||||
|
"Pack.part2.rar",
|
||||||
|
"Pack.part3.rar",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats unrelated singles as their own non-multipart sets", () => {
|
||||||
|
const sets = groupArchiveSets([msg("A.zip"), msg("B.rar")]);
|
||||||
|
expect(sets).toHaveLength(2);
|
||||||
|
expect(sets.every((s) => !s.isMultipart)).toBe(true);
|
||||||
|
expect(sets.every((s) => s.parts.length === 1)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -78,10 +78,17 @@ export function groupArchiveSets(messages: TelegramMessage[]): ArchiveSet[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by part number (singles get a very high number so they come last — they're the final part)
|
// Sort by part number. A bare single (partNumber -1) sits at a different end of the
|
||||||
|
// set depending on the legacy scheme: in a .zip/.z01/.z02 set the bare pack.zip is the
|
||||||
|
// FINAL disk, but in a .rar/.r00/.r01 set the bare pack.rar is volume 1 and .r00
|
||||||
|
// onwards follow it. Getting this backwards makes parts[0] a headerless continuation
|
||||||
|
// volume, which breaks listing and mislabels the package.
|
||||||
|
const singleRank = multipartEntries.some((e) => e.info.pattern === "RAR_LEGACY")
|
||||||
|
? -1 // before .r00
|
||||||
|
: 999999;
|
||||||
allEntries.sort((a, b) => {
|
allEntries.sort((a, b) => {
|
||||||
const aNum = a.info.partNumber === -1 ? 999999 : a.info.partNumber;
|
const aNum = a.info.partNumber === -1 ? singleRank : a.info.partNumber;
|
||||||
const bNum = b.info.partNumber === -1 ? 999999 : b.info.partNumber;
|
const bNum = b.info.partNumber === -1 ? singleRank : b.info.partNumber;
|
||||||
return aNum - bNum;
|
return aNum - bNum;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user