diff --git a/package.json b/package.json index ec5ee83..5e6025c 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,9 @@ "dependencies": { "@atcute/atproto": "^3.1.10", "@atcute/bluesky": "^3.2.14", + "@atcute/car": "^5.0.0", + "@atcute/cbor": "^2.2.8", + "@atcute/cid": "^2.3.0", "@atcute/client": "^4.2.0", "@atcute/crypto": "^2.3.0", "@atcute/did-plc": "^0.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7559d9e..ebee782 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,15 @@ importers: '@atcute/bluesky': specifier: ^3.2.14 version: 3.2.14 + '@atcute/car': + specifier: ^5.0.0 + version: 5.0.0 + '@atcute/cbor': + specifier: ^2.2.8 + version: 2.2.8 + '@atcute/cid': + specifier: ^2.3.0 + version: 2.3.0 '@atcute/client': specifier: ^4.2.0 version: 4.2.0 diff --git a/src/components/json.tsx b/src/components/json.tsx index 1e9b582..add79cb 100644 --- a/src/components/json.tsx +++ b/src/components/json.tsx @@ -20,6 +20,7 @@ interface JSONContext { repo: string; truncate?: boolean; parentIsBlob?: boolean; + newTab?: boolean; } const JSONCtx = createContext(); @@ -53,7 +54,9 @@ const JSONString = (props: { data: string; isType?: boolean; isLink?: boolean }) const authority = await resolveLexiconAuthority(nsid as Nsid); const hash = anchor ? `#schema:${anchor}` : "#schema"; - navigate(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`); + if (ctx.newTab) + window.open(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`, "_blank"); + else navigate(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`); } catch (err) { console.error("Failed to resolve lexicon authority:", err); const id = addNotification({ @@ -76,11 +79,19 @@ const JSONString = (props: { data: string; isType?: boolean; isLink?: boolean }) {(part) => ( <> {isResourceUri(part) ? - + {part} : isDid(part) ? - + {part} : isNsid(part.split("#")[0]) && props.isType ? @@ -297,9 +308,14 @@ const JSONValueInner = (props: { data: JSONType; isType?: boolean; isLink?: bool return ; }; -export const JSONValue = (props: { data: JSONType; repo: string; truncate?: boolean }) => { +export const JSONValue = (props: { + data: JSONType; + repo: string; + truncate?: boolean; + newTab?: boolean; +}) => { return ( - + ); diff --git a/src/index.tsx b/src/index.tsx index bd2ba37..d2463ed 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,6 +3,7 @@ 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 { CollectionView } from "./views/collection.tsx"; import { Home } from "./views/home.tsx"; import { LabelView } from "./views/labels.tsx"; @@ -18,6 +19,7 @@ render( + diff --git a/src/layout.tsx b/src/layout.tsx index 9f259a4..6f7233e 100644 --- a/src/layout.tsx +++ b/src/layout.tsx @@ -159,6 +159,7 @@ const Layout = (props: RouteSectionProps) => { + { + 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 = () => { + const [archive, setArchive] = createSignal(null); + const [loading, setLoading] = createSignal(false); + const [error, setError] = createSignal(); + const [view, setView] = createSignal({ type: "repo" }); + + const parseCarFile = async (file: File) => { + setLoading(true); + setError(undefined); + + try { + // Read file as ArrayBuffer to extract DID from commit block + const buffer = new Uint8Array(await file.arrayBuffer()); + const car = CAR.fromUint8Array(buffer); + + // Get DID from commit block + let did = ""; + 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; + } + break; + } + } + } + + // Now parse records using fromStream + const stream = file.stream(); + await using repo = fromStream(stream); + + const collections = new Map(); + const result: Archive = { + file, + did, + entries: [], + }; + + 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, + }); + } + + const record = toJsonValue(entry.record); + list.push({ + key: entry.rkey, + cid: entry.cid.$link, + record, + }); + } + + setArchive(result); + setView({ type: "repo" }); + } catch (err) { + console.error("Failed to parse CAR file:", err); + setError(err instanceof Error ? err.message : "Failed to parse CAR file"); + } finally { + setLoading(false); + } + }; + + const handleFileChange = (e: Event) => { + const input = e.target as HTMLInputElement; + const file = input.files?.[0]; + if (file) { + parseCarFile(file); + } + }; + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + const file = e.dataTransfer?.files?.[0]; + if (file && (file.name.endsWith(".car") || file.type === "application/vnd.ipld.car")) { + parseCarFile(file); + } + }; + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + }; + + const reset = () => { + setArchive(null); + setView({ type: "repo" }); + setError(undefined); + }; + + 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. +

+
+ +
+ + + + Reading CAR file... + +
+ } + > + +
+

+ Drag and drop a CAR file here +

+

or

