feat: libs 0009-B — footprint serve + read wiring (GPL backend + standalone): extForKind .kicad_mod read/write, kind-filtered listLibs, LibsSource.listLibs(kind) threaded + boot per-tool kind; footprint-browse-remote e2e green (opens real capped 10.0.3 fp); bump pcbjam-shared

This commit is contained in:
Gergő Törcsvári 2026-06-14 07:55:24 +02:00
commit 2c52637b1a
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
10 changed files with 163 additions and 26 deletions

View file

@ -31,6 +31,13 @@ function safeLibDir(root: string, lib: string): string | null {
return path.join(root, lib);
}
/** Per-kind body file extension (one file per item), or null for unknown kinds. */
export function extForKind(kind: string): string | null {
if (kind === "symbol") return ".kicad_sym";
if (kind === "footprint") return ".kicad_mod";
return null;
}
interface IndexFile {
items?: { kind: string; name: string; description?: string | null; keywords?: string | null }[];
description?: string | null;
@ -44,7 +51,10 @@ async function readIndex(dir: string): Promise<IndexFile | null> {
}
}
export async function listLibs(cfg: LibsConfig): Promise<Lib[]> {
export async function listLibs(
cfg: LibsConfig,
kind?: string,
): Promise<Lib[]> {
if (!cfg.dir) return [];
let entries: import("node:fs").Dirent[];
try {
@ -56,6 +66,9 @@ export async function listLibs(cfg: LibsConfig): Promise<Lib[]> {
for (const e of entries) {
if (!e.isDirectory() || e.name.startsWith(".")) continue;
const idx = await readIndex(path.join(cfg.dir, e.name));
// Filter origins by item kind when requested (a footprint tool shouldn't
// list symbol-only origins, and vice versa).
if (kind && !(idx?.items ?? []).some((i) => i.kind === kind)) continue;
libs.push({
id: e.name,
name: e.name,
@ -92,14 +105,15 @@ export function itemBodyPath(
kind: string,
name: string,
): string | null {
if (!cfg.dir || kind !== "symbol") return null;
const ext = extForKind(kind);
if (!cfg.dir || !ext) return null;
const dir = safeLibDir(cfg.dir, lib);
if (!dir) return null;
// Symbol names allow a wide charset but never path separators.
// Item names allow a wide charset but never path separators.
if (name.includes("/") || name.includes("\\") || name.includes("..")) {
return null;
}
return path.join(dir, `${name}.kicad_sym`);
return path.join(dir, `${name}${ext}`);
}
export function streamBody(absPath: string) {

View file

@ -177,10 +177,12 @@ async function main(): Promise<void> {
}
return { status: 200 as const, body: await walk(PROJECT_DIR) };
},
listLibs: async ({ headers }) => {
listLibs: async ({ headers, query }) => {
const owner = ownerOf(headers);
// Origins filtered by item kind (?kind); user libs are kind-agnostic
// containers → always listed.
const [origins, user] = await Promise.all([
listLibs(libs),
listLibs(libs, query.kind),
listUserLibs(userLibs, owner),
]);
return { status: 200 as const, body: [...origins, ...user] };

View file

@ -5,16 +5,19 @@
// plain files under USER_LIBS_DIR, owner-namespaced, in the same per-lib layout
// the read side uses:
//
// <USER_LIBS_DIR>/<owner>/<libId>/index.json { items: [...], type, name }
// <USER_LIBS_DIR>/<owner>/<libId>/<Symbol>.kicad_sym the saved body (verbatim)
// <USER_LIBS_DIR>/<owner>/<libId>/index.json { items: [...], type, name }
// <USER_LIBS_DIR>/<owner>/<libId>/<Symbol>.kicad_sym a saved symbol body
// <USER_LIBS_DIR>/<owner>/<libId>/<Footprint>.kicad_mod a saved footprint body
//
// No parsing: the editor sends fork-native kicad_symbol_lib bytes, stored as-is
// (decision: user-saved bodies round-trip without a version shim). Owner comes
// from OWNER_HEADER; absent ⇒ "default".
// No parsing: the editor sends fork-native bytes, stored as-is (decision:
// user-saved bodies round-trip without a version shim). A user lib is a
// kind-agnostic container (symbols + footprints can coexist). Owner comes from
// OWNER_HEADER; absent ⇒ "default".
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { Lib, LibItem } from "@pcbjam/shared";
import { extForKind } from "./libs.js";
export const DEFAULT_OWNER = "default";
@ -161,11 +164,12 @@ export function userItemBodyPath(
name: string,
): string | null {
const dir = libDir(cfg, owner, lib);
if (!dir || kind !== "symbol" || !safeSeg(name)) return null;
return path.join(dir, `${name}.kicad_sym`);
const ext = extForKind(kind);
if (!dir || !ext || !safeSeg(name)) return null;
return path.join(dir, `${name}${ext}`);
}
/** Write one symbol body into a user lib + index it. */
/** Write one item body (symbol or footprint) into a user lib + index it. */
export async function writeUserItem(
cfg: UserLibsConfig,
owner: string,
@ -174,13 +178,14 @@ export async function writeUserItem(
name: string,
body: string,
): Promise<LibItem> {
if (kind !== "symbol") throw new UserLibError(400, "only symbols for now");
const ext = extForKind(kind);
if (!ext) throw new UserLibError(400, `unsupported kind "${kind}"`);
const dir = libDir(cfg, owner, lib);
if (!dir || !safeSeg(name)) throw new UserLibError(400, "bad lib or item name");
const idx = await readIndex(dir);
if (!idx) throw new UserLibError(404, `user library "${lib}" not found`);
await fs.writeFile(path.join(dir, `${name}.kicad_sym`), body, "utf8");
await fs.writeFile(path.join(dir, `${name}${ext}`), body, "utf8");
const items = idx.items ?? [];
const existing = items.find((i) => i.kind === kind && i.name === name);

@ -1 +1 @@
Subproject commit 3cb0a2ff3f746e52d48d389c19154ffa076cc288
Subproject commit f21b6ea006bcfb8bd92c2d6d2afa42504db6bda4

View file

@ -121,7 +121,8 @@ async function doBoot(opts: BootOptions): Promise<void> {
installLibsProvider(libsSource, log);
try {
// Ensure the owner has at least one writable user lib to save items into.
let libsList = await libsSource.listLibs();
// Pass the tool's kind so origins are filtered to the right domain.
let libsList = await libsSource.listLibs(libKind);
if (libsSource.createLib && !libsList.some((l) => l.type === "user")) {
const created = await libsSource.createLib(DEFAULT_USER_LIB_NAME);
if (created) {

View file

@ -30,8 +30,8 @@ export function remoteLibsSource(
`${encodeURIComponent(kind)}/${encodeURIComponent(name)}`;
return {
async listLibs(): Promise<LibInfo[]> {
const res = await client.listLibs();
async listLibs(kind?: string): Promise<LibInfo[]> {
const res = await client.listLibs({ query: { kind } });
if (res.status !== 200) return [];
return res.body.map((l) => ({
id: l.id,

View file

@ -21,8 +21,13 @@ export interface LibItemInfo {
}
export interface LibsSource {
/** Libraries to expose to the editor (one sym-lib-table row each). */
listLibs(): Promise<LibInfo[]>;
/**
* Libraries to expose to the editor (one lib-table row each). `kind` (the
* current tool's item kind, "symbol" | "footprint") filters origins to those
* holding that kind; user libs are kind-agnostic containers, always listed.
* Omitted ⇒ all libs.
*/
listLibs(kind?: string): Promise<LibInfo[]>;
/** Items in a library (by lib id). */
listItems(libId: string): Promise<LibItemInfo[]>;
/**

View file

@ -36,16 +36,17 @@ function withSpikeKindLib(
const isSpike = (libId: string) => libId === spec.id;
return {
async listLibs(): Promise<LibInfo[]> {
async listLibs(kind?: string): Promise<LibInfo[]> {
// Resilient to a missing backend: the spike must boot standalone (the
// writable lib is in-memory), so an unreachable inner source just yields
// no origins rather than failing the whole table.
let base: LibInfo[] = [];
try {
base = inner ? await inner.listLibs() : [];
base = inner ? await inner.listLibs(kind) : [];
} catch (e) {
log(`[libs] spike: inner listLibs failed, origins omitted: ${String(e)}`);
}
// The spike lib is the writable target for its kind (mimics a user lib).
return [...base, { id: spec.id, name: spec.name, type: "user" }];
},

View file

@ -58,8 +58,9 @@ const SYMBOLS: Record<string, string> = {
export function staticLibsSource(): LibsSource {
return {
async listLibs(): Promise<LibInfo[]> {
return [STATIC_LIB];
async listLibs(kind?: string): Promise<LibInfo[]> {
// The built-in example lib is symbols-only.
return !kind || kind === "symbol" ? [STATIC_LIB] : [];
},
async listItems(libId: string): Promise<LibItemInfo[]> {
if (libId !== STATIC_LIB.id) return [];