content-explorer/ui/lib/catalog.ts
tegwick e16aa03393 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
2026-10-13 22:56:52 +02:00

263 lines
7.9 KiB
TypeScript

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,
};
}