+
+ + +
+ + +
+ {props.error} +
+
+ + ); +}; + +const ExploreView = (props: { + archive: Archive; + view: () => View; + setView: (view: View) => void; + onClose: () => void; +}) => { + return ( +
+
+ + {/* Collection Level */} + { + const v = props.view(); + return v.type === "collection" || v.type === "record" ? v.collection : null; + })()} + > + {(collection) => ( + +
+ + {collection().name} +
+ + } + > + +
+ )} +
+ + {/* Record Level */} + { + const v = props.view(); + return v.type === "record" ? v.record : null; + })()} + > + {(record) => ( +
+
+ + {record().key} +
+
+ )} +
+ + +
+ + + + + + { + const v = props.view(); + return v.type === "collection" ? v : null; + })()} + keyed + > + {({ collection }) => ( + + )} + + + { + const v = props.view(); + return v.type === "record" ? v : null; + })()} + keyed + > + {({ collection, record }) => ( + + )} + + +
+ + ); +}; + +const RepoSubview = (props: { archive: Archive; onRoute: (view: View) => void }) => { + const [filter, setFilter] = createSignal(""); + + const sortedEntries = createMemo(() => { + return [...props.archive.entries].sort((a, b) => a.name.localeCompare(b.name)); + }); + + const filteredEntries = createMemo(() => { + const f = filter().toLowerCase().trim(); + if (!f) return sortedEntries(); + return sortedEntries().filter((entry) => entry.name.toLowerCase().includes(f)); + }); + + const totalRecords = createMemo(() => + props.archive.entries.reduce((sum, entry) => sum + entry.entries.length, 0), + ); + + return ( +
+
+ {props.archive.entries.length} collection{props.archive.entries.length > 1 ? "s" : ""} + ยท + {totalRecords()} record{totalRecords() > 1 ? "s" : ""} +
+ + setFilter(e.currentTarget.value)} + class="text-sm" + /> + +
    + + {(entry) => { + const hasSingleEntry = entry.entries.length === 1; + + return ( +
  • + +
  • + ); + }} +
    +
+ + +
+ +

+ No collections match your filter +

+
+
+
+ ); +}; + +const RECORDS_PER_PAGE = 100; + +const CollectionSubview = (props: { + archive: Archive; + collection: CollectionEntry; + onRoute: (view: View) => void; +}) => { + const [filter, setFilter] = createSignal(""); + const [displayCount, setDisplayCount] = createSignal(RECORDS_PER_PAGE); + + // Sort entries by TID timestamp (most recent first), non-TID entries go to the end + const sortedEntries = createMemo(() => { + return [...props.collection.entries].sort((a, b) => { + const aIsTid = TID.validate(a.key); + const bIsTid = TID.validate(b.key); + + if (aIsTid && bIsTid) { + return TID.parse(b.key).timestamp - TID.parse(a.key).timestamp; + } + if (aIsTid) return -1; + if (bIsTid) return 1; + return b.key.localeCompare(a.key); + }); + }); + + const filteredEntries = createMemo(() => { + const f = filter().toLowerCase().trim(); + if (!f) return sortedEntries(); + return sortedEntries().filter((entry) => + JSON.stringify(entry.record).toLowerCase().includes(f), + ); + }); + + const displayedEntries = createMemo(() => { + return filteredEntries().slice(0, displayCount()); + }); + + const hasMore = createMemo(() => filteredEntries().length > displayCount()); + + const loadMore = () => { + setDisplayCount((prev) => prev + RECORDS_PER_PAGE); + }; + + return ( +
+ + {filteredEntries().length} record{filteredEntries().length > 1 ? "s" : ""} + {filter() && filteredEntries().length !== props.collection.entries.length && ( + + {" "} + (of {props.collection.entries.length}) + + )} + + +
+ { + setFilter(e.currentTarget.value); + setDisplayCount(RECORDS_PER_PAGE); + }} + class="grow text-sm" + /> + + + + {displayedEntries().length}/{filteredEntries().length} + + + + +
+ +
+ + {(entry) => { + const isTid = TID.validate(entry.key); + const timestamp = isTid ? TID.parse(entry.key).timestamp / 1_000 : null; + const [hover, setHover] = createSignal(false); + const [previewHeight, setPreviewHeight] = createSignal(0); + let rkeyRef!: HTMLButtonElement; + let previewRef!: HTMLSpanElement; + + createEffect(() => { + if (hover()) setPreviewHeight(previewRef.offsetHeight); + }); + + const isOverflowing = (previewHeight: number) => + rkeyRef.offsetTop - window.scrollY + previewHeight + 32 > window.innerHeight; + + return ( + + ); + }} + +
+ + +
+ +

No records match your filter

+
+
+
+ ); +}; + +const RecordSubview = (props: { + archive: Archive; + collection: CollectionEntry; + record: RecordEntry; +}) => { + return ( +
+
+ + {props.record.cid} +
+ + + Failed to decode record +
+ } + > +
+ +
+ + + ); +};