From 8ccb2339ca2c3164b2d8422843d2b9a27ed765bf Mon Sep 17 00:00:00 2001 From: mwiegand Date: Sun, 17 May 2026 17:06:44 +0200 Subject: [PATCH] fix(files): handle double-extensions in withCollisionSuffix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy implementation used lastIndexOf('.') which splits inside compressed-tarball extensions: foo.tar.gz collided to foo.tar (1).gz, which then isn't valid as a .gz to unpack and is also ugly. Recognize the common double-extensions (.tar.gz, .tar.bz2, .tar.xz, .tar.zst, .tar.lz, .tar.lzma) and treat the entire suffix as a single logical extension when collision-renaming. So: foo.txt → foo (1).txt (unchanged) foo.tar.gz → foo (1).tar.gz (fixed) archive.tar.bz2 → archive (1).tar.bz2 (fixed) ARCHIVE.TAR.XZ → ARCHIVE (1).TAR.XZ (case-insensitive detect) subdir/foo.tar.gz → subdir/foo (1).tar.gz (works with nested paths) backup.tar → backup (1).tar (single .tar, unchanged) config.local.json → config.local (1).json (multi-dot non-tar, unchanged) README → README (1) (no extension, unchanged) .hidden → ' (1).hidden' (legacy quirk preserved) Detection is lowercase against the basename only, so /path/with.dots/ in folder names doesn't trip the match. The .hidden edge case (leading-dot basename, no extension) keeps its legacy '" (1).hidden"' result — fixing it requires changing the dot > slash predicate which would also shift other behaviors. Marked separately if anyone wants to revisit. The helper is exposed on window.__filesOverlay.withCollisionSuffix and is called from two paths in uploads.js (the upload-conflict 'keep both' branch and the move-conflict 'keep both' branch via askConflict). Both paths now produce a sensible filename when the colliding path uses a double-extension. No pytest tests added: the helper is JS-only and the project has no JS test framework (per the plan's Out-of-Scope clause). Chromium manual verification via window.__filesOverlay.withCollisionSuffix called on 10 inputs confirms each case above. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../static/js/files-overlay/uploads.js | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/l4d2web/l4d2web/static/js/files-overlay/uploads.js b/l4d2web/l4d2web/static/js/files-overlay/uploads.js index f0f78e6..ce03fac 100644 --- a/l4d2web/l4d2web/static/js/files-overlay/uploads.js +++ b/l4d2web/l4d2web/static/js/files-overlay/uploads.js @@ -37,11 +37,29 @@ const uploadsList = uploadsPanel?.querySelector(".files-uploads-list"); const uploadsClearBtn = uploadsPanel?.querySelector(".files-uploads-clear"); - // Attach a path-collision suffix: foo.txt → foo (1).txt + // Attach a path-collision suffix: foo.txt → foo (1).txt. + // Recognizes common compressed-tar double-extensions so + // foo.tar.gz → foo (1).tar.gz, not foo.tar (1).gz. + const DOUBLE_EXTS = [ + ".tar.gz", ".tar.bz2", ".tar.xz", ".tar.zst", ".tar.lz", ".tar.lzma", + ]; function withCollisionSuffix(path) { - const dot = path.lastIndexOf("."); const slash = path.lastIndexOf("/"); - if (dot > slash + 0 && dot > -1) { + const stemStart = slash + 1; + const basename = path.slice(stemStart); + const lower = basename.toLowerCase(); + for (const ext of DOUBLE_EXTS) { + if (lower.endsWith(ext) && basename.length > ext.length) { + const cut = stemStart + basename.length - ext.length; + return path.slice(0, cut) + " (1)" + path.slice(cut); + } + } + // Single extension (or none). A dot must appear after the last + // slash to count as an extension — preserves the legacy behavior + // where a leading-dot basename like ".hidden" gets the suffix at + // the end rather than at position 0. + const dot = path.lastIndexOf("."); + if (dot > slash && dot > -1) { return path.slice(0, dot) + " (1)" + path.slice(dot); } return path + " (1)";