Add registry kind indexes and entity pages.
CONTENT-WP-0002-T02 reads kinds from each snapshot and serves both example dataspaces without oncology imports. Assistant: grok Assistant-Session: 01a1346c-fdff-7741-b1af-b7283661554b
This commit is contained in:
parent
a6e360aaa5
commit
e16aa03393
21 changed files with 608 additions and 59 deletions
34
ui/lib/catalog.test.ts
Normal file
34
ui/lib/catalog.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { entitySlug, findDataspace, findEntity, findKind } from "./catalog";
|
||||
|
||||
const root = join(process.cwd(), "../examples/dataspaces");
|
||||
|
||||
function titles(dataspaceId: string, kindId: string): string[] {
|
||||
const snapshot = JSON.parse(readFileSync(join(root, dataspaceId, "snapshot.json"), "utf8")) as {
|
||||
entities: { kindId: string; title: string }[];
|
||||
};
|
||||
return snapshot.entities.filter((entity) => entity.kindId === kindId).map((entity) => entity.title).sort();
|
||||
}
|
||||
|
||||
describe("catalog", () => {
|
||||
it("takes the kind index from the snapshot registry", () => {
|
||||
const concept = findKind("systems-thinking", "concept");
|
||||
const pattern = findKind("software-architecture", "pattern");
|
||||
expect(concept?.plural).toBe("Concepts");
|
||||
expect(concept?.rows.map((row) => row.title).sort()).toEqual(titles("systems-thinking", "concept"));
|
||||
expect(pattern?.rows.map((row) => row.title).sort()).toEqual(titles("software-architecture", "pattern"));
|
||||
expect(concept?.rows.every((row) => row.href?.startsWith("/systems-thinking/concept/"))).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves one entity and hides unknown routes", () => {
|
||||
expect(entitySlug("urn:ce:systems-thinking:concept:system-boundary", "concept")).toBe("system-boundary");
|
||||
const feedback = findEntity("systems-thinking", "concept", "feedback");
|
||||
expect(feedback?.title).toBe("Feedback");
|
||||
expect(feedback?.relations.some((relation) => relation.otherTitle === "Causal loop diagram")).toBe(true);
|
||||
expect(findEntity("systems-thinking", "concept", "missing")).toBeNull();
|
||||
expect(findKind("systems-thinking", "no-such-kind")).toBeNull();
|
||||
expect(findDataspace("software-architecture")?.title).toBe("Software Architecture");
|
||||
});
|
||||
});
|
||||
263
ui/lib/catalog.ts
Normal file
263
ui/lib/catalog.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { RecordRow } from "./types";
|
||||
|
||||
type KindRecord = { id: string; label: string; plural: string };
|
||||
|
||||
type EntityRecord = {
|
||||
id: string;
|
||||
kindId: string;
|
||||
title: string;
|
||||
tldr: string;
|
||||
summary: string;
|
||||
tags?: string[];
|
||||
sourceRefs?: string[];
|
||||
claimRefs?: string[];
|
||||
};
|
||||
|
||||
type SourceRecord = { id: string; title: string; license: string };
|
||||
|
||||
type ClaimRecord = { id: string; subjectId: string; key: string; value?: string; status: string };
|
||||
|
||||
type RelationRecord = { id: string; predicate: string; from: string; to: string };
|
||||
|
||||
type SnapshotFile = {
|
||||
dataspace: { id: string; title: string; description: string };
|
||||
kinds: KindRecord[];
|
||||
sources?: SourceRecord[];
|
||||
entities: EntityRecord[];
|
||||
relations?: RelationRecord[];
|
||||
claims?: ClaimRecord[];
|
||||
};
|
||||
|
||||
type ManifestFile = {
|
||||
relationLabels?: Record<string, { forward?: string; inverse?: string }>;
|
||||
};
|
||||
|
||||
export type DataspaceNav = {
|
||||
id: string;
|
||||
title: string;
|
||||
kinds: { id: string; plural: string; href: string }[];
|
||||
};
|
||||
|
||||
export type DataspaceView = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
kinds: { id: string; label: string; plural: string; href: string }[];
|
||||
rows: RecordRow[];
|
||||
};
|
||||
|
||||
export type KindView = {
|
||||
dataspaceId: string;
|
||||
dataspaceTitle: string;
|
||||
kindId: string;
|
||||
label: string;
|
||||
plural: string;
|
||||
rows: RecordRow[];
|
||||
};
|
||||
|
||||
export type RelationView = {
|
||||
wording: string;
|
||||
direction: "out" | "in";
|
||||
otherTitle: string;
|
||||
otherHref: string | null;
|
||||
};
|
||||
|
||||
export type EntityView = {
|
||||
id: string;
|
||||
title: string;
|
||||
tldr: string;
|
||||
summary: string;
|
||||
tags: string[];
|
||||
dataspaceId: string;
|
||||
dataspaceTitle: string;
|
||||
kindId: string;
|
||||
kindLabel: string;
|
||||
kindHref: string;
|
||||
sources: { title: string; license: string }[];
|
||||
claims: { key: string; status: string; value: string | null }[];
|
||||
relations: RelationView[];
|
||||
};
|
||||
|
||||
type Loaded = {
|
||||
snapshot: SnapshotFile;
|
||||
labels: Map<string, { forward: string; inverse: string }>;
|
||||
byId: Map<string, EntityRecord>;
|
||||
};
|
||||
|
||||
const ROOT = join(process.cwd(), "../examples/dataspaces");
|
||||
|
||||
let cache: Map<string, Loaded> | null = null;
|
||||
|
||||
export function entitySlug(id: string, kindId: string): string {
|
||||
const marker = `:${kindId}:`;
|
||||
const at = id.lastIndexOf(marker);
|
||||
if (at >= 0) return id.slice(at + marker.length);
|
||||
const tail = id.split(":").pop();
|
||||
return tail && tail.length > 0 ? tail : id;
|
||||
}
|
||||
|
||||
export function kindHref(dataspaceId: string, kindId: string): string {
|
||||
return `/${dataspaceId}/${kindId}/`;
|
||||
}
|
||||
|
||||
export function entityHref(dataspaceId: string, kindId: string, id: string): string {
|
||||
return `/${dataspaceId}/${kindId}/${encodeURIComponent(entitySlug(id, kindId))}/`;
|
||||
}
|
||||
|
||||
function loadAll(): Map<string, Loaded> {
|
||||
if (cache) return cache;
|
||||
const loaded = new Map<string, Loaded>();
|
||||
for (const name of readdirSync(ROOT).sort()) {
|
||||
const snapshotPath = join(ROOT, name, "snapshot.json");
|
||||
let snapshot: SnapshotFile;
|
||||
try {
|
||||
snapshot = JSON.parse(readFileSync(snapshotPath, "utf8")) as SnapshotFile;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!snapshot.dataspace?.id || !Array.isArray(snapshot.kinds) || !Array.isArray(snapshot.entities)) continue;
|
||||
let manifest: ManifestFile = {};
|
||||
try {
|
||||
manifest = JSON.parse(readFileSync(join(ROOT, name, "manifest.json"), "utf8")) as ManifestFile;
|
||||
} catch {
|
||||
manifest = {};
|
||||
}
|
||||
const labels = new Map<string, { forward: string; inverse: string }>();
|
||||
for (const [predicate, wording] of Object.entries(manifest.relationLabels ?? {})) {
|
||||
labels.set(predicate, {
|
||||
forward: wording.forward ?? predicate,
|
||||
inverse: wording.inverse ?? predicate,
|
||||
});
|
||||
}
|
||||
loaded.set(snapshot.dataspace.id, {
|
||||
snapshot,
|
||||
labels,
|
||||
byId: new Map(snapshot.entities.map((entity) => [entity.id, entity])),
|
||||
});
|
||||
}
|
||||
cache = loaded;
|
||||
return loaded;
|
||||
}
|
||||
|
||||
function kindOf(loaded: Loaded, kindId: string): KindRecord | null {
|
||||
return loaded.snapshot.kinds.find((kind) => kind.id === kindId) ?? null;
|
||||
}
|
||||
|
||||
function rowFor(loaded: Loaded, entity: EntityRecord): RecordRow {
|
||||
const kind = kindOf(loaded, entity.kindId);
|
||||
return {
|
||||
id: entity.id,
|
||||
kindId: entity.kindId,
|
||||
kindLabel: kind?.label ?? entity.kindId,
|
||||
title: entity.title,
|
||||
tldr: entity.tldr,
|
||||
href: entityHref(loaded.snapshot.dataspace.id, entity.kindId, entity.id),
|
||||
kindHref: kindHref(loaded.snapshot.dataspace.id, entity.kindId),
|
||||
};
|
||||
}
|
||||
|
||||
export function listNavigation(): DataspaceNav[] {
|
||||
return [...loadAll().values()].map((loaded) => ({
|
||||
id: loaded.snapshot.dataspace.id,
|
||||
title: loaded.snapshot.dataspace.title,
|
||||
kinds: loaded.snapshot.kinds.map((kind) => ({
|
||||
id: kind.id,
|
||||
plural: kind.plural,
|
||||
href: kindHref(loaded.snapshot.dataspace.id, kind.id),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function findDataspace(id: string): DataspaceView | null {
|
||||
const loaded = loadAll().get(id);
|
||||
if (!loaded) return null;
|
||||
return {
|
||||
id: loaded.snapshot.dataspace.id,
|
||||
title: loaded.snapshot.dataspace.title,
|
||||
description: loaded.snapshot.dataspace.description,
|
||||
kinds: loaded.snapshot.kinds.map((kind) => ({
|
||||
id: kind.id,
|
||||
label: kind.label,
|
||||
plural: kind.plural,
|
||||
href: kindHref(loaded.snapshot.dataspace.id, kind.id),
|
||||
})),
|
||||
rows: loaded.snapshot.entities.map((entity) => rowFor(loaded, entity)),
|
||||
};
|
||||
}
|
||||
|
||||
export function findKind(dataspaceId: string, kindId: string): KindView | null {
|
||||
const loaded = loadAll().get(dataspaceId);
|
||||
if (!loaded) return null;
|
||||
const kind = kindOf(loaded, kindId);
|
||||
if (!kind) return null;
|
||||
return {
|
||||
dataspaceId,
|
||||
dataspaceTitle: loaded.snapshot.dataspace.title,
|
||||
kindId: kind.id,
|
||||
label: kind.label,
|
||||
plural: kind.plural,
|
||||
rows: loaded.snapshot.entities.filter((entity) => entity.kindId === kind.id).map((entity) => rowFor(loaded, entity)),
|
||||
};
|
||||
}
|
||||
|
||||
function hrefForEntity(loaded: Loaded, id: string): string | null {
|
||||
const entity = loaded.byId.get(id);
|
||||
if (!entity) return null;
|
||||
return entityHref(loaded.snapshot.dataspace.id, entity.kindId, entity.id);
|
||||
}
|
||||
|
||||
export function findEntity(dataspaceId: string, kindId: string, slug: string): EntityView | null {
|
||||
const loaded = loadAll().get(dataspaceId);
|
||||
if (!loaded) return null;
|
||||
const kind = kindOf(loaded, kindId);
|
||||
if (!kind) return null;
|
||||
const matches = loaded.snapshot.entities.filter(
|
||||
(entity) => entity.kindId === kind.id && entitySlug(entity.id, kind.id) === slug,
|
||||
);
|
||||
if (matches.length !== 1) return null;
|
||||
const entity = matches[0];
|
||||
const sources = new Map((loaded.snapshot.sources ?? []).map((source) => [source.id, source]));
|
||||
const relations: RelationView[] = [];
|
||||
for (const relation of loaded.snapshot.relations ?? []) {
|
||||
const wording = loaded.labels.get(relation.predicate);
|
||||
if (relation.from === entity.id) {
|
||||
const other = loaded.byId.get(relation.to);
|
||||
relations.push({
|
||||
wording: wording?.forward ?? relation.predicate,
|
||||
direction: "out",
|
||||
otherTitle: other?.title ?? relation.to,
|
||||
otherHref: hrefForEntity(loaded, relation.to),
|
||||
});
|
||||
} else if (relation.to === entity.id) {
|
||||
const other = loaded.byId.get(relation.from);
|
||||
relations.push({
|
||||
wording: wording?.inverse ?? relation.predicate,
|
||||
direction: "in",
|
||||
otherTitle: other?.title ?? relation.from,
|
||||
otherHref: hrefForEntity(loaded, relation.from),
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: entity.id,
|
||||
title: entity.title,
|
||||
tldr: entity.tldr,
|
||||
summary: entity.summary,
|
||||
tags: entity.tags ?? [],
|
||||
dataspaceId,
|
||||
dataspaceTitle: loaded.snapshot.dataspace.title,
|
||||
kindId: kind.id,
|
||||
kindLabel: kind.label,
|
||||
kindHref: kindHref(dataspaceId, kind.id),
|
||||
sources: (entity.sourceRefs ?? []).map((id) => {
|
||||
const source = sources.get(id);
|
||||
return { title: source?.title ?? id, license: source?.license ?? "" };
|
||||
}),
|
||||
claims: (loaded.snapshot.claims ?? [])
|
||||
.filter((claim) => claim.subjectId === entity.id)
|
||||
.map((claim) => ({ key: claim.key, status: claim.status, value: claim.value ?? null })),
|
||||
relations,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { RecordRow } from "./types";
|
||||
|
||||
type Snapshot = {
|
||||
dataspace: { id: string; title: string; description: string };
|
||||
kinds: { id: string; label: string; plural: string }[];
|
||||
entities: { id: string; kindId: string; title: string; tldr: string }[];
|
||||
};
|
||||
|
||||
export function loadSystemsThinking(): { title: string; description: string; rows: RecordRow[] } {
|
||||
const path = join(process.cwd(), "../examples/dataspaces/systems-thinking/snapshot.json");
|
||||
const snapshot = JSON.parse(readFileSync(path, "utf8")) as Snapshot;
|
||||
const labels = new Map(snapshot.kinds.map((kind) => [kind.id, kind.label]));
|
||||
const rows = snapshot.entities.map((entity) => ({
|
||||
id: entity.id,
|
||||
kindId: entity.kindId,
|
||||
kindLabel: labels.get(entity.kindId) ?? entity.kindId,
|
||||
title: entity.title,
|
||||
tldr: entity.tldr,
|
||||
}));
|
||||
return {
|
||||
title: snapshot.dataspace.title,
|
||||
description: snapshot.dataspace.description,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@ export type RecordRow = {
|
|||
kindLabel: string;
|
||||
title: string;
|
||||
tldr: string;
|
||||
href?: string;
|
||||
kindHref?: string;
|
||||
};
|
||||
|
||||
export type SortState = { key: string; dir: 1 | -1 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue