feat(standalone): browser-local (IndexedDB) virtual projects
Loading a folder now imports a COPY into a browser-local IndexedDB project with its own /p/:slug URL (original disk files untouched): edits persist to IDB across visits, and the project exports via Download .zip or per-file. Gated by VITE_LOCAL_PROJECTS=idb (on for the demo; off keeps the File System Access write-back flow), so it's configurable per deployment. Modular, swappable project sources behind the shared ProjectSource interface, each self-describing via a SourceDescriptor whose kind is shown verbatim in the UI — "Local (this browser)", "Remote · read-only", "Remote · editable" — on the home page, the project view, and inside the editor, so the user always knows whether/how Save persists: - remote → REST backend (remote-rw) - static → CDN gallery (remote-ro), saves download - local (new) → idbProjectStore, writable IDB store A composite layers the local store over the configured remote/gallery source, routing per slug so imported/saved projects and the gallery share one namespace. New, dependency-free (matching sync-client's raw-IDB ethos): - lib/idb-project-store.ts raw IndexedDB store + create/delete/rename/export - lib/zip.ts store-only ZIP writer (verified via system unzip) - lib/import-folder.ts FSA/webkitdirectory folder → in-memory bytes - lib/project-source-shared.ts source descriptors + deterministic uuid - components/SourceChip, components/LocalProjectsSection Home lists local projects (open/export/rename/delete). Verified: typecheck, project-source tests, prod build, and IDB key-range project isolation in-browser (prefix-colliding slugs don't leak). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0f53984407
commit
ce9ede1e9e
16 changed files with 743 additions and 81 deletions
|
|
@ -1,21 +1,49 @@
|
|||
import type { DriftReportBody, Lib } from "@pcbjam/shared";
|
||||
import type { DriftReportBody, Lib, Project } from "@pcbjam/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { API_BASE_URL, PROJECT_SOURCE_KIND } from "./config";
|
||||
import { client } from "./contract-client";
|
||||
import { downloadBytes } from "./download";
|
||||
import { projectSource } from "./project-source";
|
||||
import {
|
||||
ReadOnlyProjectError,
|
||||
descriptorForSlug,
|
||||
listPrimaryProjects,
|
||||
localProjectStore,
|
||||
projectSource,
|
||||
} from "./project-source";
|
||||
import type { SourceDescriptor } from "./project-source-shared";
|
||||
|
||||
/**
|
||||
* Project/file reads go through the active PROJECT SOURCE (lib/project-source.ts):
|
||||
* the @pcbjam/shared REST backend, or the read-only static gallery (demo mode).
|
||||
* Libraries + collab drift reporting are backend-only and stay on the contract
|
||||
* client here.
|
||||
* the @pcbjam/shared REST backend, the read-only static gallery (demo mode), or
|
||||
* the browser-local IndexedDB store — composited per slug. Libraries + collab
|
||||
* drift reporting are backend-only and stay on the contract client here.
|
||||
*/
|
||||
|
||||
/** Remote/gallery projects (excludes browser-local ones — those have their own
|
||||
* hook so the home page can list + manage them as a distinct section). */
|
||||
export function useProjects() {
|
||||
return useQuery({
|
||||
queryKey: ["projects"],
|
||||
queryFn: () => projectSource().listProjects(),
|
||||
queryFn: () => listPrimaryProjects(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Browser-local (IndexedDB) projects; empty when the local store is disabled. */
|
||||
export function useLocalProjects() {
|
||||
return useQuery({
|
||||
queryKey: ["local-projects"],
|
||||
queryFn: (): Promise<Project[]> =>
|
||||
localProjectStore()?.listProjects() ?? Promise.resolve([]),
|
||||
});
|
||||
}
|
||||
|
||||
/** The source kind that owns `slug` (local / remote-ro / remote-rw) — for the
|
||||
* "where your edits go" chip on the project + editor views. */
|
||||
export function useSourceDescriptor(slug: string) {
|
||||
return useQuery({
|
||||
queryKey: ["source-descriptor", slug],
|
||||
queryFn: (): Promise<SourceDescriptor> => descriptorForSlug(slug),
|
||||
enabled: !!slug,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -57,17 +85,26 @@ export function fetchFileBytes(
|
|||
* to the user's machine instead. The remote-vs-static choice is config-driven
|
||||
* (the active project source), so callers just call this.
|
||||
*/
|
||||
export function uploadFileBytes(
|
||||
export async function uploadFileBytes(
|
||||
slug: string,
|
||||
relPath: string,
|
||||
bytes: Uint8Array,
|
||||
): Promise<void> {
|
||||
const source = projectSource();
|
||||
if (source.uploadFileBytes) {
|
||||
return source.uploadFileBytes(slug, relPath, bytes);
|
||||
if (!source.uploadFileBytes) {
|
||||
downloadBytes(relPath, bytes);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await source.uploadFileBytes(slug, relPath, bytes);
|
||||
} catch (e) {
|
||||
// Composite write to a read-only (gallery) project → fall back to download.
|
||||
if (e instanceof ReadOnlyProjectError) {
|
||||
downloadBytes(relPath, bytes);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
downloadBytes(relPath, bytes);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// --- collaboration drift reporting (ysync; backend-only) ---
|
||||
|
|
|
|||
|
|
@ -60,6 +60,17 @@ export const PROJECT_SOURCE_KIND: ProjectSourceKind =
|
|||
* "https://cdn.pcbjam.com/content/2.7.7/manifest.json". Required for "static". */
|
||||
export const PROJECT_MANIFEST_URL = import.meta.env.VITE_PROJECT_MANIFEST_URL || null;
|
||||
|
||||
/**
|
||||
* When "idb", loaded folders import into a browser-local (IndexedDB) project
|
||||
* with its own /p/:slug URL — editable, persistent across visits, exported via
|
||||
* Download .zip / per-file — instead of the in-page File System Access flow.
|
||||
* The local store is layered (composite) alongside the configured remote/gallery
|
||||
* source. Off by default (plain dev keeps disk write-back); build-demo.mjs turns
|
||||
* it on for the demo. See lib/idb-project-store.ts + lib/project-source.ts.
|
||||
*/
|
||||
export const LOCAL_PROJECTS_ENABLED =
|
||||
import.meta.env.VITE_LOCAL_PROJECTS === "idb";
|
||||
|
||||
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
|
||||
import { remoteLibsSource } from "@/wasm/libs/remote-source";
|
||||
import { scopedLibsSource } from "@/wasm/libs/scoped-source";
|
||||
|
|
|
|||
BIN
web/standalone/src/lib/idb-project-store.ts
Normal file
BIN
web/standalone/src/lib/idb-project-store.ts
Normal file
Binary file not shown.
55
web/standalone/src/lib/import-folder.ts
Normal file
55
web/standalone/src/lib/import-folder.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type { NewFile } from "./idb-project-store";
|
||||
|
||||
/**
|
||||
* Read a picked folder's bytes into memory so it can be COPIED into a
|
||||
* browser-local (IndexedDB) project. Unlike the File System Access write-back
|
||||
* flow, this never holds disk handles — the import is a snapshot; the user's
|
||||
* original files are untouched and edits live only in IDB (export to get them
|
||||
* back out). Two pickers, same result: FSA directory handle (Chromium) or a
|
||||
* webkitdirectory FileList (Firefox/Safari).
|
||||
*/
|
||||
export interface ImportedFolder {
|
||||
name: string;
|
||||
files: NewFile[];
|
||||
}
|
||||
|
||||
/** Walk a File System Access directory handle, reading every file's bytes. */
|
||||
export async function importFsaFolder(
|
||||
root: FileSystemDirectoryHandle,
|
||||
): Promise<ImportedFolder> {
|
||||
const files: NewFile[] = [];
|
||||
async function walk(
|
||||
dir: FileSystemDirectoryHandle,
|
||||
prefix: string,
|
||||
): Promise<void> {
|
||||
for await (const [name, handle] of dir.entries()) {
|
||||
if (handle.kind === "file") {
|
||||
const file = await (handle as FileSystemFileHandle).getFile();
|
||||
files.push({
|
||||
path: prefix + name,
|
||||
bytes: new Uint8Array(await file.arrayBuffer()),
|
||||
});
|
||||
} else {
|
||||
await walk(handle as FileSystemDirectoryHandle, `${prefix}${name}/`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(root, "");
|
||||
return { name: root.name, files };
|
||||
}
|
||||
|
||||
/** Read a webkitdirectory FileList, stripping the leading top-folder segment. */
|
||||
export async function importFileList(fileList: FileList): Promise<ImportedFolder> {
|
||||
const list = Array.from(fileList);
|
||||
const first = list[0];
|
||||
const topPrefix = first?.webkitRelativePath?.includes("/")
|
||||
? first.webkitRelativePath.split("/")[0] + "/"
|
||||
: "";
|
||||
const files: NewFile[] = [];
|
||||
for (const f of list) {
|
||||
const rel = f.webkitRelativePath || f.name;
|
||||
const path = rel.startsWith(topPrefix) ? rel.slice(topPrefix.length) : rel;
|
||||
files.push({ path, bytes: new Uint8Array(await f.arrayBuffer()) });
|
||||
}
|
||||
return { name: topPrefix ? topPrefix.slice(0, -1) : "local", files };
|
||||
}
|
||||
73
web/standalone/src/lib/project-source-shared.ts
Normal file
73
web/standalone/src/lib/project-source-shared.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Shared vocabulary for the pluggable PROJECT SOURCES (lib/project-source.ts) and
|
||||
* the local IndexedDB store (lib/idb-project-store.ts). Kept in its own module so
|
||||
* both can import the descriptor types + the deterministic-uuid helper without a
|
||||
* runtime import cycle.
|
||||
*
|
||||
* A source has one of three KINDS, surfaced verbatim in the UI so the user always
|
||||
* knows where their edits go:
|
||||
* - "remote-rw" — a backend the editor reads AND writes (saves upload).
|
||||
* - "remote-ro" — a read-only remote (the demo's CDN gallery); saves download.
|
||||
* - "local" — this browser's IndexedDB; saves persist locally, export by zip.
|
||||
*/
|
||||
export type SourceKind = "remote-rw" | "remote-ro" | "local";
|
||||
|
||||
export interface SourceDescriptor {
|
||||
kind: SourceKind;
|
||||
/** Whether editor saves persist back to this source. */
|
||||
writable: boolean;
|
||||
/** Short chip label, e.g. "Local (this browser)". */
|
||||
label: string;
|
||||
/** One-line explanation of what saving does, for tooltips/help text. */
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const SOURCE_DESCRIPTORS: Record<SourceKind, SourceDescriptor> = {
|
||||
"remote-rw": {
|
||||
kind: "remote-rw",
|
||||
writable: true,
|
||||
label: "Remote · editable",
|
||||
description: "Stored on the backend — your saves are uploaded there.",
|
||||
},
|
||||
"remote-ro": {
|
||||
kind: "remote-ro",
|
||||
writable: false,
|
||||
label: "Remote · read-only",
|
||||
description:
|
||||
"A read-only example from the server — edits aren't saved back; Save downloads the file to your machine.",
|
||||
},
|
||||
local: {
|
||||
kind: "local",
|
||||
writable: true,
|
||||
label: "Local (this browser)",
|
||||
description:
|
||||
"Stored in this browser only — saves persist here across visits. Export with Download .zip (nothing is uploaded).",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Stable v4-format UUID from a seed (cyrb128). The contract ids are UUIDs and the
|
||||
* editor uses project.id for the (broadcast-only here) collab room name, so a
|
||||
* deterministic id keeps that stable across reloads/tabs for the same slug.
|
||||
*/
|
||||
export function deterministicUuid(seed: string): string {
|
||||
let h1 = 1779033703,
|
||||
h2 = 3144134277,
|
||||
h3 = 1013904242,
|
||||
h4 = 2773480762;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
const k = seed.charCodeAt(i);
|
||||
h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
|
||||
h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
|
||||
h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
|
||||
h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
|
||||
}
|
||||
h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
|
||||
h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
|
||||
h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
|
||||
h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
|
||||
const hex = (n: number) => (n >>> 0).toString(16).padStart(8, "0");
|
||||
const h = hex(h1) + hex(h2) + hex(h3) + hex(h4);
|
||||
const variant = ((parseInt(h.charAt(16), 16) & 0x3) | 0x8).toString(16);
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${variant}${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
||||
}
|
||||
|
|
@ -25,6 +25,9 @@ async function loadStatic() {
|
|||
API_BASE_URL: "http://localhost:3050",
|
||||
PROJECT_SOURCE_KIND: "static",
|
||||
PROJECT_MANIFEST_URL: MANIFEST_URL,
|
||||
// Local IDB store off ⇒ the active source is the plain static gallery
|
||||
// (no composite), which is what these read-only assertions cover.
|
||||
LOCAL_PROJECTS_ENABLED: false,
|
||||
}));
|
||||
return (await import("./project-source")).projectSource;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,33 @@
|
|||
import type { Project, ProjectFile, ProjectWithFiles } from "@pcbjam/shared";
|
||||
import {
|
||||
API_BASE_URL,
|
||||
LOCAL_PROJECTS_ENABLED,
|
||||
PROJECT_MANIFEST_URL,
|
||||
PROJECT_SOURCE_KIND,
|
||||
} from "./config";
|
||||
import { client } from "./contract-client";
|
||||
import { idbProjectStore, type LocalProjectStore } from "./idb-project-store";
|
||||
import {
|
||||
SOURCE_DESCRIPTORS,
|
||||
type SourceDescriptor,
|
||||
deterministicUuid,
|
||||
} from "./project-source-shared";
|
||||
|
||||
/**
|
||||
* Where the standalone gets its PROJECTS. The default `remote` source talks the
|
||||
* @pcbjam/shared REST contract; the `static` source serves a read-only example
|
||||
* gallery (manifest + file bytes) from a CDN with no backend — the
|
||||
* demo.pcbjam.com mode, where Save downloads to local (`uploadFileBytes` absent
|
||||
* ⇒ the caller downloads). One source is active per deployment, selected by
|
||||
* PROJECT_SOURCE_KIND. See docs/features/demo-deploy/.
|
||||
* Where the standalone gets its PROJECTS. Every source implements this one
|
||||
* interface (so they're swappable) and self-describes via `descriptor`:
|
||||
* - remote → REST backend over the @pcbjam/shared contract (remote-rw).
|
||||
* - static → read-only example gallery from a CDN, no backend (remote-ro);
|
||||
* Save downloads to local (`uploadFileBytes` absent).
|
||||
* - local → this browser's IndexedDB (idb-project-store.ts), writable.
|
||||
* The configured PROJECT_SOURCE_KIND picks the remote/gallery source; when
|
||||
* LOCAL_PROJECTS_ENABLED, the local IDB store is layered on top (a composite
|
||||
* that routes per slug) so loaded folders + saved work coexist with the gallery.
|
||||
* See docs/features/demo-deploy/.
|
||||
*/
|
||||
export interface ProjectSource {
|
||||
/** What this source is + whether saves persist (surfaced in the UI). */
|
||||
readonly descriptor: SourceDescriptor;
|
||||
/** No write-back target — the editor should download saves to local. */
|
||||
readonly readOnly: boolean;
|
||||
listProjects(): Promise<Project[]>;
|
||||
|
|
@ -41,6 +54,7 @@ function remoteProjectSource(): ProjectSource {
|
|||
const fileUrl = (slug: string, relPath: string) =>
|
||||
`${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files/${encodePath(relPath)}`;
|
||||
return {
|
||||
descriptor: SOURCE_DESCRIPTORS["remote-rw"],
|
||||
readOnly: false,
|
||||
async listProjects() {
|
||||
const res = await client.listProjects();
|
||||
|
|
@ -92,31 +106,6 @@ interface StaticManifest {
|
|||
projects: StaticManifestProject[];
|
||||
}
|
||||
|
||||
/** Stable v4-format UUID from a seed (cyrb128) — the contract ids are UUIDs and
|
||||
* the editor uses project.id for the (broadcast-only here) collab room name, so
|
||||
* a deterministic id keeps that stable across reloads/tabs. */
|
||||
function deterministicUuid(seed: string): string {
|
||||
let h1 = 1779033703,
|
||||
h2 = 3144134277,
|
||||
h3 = 1013904242,
|
||||
h4 = 2773480762;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
const k = seed.charCodeAt(i);
|
||||
h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
|
||||
h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
|
||||
h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
|
||||
h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
|
||||
}
|
||||
h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
|
||||
h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
|
||||
h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
|
||||
h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
|
||||
const hex = (n: number) => (n >>> 0).toString(16).padStart(8, "0");
|
||||
const h = hex(h1) + hex(h2) + hex(h3) + hex(h4);
|
||||
const variant = ((parseInt(h.charAt(16), 16) & 0x3) | 0x8).toString(16);
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${variant}${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
||||
}
|
||||
|
||||
function contentTypeFor(path: string): string {
|
||||
if (/\.(kicad_\w+|net|csv|pos|drl|gbr)$/i.test(path))
|
||||
return "text/plain; charset=utf-8";
|
||||
|
|
@ -163,6 +152,7 @@ function staticProjectSource(manifestUrl: string): ProjectSource {
|
|||
};
|
||||
|
||||
return {
|
||||
descriptor: SOURCE_DESCRIPTORS["remote-ro"],
|
||||
readOnly: true,
|
||||
async listProjects() {
|
||||
const m = await load();
|
||||
|
|
@ -184,16 +174,98 @@ function staticProjectSource(manifestUrl: string): ProjectSource {
|
|||
};
|
||||
}
|
||||
|
||||
// --- composite (local IDB layered over a remote/gallery source) ---------------
|
||||
|
||||
/**
|
||||
* Routes each call to the LOCAL store or the REMOTE/gallery `primary` by which
|
||||
* one owns the slug, so browser-saved projects and the read-only gallery share
|
||||
* one `/p/:slug` namespace. A locally-stored slug always wins (local list is
|
||||
* deduped). `descriptorFor(slug)` answers which kind a given project is, for the
|
||||
* UI; reads/writes fall through to `primary` for anything not in IDB.
|
||||
*/
|
||||
function compositeProjectSource(
|
||||
local: LocalProjectStore,
|
||||
primary: ProjectSource,
|
||||
): ProjectSource {
|
||||
const route = async (slug: string): Promise<ProjectSource> =>
|
||||
(await local.hasProject(slug)) ? local : primary;
|
||||
return {
|
||||
descriptor: local.descriptor,
|
||||
readOnly: false,
|
||||
async listProjects() {
|
||||
const [a, b] = await Promise.all([
|
||||
local.listProjects(),
|
||||
primary.listProjects().catch(() => [] as Project[]),
|
||||
]);
|
||||
const localSlugs = new Set(a.map((p) => p.slug));
|
||||
return [...a, ...b.filter((p) => !localSlugs.has(p.slug))];
|
||||
},
|
||||
getProject: (slug) => route(slug).then((s) => s.getProject(slug)),
|
||||
fetchFileBytes: (slug, p) => route(slug).then((s) => s.fetchFileBytes(slug, p)),
|
||||
uploadFileBytes: async (slug, p, bytes) => {
|
||||
const s = await route(slug);
|
||||
if (s.uploadFileBytes) return s.uploadFileBytes(slug, p, bytes);
|
||||
// Read-only gallery project being edited → download (api.ts also guards).
|
||||
throw new ReadOnlyProjectError(slug);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Thrown by a composite write to a read-only project; api.ts maps it to a
|
||||
* browser download (the gallery's save-to-local behavior). */
|
||||
export class ReadOnlyProjectError extends Error {
|
||||
constructor(slug: string) {
|
||||
super(`project is read-only: ${slug}`);
|
||||
this.name = "ReadOnlyProjectError";
|
||||
}
|
||||
}
|
||||
|
||||
// --- selection ----------------------------------------------------------------
|
||||
|
||||
let cached: ProjectSource | null = null;
|
||||
let cachedPrimary: ProjectSource | null = null;
|
||||
let cachedLocal: LocalProjectStore | null = null;
|
||||
let cachedActive: ProjectSource | null = null;
|
||||
|
||||
/** The active project source for this deployment (memoized). */
|
||||
export function projectSource(): ProjectSource {
|
||||
if (cached) return cached;
|
||||
cached =
|
||||
function primarySource(): ProjectSource {
|
||||
if (cachedPrimary) return cachedPrimary;
|
||||
cachedPrimary =
|
||||
PROJECT_SOURCE_KIND === "static" && PROJECT_MANIFEST_URL
|
||||
? staticProjectSource(PROJECT_MANIFEST_URL)
|
||||
: remoteProjectSource();
|
||||
return cached;
|
||||
return cachedPrimary;
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser-local IDB project store, when enabled for this deployment
|
||||
* (LOCAL_PROJECTS_ENABLED) — used by the home page to import folders + manage
|
||||
* saved projects. `null` when the feature is off (then loaded folders use the
|
||||
* in-page File System Access flow instead).
|
||||
*/
|
||||
export function localProjectStore(): LocalProjectStore | null {
|
||||
if (!LOCAL_PROJECTS_ENABLED) return null;
|
||||
return (cachedLocal ??= idbProjectStore());
|
||||
}
|
||||
|
||||
/** The active project source for this deployment (memoized). When the local IDB
|
||||
* store is enabled it's a composite over the configured remote/gallery source. */
|
||||
export function projectSource(): ProjectSource {
|
||||
if (cachedActive) return cachedActive;
|
||||
const local = localProjectStore();
|
||||
cachedActive = local
|
||||
? compositeProjectSource(local, primarySource())
|
||||
: primarySource();
|
||||
return cachedActive;
|
||||
}
|
||||
|
||||
/** The configured remote/gallery list only (excludes browser-local projects) —
|
||||
* the home page shows local + gallery as separate, clearly-labeled sections. */
|
||||
export function listPrimaryProjects(): Promise<Project[]> {
|
||||
return primarySource().listProjects();
|
||||
}
|
||||
|
||||
/** Which source kind owns `slug` — for showing/describing it in the UI. */
|
||||
export async function descriptorForSlug(slug: string): Promise<SourceDescriptor> {
|
||||
const local = localProjectStore();
|
||||
if (local && (await local.hasProject(slug))) return local.descriptor;
|
||||
return primarySource().descriptor;
|
||||
}
|
||||
|
|
|
|||
121
web/standalone/src/lib/zip.ts
Normal file
121
web/standalone/src/lib/zip.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Minimal store-only (no compression) ZIP writer — enough to bundle a project's
|
||||
* files for a single "Download .zip" export. Hand-rolled to keep the GPL bundle
|
||||
* dependency-free (same ethos as sync-client's raw IndexedDB store). KiCad
|
||||
* projects are a handful of text files, so skipping DEFLATE is a fine trade for
|
||||
* zero deps. Entries are stored with the UTF-8 name flag set; timestamps are
|
||||
* zeroed (we don't track per-file mtimes here).
|
||||
*/
|
||||
|
||||
export interface ZipEntry {
|
||||
/** Forward-slash relative path inside the archive. */
|
||||
path: string;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[n] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
|
||||
function crc32(bytes: Uint8Array): number {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
c = CRC_TABLE[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8);
|
||||
}
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
/** Build a store-only ZIP archive from the given entries. */
|
||||
export function zipFiles(entries: ZipEntry[]): Uint8Array {
|
||||
const enc = new TextEncoder();
|
||||
const chunks: Uint8Array[] = [];
|
||||
const central: Uint8Array[] = [];
|
||||
let offset = 0;
|
||||
|
||||
const u16 = (n: number) => new Uint8Array([n & 0xff, (n >>> 8) & 0xff]);
|
||||
const u32 = (n: number) =>
|
||||
new Uint8Array([n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]);
|
||||
|
||||
for (const e of entries) {
|
||||
const name = enc.encode(e.path);
|
||||
const crc = crc32(e.bytes);
|
||||
const size = e.bytes.length;
|
||||
const flags = 0x0800; // bit 11: filename is UTF-8
|
||||
|
||||
// Local file header + name + data.
|
||||
const local = concat([
|
||||
u32(0x04034b50),
|
||||
u16(20), // version needed
|
||||
u16(flags),
|
||||
u16(0), // method 0 = store
|
||||
u16(0), // mod time
|
||||
u16(0), // mod date
|
||||
u32(crc),
|
||||
u32(size), // compressed size (== uncompressed for store)
|
||||
u32(size), // uncompressed size
|
||||
u16(name.length),
|
||||
u16(0), // extra len
|
||||
name,
|
||||
e.bytes,
|
||||
]);
|
||||
chunks.push(local);
|
||||
|
||||
// Central directory header for this entry.
|
||||
central.push(
|
||||
concat([
|
||||
u32(0x02014b50),
|
||||
u16(20), // version made by
|
||||
u16(20), // version needed
|
||||
u16(flags),
|
||||
u16(0), // method
|
||||
u16(0), // mod time
|
||||
u16(0), // mod date
|
||||
u32(crc),
|
||||
u32(size),
|
||||
u32(size),
|
||||
u16(name.length),
|
||||
u16(0), // extra len
|
||||
u16(0), // comment len
|
||||
u16(0), // disk number start
|
||||
u16(0), // internal attrs
|
||||
u32(0), // external attrs
|
||||
u32(offset), // local header offset
|
||||
name,
|
||||
]),
|
||||
);
|
||||
|
||||
offset += local.length;
|
||||
}
|
||||
|
||||
const centralBlob = concat(central);
|
||||
const eocd = concat([
|
||||
u32(0x06054b50),
|
||||
u16(0), // this disk
|
||||
u16(0), // disk with central dir
|
||||
u16(entries.length),
|
||||
u16(entries.length),
|
||||
u32(centralBlob.length),
|
||||
u32(offset), // central dir offset
|
||||
u16(0), // comment len
|
||||
]);
|
||||
|
||||
return concat([...chunks, centralBlob, eocd]);
|
||||
}
|
||||
|
||||
function concat(parts: Uint8Array[]): Uint8Array {
|
||||
let total = 0;
|
||||
for (const p of parts) total += p.length;
|
||||
const out = new Uint8Array(total);
|
||||
let at = 0;
|
||||
for (const p of parts) {
|
||||
out.set(p, at);
|
||||
at += p.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Loading…
Reference in a new issue