// Name: File Archiver // Description: Creates ZIP archives from selected files and folders with smart naming and cleanup. // Author: dallascrilley // GitHub: dallascrilley import "@johnlindquist/kit" const zipBin = which("zip") if (!zipBin) { await notify("zip utility not found on PATH") await div(md("Please install the system 'zip' utility and try again.")) exit() } type DroppedItem = { path: string; name?: string } const dropped = await drop({ placeholder: "Drop files and folders to archive", enter: "Next", }) if (!Array.isArray(dropped)) { await div(md("Please drop files and/or folders.")) exit() } let inputPaths: string[] = [] for (const item of dropped as DroppedItem[]) { if (!item?.path) continue const p = item.path try { await access(p) inputPaths.push(p) } catch { // Skip items that can't be accessed } } if (inputPaths.length === 0) { await div(md("No valid files or folders found.")) exit() } // Determine naming const firstPath = inputPaths[0] const firstStats = await lstat(firstPath) let baseName = "" if (inputPaths.length === 1 && firstStats.isDirectory()) { baseName = path.basename(firstPath) } else if (inputPaths.length === 1 && firstStats.isFile()) { const parsed = path.parse(firstPath) baseName = parsed.name } else { baseName = `Archive-${formatDate(new Date(), "yyyy-MM-dd-HHmm")}` } const nameInput = inputPaths.length === 1 && firstStats.isDirectory() ? baseName : await arg({ placeholder: "Enter archive name (without .zip)", input: baseName, validate: (val: string) => (val.trim().length ? true : "Please enter a name"), }) function ensureZipName(n: string) { return n.toLowerCase().endsWith(".zip") ? n : `${n}.zip` } const destDir = await selectFolder("Choose destination folder") let archiveName = ensureZipName(nameInput) let destZipPath = path.join(destDir, archiveName) // Prevent duplicate naming conflicts by auto-incrementing function incName(p: string) { const { dir, name, ext } = path.parse(p) const m = name.match(/^(.*)\s\((\d+)\)$/) if (m) { const base = m[1] const num = parseInt(m[2], 10) + 1 return path.join(dir, `${base} (${num})${ext}`) } else { return path.join(dir, `${name} (1)${ext}`) } } while (await pathExists(destZipPath)) { destZipPath = incName(destZipPath) } // Build the archive const sh = (s: string) => `"${s.replace(/"/g, '\\"')}"` setStatus("Preparing...") setProgress(10) const excludeArgs = `-x "*/.DS_Store" -x "__MACOSX/*"` try { if (inputPaths.length === 1) { const target = inputPaths[0] const isDirTarget = (await lstat(target)).isDirectory() const parent = path.dirname(target) const relName = path.basename(target) setStatus("Archiving...") setProgress(60) // -r recursive, -X strip extra fields, -y store symlinks as the link await exec( `zip -r -X -y ${sh(destZipPath)} ${sh(relName)} ${excludeArgs}`, { cwd: parent } ) } else { // Mixed or multiple items: stage into a temp folder so we control the root const suggestedRoot = path.parse(destZipPath).name const stagingRoot = tmpPath(`archiver-${uuid()}`) const payloadRoot = path.join(stagingRoot, suggestedRoot) await ensureDir(payloadRoot) setStatus("Staging files...") setProgress(35) for (const p of inputPaths) { const st = await lstat(p) if (st.isDirectory()) { // Copy directory into payload root cp("-R", p, payloadRoot) } else if (st.isFile()) { cp(p, payloadRoot) } } setStatus("Archiving...") setProgress(70) await exec( `zip -r -X -y ${sh(destZipPath)} ${sh(suggestedRoot)} ${excludeArgs}`, { cwd: stagingRoot } ) setStatus("Cleaning up...") setProgress(85) await remove(stagingRoot) } setProgress(100) await notify(`Archive created: ${path.basename(destZipPath)}`) await revealFile(destZipPath) } catch (err) { await div(md(`Error while creating archive:\n\n${String(err)}`)) exit(1) }