diff --git a/messages/en.json b/messages/en.json index f4cf496..7fac2f8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -50,6 +50,11 @@ "zip_file": { "extracting": "Detected ZIP archive: {filename}", "extracted": "Extracted {extract_count} files from {filename}. {ignore_count} items were ignored.", + "detected": "Detected {type} files in {filename}.", + "audio": "audio", + "video": "video", + "doc": "document", + "image": "image", "extract_error": "Error extracting {filename}: {error}" }, "large_file_warning": "Due to browser / device limitations, video to audio conversion is disabled for this file as it is larger than {limit}GB. We recommend using Firefox or Safari for files of this size since they have less limitations.", diff --git a/src/lib/converters/magick-automated.ts b/src/lib/converters/magick-automated.ts index 17df80c..18f96b8 100644 --- a/src/lib/converters/magick-automated.ts +++ b/src/lib/converters/magick-automated.ts @@ -4,7 +4,6 @@ import { FormatInfo } from "./converter.svelte"; // placed into this file to easily differentiate (and also clean up the main magick file) // some formats also have a comment from what i saw during testing export const imageFormats = [ - // TODO: yaml, json, txt - not sure when these are used (just contains image info seemingly?), probably will remove cause its not very useful for 99.99% of people new FormatInfo("a", false, true), new FormatInfo("aai", true, true), new FormatInfo("ai", false, true), diff --git a/src/lib/store/index.svelte.ts b/src/lib/store/index.svelte.ts index 7d14e56..76b36dd 100644 --- a/src/lib/store/index.svelte.ts +++ b/src/lib/store/index.svelte.ts @@ -9,7 +9,6 @@ import PQueue from "p-queue"; import { getLocale, setLocale } from "$lib/paraglide/runtime"; import { m } from "$lib/paraglide/messages"; import sanitizeHtml from "sanitize-html"; -import { unzip } from "fflate"; import { ToastManager } from "$lib/toast/index.svelte"; import { GB } from "$lib/consts"; @@ -142,7 +141,6 @@ class Files { return url; } - private _warningShown = false; private async _handleZipFile(file: File): Promise { try { log(["files"], `extracting zip file: ${file.name}`); @@ -152,61 +150,95 @@ class Files { filename: file.name, }), }); - const arrayBuffer = await file.arrayBuffer(); - const uint8Array = new Uint8Array(arrayBuffer); - return new Promise((resolve, reject) => { - unzip(uint8Array, (err, unzipped) => { - if (err) { - error( - ["files"], - `failed to extract zip: ${err.message}`, - ); - reject(err); - return; - } + const { extractZip } = await import("$lib/zip"); + const entries = await extractZip(file); - const itemCount = Object.keys(unzipped).length; - let ignoreCount = 0; + const totalEntries = entries.length; + log(["files"], `extracted ${totalEntries} files from zip`); - log(["files"], `extracted ${itemCount} files from zip`); + // check if all files in zip use the same converter and are compatible + const convertersUsed = new Set(); + let incompatibleFiles = false; - for (const [filename, data] of Object.entries(unzipped)) { - if ( - filename.startsWith(".") || - filename.includes("/__MACOSX/") || - filename.endsWith("/") - ) { - ignoreCount++; - continue; - } + for (const { filename } of entries) { + const format = "." + filename.split(".").pop()?.toLowerCase(); + if (!format || format === ".zip") { + incompatibleFiles = true; + continue; + } - const buffer = Array.from(data); - const extractedFile = new File( - [new Uint8Array(buffer)], - filename, - { type: "application/octet-stream" }, - ); - this._add(extractedFile); - } + const converter = converters + .sort(byNative(format)) + .find((c) => c.formatStrings().includes(format)); - ToastManager.add({ - type: "success", - message: m["convert.zip_file.extracted"]({ - filename: file.name, - extract_count: itemCount - ignoreCount, - ignore_count: ignoreCount, - }), - }); + if (converter) convertersUsed.add(converter.name); + else incompatibleFiles = true; + } - resolve(); + const converterCount = convertersUsed.size; + const canConvertAsOne = + converterCount === 1 && !incompatibleFiles; + + log( + ["files"], + `extracted ${entries.length} files from zip (converters: ${converterCount}, compatible: ${canConvertAsOne})`, + ); + + // TODO: allow user to extract zip if they want to convert individuals (along with image sequence option) in dropdown + if (canConvertAsOne) { + // all files use same converter - add zip as a single VertFile file + const vf = new VertFile(file, ".zip"); + vf.converters = converters.filter( + (c) => c.name === Array.from(convertersUsed)[0], + ); + + const converterName = vf.converters[0].name; + const type = + converterName === "imagemagick" + ? "image" + : converterName === "ffmpeg" + ? "audio" + : converterName === "pandoc" + ? "doc" + : "video"; + + this.files.push(vf); + this._addThumbnail(vf); + + ToastManager.add({ + type: "success", + message: m["convert.zip_file.detected"]({ + type: m[`convert.zip_file.${type}`](), + filename: file.name, + }), }); - }); + } else { + // mixed converters/incompatible files - extract all individually + for (const { filename, data } of entries) { + this._add( + new File([new Uint8Array(data)], filename, { + type: "application/octet-stream", + }), + ); + } + + ToastManager.add({ + type: "success", + message: m["convert.zip_file.extracted"]({ + filename: file.name, + extract_count: entries.length, + ignore_count: 0, + }), + }); + } } catch (e) { error(["files"], `error processing zip file: ${e}`); + throw e; } } + private _warningShown = false; private async _add(file: VertFile | File) { if (file instanceof VertFile) { this.files.push(file); diff --git a/src/lib/types/file.svelte.ts b/src/lib/types/file.svelte.ts index a098faa..093820e 100644 --- a/src/lib/types/file.svelte.ts +++ b/src/lib/types/file.svelte.ts @@ -30,6 +30,8 @@ export class VertFile { public converters: Converter[] = []; + public isZip = $state(() => this.from === ".zip"); + public findConverters(supportedFormats: string[] = [this.from]) { const converter = this.converters .filter((converter) => @@ -42,6 +44,9 @@ export class VertFile { } public findConverter() { + // zip will always only be added if there's one converter that supports all files - handled in store's _handleZipFile() + if (this.isZip()) return this.converters[0]; + const converter = this.converters.find((converter) => { if ( !converter.formatStrings().includes(this.from) || @@ -101,18 +106,65 @@ export class VertFile { this.cancelled = false; let res; try { - res = await converter.convert(this, this.to, ...args); + // for zips: extract > convert each > re-zip + // else convert normally + res = this.isZip() + ? await this.convertZip(converter) + : await converter.convert(this, this.to, ...args); this.result = res; } catch (err) { - if (!this.cancelled) { - this.toastErr(err); - } + if (!this.cancelled) this.toastErr(err); this.result = null; } this.processing = false; return res; } + private async convertZip(converter: Converter): Promise { + const { extractZip, createZip } = await import("$lib/zip"); + + const entries = await extractZip(this.file); + const totalFiles = entries.length; + let processedFiles = 0; + const convertedFiles: File[] = []; + + // convert all files in the zip + for (const { filename, data } of entries) { + if (this.cancelled) { + throw new Error("Conversion cancelled"); + } + + const file = new File([new Uint8Array(data)], filename, { + type: "application/octet-stream", + }); + const tempVFile = new VertFile(file, this.to); + tempVFile.converters = [converter]; + + const converted = await converter.convert(tempVFile, this.to); + + let outputExt = this.to; + if (!outputExt.startsWith(".")) outputExt = `.${outputExt}`; + const baseName = filename.replace(/\.[^/.]+$/, ""); + const outputFilename = `${baseName}${outputExt}`; + + convertedFiles.push( + new File([await converted.file.arrayBuffer()], outputFilename), + ); + + processedFiles++; + this.progress = Math.round((processedFiles / totalFiles) * 100); // TODO: show live progress between all files rather than per file + } + + // return zip of converted files + const resultArray = await createZip(convertedFiles); + const outputFilename = this.file.name.replace(/\.[^/.]+$/, ".zip"); + const resultFile = new File( + [new Uint8Array(resultArray)], + outputFilename, + ); + return new VertFile(resultFile, ".zip"); + } + public async cancel() { if (!this.processing) return; const converter = this.findConverter(); diff --git a/src/lib/zip.ts b/src/lib/zip.ts new file mode 100644 index 0000000..0f54696 --- /dev/null +++ b/src/lib/zip.ts @@ -0,0 +1,49 @@ +import { error, log } from "$lib/logger"; +import { unzip } from "fflate"; +import { downloadZip } from "client-zip"; + +export interface ZipEntry { + filename: string; + data: Uint8Array; +} + +export async function extractZip(file: File): Promise { + log(["zip"], `extracting zip: ${file.name}`); + + const arrayBuffer = await file.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + + return new Promise((resolve, reject) => { + unzip(uint8Array, (err, unzipped) => { + if (err) { + error(["zip"], `failed to extract zip: ${err.message}`); + reject(new Error(`Failed to extract zip: ${err.message}`)); + return; + } + + const entries = Object.entries(unzipped) + .filter(([filename]) => !ignoreEntry(filename)) + .map(([filename, data]) => ({ + filename, + data: new Uint8Array(data), + })); + + log(["zip"], `extracted ${entries.length} entries from ${file.name}`); + resolve(entries); + }); + }); +} + +export async function createZip(files: File[]): Promise { + log(["zip"], `creating zip with ${files.length} files`); + const zipBlob = await downloadZip(files).blob(); + return new Uint8Array(await zipBlob.arrayBuffer()); +} + +export function ignoreEntry(filename: string): boolean { + return ( + filename.startsWith(".") || + filename.includes("/__MACOSX/") || + filename.endsWith("/") + ); +} diff --git a/src/routes/convert/+page.svelte b/src/routes/convert/+page.svelte index e6959f2..98f862a 100644 --- a/src/routes/convert/+page.svelte +++ b/src/routes/convert/+page.svelte @@ -127,8 +127,6 @@ } else showGradient.set(true); gradientColor.set(type); - - // TODO: filter out categories that cant be converted between });