diff --git a/package.json b/package.json index 5e6025c..ec63c51 100644 --- a/package.json +++ b/package.json @@ -48,10 +48,12 @@ "@fsegurai/codemirror-theme-basic-dark": "^6.2.3", "@fsegurai/codemirror-theme-basic-light": "^6.2.3", "@mary/exif-rm": "jsr:^0.2.2", + "@mary/zip": "jsr:^0.1.1", "@skyware/firehose": "^0.5.2", "@solidjs/meta": "^0.29.4", "@solidjs/router": "^0.15.4", "codemirror": "^6.0.2", + "native-file-system-adapter": "^3.0.1", "solid-js": "^1.9.10" }, "packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebee782..ce8aafc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: '@mary/exif-rm': specifier: jsr:^0.2.2 version: '@jsr/mary__exif-rm@0.2.2' + '@mary/zip': + specifier: jsr:^0.1.1 + version: '@jsr/mary__zip@0.1.1' '@skyware/firehose': specifier: ^0.5.2 version: 0.5.2 @@ -101,6 +104,9 @@ importers: codemirror: specifier: ^6.0.2 version: 6.0.2 + native-file-system-adapter: + specifier: ^3.0.1 + version: 3.0.1 solid-js: specifier: ^1.9.10 version: 1.9.10 @@ -685,9 +691,24 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jsr/mary__date-fns@0.1.3': + resolution: {integrity: sha512-kjS04BESEHO9ZTqjOxk4ip8DsAdVDmt/jC5V4zVIYq3VD/04+WJK9kjdQda23eVZMuF9ZZY0zMswU7UXG+PSrg==, tarball: https://npm.jsr.io/~/11/@jsr/mary__date-fns/0.1.3.tgz} + + '@jsr/mary__ds-circular-buffer@0.1.0': + resolution: {integrity: sha512-2aQ7G++qZ+WGBrfuyQduAc4qyv610Ywab5hLe8y36/s5/Pi+fV+7L3g3g0dVqSChGQfi/qyIP/WDtXwM97nUtA==, tarball: https://npm.jsr.io/~/11/@jsr/mary__ds-circular-buffer/0.1.0.tgz} + + '@jsr/mary__ds-queue@0.1.3': + resolution: {integrity: sha512-gGqIHXiAmhUUtonNI6YVvL7VlXjEHUpGdc7RGU8BLP4XnFvqovDTH5y9VlBZmvozTWgTIMoZF6/1//sMrvYKtQ==, tarball: https://npm.jsr.io/~/11/@jsr/mary__ds-queue/0.1.3.tgz} + '@jsr/mary__exif-rm@0.2.2': resolution: {integrity: sha512-+ZpLaC+1CyqWhH608Sqd6/yTG0pOlokn2tCXha7s1SMQ+GLKo4Nn/PskTeeP9Pt+6gNYSu6ednoSlRvXb2ZGxg==, tarball: https://npm.jsr.io/~/11/@jsr/mary__exif-rm/0.2.2.tgz} + '@jsr/mary__mutex@0.1.0': + resolution: {integrity: sha512-0G3CXC6VCZoVn2b97Xvvqs6zTGzjnAZiNA84CZjeA+g09UmFHq+ji44lq566NruUfG8Pq58n+6Uln8FGrL8G3w==, tarball: https://npm.jsr.io/~/11/@jsr/mary__mutex/0.1.0.tgz} + + '@jsr/mary__zip@0.1.1': + resolution: {integrity: sha512-RIt6xSshG6x1X4qUVGXukgS8ysot+wLQc/WAkNogbiDwm3tzFyDxZdEs8TVDQyT1L7lApPPgbEgdcex7NcfGrw==, tarball: https://npm.jsr.io/~/11/@jsr/mary__zip/0.1.1.tgz} + '@lezer/common@1.5.0': resolution: {integrity: sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==} @@ -1082,6 +1103,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -1234,6 +1259,15 @@ packages: engines: {node: ^18 || >=20} hasBin: true + native-file-system-adapter@3.0.1: + resolution: {integrity: sha512-ocuhsYk2SY0906LPc3QIMW+rCV3MdhqGiy7wV5Bf0e8/5TsMjDdyIwhNiVPiKxzTJLDrLT6h8BoV9ERfJscKhw==} + engines: {node: '>=14.8.0'} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + node-gyp-build@4.8.4: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true @@ -1486,6 +1520,10 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2038,8 +2076,23 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jsr/mary__date-fns@0.1.3': {} + + '@jsr/mary__ds-circular-buffer@0.1.0': {} + + '@jsr/mary__ds-queue@0.1.3': {} + '@jsr/mary__exif-rm@0.2.2': {} + '@jsr/mary__mutex@0.1.0': {} + + '@jsr/mary__zip@0.1.1': + dependencies: + '@jsr/mary__date-fns': 0.1.3 + '@jsr/mary__ds-circular-buffer': 0.1.0 + '@jsr/mary__ds-queue': 0.1.3 + '@jsr/mary__mutex': 0.1.0 + '@lezer/common@1.5.0': {} '@lezer/highlight@1.2.3': @@ -2418,6 +2471,12 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + optional: true + fflate@0.8.2: {} fsevents@2.3.3: @@ -2526,6 +2585,13 @@ snapshots: nanoid@5.1.6: {} + native-file-system-adapter@3.0.1: + optionalDependencies: + fetch-blob: 3.2.0 + + node-domexception@1.0.0: + optional: true + node-gyp-build@4.8.4: {} node-releases@2.0.27: {} @@ -2711,6 +2777,9 @@ snapshots: w3c-keyname@2.2.8: {} + web-streams-polyfill@3.3.3: + optional: true + yallist@3.1.1: {} yocto-queue@1.2.2: {} diff --git a/src/components/tooltip.tsx b/src/components/tooltip.tsx index 4ec1a45..b5c2872 100644 --- a/src/components/tooltip.tsx +++ b/src/components/tooltip.tsx @@ -2,17 +2,17 @@ import { JSX, Show } from "solid-js"; import { isTouchDevice } from "../layout"; const Tooltip = (props: { text: string; children: JSX.Element }) => ( -
+ {props.children} -
+ ); export default Tooltip; diff --git a/src/index.tsx b/src/index.tsx index d2463ed..d562e28 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,7 +3,9 @@ import { Route, Router } from "@solidjs/router"; import { render } from "solid-js/web"; import { Layout } from "./layout.tsx"; import "./styles/index.css"; -import { CarView } from "./views/car.tsx"; +import { ExploreToolView } from "./views/car/explore.tsx"; +import { CarView } from "./views/car/index.tsx"; +import { UnpackToolView } from "./views/car/unpack.tsx"; import { CollectionView } from "./views/collection.tsx"; import { Home } from "./views/home.tsx"; import { LabelView } from "./views/labels.tsx"; @@ -20,6 +22,8 @@ render( + + diff --git a/src/layout.tsx b/src/layout.tsx index 6f7233e..4eee740 100644 --- a/src/layout.tsx +++ b/src/layout.tsx @@ -159,7 +159,7 @@ const Layout = (props: RouteSectionProps) => { - + 1); - -// Convert CBOR-decoded objects to JSON-friendly format -const toJsonValue = (obj: unknown): JSONType => { - if (obj === null || obj === undefined) return null; - - if (CID.isCidLink(obj)) { - return { $link: obj.$link }; - } - - if ( - obj && - typeof obj === "object" && - "version" in obj && - "codec" in obj && - "digest" in obj && - "bytes" in obj - ) { - try { - return { $link: CID.toString(obj as CID.Cid) }; - } catch {} - } - - if (CBOR.isBytes(obj)) { - return { $bytes: obj.$bytes }; - } - - if (Array.isArray(obj)) { - return obj.map(toJsonValue); - } - - if (typeof obj === "object") { - const result: Record = {}; - for (const [key, value] of Object.entries(obj)) { - result[key] = toJsonValue(value); - } - return result; - } - - return obj as JSONType; -}; - -interface Archive { - file: File; - did: string; - entries: CollectionEntry[]; -} - -interface CollectionEntry { - name: string; - entries: RecordEntry[]; -} - -interface RecordEntry { - key: string; - cid: string; - record: JSONType; -} - -type View = - | { type: "repo" } - | { type: "collection"; collection: CollectionEntry } - | { type: "record"; collection: CollectionEntry; record: RecordEntry }; - -export const CarView = () => { +import { Button } from "../../components/button.jsx"; +import { JSONValue } from "../../components/json.jsx"; +import { TextInput } from "../../components/text-input.jsx"; +import { isTouchDevice } from "../../layout.jsx"; +import { localDateFromTimestamp } from "../../utils/date.js"; +import { + type Archive, + type CollectionEntry, + type RecordEntry, + type View, + toJsonValue, + WelcomeView, +} from "./shared.jsx"; + +export const ExploreToolView = () => { const [archive, setArchive] = createSignal(null); const [loading, setLoading] = createSignal(false); const [error, setError] = createSignal(); @@ -97,12 +39,16 @@ export const CarView = () => { const rootCid = car.roots[0]?.$link; if (rootCid) { for (const entry of car) { - if (CID.toString(entry.cid) === rootCid) { - const commit = CBOR.decode(entry.bytes); - if (isCommit(commit)) { - did = commit.did; + try { + if (CID.toString(entry.cid) === rootCid) { + const commit = CBOR.decode(entry.bytes); + if (isCommit(commit)) { + did = commit.did; + } + break; } - break; + } catch { + // Skip entries with invalid CIDs } } } @@ -118,21 +64,25 @@ export const CarView = () => { const repo = fromStream(stream); try { for await (const entry of repo) { - let list = collections.get(entry.collection); - if (list === undefined) { - collections.set(entry.collection, (list = [])); - result.entries.push({ - name: entry.collection, - entries: list, + try { + let list = collections.get(entry.collection); + if (list === undefined) { + collections.set(entry.collection, (list = [])); + result.entries.push({ + name: entry.collection, + entries: list, + }); + } + + const record = toJsonValue(entry.record); + list.push({ + key: entry.rkey, + cid: entry.cid.$link, + record, }); + } catch { + // Skip entries with invalid data } - - const record = toJsonValue(entry.record); - list.push({ - key: entry.rkey, - cid: entry.cid.$link, - record, - }); } } finally { await repo.dispose(); @@ -176,85 +126,24 @@ export const CarView = () => { return ( <> - CAR explorer - PDSls -
- - } - > - {(arch) => } - -
- - ); -}; - -const WelcomeView = (props: { - loading: boolean; - error?: string; - onFileChange: (e: Event) => void; - onDrop: (e: DragEvent) => void; - onDragOver: (e: DragEvent) => void; -}) => { - return ( -
-
-

CAR explorer

-

- Upload a CAR (Content Addressable aRchive) file to explore its contents. -

-
- -
Explore archive - PDSls + + } > - - - - Reading CAR file... - -
- } - > - -
-

