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:
2026-08-22 17:16:09 +02:00
co-authored by Claude Sonnet 5
parent d786f3f23b
commit d8079c412a
4 changed files with 371 additions and 40 deletions
+72
View File
@@ -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);
});
});