atmosphere explorer pds.ls
tool typescript atproto
434
fork

Configure Feed

Select the types of activity you want to include in your feed.

merge car tools

Juliet 740b60db a975fcad

+284 -628
+1 -5
src/index.tsx
··· 3 3 import { render } from "solid-js/web"; 4 4 import { Layout } from "./layout.tsx"; 5 5 import "./styles/index.css"; 6 - import { ExploreToolView } from "./views/car/explore.tsx"; 7 - import { CarView } from "./views/car/index.tsx"; 8 - import { UnpackToolView } from "./views/car/unpack.tsx"; 6 + import { CarView } from "./views/car/explore.tsx"; 9 7 import { CollectionLayout } from "./views/collection.tsx"; 10 8 import { Home } from "./views/home.tsx"; 11 9 import { LabelView } from "./views/labels.tsx"; ··· 22 20 <Route path={["/jetstream", "/firehose", "/spacedust"]} component={StreamView} /> 23 21 <Route path="/labels" component={LabelView} /> 24 22 <Route path="/car" component={CarView} /> 25 - <Route path="/car/explore" component={ExploreToolView} /> 26 - <Route path="/car/unpack" component={UnpackToolView} /> 27 23 <Route path="/settings" component={Settings} /> 28 24 <Route path="/:pds" component={PdsLayout}> 29 25 <Route path="/" />
+1 -1
src/layout.tsx
··· 153 153 <NavMenu href="/spacedust" label="Spacedust" icon="lucide--sparkles" /> 154 154 <MenuSeparator /> 155 155 <NavMenu href="/labels" label="Labels" icon="lucide--tag" /> 156 - <NavMenu href="/car" label="Archive tools" icon="lucide--folder-archive" /> 156 + <NavMenu href="/car" label="CAR explorer" icon="lucide--folder-archive" /> 157 157 <MenuSeparator /> 158 158 <NavMenu href="/settings" label="Settings" icon="lucide--settings" /> 159 159 </DropdownMenu>
+279 -21
src/views/car/explore.tsx
··· 4 4 import { Did } from "@atcute/lexicons"; 5 5 import { fromStream, isCommit } from "@atcute/repo"; 6 6 import * as TID from "@atcute/tid"; 7 + import { zip, type ZipEntry } from "@mary/zip"; 7 8 import { useLocation, useNavigate } from "@solidjs/router"; 8 9 import { 9 10 createEffect, ··· 18 19 import { Button } from "../../components/button.jsx"; 19 20 import { Favicon } from "../../components/favicon.jsx"; 20 21 import HoverCard from "../../components/hover-card/base"; 21 - import { JSONValue } from "../../components/json.jsx"; 22 + import { JSONValue, type JSONType } from "../../components/json.jsx"; 22 23 import { TextInput } from "../../components/text-input.jsx"; 23 24 import { didDocCache, resolveDidDoc } from "../../lib/api.js"; 24 25 import { createDebouncedValue } from "../../lib/debounced.js"; 25 26 import { localDateFromTimestamp } from "../../utils/date.js"; 26 27 import { createDropHandler, createFileChangeHandler, handleDragOver } from "./file-handlers.js"; 27 - import { 28 - type Archive, 29 - type CollectionEntry, 30 - type RecordEntry, 31 - type View, 32 - toJsonValue, 33 - WelcomeView, 34 - } from "./shared.jsx"; 28 + 29 + const isIOS = 30 + /iPad|iPhone|iPod/.test(navigator.userAgent) || 31 + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); 32 + 33 + const toJsonValue = (obj: unknown): JSONType => { 34 + if (obj === null || obj === undefined) return null; 35 + 36 + if (CID.isCidLink(obj)) { 37 + return { $link: obj.$link }; 38 + } 39 + 40 + if ( 41 + obj && 42 + typeof obj === "object" && 43 + "version" in obj && 44 + "codec" in obj && 45 + "digest" in obj && 46 + "bytes" in obj 47 + ) { 48 + try { 49 + return { $link: CID.toString(obj as CID.Cid) }; 50 + } catch {} 51 + } 52 + 53 + if (CBOR.isBytes(obj)) { 54 + return { $bytes: obj.$bytes }; 55 + } 56 + 57 + if (Array.isArray(obj)) { 58 + return obj.map(toJsonValue); 59 + } 60 + 61 + if (typeof obj === "object") { 62 + const result: Record<string, JSONType> = {}; 63 + for (const [key, value] of Object.entries(obj)) { 64 + result[key] = toJsonValue(value); 65 + } 66 + return result; 67 + } 68 + 69 + return obj as JSONType; 70 + }; 71 + 72 + interface Archive { 73 + file: File; 74 + did: string; 75 + entries: CollectionEntry[]; 76 + } 77 + 78 + interface CollectionEntry { 79 + name: string; 80 + entries: RecordEntry[]; 81 + } 82 + 83 + interface RecordEntry { 84 + key: string; 85 + cid: string; 86 + record: JSONType; 87 + } 88 + 89 + type View = 90 + | { type: "repo" } 91 + | { type: "collection"; collection: CollectionEntry } 92 + | { type: "record"; collection: CollectionEntry; record: RecordEntry }; 93 + 94 + const WelcomeView = (props: { 95 + title: string; 96 + subtitle: string; 97 + loading: boolean; 98 + progress?: number; 99 + error?: string; 100 + onFileChange: (e: Event) => void; 101 + onDrop: (e: DragEvent) => void; 102 + onDragOver: (e: DragEvent) => void; 103 + }) => { 104 + return ( 105 + <div class="flex w-full max-w-3xl flex-col gap-y-4 px-2"> 106 + <div class="flex flex-col gap-y-1"> 107 + <div class="flex items-center gap-2 text-lg"> 108 + <h1 class="font-semibold">{props.title}</h1> 109 + </div> 110 + <p class="text-sm text-neutral-600 dark:text-neutral-400">{props.subtitle}</p> 111 + </div> 112 + 113 + <div 114 + class="dark:bg-dark-300 flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-neutral-300 bg-neutral-50 p-8 transition-colors hover:border-neutral-400 dark:border-neutral-600 dark:hover:border-neutral-500" 115 + onDrop={props.onDrop} 116 + onDragOver={props.onDragOver} 117 + > 118 + <Show 119 + when={!props.loading} 120 + fallback={ 121 + <div class="flex flex-col items-center gap-2"> 122 + <span class="iconify lucide--loader-circle animate-spin text-3xl text-neutral-400" /> 123 + <span class="text-sm font-medium text-neutral-600 dark:text-neutral-400"> 124 + Reading CAR file... 125 + </span> 126 + <Show when={props.progress && props.progress > 0}> 127 + <span class="text-xs text-neutral-500 dark:text-neutral-400"> 128 + {props.progress?.toLocaleString()} records processed 129 + </span> 130 + </Show> 131 + </div> 132 + } 133 + > 134 + <span class="iconify lucide--folder-archive text-3xl text-neutral-400" /> 135 + <div class="text-center"> 136 + <p class="text-sm font-medium text-neutral-700 dark:text-neutral-300"> 137 + Drag and drop a CAR file here 138 + </p> 139 + <p class="text-xs text-neutral-500 dark:text-neutral-400">or</p> 140 + </div> 141 + <label class="dark:bg-dark-300 dark:hover:bg-dark-200 dark:active:bg-dark-100 flex items-center gap-1 rounded-md border border-neutral-300 bg-neutral-50 px-2.5 py-1.5 text-sm text-neutral-700 transition-colors select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:text-neutral-300"> 142 + <input 143 + type="file" 144 + accept={isIOS ? undefined : ".car,application/vnd.ipld.car"} 145 + onChange={props.onFileChange} 146 + class="hidden" 147 + /> 148 + <span class="iconify lucide--upload text-sm" /> 149 + Choose file 150 + </label> 151 + </Show> 152 + </div> 153 + 154 + <Show when={props.error}> 155 + <div class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300"> 156 + {props.error} 157 + </div> 158 + </Show> 159 + </div> 160 + ); 161 + }; 35 162 36 163 const viewToHash = (view: View): string => { 37 164 switch (view.type) { ··· 68 195 return { type: "repo" }; 69 196 }; 70 197 71 - export const ExploreToolView = () => { 198 + // Check if browser natively supports File System Access API 199 + const hasNativeFileSystemAccess = "showSaveFilePicker" in window; 200 + 201 + // HACK: Disable compression on WebKit due to an error being thrown 202 + const isWebKit = 203 + isIOS || (/AppleWebKit/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent)); 204 + 205 + const INVALID_CHAR_RE = /[<>:"/\\|?*\x00-\x1F]/g; 206 + const filenamify = (name: string) => { 207 + return name.replace(INVALID_CHAR_RE, "~"); 208 + }; 209 + 210 + export const CarView = () => { 72 211 const location = useLocation(); 73 212 const navigate = useNavigate(); 74 213 ··· 187 326 if (location.hash) navigate(location.pathname, { replace: true }); 188 327 }; 189 328 190 - document.title = "Explore archive - PDSls"; 329 + document.title = "CAR explorer - PDSls"; 191 330 return ( 192 331 <> 193 332 <Show 194 333 when={archive()} 195 334 fallback={ 196 335 <WelcomeView 197 - title="Explore archive" 198 - subtitle="Upload a CAR file to explore its contents." 336 + title="CAR explorer" 337 + subtitle="Upload an archive to explore or export its contents." 199 338 loading={loading()} 200 339 progress={progress()} 201 340 error={error()} ··· 213 352 ); 214 353 }; 215 354 355 + const exportToZip = async ( 356 + archive: Archive, 357 + onProgress: (count: number) => void, 358 + onSaving: () => void, 359 + ) => { 360 + const filename = archive.file.name.replace(/\.car$/, ""); 361 + 362 + const entryGenerator = async function* (): AsyncGenerator<ZipEntry> { 363 + let count = 0; 364 + for (const collection of archive.entries) { 365 + for (const record of collection.entries) { 366 + const path = `${collection.name}/${filenamify(record.key)}.json`; 367 + const data = JSON.stringify(record.record, null, 2); 368 + yield { filename: path, data, compress: isWebKit ? false : "deflate" }; 369 + if (++count % 500 === 0) { 370 + onProgress(count); 371 + await new Promise((resolve) => requestAnimationFrame(resolve)); 372 + } 373 + } 374 + } 375 + onProgress(count); 376 + }; 377 + 378 + if (hasNativeFileSystemAccess) { 379 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 380 + const fd = await (window as any) 381 + .showSaveFilePicker({ 382 + suggestedName: `${filename}.zip`, 383 + id: "car-unpack", 384 + startIn: "downloads", 385 + types: [ 386 + { 387 + description: "ZIP archive", 388 + accept: { "application/zip": [".zip"] }, 389 + }, 390 + ], 391 + }) 392 + .catch(() => undefined); 393 + 394 + if (!fd) return; 395 + 396 + const writable = await fd.createWritable(); 397 + if (!writable) return; 398 + 399 + let writeCount = 0; 400 + for await (const chunk of zip(entryGenerator())) { 401 + writeCount++; 402 + if (writeCount % 100 === 0) { 403 + await writable.write(chunk as BufferSource); 404 + } else { 405 + writable.write(chunk as BufferSource); 406 + } 407 + } 408 + 409 + onSaving(); 410 + await new Promise((resolve) => requestAnimationFrame(resolve)); 411 + await writable.close(); 412 + } else { 413 + const chunks: BlobPart[] = []; 414 + for await (const chunk of zip(entryGenerator())) { 415 + chunks.push(chunk as BlobPart); 416 + } 417 + 418 + onSaving(); 419 + await new Promise((resolve) => requestAnimationFrame(resolve)); 420 + const blob = new Blob(chunks, { type: "application/zip" }); 421 + const url = URL.createObjectURL(blob); 422 + const a = document.createElement("a"); 423 + a.href = url; 424 + a.download = `${filename}.zip`; 425 + document.body.appendChild(a); 426 + a.click(); 427 + document.body.removeChild(a); 428 + URL.revokeObjectURL(url); 429 + } 430 + }; 431 + 216 432 const ExploreView = (props: { 217 433 archive: Archive; 218 434 view: () => View; 219 435 setView: (view: View) => void; 220 436 onClose: () => void; 221 437 }) => { 438 + const [exporting, setExporting] = createSignal(false); 439 + const [exportCount, setExportCount] = createSignal(0); 440 + const [exportSaving, setExportSaving] = createSignal(false); 441 + 442 + const totalRecords = createMemo(() => 443 + props.archive.entries.reduce((sum, entry) => sum + entry.entries.length, 0), 444 + ); 445 + 446 + const handleExport = async () => { 447 + setExporting(true); 448 + setExportCount(0); 449 + setExportSaving(false); 450 + try { 451 + await exportToZip(props.archive, setExportCount, () => { 452 + setExportSaving(true); 453 + }); 454 + } finally { 455 + setExporting(false); 456 + setExportSaving(false); 457 + } 458 + }; 459 + 222 460 const handle = 223 461 didDocCache[props.archive.did]?.alsoKnownAs 224 462 ?.filter((alias) => alias.startsWith("at://"))[0] ··· 265 503 </span> 266 504 </button> 267 505 </Show> 268 - <button 269 - type="button" 270 - onClick={props.onClose} 271 - title="Close and upload a different file" 272 - class="flex shrink-0 items-center rounded px-2 py-1 text-neutral-500 transition-all duration-200 hover:bg-neutral-200/70 hover:text-neutral-600 active:bg-neutral-300/70 sm:py-1.5 dark:text-neutral-400 dark:hover:bg-neutral-700/70 dark:hover:text-neutral-300 dark:active:bg-neutral-600/70" 273 - > 274 - <span class="iconify lucide--x" /> 275 - </button> 506 + <div class="flex shrink-0 items-center gap-0.5"> 507 + <Show 508 + when={exporting()} 509 + fallback={ 510 + <button 511 + type="button" 512 + onClick={handleExport} 513 + class="flex shrink-0 items-center rounded px-2 py-1 text-xs text-neutral-500 transition-all duration-200 hover:bg-neutral-200/70 hover:text-neutral-600 active:bg-neutral-300/70 sm:py-1.5 dark:text-neutral-400 dark:hover:bg-neutral-700/70 dark:hover:text-neutral-300 dark:active:bg-neutral-600/70" 514 + > 515 + Export ZIP 516 + </button> 517 + } 518 + > 519 + <div class="flex items-center gap-2 px-2 py-1 sm:py-1.5"> 520 + <span class="iconify lucide--loader-circle animate-spin text-neutral-400" /> 521 + <span class="w-[4ch] text-right text-xs text-neutral-500 dark:text-neutral-400"> 522 + {exportSaving() ? "" : `${Math.round((exportCount() / totalRecords()) * 100)}%`} 523 + </span> 524 + </div> 525 + </Show> 526 + <button 527 + type="button" 528 + onClick={props.onClose} 529 + class="flex shrink-0 items-center rounded px-2 py-1 text-neutral-500 transition-all duration-200 hover:bg-neutral-200/70 hover:text-neutral-600 active:bg-neutral-300/70 sm:py-1.5 dark:text-neutral-400 dark:hover:bg-neutral-700/70 dark:hover:text-neutral-300 dark:active:bg-neutral-600/70" 530 + > 531 + <span class="iconify lucide--x" /> 532 + </button> 533 + </div> 276 534 </div> 277 535 278 536 {/* Collection Level */}
-43
src/views/car/index.tsx
··· 1 - import { A } from "@solidjs/router"; 2 - 3 - export const CarView = () => { 4 - document.title = "Archive tools - PDSls"; 5 - return ( 6 - <div class="flex w-full max-w-3xl flex-col gap-y-4 px-2"> 7 - <div class="flex flex-col gap-y-1"> 8 - <h1 class="text-lg font-semibold">Archive tools</h1> 9 - <p class="text-sm text-neutral-600 dark:text-neutral-400"> 10 - Tools for working with CAR (Content Addressable aRchive) files. 11 - </p> 12 - </div> 13 - 14 - <div class="flex flex-col gap-3"> 15 - <A 16 - href="explore" 17 - class="dark:bg-dark-300 flex items-start gap-3 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 text-left transition-colors hover:border-neutral-400 hover:bg-neutral-100 dark:border-neutral-600 dark:hover:border-neutral-500 dark:hover:bg-neutral-800" 18 - > 19 - <span class="iconify lucide--folder-search mt-0.5 shrink-0 text-xl text-neutral-500 dark:text-neutral-400" /> 20 - <div class="flex flex-col gap-1"> 21 - <span class="font-medium">Explore archive</span> 22 - <span class="text-sm text-neutral-600 dark:text-neutral-400"> 23 - Browse records inside a repository archive 24 - </span> 25 - </div> 26 - </A> 27 - 28 - <A 29 - href="unpack" 30 - class="dark:bg-dark-300 flex items-start gap-3 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 text-left transition-colors hover:border-neutral-400 hover:bg-neutral-100 dark:border-neutral-600 dark:hover:border-neutral-500 dark:hover:bg-neutral-800" 31 - > 32 - <span class="iconify lucide--file-archive mt-0.5 shrink-0 text-xl text-neutral-500 dark:text-neutral-400" /> 33 - <div class="flex flex-col gap-1"> 34 - <span class="font-medium">Unpack archive</span> 35 - <span class="text-sm text-neutral-600 dark:text-neutral-400"> 36 - Extract records from an archive into a ZIP file 37 - </span> 38 - </div> 39 - </A> 40 - </div> 41 - </div> 42 - ); 43 - };
-152
src/views/car/logger.tsx
··· 1 - import { For } from "solid-js"; 2 - import { createMutable } from "solid-js/store"; 3 - 4 - interface LogEntry { 5 - type: "log" | "info" | "warn" | "error"; 6 - at: number; 7 - msg: string; 8 - } 9 - 10 - interface PendingLogEntry { 11 - msg: string; 12 - } 13 - 14 - export const createLogger = () => { 15 - const pending = createMutable<PendingLogEntry[]>([]); 16 - 17 - let backlog: LogEntry[] | undefined = []; 18 - let push = (entry: LogEntry) => { 19 - backlog!.push(entry); 20 - }; 21 - 22 - return { 23 - internal: { 24 - get pending() { 25 - return pending; 26 - }, 27 - attach(fn: (entry: LogEntry) => void) { 28 - if (backlog !== undefined) { 29 - for (let idx = 0, len = backlog.length; idx < len; idx++) { 30 - fn(backlog[idx]); 31 - } 32 - backlog = undefined; 33 - } 34 - push = fn; 35 - }, 36 - }, 37 - log(msg: string) { 38 - push({ type: "log", at: Date.now(), msg }); 39 - }, 40 - info(msg: string) { 41 - push({ type: "info", at: Date.now(), msg }); 42 - }, 43 - warn(msg: string) { 44 - push({ type: "warn", at: Date.now(), msg }); 45 - }, 46 - error(msg: string) { 47 - push({ type: "error", at: Date.now(), msg }); 48 - }, 49 - progress(initialMsg: string, throttleMs = 500) { 50 - pending.unshift({ msg: initialMsg }); 51 - 52 - let entry: PendingLogEntry | undefined = pending[0]; 53 - 54 - return { 55 - update: throttle((msg: string) => { 56 - if (entry !== undefined) { 57 - entry.msg = msg; 58 - } 59 - }, throttleMs), 60 - destroy() { 61 - if (entry !== undefined) { 62 - const index = pending.indexOf(entry); 63 - pending.splice(index, 1); 64 - entry = undefined; 65 - } 66 - }, 67 - [Symbol.dispose]() { 68 - this.destroy(); 69 - }, 70 - }; 71 - }, 72 - }; 73 - }; 74 - 75 - export type Logger = ReturnType<typeof createLogger>; 76 - 77 - const formatter = new Intl.DateTimeFormat("en-US", { timeStyle: "short", hour12: false }); 78 - 79 - export const LoggerView = (props: { logger: Logger }) => { 80 - return ( 81 - <ul class="flex flex-col font-mono text-xs empty:hidden"> 82 - <For each={props.logger.internal.pending}> 83 - {(entry) => ( 84 - <li class="flex gap-2 px-4 py-1 whitespace-pre-wrap"> 85 - <span class="shrink-0 font-medium whitespace-pre-wrap text-neutral-400">-----</span> 86 - <span class="wrap-break-word">{entry.msg}</span> 87 - </li> 88 - )} 89 - </For> 90 - 91 - <div 92 - ref={(node) => { 93 - props.logger.internal.attach(({ type, at, msg }) => { 94 - let ecn = `flex gap-2 whitespace-pre-wrap px-4 py-1`; 95 - let tcn = `shrink-0 whitespace-pre-wrap font-medium`; 96 - if (type === "log") { 97 - tcn += ` text-neutral-500`; 98 - } else if (type === "info") { 99 - ecn += ` bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300`; 100 - tcn += ` text-blue-500`; 101 - } else if (type === "warn") { 102 - ecn += ` bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300`; 103 - tcn += ` text-amber-500`; 104 - } else if (type === "error") { 105 - ecn += ` bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300`; 106 - tcn += ` text-red-500`; 107 - } 108 - 109 - const item = ( 110 - <li class={ecn}> 111 - <span class={tcn}>{formatter.format(at)}</span> 112 - <span class="wrap-break-word">{msg}</span> 113 - </li> 114 - ); 115 - 116 - if (item instanceof Node) { 117 - node.after(item); 118 - } 119 - }); 120 - }} 121 - /> 122 - </ul> 123 - ); 124 - }; 125 - 126 - const throttle = <T extends (...args: any[]) => void>(func: T, wait: number) => { 127 - let timeout: ReturnType<typeof setTimeout> | null = null; 128 - let lastArgs: Parameters<T> | null = null; 129 - let lastCallTime = 0; 130 - 131 - const invoke = () => { 132 - func(...lastArgs!); 133 - lastCallTime = Date.now(); 134 - timeout = null; 135 - }; 136 - 137 - return (...args: Parameters<T>) => { 138 - const now = Date.now(); 139 - const timeSinceLastCall = now - lastCallTime; 140 - 141 - lastArgs = args; 142 - 143 - if (timeSinceLastCall >= wait) { 144 - if (timeout !== null) { 145 - clearTimeout(timeout); 146 - } 147 - invoke(); 148 - } else if (timeout === null) { 149 - timeout = setTimeout(invoke, wait - timeSinceLastCall); 150 - } 151 - }; 152 - };
-146
src/views/car/shared.tsx
··· 1 - import * as CBOR from "@atcute/cbor"; 2 - import * as CID from "@atcute/cid"; 3 - import { A } from "@solidjs/router"; 4 - import { Show } from "solid-js"; 5 - import { type JSONType } from "../../components/json.jsx"; 6 - 7 - export const isIOS = 8 - /iPad|iPhone|iPod/.test(navigator.userAgent) || 9 - (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); 10 - 11 - // Convert CBOR-decoded objects to JSON-friendly format 12 - export const toJsonValue = (obj: unknown): JSONType => { 13 - if (obj === null || obj === undefined) return null; 14 - 15 - if (CID.isCidLink(obj)) { 16 - return { $link: obj.$link }; 17 - } 18 - 19 - if ( 20 - obj && 21 - typeof obj === "object" && 22 - "version" in obj && 23 - "codec" in obj && 24 - "digest" in obj && 25 - "bytes" in obj 26 - ) { 27 - try { 28 - return { $link: CID.toString(obj as CID.Cid) }; 29 - } catch {} 30 - } 31 - 32 - if (CBOR.isBytes(obj)) { 33 - return { $bytes: obj.$bytes }; 34 - } 35 - 36 - if (Array.isArray(obj)) { 37 - return obj.map(toJsonValue); 38 - } 39 - 40 - if (typeof obj === "object") { 41 - const result: Record<string, JSONType> = {}; 42 - for (const [key, value] of Object.entries(obj)) { 43 - result[key] = toJsonValue(value); 44 - } 45 - return result; 46 - } 47 - 48 - return obj as JSONType; 49 - }; 50 - 51 - export interface Archive { 52 - file: File; 53 - did: string; 54 - entries: CollectionEntry[]; 55 - } 56 - 57 - export interface CollectionEntry { 58 - name: string; 59 - entries: RecordEntry[]; 60 - } 61 - 62 - export interface RecordEntry { 63 - key: string; 64 - cid: string; 65 - record: JSONType; 66 - } 67 - 68 - export type View = 69 - | { type: "repo" } 70 - | { type: "collection"; collection: CollectionEntry } 71 - | { type: "record"; collection: CollectionEntry; record: RecordEntry }; 72 - 73 - export const WelcomeView = (props: { 74 - title: string; 75 - subtitle: string; 76 - loading: boolean; 77 - progress?: number; 78 - error?: string; 79 - onFileChange: (e: Event) => void; 80 - onDrop: (e: DragEvent) => void; 81 - onDragOver: (e: DragEvent) => void; 82 - }) => { 83 - return ( 84 - <div class="flex w-full max-w-3xl flex-col gap-y-4 px-2"> 85 - <div class="flex flex-col gap-y-1"> 86 - <div class="flex items-center gap-2 text-lg"> 87 - <A 88 - href="/car" 89 - class="flex size-7 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-200 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200" 90 - > 91 - <span class="iconify lucide--arrow-left" /> 92 - </A> 93 - <h1 class="font-semibold">{props.title}</h1> 94 - </div> 95 - <p class="text-sm text-neutral-600 dark:text-neutral-400">{props.subtitle}</p> 96 - </div> 97 - 98 - <div 99 - class="dark:bg-dark-300 flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-neutral-300 bg-neutral-50 p-8 transition-colors hover:border-neutral-400 dark:border-neutral-600 dark:hover:border-neutral-500" 100 - onDrop={props.onDrop} 101 - onDragOver={props.onDragOver} 102 - > 103 - <Show 104 - when={!props.loading} 105 - fallback={ 106 - <div class="flex flex-col items-center gap-2"> 107 - <span class="iconify lucide--loader-circle animate-spin text-3xl text-neutral-400" /> 108 - <span class="text-sm font-medium text-neutral-600 dark:text-neutral-400"> 109 - Reading CAR file... 110 - </span> 111 - <Show when={props.progress && props.progress > 0}> 112 - <span class="text-xs text-neutral-500 dark:text-neutral-400"> 113 - {props.progress?.toLocaleString()} records processed 114 - </span> 115 - </Show> 116 - </div> 117 - } 118 - > 119 - <span class="iconify lucide--folder-archive text-3xl text-neutral-400" /> 120 - <div class="text-center"> 121 - <p class="text-sm font-medium text-neutral-700 dark:text-neutral-300"> 122 - Drag and drop a CAR file here 123 - </p> 124 - <p class="text-xs text-neutral-500 dark:text-neutral-400">or</p> 125 - </div> 126 - <label class="dark:bg-dark-300 dark:hover:bg-dark-200 dark:active:bg-dark-100 flex items-center gap-1 rounded-md border border-neutral-300 bg-neutral-50 px-2.5 py-1.5 text-sm text-neutral-700 transition-colors select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:text-neutral-300"> 127 - <input 128 - type="file" 129 - accept={isIOS ? undefined : ".car,application/vnd.ipld.car"} 130 - onChange={props.onFileChange} 131 - class="hidden" 132 - /> 133 - <span class="iconify lucide--upload text-sm" /> 134 - Choose file 135 - </label> 136 - </Show> 137 - </div> 138 - 139 - <Show when={props.error}> 140 - <div class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300"> 141 - {props.error} 142 - </div> 143 - </Show> 144 - </div> 145 - ); 146 - };
-249
src/views/car/unpack.tsx
··· 1 - import { fromStream } from "@atcute/repo"; 2 - import { zip, type ZipEntry } from "@mary/zip"; 3 - import { createSignal, onCleanup } from "solid-js"; 4 - import { createDropHandler, createFileChangeHandler, handleDragOver } from "./file-handlers.js"; 5 - import { createLogger, LoggerView } from "./logger.jsx"; 6 - import { isIOS, toJsonValue, WelcomeView } from "./shared.jsx"; 7 - 8 - // Check if browser natively supports File System Access API 9 - const hasNativeFileSystemAccess = "showSaveFilePicker" in window; 10 - 11 - // HACK: Disable compression on WebKit due to an error being thrown 12 - const isWebKit = 13 - isIOS || (/AppleWebKit/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent)); 14 - 15 - const INVALID_CHAR_RE = /[<>:"/\\|?*\x00-\x1F]/g; 16 - const filenamify = (name: string) => { 17 - return name.replace(INVALID_CHAR_RE, "~"); 18 - }; 19 - 20 - export const UnpackToolView = () => { 21 - const logger = createLogger(); 22 - const [pending, setPending] = createSignal(false); 23 - 24 - let abortController: AbortController | undefined; 25 - 26 - onCleanup(() => { 27 - abortController?.abort(); 28 - }); 29 - 30 - const unpackToZip = async (file: File) => { 31 - abortController?.abort(); 32 - abortController = new AbortController(); 33 - const signal = abortController.signal; 34 - 35 - setPending(true); 36 - logger.log(`Starting extraction`); 37 - 38 - let repo: Awaited<ReturnType<typeof fromStream>> | undefined; 39 - 40 - const stream = file.stream(); 41 - repo = fromStream(stream); 42 - 43 - try { 44 - let count = 0; 45 - 46 - // On Safari/browsers without native File System Access API, use blob download 47 - if (!hasNativeFileSystemAccess) { 48 - const chunks: BlobPart[] = []; 49 - 50 - const entryGenerator = async function* (): AsyncGenerator<ZipEntry> { 51 - const progress = logger.progress(`Unpacking records (0 entries)`); 52 - 53 - try { 54 - for await (const entry of repo) { 55 - if (signal.aborted) return; 56 - 57 - try { 58 - const record = toJsonValue(entry.record); 59 - const filename = `${entry.collection}/${filenamify(entry.rkey)}.json`; 60 - const data = JSON.stringify(record, null, 2); 61 - 62 - yield { filename, data, compress: isWebKit ? false : "deflate" }; 63 - count++; 64 - progress.update(`Unpacking records (${count} entries)`); 65 - } catch { 66 - // Skip entries with invalid data 67 - } 68 - } 69 - } finally { 70 - progress[Symbol.dispose]?.(); 71 - } 72 - }; 73 - 74 - for await (const chunk of zip(entryGenerator())) { 75 - if (signal.aborted) return; 76 - chunks.push(chunk as BlobPart); 77 - } 78 - 79 - if (signal.aborted) return; 80 - 81 - logger.log(`${count} records extracted`); 82 - logger.log(`Creating download...`); 83 - 84 - const blob = new Blob(chunks, { type: "application/zip" }); 85 - const url = URL.createObjectURL(blob); 86 - const a = document.createElement("a"); 87 - a.href = url; 88 - a.download = `${file.name.replace(/\.car$/, "")}.zip`; 89 - document.body.appendChild(a); 90 - a.click(); 91 - document.body.removeChild(a); 92 - URL.revokeObjectURL(url); 93 - 94 - logger.log(`Finished! Download started.`); 95 - setPending(false); 96 - return; 97 - } 98 - 99 - // Native File System Access API path 100 - let writable: FileSystemWritableFileStream | undefined; 101 - 102 - // Create async generator that yields ZipEntry as we read from CAR 103 - const entryGenerator = async function* (): AsyncGenerator<ZipEntry> { 104 - const progress = logger.progress(`Unpacking records (0 entries)`); 105 - 106 - try { 107 - for await (const entry of repo) { 108 - if (signal.aborted) return; 109 - 110 - // Prompt for save location on first record 111 - if (writable === undefined) { 112 - const waiting = logger.progress(`Waiting for user...`); 113 - 114 - try { 115 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 116 - const fd = await (window as any) 117 - .showSaveFilePicker({ 118 - suggestedName: `${file.name.replace(/\.car$/, "")}.zip`, 119 - id: "car-unpack", 120 - startIn: "downloads", 121 - types: [ 122 - { 123 - description: "ZIP archive", 124 - accept: { "application/zip": [".zip"] }, 125 - }, 126 - ], 127 - }) 128 - .catch((err: unknown) => { 129 - if (err instanceof DOMException && err.name === "AbortError") { 130 - logger.warn(`File picker was cancelled`); 131 - } else { 132 - logger.warn(`Something went wrong when opening the file picker`); 133 - } 134 - return undefined; 135 - }); 136 - 137 - if (!fd) { 138 - logger.warn(`No file handle obtained`); 139 - return; 140 - } 141 - 142 - writable = await fd.createWritable(); 143 - 144 - if (writable === undefined) { 145 - logger.warn(`Failed to create writable stream`); 146 - return; 147 - } 148 - } finally { 149 - waiting[Symbol.dispose]?.(); 150 - } 151 - } 152 - 153 - try { 154 - const record = toJsonValue(entry.record); 155 - const filename = `${entry.collection}/${filenamify(entry.rkey)}.json`; 156 - const data = JSON.stringify(record, null, 2); 157 - 158 - yield { filename, data, compress: isWebKit ? false : "deflate" }; 159 - count++; 160 - progress.update(`Unpacking records (${count} entries)`); 161 - } catch { 162 - // Skip entries with invalid data 163 - } 164 - } 165 - } finally { 166 - progress[Symbol.dispose]?.(); 167 - } 168 - }; 169 - 170 - // Stream entries directly to zip, then to file 171 - let writeCount = 0; 172 - for await (const chunk of zip(entryGenerator())) { 173 - if (signal.aborted) { 174 - await writable?.abort(); 175 - return; 176 - } 177 - if (writable === undefined) { 178 - // User cancelled file picker 179 - setPending(false); 180 - return; 181 - } 182 - writeCount++; 183 - // Await every 100th write to apply backpressure 184 - if (writeCount % 100 === 0) { 185 - await writable.write(chunk as BufferSource); 186 - } else { 187 - writable.write(chunk as BufferSource); 188 - } 189 - } 190 - 191 - if (signal.aborted) return; 192 - 193 - if (writable === undefined) { 194 - logger.warn(`CAR file has no records`); 195 - setPending(false); 196 - return; 197 - } 198 - 199 - logger.log(`${count} records extracted`); 200 - 201 - { 202 - const flushProgress = logger.progress(`Flushing writes...`); 203 - try { 204 - await writable.close(); 205 - logger.log(`Finished! File saved successfully.`); 206 - } catch (err) { 207 - logger.error(`Failed to save file: ${err}`); 208 - throw err; // Re-throw to be caught by outer catch 209 - } finally { 210 - flushProgress[Symbol.dispose]?.(); 211 - } 212 - } 213 - } catch (err) { 214 - if (signal.aborted) return; 215 - logger.error(`Error: ${err}\nFile might be malformed, or might not be a CAR archive`); 216 - } finally { 217 - await repo?.dispose(); 218 - if (!signal.aborted) { 219 - setPending(false); 220 - } 221 - } 222 - }; 223 - 224 - const handleFileChange = createFileChangeHandler(unpackToZip); 225 - 226 - // Wrap handleDrop to prevent multiple simultaneous uploads 227 - const baseDrop = createDropHandler(unpackToZip); 228 - const handleDrop = (e: DragEvent) => { 229 - if (pending()) return; 230 - baseDrop(e); 231 - }; 232 - 233 - document.title = "Unpack archive - PDSls"; 234 - return ( 235 - <> 236 - <WelcomeView 237 - title="Unpack archive" 238 - subtitle="Upload a CAR file to extract all records into a ZIP archive." 239 - loading={pending()} 240 - onFileChange={handleFileChange} 241 - onDrop={handleDrop} 242 - onDragOver={handleDragOver} 243 - /> 244 - <div class="w-full max-w-3xl px-2"> 245 - <LoggerView logger={logger} /> 246 - </div> 247 - </> 248 - ); 249 - };
+1 -1
src/views/home.tsx
··· 188 188 href="/car" 189 189 icon="lucide--folder-archive" 190 190 title="Archive" 191 - description="Unpack CAR files" 191 + description="Explore CAR files" 192 192 accent="violet" 193 193 /> 194 194 </div>
+2 -10
src/worker.js
··· 365 365 }, 366 366 "/labels": { title: "Labels", description: "Query labels applied to accounts and records." }, 367 367 "/car": { 368 - title: "Archive tools", 369 - description: "Tools for working with CAR (Content Addressable aRchive) files.", 370 - }, 371 - "/car/explore": { 372 - title: "Explore archive", 373 - description: "Upload a CAR file to explore its contents.", 374 - }, 375 - "/car/unpack": { 376 - title: "Unpack archive", 377 - description: "Upload a CAR file to extract all records into a ZIP archive.", 368 + title: "CAR explorer", 369 + description: "Upload an archive to explore or export its contents.", 378 370 }, 379 371 "/settings": { title: "Settings", description: "Browse the public data on atproto" }, 380 372 };