- Drag and drop a CAR file here -

-

or

-
- - -
- - -
- {props.error} -
+ {(arch) => }
- + ); }; @@ -411,9 +300,9 @@ const RepoSubview = (props: { archive: Archive; onRoute: (view: View) => void }) return (
- {props.archive.entries.length} collection{props.archive.entries.length > 1 ? "s" : ""} + {props.archive.entries.length} collection{props.archive.entries.length !== 1 ? "s" : ""} ยท - {totalRecords()} record{totalRecords() > 1 ? "s" : ""} + {totalRecords()} record{totalRecords() !== 1 ? "s" : ""}
- {filteredEntries().length} record{filteredEntries().length > 1 ? "s" : ""} + {filteredEntries().length} record{filteredEntries().length !== 1 ? "s" : ""} {filter() && filteredEntries().length !== props.collection.entries.length && ( {" "} diff --git a/src/views/car/index.tsx b/src/views/car/index.tsx new file mode 100644 index 0000000..659be28 --- /dev/null +++ b/src/views/car/index.tsx @@ -0,0 +1,44 @@ +import { Title } from "@solidjs/meta"; +import { A } from "@solidjs/router"; + +export const CarView = () => { + return ( +
+ CAR tools - PDSls +
+

CAR tools

+

+ Tools for working with Content Addressable aRchive files. +

+
+ + +
+ ); +}; diff --git a/src/views/car/logger.tsx b/src/views/car/logger.tsx new file mode 100644 index 0000000..dc15701 --- /dev/null +++ b/src/views/car/logger.tsx @@ -0,0 +1,152 @@ +import { For } from "solid-js"; +import { createMutable } from "solid-js/store"; + +interface LogEntry { + type: "log" | "info" | "warn" | "error"; + at: number; + msg: string; +} + +interface PendingLogEntry { + msg: string; +} + +export const createLogger = () => { + const pending = createMutable([]); + + let backlog: LogEntry[] | undefined = []; + let push = (entry: LogEntry) => { + backlog!.push(entry); + }; + + return { + internal: { + get pending() { + return pending; + }, + attach(fn: (entry: LogEntry) => void) { + if (backlog !== undefined) { + for (let idx = 0, len = backlog.length; idx < len; idx++) { + fn(backlog[idx]); + } + backlog = undefined; + } + push = fn; + }, + }, + log(msg: string) { + push({ type: "log", at: Date.now(), msg }); + }, + info(msg: string) { + push({ type: "info", at: Date.now(), msg }); + }, + warn(msg: string) { + push({ type: "warn", at: Date.now(), msg }); + }, + error(msg: string) { + push({ type: "error", at: Date.now(), msg }); + }, + progress(initialMsg: string, throttleMs = 500) { + pending.unshift({ msg: initialMsg }); + + let entry: PendingLogEntry | undefined = pending[0]; + + return { + update: throttle((msg: string) => { + if (entry !== undefined) { + entry.msg = msg; + } + }, throttleMs), + destroy() { + if (entry !== undefined) { + const index = pending.indexOf(entry); + pending.splice(index, 1); + entry = undefined; + } + }, + [Symbol.dispose]() { + this.destroy(); + }, + }; + }, + }; +}; + +export type Logger = ReturnType; + +const formatter = new Intl.DateTimeFormat("en-US", { timeStyle: "short", hour12: false }); + +export const LoggerView = (props: { logger: Logger }) => { + return ( +
    + + {(entry) => ( +
  • + ----- + {entry.msg} +
  • + )} +
    + +
    { + props.logger.internal.attach(({ type, at, msg }) => { + let ecn = `flex gap-2 whitespace-pre-wrap px-4 py-1`; + let tcn = `shrink-0 whitespace-pre-wrap font-medium`; + if (type === "log") { + tcn += ` text-neutral-500`; + } else if (type === "info") { + ecn += ` bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300`; + tcn += ` text-blue-500`; + } else if (type === "warn") { + ecn += ` bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300`; + tcn += ` text-amber-500`; + } else if (type === "error") { + ecn += ` bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300`; + tcn += ` text-red-500`; + } + + const item = ( +
  • + {formatter.format(at)} + {msg} +
  • + ); + + if (item instanceof Node) { + node.after(item); + } + }); + }} + /> +
+ ); +}; + +const throttle = void>(func: T, wait: number) => { + let timeout: ReturnType | null = null; + let lastArgs: Parameters | null = null; + let lastCallTime = 0; + + const invoke = () => { + func(...lastArgs!); + lastCallTime = Date.now(); + timeout = null; + }; + + return (...args: Parameters) => { + const now = Date.now(); + const timeSinceLastCall = now - lastCallTime; + + lastArgs = args; + + if (timeSinceLastCall >= wait) { + if (timeout !== null) { + clearTimeout(timeout); + } + invoke(); + } else if (timeout === null) { + timeout = setTimeout(invoke, wait - timeSinceLastCall); + } + }; +}; diff --git a/src/views/car/shared.tsx b/src/views/car/shared.tsx new file mode 100644 index 0000000..21a667e --- /dev/null +++ b/src/views/car/shared.tsx @@ -0,0 +1,148 @@ +import * as CBOR from "@atcute/cbor"; +import * as CID from "@atcute/cid"; +import { A } from "@solidjs/router"; +import { Show } from "solid-js"; +import { type JSONType } from "../../components/json.jsx"; + +export const isIOS = + /iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); + +// Convert CBOR-decoded objects to JSON-friendly format +export const toJsonValue = (obj: unknown): JSONType => { + if (obj === null || obj === undefined) return null; + + if (CID.isCidLink(obj)) { + return { $link: obj.$link }; + } + + if ( + obj && + typeof obj === "object" && + "version" in obj && + "codec" in obj && + "digest" in obj && + "bytes" in obj + ) { + try { + return { $link: CID.toString(obj as CID.Cid) }; + } catch {} + } + + if (CBOR.isBytes(obj)) { + return { $bytes: obj.$bytes }; + } + + if (Array.isArray(obj)) { + return obj.map(toJsonValue); + } + + if (typeof obj === "object") { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = toJsonValue(value); + } + return result; + } + + return obj as JSONType; +}; + +export interface Archive { + file: File; + did: string; + entries: CollectionEntry[]; +} + +export interface CollectionEntry { + name: string; + entries: RecordEntry[]; +} + +export interface RecordEntry { + key: string; + cid: string; + record: JSONType; +} + +export type View = + | { type: "repo" } + | { type: "collection"; collection: CollectionEntry } + | { type: "record"; collection: CollectionEntry; record: RecordEntry }; + +export const WelcomeView = (props: { + title: string; + subtitle: string; + loading: boolean; + error?: string; + progress?: string; + onFileChange: (e: Event) => void; + onDrop: (e: DragEvent) => void; + onDragOver: (e: DragEvent) => void; +}) => { + return ( +
+
+
+ + + +

{props.title}

+
+

{props.subtitle}

+
+ +
+ + + + {props.progress ?? "Reading CAR file..."} + +
+ } + > + +
+

