diff --git a/worker/src/archive/detect.test.ts b/worker/src/archive/detect.test.ts index 49e627e..03f1556 100644 --- a/worker/src/archive/detect.test.ts +++ b/worker/src/archive/detect.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; 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", () => { 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", partNumber: 1, 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", }); }); + + 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(); + }); }); diff --git a/worker/src/archive/detect.ts b/worker/src/archive/detect.ts index 2fe5853..6013d77 100644 --- a/worker/src/archive/detect.ts +++ b/worker/src/archive/detect.ts @@ -4,25 +4,29 @@ export interface MultipartInfo { baseName: string; partNumber: number; 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: { 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"]; getBaseName: (match: RegExpMatchArray) => string; 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, - format: "ZIP", - pattern: "ZIP_NUMBERED", - getBaseName: (m) => m[1], - getPartNumber: (m) => parseInt(m[2], 10), + regex: /^(.+\.(zip|7z|rar))\.(\d{2,})$/i, + // The regex only ever captures zip/7z/rar, so uppercasing yields a valid ArchiveFormat. + format: (m) => m[2].toUpperCase() as ArchiveFormat, + pattern: "ARCHIVE_NUMBERED", + 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, format: "ZIP", @@ -30,15 +34,16 @@ const patterns: { getBaseName: (m) => m[1], 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", pattern: "RAR_PART", getBaseName: (m) => m[1], 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, format: "RAR", @@ -46,50 +51,53 @@ const patterns: { getBaseName: (m) => m[1], 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) */ -const DOCUMENT_EXTENSIONS = /\.(pdf|stl|obj|3mf|step|stp|blend|gcode|svg|dxf|ai|eps|psd)$/i; +/** Extensions we recognize as fetchable documents (archives + standalone files). + * 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. */ 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 for (const p of patterns) { - const match = fileName.match(p.regex); + const match = name.match(p.regex); if (match) { return { baseName: p.getBaseName(match), partNumber: p.getPartNumber(match), - format: p.format, + format: typeof p.format === "function" ? p.format(match) : p.format, pattern: p.pattern, }; } } // 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 { - baseName: fileName.replace(/\.zip$/i, ""), + baseName: name.replace(/\.zip$/i, ""), partNumber: -1, // -1 signals "could be single or final legacy part" format: "ZIP", pattern: "SINGLE", }; } - // Single .rar file — could be standalone or final part of RAR_LEGACY set - if (/\.rar$/i.test(fileName)) { + // Single .rar file — could be standalone or the FIRST part of a RAR_LEGACY set + if (/\.rar$/i.test(name)) { return { - baseName: fileName.replace(/\.rar$/i, ""), + baseName: name.replace(/\.rar$/i, ""), partNumber: -1, format: "RAR", pattern: "SINGLE", @@ -97,20 +105,19 @@ export function detectArchive(fileName: string): MultipartInfo | null { } // Single .7z file - if (/\.7z$/i.test(fileName)) { + if (/\.7z$/i.test(name)) { return { - baseName: fileName.replace(/\.7z$/i, ""), + baseName: name.replace(/\.7z$/i, ""), partNumber: -1, format: "7Z", pattern: "SINGLE", }; } - // Standalone documents (PDFs, STLs, 3D files, etc.) - if (DOCUMENT_EXTENSIONS.test(fileName)) { - const ext = fileName.match(DOCUMENT_EXTENSIONS)![0]; + // Standalone documents (PDFs, STLs, 3D files, slicer projects, etc.) + if (DOCUMENT_EXTENSIONS.test(name)) { return { - baseName: fileName.replace(DOCUMENT_EXTENSIONS, ""), + baseName: name.replace(DOCUMENT_EXTENSIONS, ""), partNumber: -1, format: "DOCUMENT", pattern: "SINGLE", diff --git a/worker/src/archive/multipart.test.ts b/worker/src/archive/multipart.test.ts new file mode 100644 index 0000000..6ada86f --- /dev/null +++ b/worker/src/archive/multipart.test.ts @@ -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); + }); +}); diff --git a/worker/src/archive/multipart.ts b/worker/src/archive/multipart.ts index cf5dca7..d74f793 100644 --- a/worker/src/archive/multipart.ts +++ b/worker/src/archive/multipart.ts @@ -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) => { - const aNum = a.info.partNumber === -1 ? 999999 : a.info.partNumber; - const bNum = b.info.partNumber === -1 ? 999999 : b.info.partNumber; + const aNum = a.info.partNumber === -1 ? singleRank : a.info.partNumber; + const bNum = b.info.partNumber === -1 ? singleRank : b.info.partNumber; return aNum - bNum; });