···11-import { fromStream } from "@atcute/repo";
22-import { zip, type ZipEntry } from "@mary/zip";
33-import { createSignal, onCleanup } from "solid-js";
44-import { createDropHandler, createFileChangeHandler, handleDragOver } from "./file-handlers.js";
55-import { createLogger, LoggerView } from "./logger.jsx";
66-import { isIOS, toJsonValue, WelcomeView } from "./shared.jsx";
77-88-// Check if browser natively supports File System Access API
99-const hasNativeFileSystemAccess = "showSaveFilePicker" in window;
1010-1111-// HACK: Disable compression on WebKit due to an error being thrown
1212-const isWebKit =
1313- isIOS || (/AppleWebKit/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent));
1414-1515-const INVALID_CHAR_RE = /[<>:"/\\|?*\x00-\x1F]/g;
1616-const filenamify = (name: string) => {
1717- return name.replace(INVALID_CHAR_RE, "~");
1818-};
1919-2020-export const UnpackToolView = () => {
2121- const logger = createLogger();
2222- const [pending, setPending] = createSignal(false);
2323-2424- let abortController: AbortController | undefined;
2525-2626- onCleanup(() => {
2727- abortController?.abort();
2828- });
2929-3030- const unpackToZip = async (file: File) => {
3131- abortController?.abort();
3232- abortController = new AbortController();
3333- const signal = abortController.signal;
3434-3535- setPending(true);
3636- logger.log(`Starting extraction`);
3737-3838- let repo: Awaited<ReturnType<typeof fromStream>> | undefined;
3939-4040- const stream = file.stream();
4141- repo = fromStream(stream);
4242-4343- try {
4444- let count = 0;
4545-4646- // On Safari/browsers without native File System Access API, use blob download
4747- if (!hasNativeFileSystemAccess) {
4848- const chunks: BlobPart[] = [];
4949-5050- const entryGenerator = async function* (): AsyncGenerator<ZipEntry> {
5151- const progress = logger.progress(`Unpacking records (0 entries)`);
5252-5353- try {
5454- for await (const entry of repo) {
5555- if (signal.aborted) return;
5656-5757- try {
5858- const record = toJsonValue(entry.record);
5959- const filename = `${entry.collection}/${filenamify(entry.rkey)}.json`;
6060- const data = JSON.stringify(record, null, 2);
6161-6262- yield { filename, data, compress: isWebKit ? false : "deflate" };
6363- count++;
6464- progress.update(`Unpacking records (${count} entries)`);
6565- } catch {
6666- // Skip entries with invalid data
6767- }
6868- }
6969- } finally {
7070- progress[Symbol.dispose]?.();
7171- }
7272- };
7373-7474- for await (const chunk of zip(entryGenerator())) {
7575- if (signal.aborted) return;
7676- chunks.push(chunk as BlobPart);
7777- }
7878-7979- if (signal.aborted) return;
8080-8181- logger.log(`${count} records extracted`);
8282- logger.log(`Creating download...`);
8383-8484- const blob = new Blob(chunks, { type: "application/zip" });
8585- const url = URL.createObjectURL(blob);
8686- const a = document.createElement("a");
8787- a.href = url;
8888- a.download = `${file.name.replace(/\.car$/, "")}.zip`;
8989- document.body.appendChild(a);
9090- a.click();
9191- document.body.removeChild(a);
9292- URL.revokeObjectURL(url);
9393-9494- logger.log(`Finished! Download started.`);
9595- setPending(false);
9696- return;
9797- }
9898-9999- // Native File System Access API path
100100- let writable: FileSystemWritableFileStream | undefined;
101101-102102- // Create async generator that yields ZipEntry as we read from CAR
103103- const entryGenerator = async function* (): AsyncGenerator<ZipEntry> {
104104- const progress = logger.progress(`Unpacking records (0 entries)`);
105105-106106- try {
107107- for await (const entry of repo) {
108108- if (signal.aborted) return;
109109-110110- // Prompt for save location on first record
111111- if (writable === undefined) {
112112- const waiting = logger.progress(`Waiting for user...`);
113113-114114- try {
115115- // eslint-disable-next-line @typescript-eslint/no-explicit-any
116116- const fd = await (window as any)
117117- .showSaveFilePicker({
118118- suggestedName: `${file.name.replace(/\.car$/, "")}.zip`,
119119- id: "car-unpack",
120120- startIn: "downloads",
121121- types: [
122122- {
123123- description: "ZIP archive",
124124- accept: { "application/zip": [".zip"] },
125125- },
126126- ],
127127- })
128128- .catch((err: unknown) => {
129129- if (err instanceof DOMException && err.name === "AbortError") {
130130- logger.warn(`File picker was cancelled`);
131131- } else {
132132- logger.warn(`Something went wrong when opening the file picker`);
133133- }
134134- return undefined;
135135- });
136136-137137- if (!fd) {
138138- logger.warn(`No file handle obtained`);
139139- return;
140140- }
141141-142142- writable = await fd.createWritable();
143143-144144- if (writable === undefined) {
145145- logger.warn(`Failed to create writable stream`);
146146- return;
147147- }
148148- } finally {
149149- waiting[Symbol.dispose]?.();
150150- }
151151- }
152152-153153- try {
154154- const record = toJsonValue(entry.record);
155155- const filename = `${entry.collection}/${filenamify(entry.rkey)}.json`;
156156- const data = JSON.stringify(record, null, 2);
157157-158158- yield { filename, data, compress: isWebKit ? false : "deflate" };
159159- count++;
160160- progress.update(`Unpacking records (${count} entries)`);
161161- } catch {
162162- // Skip entries with invalid data
163163- }
164164- }
165165- } finally {
166166- progress[Symbol.dispose]?.();
167167- }
168168- };
169169-170170- // Stream entries directly to zip, then to file
171171- let writeCount = 0;
172172- for await (const chunk of zip(entryGenerator())) {
173173- if (signal.aborted) {
174174- await writable?.abort();
175175- return;
176176- }
177177- if (writable === undefined) {
178178- // User cancelled file picker
179179- setPending(false);
180180- return;
181181- }
182182- writeCount++;
183183- // Await every 100th write to apply backpressure
184184- if (writeCount % 100 === 0) {
185185- await writable.write(chunk as BufferSource);
186186- } else {
187187- writable.write(chunk as BufferSource);
188188- }
189189- }
190190-191191- if (signal.aborted) return;
192192-193193- if (writable === undefined) {
194194- logger.warn(`CAR file has no records`);
195195- setPending(false);
196196- return;
197197- }
198198-199199- logger.log(`${count} records extracted`);
200200-201201- {
202202- const flushProgress = logger.progress(`Flushing writes...`);
203203- try {
204204- await writable.close();
205205- logger.log(`Finished! File saved successfully.`);
206206- } catch (err) {
207207- logger.error(`Failed to save file: ${err}`);
208208- throw err; // Re-throw to be caught by outer catch
209209- } finally {
210210- flushProgress[Symbol.dispose]?.();
211211- }
212212- }
213213- } catch (err) {
214214- if (signal.aborted) return;
215215- logger.error(`Error: ${err}\nFile might be malformed, or might not be a CAR archive`);
216216- } finally {
217217- await repo?.dispose();
218218- if (!signal.aborted) {
219219- setPending(false);
220220- }
221221- }
222222- };
223223-224224- const handleFileChange = createFileChangeHandler(unpackToZip);
225225-226226- // Wrap handleDrop to prevent multiple simultaneous uploads
227227- const baseDrop = createDropHandler(unpackToZip);
228228- const handleDrop = (e: DragEvent) => {
229229- if (pending()) return;
230230- baseDrop(e);
231231- };
232232-233233- document.title = "Unpack archive - PDSls";
234234- return (
235235- <>
236236- <WelcomeView
237237- title="Unpack archive"
238238- subtitle="Upload a CAR file to extract all records into a ZIP archive."
239239- loading={pending()}
240240- onFileChange={handleFileChange}
241241- onDrop={handleDrop}
242242- onDragOver={handleDragOver}
243243- />
244244- <div class="w-full max-w-3xl px-2">
245245- <LoggerView logger={logger} />
246246- </div>
247247- </>
248248- );
249249-};
+1-1
src/views/home.tsx
···188188 href="/car"
189189 icon="lucide--folder-archive"
190190 title="Archive"
191191- description="Unpack CAR files"
191191+ description="Explore CAR files"
192192 accent="violet"
193193 />
194194 </div>
+2-10
src/worker.js
···365365 },
366366 "/labels": { title: "Labels", description: "Query labels applied to accounts and records." },
367367 "/car": {
368368- title: "Archive tools",
369369- description: "Tools for working with CAR (Content Addressable aRchive) files.",
370370- },
371371- "/car/explore": {
372372- title: "Explore archive",
373373- description: "Upload a CAR file to explore its contents.",
374374- },
375375- "/car/unpack": {
376376- title: "Unpack archive",
377377- description: "Upload a CAR file to extract all records into a ZIP archive.",
368368+ title: "CAR explorer",
369369+ description: "Upload an archive to explore or export its contents.",
378370 },
379371 "/settings": { title: "Settings", description: "Browse the public data on atproto" },
380372};