+ Drag and drop a CAR file here +

+

or

+
+ + +
+ + +
+ + {props.progress} +
+
+ + +
+ {props.error} +
+
+
+ ); +}; diff --git a/src/views/car/unpack.tsx b/src/views/car/unpack.tsx new file mode 100644 index 0000000..466016a --- /dev/null +++ b/src/views/car/unpack.tsx @@ -0,0 +1,222 @@ +import { fromStream } from "@atcute/repo"; +import { zip, type ZipEntry } from "@mary/zip"; +import { Title } from "@solidjs/meta"; +import { A } from "@solidjs/router"; +import { FileSystemWritableFileStream, showSaveFilePicker } from "native-file-system-adapter"; +import { createSignal, onCleanup, Show } from "solid-js"; +import { createLogger, LoggerView } from "./logger.jsx"; +import { isIOS, toJsonValue } from "./shared.jsx"; + +const INVALID_CHAR_RE = /[<>:"/\\|?*\x00-\x1F]/g; +const filenamify = (name: string) => { + return name.replace(INVALID_CHAR_RE, "~"); +}; + +export const UnpackToolView = () => { + const logger = createLogger(); + const [pending, setPending] = createSignal(false); + + let abortController: AbortController | undefined; + + onCleanup(() => { + abortController?.abort(); + }); + + const unpackToZip = async (file: File) => { + abortController?.abort(); + abortController = new AbortController(); + const signal = abortController.signal; + + setPending(true); + logger.log(`Starting extraction`); + + let repo: Awaited> | undefined; + + const stream = file.stream(); + repo = fromStream(stream); + + try { + let count = 0; + let writable: FileSystemWritableFileStream | undefined; + + // Create async generator that yields ZipEntry as we read from CAR + const entryGenerator = async function* (): AsyncGenerator { + using progress = logger.progress(`Unpacking records (0 entries)`); + + for await (const entry of repo) { + if (signal.aborted) return; + + // Prompt for save location on first record + if (writable === undefined) { + using _waiting = logger.progress(`Waiting for user...`); + + const fd = await showSaveFilePicker({ + suggestedName: `${file.name.replace(/\.car$/, "")}.zip`, + // @ts-expect-error: ponyfill doesn't have full typings + id: "car-unpack", + startIn: "downloads", + types: [ + { + description: "ZIP archive", + accept: { "application/zip": [".zip"] }, + }, + ], + }).catch((err) => { + if (err instanceof DOMException && err.name === "AbortError") { + logger.warn(`File picker was cancelled`); + } else { + console.warn(err); + logger.warn(`Something went wrong when opening the file picker`); + } + return undefined; + }); + + writable = await fd?.createWritable(); + + if (writable === undefined) { + return; + } + } + + try { + const record = toJsonValue(entry.record); + const filename = `${entry.collection}/${filenamify(entry.rkey)}.json`; + const data = JSON.stringify(record, null, 2); + + yield { filename, data, compress: "deflate" }; + count++; + progress.update(`Unpacking records (${count} entries)`); + } catch { + // Skip entries with invalid data + } + } + }; + + // Stream entries directly to zip, then to file + let writeCount = 0; + for await (const chunk of zip(entryGenerator())) { + if (signal.aborted) { + await writable?.abort(); + return; + } + if (writable === undefined) { + // User cancelled file picker + setPending(false); + return; + } + writeCount++; + if (writeCount % 100 !== 0) { + writable.write(chunk); // Fire and forget + } else { + await writable.write(chunk); // Await periodically to apply backpressure + } + } + + if (signal.aborted) return; + + if (writable === undefined) { + logger.warn(`CAR file has no records`); + setPending(false); + return; + } + + logger.log(`${count} records extracted`); + + { + using _progress = logger.progress(`Flushing writes...`); + await writable.close(); + } + + logger.log(`Finished!`); + } catch (err) { + if (signal.aborted) return; + console.error("Failed to unpack CAR file:", err); + logger.error(`Error: ${err}\nFile might be malformed, or might not be a CAR archive`); + } finally { + await repo?.dispose(); + if (!signal.aborted) { + setPending(false); + } + } + }; + + const handleFileChange = (e: Event) => { + const input = e.target as HTMLInputElement; + const file = input.files?.[0]; + if (file) { + unpackToZip(file); + } + input.value = ""; + }; + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + if (pending()) return; + const file = e.dataTransfer?.files?.[0]; + if (file && (file.name.endsWith(".car") || file.type === "application/vnd.ipld.car")) { + unpackToZip(file); + } + }; + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + }; + + return ( +
+ Unpack archive - PDSls +
+
+ + + +

Unpack archive

+
+

+ Upload a CAR file to extract all records into a ZIP archive. +

+
+ +
+ + + + Processing... + +
+ } + > + +
+

+ Drag and drop a CAR file here +

+

or

+
+ + +
+ + + + ); +}; diff --git a/src/views/record.tsx b/src/views/record.tsx index e13bed9..0f49dc8 100644 --- a/src/views/record.tsx +++ b/src/views/record.tsx @@ -263,7 +263,6 @@ export const RecordView = () => { } const lexiconDocs = Object.fromEntries(resolved); - console.log(lexiconDocs); const validator = new RecordValidator(lexiconDocs, params.collection as Nsid); validator.parse({