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}
{props.text}
-
+
);
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.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
+
+
+
+ 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({