content-explorer/ui/lib/catalog.ts
tegwick f8c94d3272
All checks were successful
synthetic / suite (push) Successful in 56s
Finish the computing-basics pilot and the local OnCo reference slice.
The single-operator pilot keeps static projection. Local OnCo use requires a CC BY-NC origin notice, and the slice stays out of public CI.

Assistant: grok
Assistant-Session: 01a15378-f601-7fe2-a143-8e1500a1d59c
2026-10-19 13:04:55 +02:00

475 lines
14 KiB
TypeScript

import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { uniqueOriginNotices, type SourceCredit } from "./attribution";
import { columnDefs, HOME_COLUMNS, mapBrowserRow, type BrowserRow, type ColumnDef } from "./browser-row";
import { memberLinks, partitionIncident, readingPathCrumbs, type GraphEdge, type RelationLabels } from "./graph";
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[];
properties?: unknown;
};
type SourceRecord = {
id: string;
title: string;
license: string;
uri?: string;
attribution?: string;
licenseUrl?: string;
retrievedAt?: string;
originSha?: string;
modificationNotice?: 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 KindViewConfig = { columns?: string[]; facets?: string[] };
type ManifestFile = {
relationLabels?: Record<string, { forward?: string; inverse?: string }>;
kindViews?: Record<string, KindViewConfig>;
};
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: BrowserRow[];
columns: ColumnDef[];
};
export type KindView = {
dataspaceId: string;
dataspaceTitle: string;
kindId: string;
label: string;
plural: string;
rows: BrowserRow[];
columns: ColumnDef[];
};
export type RelationView = {
wording: string;
direction: "out" | "in";
otherTitle: string;
otherHref: string | null;
};
export type EntityLink = {
id: string;
title: string;
href: string | null;
glossary: string | null;
preview: { title: string; summary: string } | null;
};
export type RelationItem = {
id: string;
predicate: string;
direction: "out" | "in";
wording: string;
other: EntityLink;
};
export type EntityView = {
id: string;
title: string;
tldr: string;
summary: string;
tags: string[];
dataspaceId: string;
dataspaceTitle: string;
kindId: string;
kindLabel: string;
kindHref: string;
graphHref: string;
glossary: string | null;
sources: {
title: string;
license: string;
uri?: string;
attribution?: string;
licenseUrl?: string;
retrievedAt?: string;
originSha?: string;
modificationNotice?: string;
}[];
claims: { key: string; status: string; value: string | null }[];
relations: RelationView[];
outgoing: RelationItem[];
incoming: RelationItem[];
relationRemainder: number;
collection: { title: string; members: EntityLink[] } | null;
related: EntityLink[];
readingPath: EntityLink[];
};
export type GraphNodeRecord = {
id: string;
title: string;
summary: string;
href: string;
};
export type GraphEdgeRecord = GraphEdge & {
forward: string;
inverse: string;
};
export type GraphDataset = {
dataspaceId: string;
dataspaceTitle: string;
nodes: GraphNodeRecord[];
edges: GraphEdgeRecord[];
labels: RelationLabels;
defaultFocusId: string;
};
type Loaded = {
snapshot: SnapshotFile;
labels: Map<string, { forward: string; inverse: string }>;
views: Record<string, KindViewConfig>;
byId: Map<string, EntityRecord>;
};
const ROOT = join(process.cwd(), "../examples/dataspaces");
export function dataspaceRoots(): string[] {
const roots = [ROOT];
if (process.env.CONTENT_EXPLORER_LICENSED === "1") {
roots.push(join(process.cwd(), "../tests/onco-reference/local"));
}
return roots;
}
let cache: Map<string, Loaded> | null = null;
export function clearCatalogCache(): void {
cache = 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 root of dataspaceRoots()) {
let names: string[] = [];
try {
names = readdirSync(/*turbopackIgnore: true*/ root);
} catch {
continue;
}
for (const name of names.sort()) {
const snapshotPath = join(root, name, "snapshot.json");
let snapshot: SnapshotFile;
try {
snapshot = JSON.parse(readFileSync(/*turbopackIgnore: true*/ 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(/*turbopackIgnore: true*/ 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,
views: manifest.kindViews ?? {},
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): BrowserRow {
const kind = kindOf(loaded, entity.kindId);
const dataspaceId = loaded.snapshot.dataspace.id;
return mapBrowserRow(
{
id: entity.id,
title: entity.title,
tldr: entity.tldr,
tags: entity.tags,
kindLabel: kind?.label ?? entity.kindId,
route: entityHref(dataspaceId, entity.kindId, entity.id),
kindHref: kindHref(dataspaceId, entity.kindId),
},
loaded.views[entity.kindId]?.facets ?? [],
);
}
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),
})),
}));
}
function sourceCredit(source: SourceRecord): SourceCredit {
return {
title: source.title,
license: source.license,
uri: source.uri,
attribution: source.attribution,
licenseUrl: source.licenseUrl,
retrievedAt: source.retrievedAt,
originSha: source.originSha,
modificationNotice: source.modificationNotice,
};
}
export function originNotices(dataspaceId: string): SourceCredit[] {
const loaded = loadAll().get(dataspaceId);
if (!loaded) return [];
return uniqueOriginNotices((loaded.snapshot.sources ?? []).map(sourceCredit));
}
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)),
columns: columnDefs(HOME_COLUMNS),
};
}
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)),
columns: columnDefs(loaded.views[kind.id]?.columns ?? ["name", "tldr", "tags"]),
};
}
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);
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asText(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value : null;
}
function asIdList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === "string" && item.length > 0);
}
function linkFor(loaded: Loaded, id: string): EntityLink {
const entity = loaded.byId.get(id);
if (!entity) return { id, title: id, href: null, glossary: null, preview: null };
const props = asRecord(entity.properties);
const preview = asRecord(props?.preview);
const previewTitle = asText(preview?.title);
const previewSummary = asText(preview?.summary);
return {
id: entity.id,
title: entity.title,
href: hrefForEntity(loaded, entity.id),
glossary: asText(props?.glossary),
preview: previewTitle && previewSummary ? { title: previewTitle, summary: previewSummary } : null,
};
}
function labelRecord(loaded: Loaded): RelationLabels {
const labels: Record<string, { forward: string; inverse: string }> = {};
for (const [predicate, wording] of loaded.labels) labels[predicate] = wording;
return labels;
}
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 edges = loaded.snapshot.relations ?? [];
const blocks = partitionIncident(entity.id, edges, labelRecord(loaded));
const toItem = (relation: (typeof blocks.outgoing)[number]): RelationItem => ({
id: relation.id,
predicate: relation.predicate,
direction: relation.direction,
wording: relation.wording,
other: linkFor(loaded, relation.otherId),
});
const outgoing = blocks.outgoing.map(toItem);
const incoming = blocks.incoming.map(toItem);
const relations: RelationView[] = [...outgoing, ...incoming].map((relation) => ({
wording: relation.wording,
direction: relation.direction,
otherTitle: relation.other.title,
otherHref: relation.other.href,
}));
const props = asRecord(entity.properties);
const collection = asRecord(props?.collection);
const collectionTitle = asText(collection?.title);
const resolve = (id: string) => {
const link = linkFor(loaded, id);
return { title: link.title, href: link.href };
};
const readingIds = asIdList(props?.readingPath);
const reading = readingPathCrumbs(readingIds, resolve).map((step) => linkFor(loaded, step.id));
const related = memberLinks(asIdList(props?.related), resolve).map((step) => linkFor(loaded, step.id));
const self = linkFor(loaded, entity.id);
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),
graphHref: `/${dataspaceId}/graph/?${new URLSearchParams({ focus: entity.id }).toString()}`,
glossary: self.glossary,
sources: (entity.sourceRefs ?? []).map((id) => {
const source = sources.get(id);
return {
title: source?.title ?? id,
license: source?.license ?? "",
uri: source?.uri,
attribution: source?.attribution,
licenseUrl: source?.licenseUrl,
retrievedAt: source?.retrievedAt,
originSha: source?.originSha,
modificationNotice: source?.modificationNotice,
};
}),
claims: (loaded.snapshot.claims ?? [])
.filter((claim) => claim.subjectId === entity.id)
.map((claim) => ({ key: claim.key, status: claim.status, value: claim.value ?? null })),
relations,
outgoing,
incoming,
relationRemainder: blocks.remainder,
collection: collectionTitle
? { title: collectionTitle, members: memberLinks(asIdList(collection?.members), resolve).map((step) => linkFor(loaded, step.id)) }
: null,
related,
readingPath: reading,
};
}
export function findGraph(dataspaceId: string): GraphDataset | null {
const loaded = loadAll().get(dataspaceId);
if (!loaded || loaded.snapshot.entities.length === 0) return null;
const labels = labelRecord(loaded);
const nodes = loaded.snapshot.entities.map((entity) => ({
id: entity.id,
title: entity.title,
summary: entity.summary,
href: entityHref(loaded.snapshot.dataspace.id, entity.kindId, entity.id),
}));
const preferred = nodes.find((node) => node.id.endsWith(":feedback"));
return {
dataspaceId,
dataspaceTitle: loaded.snapshot.dataspace.title,
nodes,
edges: (loaded.snapshot.relations ?? []).map((relation) => {
const pair = labels[relation.predicate];
return {
id: relation.id,
predicate: relation.predicate,
from: relation.from,
to: relation.to,
forward: pair?.forward || relation.predicate,
inverse: pair?.inverse || relation.predicate,
};
}),
labels,
defaultFocusId: preferred?.id ?? nodes[0].id,
};
}