atmosphere explorer

add unpack archive

handle.invalid 22fd9fd1 df77a5c2

verified
+705 -176
+2
package.json
··· 48 48 "@fsegurai/codemirror-theme-basic-dark": "^6.2.3", 49 49 "@fsegurai/codemirror-theme-basic-light": "^6.2.3", 50 50 "@mary/exif-rm": "jsr:^0.2.2", 51 + "@mary/zip": "jsr:^0.1.1", 51 52 "@skyware/firehose": "^0.5.2", 52 53 "@solidjs/meta": "^0.29.4", 53 54 "@solidjs/router": "^0.15.4", 54 55 "codemirror": "^6.0.2", 56 + "native-file-system-adapter": "^3.0.1", 55 57 "solid-js": "^1.9.10" 56 58 }, 57 59 "packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a"
+69
pnpm-lock.yaml
··· 89 89 '@mary/exif-rm': 90 90 specifier: jsr:^0.2.2 91 91 version: '@jsr/mary__exif-rm@0.2.2' 92 + '@mary/zip': 93 + specifier: jsr:^0.1.1 94 + version: '@jsr/mary__zip@0.1.1' 92 95 '@skyware/firehose': 93 96 specifier: ^0.5.2 94 97 version: 0.5.2 ··· 101 104 codemirror: 102 105 specifier: ^6.0.2 103 106 version: 6.0.2 107 + native-file-system-adapter: 108 + specifier: ^3.0.1 109 + version: 3.0.1 104 110 solid-js: 105 111 specifier: ^1.9.10 106 112 version: 1.9.10 ··· 685 691 '@jridgewell/trace-mapping@0.3.31': 686 692 resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} 687 693 694 + '@jsr/mary__date-fns@0.1.3': 695 + resolution: {integrity: sha512-kjS04BESEHO9ZTqjOxk4ip8DsAdVDmt/jC5V4zVIYq3VD/04+WJK9kjdQda23eVZMuF9ZZY0zMswU7UXG+PSrg==, tarball: https://npm.jsr.io/~/11/@jsr/mary__date-fns/0.1.3.tgz} 696 + 697 + '@jsr/mary__ds-circular-buffer@0.1.0': 698 + 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} 699 + 700 + '@jsr/mary__ds-queue@0.1.3': 701 + resolution: {integrity: sha512-gGqIHXiAmhUUtonNI6YVvL7VlXjEHUpGdc7RGU8BLP4XnFvqovDTH5y9VlBZmvozTWgTIMoZF6/1//sMrvYKtQ==, tarball: https://npm.jsr.io/~/11/@jsr/mary__ds-queue/0.1.3.tgz} 702 + 688 703 '@jsr/mary__exif-rm@0.2.2': 689 704 resolution: {integrity: sha512-+ZpLaC+1CyqWhH608Sqd6/yTG0pOlokn2tCXha7s1SMQ+GLKo4Nn/PskTeeP9Pt+6gNYSu6ednoSlRvXb2ZGxg==, tarball: https://npm.jsr.io/~/11/@jsr/mary__exif-rm/0.2.2.tgz} 690 705 706 + '@jsr/mary__mutex@0.1.0': 707 + resolution: {integrity: sha512-0G3CXC6VCZoVn2b97Xvvqs6zTGzjnAZiNA84CZjeA+g09UmFHq+ji44lq566NruUfG8Pq58n+6Uln8FGrL8G3w==, tarball: https://npm.jsr.io/~/11/@jsr/mary__mutex/0.1.0.tgz} 708 + 709 + '@jsr/mary__zip@0.1.1': 710 + resolution: {integrity: sha512-RIt6xSshG6x1X4qUVGXukgS8ysot+wLQc/WAkNogbiDwm3tzFyDxZdEs8TVDQyT1L7lApPPgbEgdcex7NcfGrw==, tarball: https://npm.jsr.io/~/11/@jsr/mary__zip/0.1.1.tgz} 711 + 691 712 '@lezer/common@1.5.0': 692 713 resolution: {integrity: sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==} 693 714 ··· 1082 1103 picomatch: 1083 1104 optional: true 1084 1105 1106 + fetch-blob@3.2.0: 1107 + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} 1108 + engines: {node: ^12.20 || >= 14.13} 1109 + 1085 1110 fflate@0.8.2: 1086 1111 resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} 1087 1112 ··· 1234 1259 engines: {node: ^18 || >=20} 1235 1260 hasBin: true 1236 1261 1262 + native-file-system-adapter@3.0.1: 1263 + resolution: {integrity: sha512-ocuhsYk2SY0906LPc3QIMW+rCV3MdhqGiy7wV5Bf0e8/5TsMjDdyIwhNiVPiKxzTJLDrLT6h8BoV9ERfJscKhw==} 1264 + engines: {node: '>=14.8.0'} 1265 + 1266 + node-domexception@1.0.0: 1267 + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} 1268 + engines: {node: '>=10.5.0'} 1269 + deprecated: Use your platform's native DOMException instead 1270 + 1237 1271 node-gyp-build@4.8.4: 1238 1272 resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} 1239 1273 hasBin: true ··· 1486 1520 w3c-keyname@2.2.8: 1487 1521 resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} 1488 1522 1523 + web-streams-polyfill@3.3.3: 1524 + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} 1525 + engines: {node: '>= 8'} 1526 + 1489 1527 yallist@3.1.1: 1490 1528 resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} 1491 1529 ··· 2038 2076 '@jridgewell/resolve-uri': 3.1.2 2039 2077 '@jridgewell/sourcemap-codec': 1.5.5 2040 2078 2079 + '@jsr/mary__date-fns@0.1.3': {} 2080 + 2081 + '@jsr/mary__ds-circular-buffer@0.1.0': {} 2082 + 2083 + '@jsr/mary__ds-queue@0.1.3': {} 2084 + 2041 2085 '@jsr/mary__exif-rm@0.2.2': {} 2042 2086 2087 + '@jsr/mary__mutex@0.1.0': {} 2088 + 2089 + '@jsr/mary__zip@0.1.1': 2090 + dependencies: 2091 + '@jsr/mary__date-fns': 0.1.3 2092 + '@jsr/mary__ds-circular-buffer': 0.1.0 2093 + '@jsr/mary__ds-queue': 0.1.3 2094 + '@jsr/mary__mutex': 0.1.0 2095 + 2043 2096 '@lezer/common@1.5.0': {} 2044 2097 2045 2098 '@lezer/highlight@1.2.3': ··· 2418 2471 optionalDependencies: 2419 2472 picomatch: 4.0.3 2420 2473 2474 + fetch-blob@3.2.0: 2475 + dependencies: 2476 + node-domexception: 1.0.0 2477 + web-streams-polyfill: 3.3.3 2478 + optional: true 2479 + 2421 2480 fflate@0.8.2: {} 2422 2481 2423 2482 fsevents@2.3.3: ··· 2525 2584 nanoid@3.3.11: {} 2526 2585 2527 2586 nanoid@5.1.6: {} 2587 + 2588 + native-file-system-adapter@3.0.1: 2589 + optionalDependencies: 2590 + fetch-blob: 3.2.0 2591 + 2592 + node-domexception@1.0.0: 2593 + optional: true 2528 2594 2529 2595 node-gyp-build@4.8.4: {} 2530 2596 ··· 2710 2776 vite: 7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2) 2711 2777 2712 2778 w3c-keyname@2.2.8: {} 2779 + 2780 + web-streams-polyfill@3.3.3: 2781 + optional: true 2713 2782 2714 2783 yallist@3.1.1: {} 2715 2784
+3 -3
src/components/tooltip.tsx
··· 2 2 import { isTouchDevice } from "../layout"; 3 3 4 4 const Tooltip = (props: { text: string; children: JSX.Element }) => ( 5 - <div class="group/tooltip relative flex items-center"> 5 + <span class="group/tooltip relative inline-flex items-center"> 6 6 {props.children} 7 7 <Show when={!isTouchDevice}> 8 8 <span 9 9 style={`transform: translate(-50%, 28px)`} 10 - class={`dark:shadow-dark-700 dark:bg-dark-300 pointer-events-none absolute left-[50%] z-20 hidden min-w-fit rounded border-[0.5px] border-neutral-300 bg-white p-1 text-center font-sans text-xs whitespace-nowrap text-neutral-900 shadow-md select-none group-hover/tooltip:inline first-letter:capitalize dark:border-neutral-600 dark:text-neutral-200`} 10 + class={`dark:shadow-dark-700 dark:bg-dark-300 pointer-events-none absolute left-[50%] z-20 hidden min-w-fit rounded border-[0.5px] border-neutral-300 bg-white p-1 text-center font-sans text-xs font-normal whitespace-nowrap text-neutral-900 shadow-md select-none group-hover/tooltip:inline first-letter:capitalize dark:border-neutral-600 dark:text-neutral-200`} 11 11 > 12 12 {props.text} 13 13 </span> 14 14 </Show> 15 - </div> 15 + </span> 16 16 ); 17 17 18 18 export default Tooltip;
+5 -1
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 { CarView } from "./views/car.tsx"; 6 + import { ExploreToolView } from "./views/car/explore.tsx"; 7 + import { CarView } from "./views/car/index.tsx"; 8 + import { UnpackToolView } from "./views/car/unpack.tsx"; 7 9 import { CollectionView } from "./views/collection.tsx"; 8 10 import { Home } from "./views/home.tsx"; 9 11 import { LabelView } from "./views/labels.tsx"; ··· 20 22 <Route path={["/jetstream", "/firehose"]} component={StreamView} /> 21 23 <Route path="/labels" component={LabelView} /> 22 24 <Route path="/car" component={CarView} /> 25 + <Route path="/car/explore" component={ExploreToolView} /> 26 + <Route path="/car/unpack" component={UnpackToolView} /> 23 27 <Route path="/settings" component={Settings} /> 24 28 <Route path="/:pds" component={PdsView} /> 25 29 <Route path="/:pds/:repo" component={RepoView} />
+1 -1
src/layout.tsx
··· 159 159 <NavMenu href="/jetstream" label="Jetstream" icon="lucide--radio-tower" /> 160 160 <NavMenu href="/firehose" label="Firehose" icon="lucide--antenna" /> 161 161 <NavMenu href="/labels" label="Labels" icon="lucide--tag" /> 162 - <NavMenu href="/car" label="CAR explorer" icon="lucide--folder-archive" /> 162 + <NavMenu href="/car" label="CAR tools" icon="lucide--folder-archive" /> 163 163 <NavMenu href="/settings" label="Settings" icon="lucide--settings" /> 164 164 <MenuSeparator /> 165 165 <NavMenu
+59 -170
src/views/car.tsx src/views/car/explore.tsx
··· 5 5 import * as TID from "@atcute/tid"; 6 6 import { Title } from "@solidjs/meta"; 7 7 import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"; 8 - import { Button } from "../components/button.jsx"; 9 - import { JSONValue, type JSONType } from "../components/json.jsx"; 10 - import { TextInput } from "../components/text-input.jsx"; 11 - import { isTouchDevice } from "../layout.jsx"; 12 - import { localDateFromTimestamp } from "../utils/date.js"; 13 - 14 - const isIOS = 15 - /iPad|iPhone|iPod/.test(navigator.userAgent) || 16 - (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); 17 - 18 - // Convert CBOR-decoded objects to JSON-friendly format 19 - const toJsonValue = (obj: unknown): JSONType => { 20 - if (obj === null || obj === undefined) return null; 21 - 22 - if (CID.isCidLink(obj)) { 23 - return { $link: obj.$link }; 24 - } 25 - 26 - if ( 27 - obj && 28 - typeof obj === "object" && 29 - "version" in obj && 30 - "codec" in obj && 31 - "digest" in obj && 32 - "bytes" in obj 33 - ) { 34 - try { 35 - return { $link: CID.toString(obj as CID.Cid) }; 36 - } catch {} 37 - } 8 + import { Button } from "../../components/button.jsx"; 9 + import { JSONValue } from "../../components/json.jsx"; 10 + import { TextInput } from "../../components/text-input.jsx"; 11 + import { isTouchDevice } from "../../layout.jsx"; 12 + import { localDateFromTimestamp } from "../../utils/date.js"; 13 + import { 14 + type Archive, 15 + type CollectionEntry, 16 + type RecordEntry, 17 + type View, 18 + toJsonValue, 19 + WelcomeView, 20 + } from "./shared.jsx"; 38 21 39 - if (CBOR.isBytes(obj)) { 40 - return { $bytes: obj.$bytes }; 41 - } 42 - 43 - if (Array.isArray(obj)) { 44 - return obj.map(toJsonValue); 45 - } 46 - 47 - if (typeof obj === "object") { 48 - const result: Record<string, JSONType> = {}; 49 - for (const [key, value] of Object.entries(obj)) { 50 - result[key] = toJsonValue(value); 51 - } 52 - return result; 53 - } 54 - 55 - return obj as JSONType; 56 - }; 57 - 58 - interface Archive { 59 - file: File; 60 - did: string; 61 - entries: CollectionEntry[]; 62 - } 63 - 64 - interface CollectionEntry { 65 - name: string; 66 - entries: RecordEntry[]; 67 - } 68 - 69 - interface RecordEntry { 70 - key: string; 71 - cid: string; 72 - record: JSONType; 73 - } 74 - 75 - type View = 76 - | { type: "repo" } 77 - | { type: "collection"; collection: CollectionEntry } 78 - | { type: "record"; collection: CollectionEntry; record: RecordEntry }; 79 - 80 - export const CarView = () => { 22 + export const ExploreToolView = () => { 81 23 const [archive, setArchive] = createSignal<Archive | null>(null); 82 24 const [loading, setLoading] = createSignal(false); 83 25 const [error, setError] = createSignal<string>(); ··· 97 39 const rootCid = car.roots[0]?.$link; 98 40 if (rootCid) { 99 41 for (const entry of car) { 100 - if (CID.toString(entry.cid) === rootCid) { 101 - const commit = CBOR.decode(entry.bytes); 102 - if (isCommit(commit)) { 103 - did = commit.did; 42 + try { 43 + if (CID.toString(entry.cid) === rootCid) { 44 + const commit = CBOR.decode(entry.bytes); 45 + if (isCommit(commit)) { 46 + did = commit.did; 47 + } 48 + break; 104 49 } 105 - break; 50 + } catch { 51 + // Skip entries with invalid CIDs 106 52 } 107 53 } 108 54 } ··· 118 64 const repo = fromStream(stream); 119 65 try { 120 66 for await (const entry of repo) { 121 - let list = collections.get(entry.collection); 122 - if (list === undefined) { 123 - collections.set(entry.collection, (list = [])); 124 - result.entries.push({ 125 - name: entry.collection, 126 - entries: list, 67 + try { 68 + let list = collections.get(entry.collection); 69 + if (list === undefined) { 70 + collections.set(entry.collection, (list = [])); 71 + result.entries.push({ 72 + name: entry.collection, 73 + entries: list, 74 + }); 75 + } 76 + 77 + const record = toJsonValue(entry.record); 78 + list.push({ 79 + key: entry.rkey, 80 + cid: entry.cid.$link, 81 + record, 127 82 }); 83 + } catch { 84 + // Skip entries with invalid data 128 85 } 129 - 130 - const record = toJsonValue(entry.record); 131 - list.push({ 132 - key: entry.rkey, 133 - cid: entry.cid.$link, 134 - record, 135 - }); 136 86 } 137 87 } finally { 138 88 await repo.dispose(); ··· 176 126 177 127 return ( 178 128 <> 179 - <Title>CAR explorer - PDSls</Title> 180 - <div class="flex w-full flex-col items-center"> 181 - <Show 182 - when={archive()} 183 - fallback={ 184 - <WelcomeView 185 - loading={loading()} 186 - error={error()} 187 - onFileChange={handleFileChange} 188 - onDrop={handleDrop} 189 - onDragOver={handleDragOver} 190 - /> 191 - } 192 - > 193 - {(arch) => <ExploreView archive={arch()} view={view} setView={setView} onClose={reset} />} 194 - </Show> 195 - </div> 196 - </> 197 - ); 198 - }; 199 - 200 - const WelcomeView = (props: { 201 - loading: boolean; 202 - error?: string; 203 - onFileChange: (e: Event) => void; 204 - onDrop: (e: DragEvent) => void; 205 - onDragOver: (e: DragEvent) => void; 206 - }) => { 207 - return ( 208 - <div class="flex w-full max-w-3xl flex-col gap-y-4 px-2"> 209 - <div class="flex flex-col gap-y-1"> 210 - <h1 class="text-lg font-semibold">CAR explorer</h1> 211 - <p class="text-sm text-neutral-600 dark:text-neutral-400"> 212 - Upload a CAR (Content Addressable aRchive) file to explore its contents. 213 - </p> 214 - </div> 215 - 216 - <div 217 - 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" 218 - onDrop={props.onDrop} 219 - onDragOver={props.onDragOver} 129 + <Title>Explore archive - PDSls</Title> 130 + <Show 131 + when={archive()} 132 + fallback={ 133 + <WelcomeView 134 + title="Explore archive" 135 + subtitle="Upload a CAR file to explore its contents." 136 + loading={loading()} 137 + error={error()} 138 + onFileChange={handleFileChange} 139 + onDrop={handleDrop} 140 + onDragOver={handleDragOver} 141 + /> 142 + } 220 143 > 221 - <Show 222 - when={!props.loading} 223 - fallback={ 224 - <div class="flex flex-col items-center gap-2"> 225 - <span class="iconify lucide--loader-circle animate-spin text-3xl text-neutral-400" /> 226 - <span class="text-sm font-medium text-neutral-600 dark:text-neutral-400"> 227 - Reading CAR file... 228 - </span> 229 - </div> 230 - } 231 - > 232 - <span class="iconify lucide--folder-archive text-3xl text-neutral-400" /> 233 - <div class="text-center"> 234 - <p class="text-sm font-medium text-neutral-700 dark:text-neutral-300"> 235 - Drag and drop a CAR file here 236 - </p> 237 - <p class="text-xs text-neutral-500 dark:text-neutral-400">or</p> 238 - </div> 239 - <label class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-8 items-center justify-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-3 py-1.5 text-sm shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800"> 240 - <input 241 - type="file" 242 - accept={isIOS ? undefined : ".car,application/vnd.ipld.car"} 243 - onChange={props.onFileChange} 244 - class="hidden" 245 - /> 246 - <span class="iconify lucide--upload text-sm" /> 247 - Choose file 248 - </label> 249 - </Show> 250 - </div> 251 - 252 - <Show when={props.error}> 253 - <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"> 254 - {props.error} 255 - </div> 144 + {(arch) => <ExploreView archive={arch()} view={view} setView={setView} onClose={reset} />} 256 145 </Show> 257 - </div> 146 + </> 258 147 ); 259 148 }; 260 149 ··· 411 300 return ( 412 301 <div class="flex flex-col gap-3"> 413 302 <div class="text-sm text-neutral-600 dark:text-neutral-400"> 414 - {props.archive.entries.length} collection{props.archive.entries.length > 1 ? "s" : ""} 303 + {props.archive.entries.length} collection{props.archive.entries.length !== 1 ? "s" : ""} 415 304 <span class="text-neutral-400 dark:text-neutral-600"> · </span> 416 - {totalRecords()} record{totalRecords() > 1 ? "s" : ""} 305 + {totalRecords()} record{totalRecords() !== 1 ? "s" : ""} 417 306 </div> 418 307 419 308 <TextInput ··· 527 416 return ( 528 417 <div class="flex flex-col gap-3"> 529 418 <span class="text-sm text-neutral-600 dark:text-neutral-400"> 530 - {filteredEntries().length} record{filteredEntries().length > 1 ? "s" : ""} 419 + {filteredEntries().length} record{filteredEntries().length !== 1 ? "s" : ""} 531 420 {filter() && filteredEntries().length !== props.collection.entries.length && ( 532 421 <span class="text-neutral-400 dark:text-neutral-500"> 533 422 {" "}
+44
src/views/car/index.tsx
··· 1 + import { Title } from "@solidjs/meta"; 2 + import { A } from "@solidjs/router"; 3 + 4 + export const CarView = () => { 5 + return ( 6 + <div class="flex w-full max-w-3xl flex-col gap-y-4 px-2"> 7 + <Title>CAR tools - PDSls</Title> 8 + <div class="flex flex-col gap-y-1"> 9 + <h1 class="text-lg font-semibold">CAR tools</h1> 10 + <p class="text-sm text-neutral-600 dark:text-neutral-400"> 11 + Tools for working with Content Addressable aRchive files. 12 + </p> 13 + </div> 14 + 15 + <div class="flex flex-col gap-3"> 16 + <A 17 + href="explore" 18 + 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" 19 + > 20 + <span class="iconify lucide--folder-search mt-0.5 shrink-0 text-xl text-neutral-500 dark:text-neutral-400" /> 21 + <div class="flex flex-col gap-1"> 22 + <span class="font-medium">Explore archive</span> 23 + <span class="text-sm text-neutral-600 dark:text-neutral-400"> 24 + Browse records inside an archive 25 + </span> 26 + </div> 27 + </A> 28 + 29 + <A 30 + href="unpack" 31 + 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" 32 + > 33 + <span class="iconify lucide--file-archive mt-0.5 shrink-0 text-xl text-neutral-500 dark:text-neutral-400" /> 34 + <div class="flex flex-col gap-1"> 35 + <span class="font-medium">Unpack archive</span> 36 + <span class="text-sm text-neutral-600 dark:text-neutral-400"> 37 + Extract all records from an archive into a ZIP file 38 + </span> 39 + </div> 40 + </A> 41 + </div> 42 + </div> 43 + ); 44 + };
+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 + };
+148
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 + error?: string; 78 + progress?: 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 + {props.progress ?? "Reading CAR file..."} 110 + </span> 111 + </div> 112 + } 113 + > 114 + <span class="iconify lucide--folder-archive text-3xl text-neutral-400" /> 115 + <div class="text-center"> 116 + <p class="text-sm font-medium text-neutral-700 dark:text-neutral-300"> 117 + Drag and drop a CAR file here 118 + </p> 119 + <p class="text-xs text-neutral-500 dark:text-neutral-400">or</p> 120 + </div> 121 + <label class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-8 items-center justify-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-3 py-1.5 text-sm shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800"> 122 + <input 123 + type="file" 124 + accept={isIOS ? undefined : ".car,application/vnd.ipld.car"} 125 + onChange={props.onFileChange} 126 + class="hidden" 127 + /> 128 + <span class="iconify lucide--upload text-sm" /> 129 + Choose file 130 + </label> 131 + </Show> 132 + </div> 133 + 134 + <Show when={props.progress && !props.loading}> 135 + <div class="flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300"> 136 + <span class="iconify lucide--check shrink-0" /> 137 + {props.progress} 138 + </div> 139 + </Show> 140 + 141 + <Show when={props.error}> 142 + <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"> 143 + {props.error} 144 + </div> 145 + </Show> 146 + </div> 147 + ); 148 + };
+222
src/views/car/unpack.tsx
··· 1 + import { fromStream } from "@atcute/repo"; 2 + import { zip, type ZipEntry } from "@mary/zip"; 3 + import { Title } from "@solidjs/meta"; 4 + import { A } from "@solidjs/router"; 5 + import { FileSystemWritableFileStream, showSaveFilePicker } from "native-file-system-adapter"; 6 + import { createSignal, onCleanup, Show } from "solid-js"; 7 + import { createLogger, LoggerView } from "./logger.jsx"; 8 + import { isIOS, toJsonValue } from "./shared.jsx"; 9 + 10 + const INVALID_CHAR_RE = /[<>:"/\\|?*\x00-\x1F]/g; 11 + const filenamify = (name: string) => { 12 + return name.replace(INVALID_CHAR_RE, "~"); 13 + }; 14 + 15 + export const UnpackToolView = () => { 16 + const logger = createLogger(); 17 + const [pending, setPending] = createSignal(false); 18 + 19 + let abortController: AbortController | undefined; 20 + 21 + onCleanup(() => { 22 + abortController?.abort(); 23 + }); 24 + 25 + const unpackToZip = async (file: File) => { 26 + abortController?.abort(); 27 + abortController = new AbortController(); 28 + const signal = abortController.signal; 29 + 30 + setPending(true); 31 + logger.log(`Starting extraction`); 32 + 33 + let repo: Awaited<ReturnType<typeof fromStream>> | undefined; 34 + 35 + const stream = file.stream(); 36 + repo = fromStream(stream); 37 + 38 + try { 39 + let count = 0; 40 + let writable: FileSystemWritableFileStream | undefined; 41 + 42 + // Create async generator that yields ZipEntry as we read from CAR 43 + const entryGenerator = async function* (): AsyncGenerator<ZipEntry> { 44 + using progress = logger.progress(`Unpacking records (0 entries)`); 45 + 46 + for await (const entry of repo) { 47 + if (signal.aborted) return; 48 + 49 + // Prompt for save location on first record 50 + if (writable === undefined) { 51 + using _waiting = logger.progress(`Waiting for user...`); 52 + 53 + const fd = await showSaveFilePicker({ 54 + suggestedName: `${file.name.replace(/\.car$/, "")}.zip`, 55 + // @ts-expect-error: ponyfill doesn't have full typings 56 + id: "car-unpack", 57 + startIn: "downloads", 58 + types: [ 59 + { 60 + description: "ZIP archive", 61 + accept: { "application/zip": [".zip"] }, 62 + }, 63 + ], 64 + }).catch((err) => { 65 + if (err instanceof DOMException && err.name === "AbortError") { 66 + logger.warn(`File picker was cancelled`); 67 + } else { 68 + console.warn(err); 69 + logger.warn(`Something went wrong when opening the file picker`); 70 + } 71 + return undefined; 72 + }); 73 + 74 + writable = await fd?.createWritable(); 75 + 76 + if (writable === undefined) { 77 + return; 78 + } 79 + } 80 + 81 + try { 82 + const record = toJsonValue(entry.record); 83 + const filename = `${entry.collection}/${filenamify(entry.rkey)}.json`; 84 + const data = JSON.stringify(record, null, 2); 85 + 86 + yield { filename, data, compress: "deflate" }; 87 + count++; 88 + progress.update(`Unpacking records (${count} entries)`); 89 + } catch { 90 + // Skip entries with invalid data 91 + } 92 + } 93 + }; 94 + 95 + // Stream entries directly to zip, then to file 96 + let writeCount = 0; 97 + for await (const chunk of zip(entryGenerator())) { 98 + if (signal.aborted) { 99 + await writable?.abort(); 100 + return; 101 + } 102 + if (writable === undefined) { 103 + // User cancelled file picker 104 + setPending(false); 105 + return; 106 + } 107 + writeCount++; 108 + if (writeCount % 100 !== 0) { 109 + writable.write(chunk); // Fire and forget 110 + } else { 111 + await writable.write(chunk); // Await periodically to apply backpressure 112 + } 113 + } 114 + 115 + if (signal.aborted) return; 116 + 117 + if (writable === undefined) { 118 + logger.warn(`CAR file has no records`); 119 + setPending(false); 120 + return; 121 + } 122 + 123 + logger.log(`${count} records extracted`); 124 + 125 + { 126 + using _progress = logger.progress(`Flushing writes...`); 127 + await writable.close(); 128 + } 129 + 130 + logger.log(`Finished!`); 131 + } catch (err) { 132 + if (signal.aborted) return; 133 + console.error("Failed to unpack CAR file:", err); 134 + logger.error(`Error: ${err}\nFile might be malformed, or might not be a CAR archive`); 135 + } finally { 136 + await repo?.dispose(); 137 + if (!signal.aborted) { 138 + setPending(false); 139 + } 140 + } 141 + }; 142 + 143 + const handleFileChange = (e: Event) => { 144 + const input = e.target as HTMLInputElement; 145 + const file = input.files?.[0]; 146 + if (file) { 147 + unpackToZip(file); 148 + } 149 + input.value = ""; 150 + }; 151 + 152 + const handleDrop = (e: DragEvent) => { 153 + e.preventDefault(); 154 + if (pending()) return; 155 + const file = e.dataTransfer?.files?.[0]; 156 + if (file && (file.name.endsWith(".car") || file.type === "application/vnd.ipld.car")) { 157 + unpackToZip(file); 158 + } 159 + }; 160 + 161 + const handleDragOver = (e: DragEvent) => { 162 + e.preventDefault(); 163 + }; 164 + 165 + return ( 166 + <div class="flex w-full max-w-3xl flex-col gap-y-4 px-2"> 167 + <Title>Unpack archive - PDSls</Title> 168 + <div class="flex flex-col gap-y-1"> 169 + <div class="flex items-center gap-2 text-lg"> 170 + <A 171 + href="/car" 172 + 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" 173 + > 174 + <span class="iconify lucide--arrow-left" /> 175 + </A> 176 + <h1 class="font-semibold">Unpack archive</h1> 177 + </div> 178 + <p class="text-sm text-neutral-600 dark:text-neutral-400"> 179 + Upload a CAR file to extract all records into a ZIP archive. 180 + </p> 181 + </div> 182 + 183 + <div 184 + 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" 185 + onDrop={handleDrop} 186 + onDragOver={handleDragOver} 187 + > 188 + <Show 189 + when={!pending()} 190 + fallback={ 191 + <div class="flex flex-col items-center gap-2"> 192 + <span class="iconify lucide--loader-circle animate-spin text-3xl text-neutral-400" /> 193 + <span class="text-sm font-medium text-neutral-600 dark:text-neutral-400"> 194 + Processing... 195 + </span> 196 + </div> 197 + } 198 + > 199 + <span class="iconify lucide--folder-archive text-3xl text-neutral-400" /> 200 + <div class="text-center"> 201 + <p class="text-sm font-medium text-neutral-700 dark:text-neutral-300"> 202 + Drag and drop a CAR file here 203 + </p> 204 + <p class="text-xs text-neutral-500 dark:text-neutral-400">or</p> 205 + </div> 206 + <label class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-8 cursor-pointer items-center justify-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-3 py-1.5 text-sm shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800"> 207 + <input 208 + type="file" 209 + accept={isIOS ? undefined : ".car,application/vnd.ipld.car"} 210 + onChange={handleFileChange} 211 + class="hidden" 212 + /> 213 + <span class="iconify lucide--upload text-sm" /> 214 + Choose file 215 + </label> 216 + </Show> 217 + </div> 218 + 219 + <LoggerView logger={logger} /> 220 + </div> 221 + ); 222 + };
-1
src/views/record.tsx
··· 263 263 } 264 264 265 265 const lexiconDocs = Object.fromEntries(resolved); 266 - console.log(lexiconDocs); 267 266 268 267 const validator = new RecordValidator(lexiconDocs, params.collection as Nsid); 269 268 validator.parse({