From 145294df80bd37e5e95fc6e74c961fec7517dd7a Mon Sep 17 00:00:00 2001 From: neoapps-dev Date: Mon, 3 Aug 2026 12:02:31 +0300 Subject: [PATCH] feat: support for split archives --- src/App.tsx | 94 +++++++++++++++++++++++++++++++++++++---- src/UploadModal.tsx | 100 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 176 insertions(+), 18 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 9894e2d..114f8c9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,58 @@ import type { RegistryResponse, MetaJson } from './api/types'; import { Key, X, Download, Upload, ExternalLink } from 'lucide-react'; import UploadModal from './UploadModal'; const GITHUB_CLIENT_ID = 'Ov23lioBHF1VmiCOoYui'; + +interface ZipGroup { + main: string; + parts: string[]; + dest: string; +} + +const groupZips = (zips: Record): ZipGroup[] => { + const entries = Object.entries(zips).sort(([a], [b]) => a.localeCompare(b)); + const consumed = new Set(); + const groups: ZipGroup[] = []; + for (const [name, dest] of entries) { + if (consumed.has(name)) continue; + if (!name.toLowerCase().endsWith('.zip')) continue; + const base = name.slice(0, -4); + const parts = [name]; + consumed.add(name); + let n = 1; + for (;;) { + const cand = `${base}.z${String(n).padStart(2, '0')}`; + if (zips[cand] !== undefined) { + parts.push(cand); + consumed.add(cand); + n++; + } else { + break; + } + } + if (parts.length === 1) { + n = 1; + for (;;) { + const cand = `${base}.zip.${String(n).padStart(3, '0')}`; + if (zips[cand] !== undefined) { + parts.push(cand); + consumed.add(cand); + n++; + } else { + break; + } + } + } + groups.push({ main: name, parts, dest }); + } + for (const [name, dest] of entries) { + if (!consumed.has(name)) { + consumed.add(name); + groups.push({ main: name, parts: [name], dest }); + } + } + return groups; +}; + function App() { const [registry, setRegistry] = useState(null); const [error, setError] = useState(null); @@ -255,15 +307,39 @@ function App() {

Downloads & Installation

- {Object.entries(selectedItem.zips).map(([zipName, extractPath]) => ( -
-
- {zipName} -
Extract to {resolvePath(extractPath)}
-
- - - + {groupZips(selectedItem.zips).map((group) => ( +
+ {group.parts.map((part, i) => ( +
+
+ + {part} + {group.parts.length > 1 && ( + + part {i + 1}/{group.parts.length} + + )} + +
+ {i === 0 + ? <>Extract to {resolvePath(group.dest)} + : <>Continuation volume — keep all parts together to extract} +
+
+ + + +
+ ))}
))}
diff --git a/src/UploadModal.tsx b/src/UploadModal.tsx index 24b190b..d9aa80e 100644 --- a/src/UploadModal.tsx +++ b/src/UploadModal.tsx @@ -33,6 +33,35 @@ const toBase64 = (file: File): Promise => reader.onerror = (error) => reject(error); }); +const isSplitPart = (name: string) => + /\.z\d+$/i.test(name) || /\.zip\.\d+$/i.test(name); + +const parentZip = (name: string): string | null => { + const z = name.match(/^(.*)\.z\d+$/i); + if (z) return `${z[1]}.zip`; + const n = name.match(/^(.*\.zip)\.\d+$/i); + if (n) return n[1]; + return null; +}; + +const syncSplitPaths = (files: { file: File; extractPath: string }[]) => { + const parentPaths = new Map(); + for (const zf of files) { + if (!isSplitPart(zf.file.name)) { + parentPaths.set(zf.file.name, zf.extractPath); + } + } + return files.map((zf) => { + if (isSplitPart(zf.file.name)) { + const parent = parentZip(zf.file.name); + if (parent && parentPaths.has(parent)) { + return { ...zf, extractPath: parentPaths.get(parent)! }; + } + } + return zf; + }); +}; + export default function UploadModal({ onClose, pat }: UploadModalProps) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -67,18 +96,25 @@ export default function UploadModal({ onClose, pat }: UploadModalProps) { const handleZipFiles = (e: React.ChangeEvent) => { if (e.target.files) { - const newFiles = Array.from(e.target.files).map((file) => ({ - file, - extractPath: "{DLCDir}", - })); - setZipFiles((prev) => [...prev, ...newFiles]); + const newFiles = Array.from(e.target.files).map((file) => { + let extractPath = "{DLCDir}"; + const parent = parentZip(file.name); + if (parent) { + const sibling = zipFiles.find((zf) => zf.file.name === parent); + if (sibling) extractPath = sibling.extractPath; + } + return { file, extractPath }; + }); + setZipFiles((prev) => syncSplitPaths([...prev, ...newFiles])); } }; const updateZipPath = (index: number, path: string) => { - const updated = [...zipFiles]; - updated[index].extractPath = path; - setZipFiles(updated); + setZipFiles((prev) => { + const updated = [...prev]; + updated[index] = { ...updated[index], extractPath: path }; + return syncSplitPaths(updated); + }); }; const removeZip = (index: number) => { @@ -107,6 +143,25 @@ export default function UploadModal({ onClose, pat }: UploadModalProps) { setError("Please provide at least one .zip file."); return; } + const zipNames = new Set(zipFiles.map((zf) => zf.file.name)); + for (const zf of zipFiles) { + if (isSplitPart(zf.file.name)) { + const parent = parentZip(zf.file.name); + if (!parent || !zipNames.has(parent)) { + setError( + `"${zf.file.name}" is part of a split archive but "${parent}" is missing. Upload the main archive and every part.`, + ); + return; + } + const parentFile = zipFiles.find((x) => x.file.name === parent); + if (parentFile && parentFile.extractPath !== zf.extractPath) { + setError( + `"${zf.file.name}" must extract to the same path as "${parent}".`, + ); + return; + } + } + } setError(null); setLoading(true); try { @@ -1125,12 +1180,24 @@ export default function UploadModal({ onClose, pat }: UploadModalProps) {
+
+ Split archives (e.g. .z01, .z02) are + supported. Select every part together and they must share the same + extraction path. +
{zipFiles.map((zf, idx) => (
{zf.file.name} + {isSplitPart(zf.file.name) && ( + + Split part + + )}