Merge pull request #521 from Hawksight-AI/feat/ontology-hub-subissue-518

feat(explorer): Ontology Hub — Registry, Loader, Entity Search & SKOS Vocabulary Manager
This commit is contained in:
Mohd Kaif
2026-05-01 17:16:43 +05:30
committed by GitHub
11 changed files with 4322 additions and 1 deletions
+32
View File
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Fix: Ontology Hub post-review bug fixes and security hardening** (follow-up to #518, closes security advisory #23, by @KaifAhmad1):
- **Broken registry filters** — `fetchRegistry` was sending toolbar filter values (`owl`, `skos`, `internal`, `external`) to the backend as the `status` query param, which only accepts `published|draft|external`, causing those filters to return empty lists. Removed the spurious `status` param; all format/kind filtering is now applied client-side via `filteredEntries`, which already had the correct logic.
- **Toggle/refresh URI corruption** — `toggle_ontology` and `refresh_ontology` applied `.removesuffix("/toggle")` / `.removesuffix("/refresh")` to the captured path parameter, which would silently corrupt any ontology URI that legitimately ends with those strings. Starlette's route regex (`/{uri:path}/toggle`) already strips the literal suffix via backtracking, so the `removesuffix` calls were removed and the raw `ontology_uri` parameter is used directly.
- **SSRF in URL fetch** — `_fetch_url_sync()` accepted arbitrary user-supplied URLs and called `requests.get()` with no validation, enabling server-side request forgery against internal services. Added `_validate_fetch_url()` which rejects non-`http`/`https` schemes and resolves the hostname via `socket.getaddrinfo`, blocking loopback, private, link-local, reserved, and multicast addresses. Applied to all three fetch sites: preview, load, and refresh.
- **File upload format misdetected** — the file picker accepted `.xml` and `.json` but `fmtMap` had no entries for those extensions, causing them to default to `turtle`. Added `xml: "xml"` and `json: "json-ld"` mappings. Changed the unknown-extension fallback from `|| "turtle"` to `?? ""` (empty string), and omit the `format` key from the request body when empty so the backend `_detect_format()` runs instead of receiving a forced incorrect value. Also added `.n3` to the accepted extension list and dropzone hint.
- **Inconsistent XML hardening** — `_parse_rdf_sync()` called `rdflib.Graph().parse()` directly, bypassing the `defusedxml`-based XXE protection already present in `semantica/explorer/utils/rdf_parser.py`. Now routes through `_safe_parse_rdf()` from that module, applying consistent protection for all RDF/XML parse paths.
- **Search scans whole graph** (`GET /api/ontology/search`) — the endpoint fetched up to 999 999 nodes and performed a linear Python substring scan on every request. Replaced with `session.search(q, limit * 6)` which uses the `GraphSearchIndex`; results are then post-filtered by `_SEARCHABLE_TYPES` and `entity_type` before being returned up to the requested limit.
- **ReDoS in format detector** (security advisory #23, CodeQL `py/polynomial-redos`, CWE-1333/730/400) — `_detect_format()` used `re.match(r"_:\w+|<[^>]+>\s+<[^>]+>", ...)` to detect N-Triples content. The `<[^>]+>\s+<[^>]+>` alternative was flagged as a polynomial regular expression on uncontrolled data. The URI-subject branch was already unreachable (strings starting with `<` return `"xml"` two lines above), so the entire regex was replaced with two O(1) string operations: `stripped.startswith("_:")` and `" <" in stripped`. `import re` removed as now unused.
- **Feature: Ontology Hub — Registry, Loader, Entity Search & SKOS Vocabulary Manager** (closes #518, part of #517, by @KaifAhmad1):
- Added a sixth workspace, **Ontology Hub** (`ontology-hub`), to the Knowledge Explorer sidebar with a `GitMerge` icon and "Schema Governance" kicker. The workspace shell hosts six tabs — Registry, Editor, Versions, Alignments, Health, and SHACL — with the active tab persisted in the `ontologyTab` URL search parameter via `window.history.replaceState`.
- **Registry tab (`OntologyManager`)** — full CRUD interface for loaded ontologies. Lists entries with color-coded status badges (published / draft / external), format badges (Turtle / XML / JSON-LD / N-Triples), per-ontology stats (class count, concept count, property count), source URL link, and enable/disable toggle, refresh, and remove (with confirmation) actions. Toolbar provides a live search input, All / OWL / SKOS / INTERNAL / EXTERNAL filter pills, an Entity Search button, and a "Load Ontology" button. Empty state surfaces a prominent CTA. Action feedback bar auto-hides after 3 seconds.
- **Load Ontology modal (`OntologyLoader`)** — three-tab modal overlay for importing ontologies:
- *URL Import*: paste any HTTP(S) URL, click "Fetch Preview" to call `POST /api/ontology/preview` (fetches up to 20 MB, parses with rdflib, returns title / namespace / version / license / format / triple count), then "Load Ontology" (`POST /api/ontology/load`). Advanced options toggle exposes format override, custom display name, description, and tags fields.
- *File Upload*: drag-and-drop zone (or browse) accepting `.ttl`, `.rdf`, `.owl`, `.nt`, `.jsonld` files; format auto-detected from extension; multipart `POST /api/ontology/load`.
- *Create New*: three modes — From Scratch (namespace + name + description + tags), From Data (sample data textarea for schema inference via `OntologyEngine.from_data()`), From Text (free-text textarea for LLM-assisted schema generation via `OntologyEngine.from_text()`); calls `POST /api/ontology/create`.
- **Entity Search panel (`OntologySearch`)** — slide-in right panel with debounced 320 ms search across all loaded ontologies via `GET /api/ontology/search`. Type filter pills: All, Class, Property, Individual, Concept, Scheme. Result rows show label, type badge, URI, definition snippet, and source ontology. Selecting a result opens a detail panel that fetches `GET /api/ontology/entity/{uri}` and renders label, URI, definition, superclasses, subclasses, domain, range, instance count, and external URI link. Long lists use a `CollapsibleList` expanding up to 12 items.
- **SKOS Vocabulary Manager (`SKOSVocabularyManager`)** — hierarchical SKOS concept browser activated when a SKOS ontology is selected in the registry. Fetches scheme hierarchy from `GET /api/vocabulary/hierarchy`, renders a recursive `ConceptTreeNode` tree with depth-based indentation, expand/collapse, and selection highlight. Client-side `filterConcepts()` matches label, altLabels, and description. Detail panel fetches `GET /api/ontology/skos/concept/{uri}` and displays all SKOS annotation properties (definition, scopeNote, example, historyNote, editorialNote, changeNote) plus broader / narrower / related / exactMatch / closeMatch lists with clickable navigation.
- **Backend (`semantica/explorer/routes/ontology.py`)** — 12 FastAPI endpoints under `GET|POST /api/ontology`:
- `GET /registry` — returns the in-memory `app.state.ontology_registry` dict as a list, with optional `q` search and `status` filter query params.
- `POST /preview` — streams up to 20 MB from a URL via `requests.get` in `asyncio.to_thread`, parses RDF with rdflib (auto-detects format or accepts `format` param), returns `OntologyPreview` metadata.
- `POST /load` — URL or multipart file load; stores parsed nodes/edges into the active graph session and registers an `OntologyEntry` in the registry.
- `POST /create` — creates an ontology from scratch, sample data, or natural-language text; falls back to a minimal ontology shell if `OntologyEngine` is unavailable.
- `GET /search` — full-text entity search with optional `type` filter across all nodes whose `node_type` maps to class, property, individual, concept, or scheme.
- `GET /entity/{uri:path}` — entity detail: label, type, definition, superclasses, subclasses, domain, range, instance count.
- `GET /skos/schemes` — lists all `skos:ConceptScheme` nodes in the active session.
- `GET /skos/concept/{uri:path}` — full SKOS concept detail including all annotation properties and relation sets.
- `DELETE /{uri:path}`, `PATCH /{uri:path}/toggle`, `POST /{uri:path}/refresh` — remove, enable/disable toggle, and re-fetch/re-parse for registered ontologies. Route ordering places all literal paths before the `:path` wildcards to avoid shadowing.
- Helper internals: `_parse_rdf_sync()` (rdflib parse → nodes/edges/metadata), `_fetch_url_sync()` (streaming requests with 20 MB cap), `_classify_node_type()` (maps raw RDF types to canonical categories), `_uri_to_prefix()` (URI → prefixed form for display).
- Editor, Versions (Subissue 2) and Alignments, Health, SHACL (Subissue 3) tabs render descriptive stub cards with amber subissue badges as placeholders for upcoming implementations.
- TypeScript compiled with zero errors; Vite dev server starts cleanly with the new workspace lazy-loaded via `React.lazy` + `Suspense`.
- **Feature: Explorer landing page redesign** (PR #516 by @ZohaibHassan16, review fixes by @KaifAhmad1):
- Replaced the plain welcome screen with a full landing composition: premium hero section, product preview mock with animated SVG graph, live graph status metrics, intelligence capability band, and consolidated workspace launcher.
- `WelcomeScreen` fetches `/api/graph/stats` on mount with `AbortController` cleanup and displays live node and edge counts; falls back to `"Live"` / `"Ready"` labels when the endpoint is unavailable.
+45
View File
@@ -19,6 +19,7 @@
"graphology-metrics": "^2.4.0",
"graphology-shortest-path": "^2.1.0",
"lucide-react": "^1.7.0",
"playwright": "^1.59.1",
"react": "^19.2.4",
"react-arborist": "^3.4.3",
"react-dom": "^19.2.4",
@@ -3467,6 +3468,50 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+1
View File
@@ -23,6 +23,7 @@
"graphology-metrics": "^2.4.0",
"graphology-shortest-path": "^2.1.0",
"lucide-react": "^1.7.0",
"playwright": "^1.59.1",
"react": "^19.2.4",
"react-arborist": "^3.4.3",
"react-dom": "^19.2.4",
+19 -1
View File
@@ -29,8 +29,9 @@ const RegistryTab = lazy(() => import('./workspaces/EnrichWorkspace/RegistryTab'
const EntityResolutionTab = lazy(() => import('./workspaces/EnrichWorkspace/EntityResolutionTab').then((module) => ({ default: module.EntityResolutionTab })));
const KGOverviewTab = lazy(() => import('./workspaces/ManageWorkspace/KGOverviewTab').then((module) => ({ default: module.KGOverviewTab })));
const OntologySummaryTab = lazy(() => import('./workspaces/ManageWorkspace/OntologySummaryTab').then((module) => ({ default: module.OntologySummaryTab })));
const OntologyWorkspace = lazy(() => import('./workspaces/OntologyWorkspace').then((module) => ({ default: module.OntologyWorkspace })));
type WorkspaceId = 'welcome' | 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage';
type WorkspaceId = 'welcome' | 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage' | 'ontology-hub';
type ExploreView = 'graph' | 'vocabulary';
type AnalyzeView = 'sparql' | 'reasoning';
type EnrichView = 'import' | 'merge' | 'registry' | 'resolve';
@@ -80,6 +81,7 @@ const navItems: NavItem[] = [
{ id: 'decisions', label: 'Decisions', hint: 'Decision chains and precedent review', icon: Scale },
{ id: 'enrich', label: 'Enrich', hint: 'Import, export, and merge workflows', icon: GitBranchPlus },
{ id: 'manage', label: 'Manage', hint: 'Lineage and governance tooling', icon: Settings2 },
{ id: 'ontology-hub', label: 'Ontology Hub', hint: 'Schema governance, registry, and vocabulary management', icon: GitMerge },
];
const shellStyles = `
@@ -1236,6 +1238,7 @@ export default function App() {
const [enrichView, setEnrichView] = useState<EnrichView>('import');
const [manageView, setManageView] = useState<ManageView>('lineage');
const renderWorkspace = () => {
if (activeWorkspace === 'welcome') {
return (
@@ -1358,6 +1361,21 @@ export default function App() {
);
}
if (activeWorkspace === 'ontology-hub') {
return (
<WorkspaceShell
title="Ontology Hub"
subtitle="Load, browse, edit, and govern ontologies and vocabularies."
kicker="Schema Governance"
compact
>
<Suspense fallback={<WorkspaceFallback />}>
<OntologyWorkspace />
</Suspense>
</WorkspaceShell>
);
}
return (
<WorkspaceShell
title="Manage"
@@ -0,0 +1,913 @@
import { useRef, useState } from "react";
import {
AlertCircle,
CheckCircle2,
ChevronDown,
FileUp,
Globe,
Loader2,
Plus,
X,
} from "lucide-react";
type LoaderMode = "url" | "file" | "create";
type CreateMode = "scratch" | "data" | "text";
interface OntologyPreview {
uri: string;
name: string;
description?: string;
namespace?: string;
version?: string;
license?: string;
format: string;
estimated_triples: number;
source_url?: string;
}
interface LoaderProps {
onLoaded: () => void;
onClose: () => void;
}
function Badge({ label, color }: { label: string; color: string }) {
return (
<span
style={{
padding: "2px 8px",
borderRadius: 999,
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.07em",
textTransform: "uppercase" as const,
background: `${color}18`,
border: `1px solid ${color}33`,
color,
}}
>
{label}
</span>
);
}
function FieldGroup({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<label style={fieldLabelStyle}>{label}</label>
{children}
</div>
);
}
function Input({
value,
onChange,
placeholder,
type = "text",
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
type?: string;
}) {
return (
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={inputStyle}
/>
);
}
function Textarea({
value,
onChange,
placeholder,
rows = 5,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
rows?: number;
}) {
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
style={{ ...inputStyle, resize: "vertical", fontFamily: "monospace" }}
/>
);
}
function PreviewCard({ preview }: { preview: OntologyPreview }) {
return (
<div style={previewCardStyle}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
<CheckCircle2 size={16} color="#4cc38a" />
<span style={{ color: "#4cc38a", fontSize: 12, fontWeight: 700 }}>
Preview ready
</span>
<Badge label={preview.format} color="#58a6ff" />
</div>
<div style={previewTitleStyle}>{preview.name}</div>
{preview.description && (
<div style={{ color: "#8fa8c6", fontSize: 12, marginTop: 6, lineHeight: 1.5 }}>
{preview.description}
</div>
)}
<div style={previewGridStyle}>
<PreviewRow label="Namespace" value={preview.namespace || preview.uri} mono />
{preview.version && <PreviewRow label="Version" value={preview.version} />}
{preview.license && <PreviewRow label="License" value={preview.license} />}
<PreviewRow
label="Estimated triples"
value={preview.estimated_triples.toLocaleString()}
/>
</div>
</div>
);
}
function PreviewRow({
label,
value,
mono = false,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={{ color: "#6a7f97", fontSize: 10, fontWeight: 700, textTransform: "uppercase" as const, letterSpacing: "0.07em" }}>
{label}
</span>
<span
style={{
color: "#c6d4e3",
fontSize: 11,
fontFamily: mono ? "monospace" : undefined,
wordBreak: "break-all",
}}
>
{value}
</span>
</div>
);
}
// ---------------------------------------------------------------------------
// URL Import panel
// ---------------------------------------------------------------------------
function URLImportPanel({ onLoaded }: { onLoaded: () => void }) {
const [url, setUrl] = useState("");
const [format, setFormat] = useState("");
const [customName, setCustomName] = useState("");
const [description, setDescription] = useState("");
const [tags, setTags] = useState("");
const [preview, setPreview] = useState<OntologyPreview | null>(null);
const [previewState, setPreviewState] = useState<"idle" | "loading" | "error">("idle");
const [loadState, setLoadState] = useState<"idle" | "loading" | "success" | "error">("idle");
const [errorMsg, setErrorMsg] = useState("");
const [showAdvanced, setShowAdvanced] = useState(false);
const handlePreview = async () => {
if (!url.trim()) return;
setPreviewState("loading");
setPreview(null);
setErrorMsg("");
try {
const res = await fetch("/api/ontology/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: url.trim(), format: format || undefined }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: "Unknown error" }));
throw new Error(err.detail || "Preview failed");
}
setPreview(await res.json());
setPreviewState("idle");
} catch (e) {
setPreviewState("error");
setErrorMsg(e instanceof Error ? e.message : "Could not fetch preview");
}
};
const handleLoad = async () => {
if (!url.trim()) return;
setLoadState("loading");
try {
const res = await fetch("/api/ontology/load", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: url.trim(),
format: format || undefined,
name: customName || undefined,
description: description || undefined,
tags: tags ? tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: "Load failed" }));
throw new Error(err.detail || "Load failed");
}
setLoadState("success");
setTimeout(() => {
setLoadState("idle");
onLoaded();
}, 1200);
} catch (e) {
setLoadState("error");
setErrorMsg(e instanceof Error ? e.message : "Load failed");
}
};
return (
<div style={panelBodyStyle}>
<FieldGroup label="Ontology URL">
<div style={{ display: "flex", gap: 8 }}>
<input
type="url"
value={url}
onChange={(e) => {
setUrl(e.target.value);
setPreview(null);
setPreviewState("idle");
}}
placeholder="https://schema.org/version/latest/schema.ttl"
style={{ ...inputStyle, flex: 1 }}
/>
<button
onClick={handlePreview}
disabled={!url.trim() || previewState === "loading"}
style={previewBtnStyle}
>
{previewState === "loading" ? (
<Loader2 size={13} style={{ animation: "spin 1s linear infinite" }} />
) : (
"Fetch Preview"
)}
</button>
</div>
</FieldGroup>
{previewState === "error" && (
<div style={errorBoxStyle}>
<AlertCircle size={13} />
<span>{errorMsg}</span>
</div>
)}
{preview && <PreviewCard preview={preview} />}
<button
onClick={() => setShowAdvanced((v) => !v)}
style={advancedToggleStyle}
>
<ChevronDown
size={13}
style={{ transform: showAdvanced ? "rotate(180deg)" : undefined, transition: "200ms" }}
/>
Advanced options
</button>
{showAdvanced && (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<FieldGroup label="Format override">
<select
value={format}
onChange={(e) => setFormat(e.target.value)}
style={selectStyle}
>
<option value="">Auto-detect</option>
<option value="turtle">Turtle (.ttl)</option>
<option value="xml">RDF/XML (.rdf, .owl)</option>
<option value="nt">N-Triples (.nt)</option>
<option value="json-ld">JSON-LD (.jsonld)</option>
</select>
</FieldGroup>
<FieldGroup label="Custom display name">
<Input value={customName} onChange={setCustomName} placeholder="Leave blank to use ontology title" />
</FieldGroup>
<FieldGroup label="Description">
<Input value={description} onChange={setDescription} placeholder="Optional description" />
</FieldGroup>
<FieldGroup label="Tags (comma-separated)">
<Input value={tags} onChange={setTags} placeholder="e.g. biology, upper-ontology" />
</FieldGroup>
</div>
)}
{loadState === "success" && (
<div style={successBoxStyle}>
<CheckCircle2 size={13} />
<span>Ontology loaded successfully</span>
</div>
)}
{loadState === "error" && (
<div style={errorBoxStyle}>
<AlertCircle size={13} />
<span>{errorMsg}</span>
</div>
)}
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 4 }}>
<button
onClick={handleLoad}
disabled={!url.trim() || loadState === "loading"}
style={primaryBtnStyle}
>
{loadState === "loading" ? (
<>
<Loader2 size={13} style={{ animation: "spin 1s linear infinite" }} />
Loading
</>
) : (
<>
<Globe size={13} />
Load Ontology
</>
)}
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// File Upload panel
// ---------------------------------------------------------------------------
function FileUploadPanel({ onLoaded }: { onLoaded: () => void }) {
const fileRef = useRef<HTMLInputElement>(null);
const [fileName, setFileName] = useState("");
const [content, setContent] = useState("");
const [format, setFormat] = useState("");
const [loadState, setLoadState] = useState<"idle" | "loading" | "success" | "error">("idle");
const [errorMsg, setErrorMsg] = useState("");
const [dragging, setDragging] = useState(false);
const handleFile = (file: File) => {
setFileName(file.name);
const ext = file.name.split(".").pop()?.toLowerCase() || "";
const fmtMap: Record<string, string> = {
ttl: "turtle", rdf: "xml", owl: "xml", xml: "xml",
nt: "nt", jsonld: "json-ld", json: "json-ld",
};
// Leave format empty for unknown extensions so the backend auto-detects
setFormat(fmtMap[ext] ?? "");
const reader = new FileReader();
reader.onload = (e) => setContent(e.target?.result as string || "");
reader.readAsText(file);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
};
const handleLoad = async () => {
if (!content) return;
setLoadState("loading");
try {
const res = await fetch("/api/ontology/load", {
method: "POST",
headers: { "Content-Type": "application/json" },
// Omit format when empty so the backend _detect_format() runs
body: JSON.stringify({ content, ...(format ? { format } : {}) }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: "Load failed" }));
throw new Error(err.detail || "Load failed");
}
setLoadState("success");
setTimeout(() => {
setLoadState("idle");
onLoaded();
}, 1200);
} catch (e) {
setLoadState("error");
setErrorMsg(e instanceof Error ? e.message : "Load failed");
}
};
return (
<div style={panelBodyStyle}>
<div
style={{
...dropzoneStyle,
borderColor: dragging
? "rgba(74,163,255,0.5)"
: "rgba(127,208,255,0.18)",
background: dragging ? "rgba(74,163,255,0.06)" : undefined,
}}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => fileRef.current?.click()}
>
<FileUp size={24} color="#4aa3ff" />
{fileName ? (
<div style={{ color: "#ebf3ff", fontSize: 13, fontWeight: 600 }}>{fileName}</div>
) : (
<>
<div style={{ color: "#8fa8c6", fontSize: 13 }}>
Drop a file here or <span style={{ color: "#4aa3ff" }}>browse</span>
</div>
<div style={{ color: "#5a7a9a", fontSize: 11 }}>
.ttl · .rdf · .owl · .xml · .nt · .jsonld · .json · .n3
</div>
</>
)}
<input
ref={fileRef}
type="file"
accept=".ttl,.rdf,.owl,.nt,.jsonld,.json,.xml,.n3"
style={{ display: "none" }}
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
/>
</div>
{content && (
<FieldGroup label="Format">
<select
value={format}
onChange={(e) => setFormat(e.target.value)}
style={selectStyle}
>
<option value="turtle">Turtle</option>
<option value="xml">RDF/XML</option>
<option value="nt">N-Triples</option>
<option value="json-ld">JSON-LD</option>
</select>
</FieldGroup>
)}
{loadState === "success" && (
<div style={successBoxStyle}>
<CheckCircle2 size={13} />
<span>Ontology loaded successfully {fileName}</span>
</div>
)}
{loadState === "error" && (
<div style={errorBoxStyle}>
<AlertCircle size={13} />
<span>{errorMsg}</span>
</div>
)}
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<button
onClick={handleLoad}
disabled={!content || loadState === "loading"}
style={primaryBtnStyle}
>
{loadState === "loading" ? (
<>
<Loader2 size={13} style={{ animation: "spin 1s linear infinite" }} />
Loading
</>
) : (
<>
<FileUp size={13} />
Load File
</>
)}
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Create New panel
// ---------------------------------------------------------------------------
function CreateNewPanel({ onLoaded }: { onLoaded: () => void }) {
const [createMode, setCreateMode] = useState<CreateMode>("scratch");
const [namespace, setNamespace] = useState("https://example.org/ontology/");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [tags, setTags] = useState("");
const [sampleData, setSampleData] = useState("");
const [schemaText, setSchemaText] = useState("");
const [createState, setCreateState] = useState<"idle" | "loading" | "success" | "error">("idle");
const [errorMsg, setErrorMsg] = useState("");
const handleCreate = async () => {
if (!name.trim() || !namespace.trim()) return;
setCreateState("loading");
try {
const res = await fetch("/api/ontology/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: createMode,
namespace: namespace.trim(),
name: name.trim(),
description: description || undefined,
tags: tags ? tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
sample_data: createMode === "data" ? sampleData : undefined,
schema_text: createMode === "text" ? schemaText : undefined,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: "Create failed" }));
throw new Error(err.detail || "Create failed");
}
setCreateState("success");
setTimeout(() => {
setCreateState("idle");
onLoaded();
}, 1200);
} catch (e) {
setCreateState("error");
setErrorMsg(e instanceof Error ? e.message : "Create failed");
}
};
return (
<div style={panelBodyStyle}>
<div style={{ display: "flex", gap: 6 }}>
{(["scratch", "data", "text"] as CreateMode[]).map((m) => (
<button
key={m}
onClick={() => setCreateMode(m)}
style={{
...modeTabBase,
...(createMode === m ? modeTabActive : modeTabIdle),
}}
>
{m === "scratch" ? "From Scratch" : m === "data" ? "From Data" : "From Text"}
</button>
))}
</div>
<FieldGroup label="Display Name *">
<Input value={name} onChange={setName} placeholder="My Ontology" />
</FieldGroup>
<FieldGroup label="Namespace URI *">
<Input value={namespace} onChange={setNamespace} placeholder="https://example.org/onto/" />
</FieldGroup>
<FieldGroup label="Description">
<Input value={description} onChange={setDescription} placeholder="Optional description" />
</FieldGroup>
<FieldGroup label="Tags (comma-separated)">
<Input value={tags} onChange={setTags} placeholder="e.g. internal, draft" />
</FieldGroup>
{createMode === "data" && (
<FieldGroup label="Sample Data (JSON or CSV)">
<Textarea
value={sampleData}
onChange={setSampleData}
placeholder={'[{"name": "Alice", "age": 30, "city": "Berlin"}]'}
rows={6}
/>
</FieldGroup>
)}
{createMode === "text" && (
<FieldGroup label="Schema Requirements (natural language)">
<Textarea
value={schemaText}
onChange={setSchemaText}
placeholder="Describe the ontology you need. E.g.: I need an ontology for a hospital domain with patients, doctors, appointments, and medications."
rows={6}
/>
</FieldGroup>
)}
{createState === "success" && (
<div style={successBoxStyle}>
<CheckCircle2 size={13} />
<span>Ontology created and opened in the Registry</span>
</div>
)}
{createState === "error" && (
<div style={errorBoxStyle}>
<AlertCircle size={13} />
<span>{errorMsg}</span>
</div>
)}
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<button
onClick={handleCreate}
disabled={!name.trim() || !namespace.trim() || createState === "loading"}
style={primaryBtnStyle}
>
{createState === "loading" ? (
<>
<Loader2 size={13} style={{ animation: "spin 1s linear infinite" }} />
Creating
</>
) : (
<>
<Plus size={13} />
Create Ontology
</>
)}
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main OntologyLoader modal
// ---------------------------------------------------------------------------
export function OntologyLoader({ onLoaded, onClose }: LoaderProps) {
const [mode, setMode] = useState<LoaderMode>("url");
return (
<div style={overlayStyle} onClick={(e) => e.target === e.currentTarget && onClose()}>
<div style={modalStyle}>
<div style={modalHeaderStyle}>
<div>
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 800 }}>Load Ontology</div>
<div style={{ color: "#8fa8c6", fontSize: 12, marginTop: 2 }}>
Import from URL, upload a file, or create a new ontology
</div>
</div>
<button onClick={onClose} style={closeIconBtnStyle}>
<X size={16} />
</button>
</div>
<div style={{ display: "flex", gap: 2, padding: "0 20px", borderBottom: "1px solid rgba(127,208,255,0.1)" }}>
{(["url", "file", "create"] as LoaderMode[]).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
style={{
...modalTabBase,
...(mode === m ? modalTabActive : modalTabIdle),
}}
>
{m === "url" ? (
<><Globe size={12} /> URL Import</>
) : m === "file" ? (
<><FileUp size={12} /> File Upload</>
) : (
<><Plus size={12} /> Create New</>
)}
</button>
))}
</div>
<div style={modalBodyStyle}>
{mode === "url" && <URLImportPanel onLoaded={onLoaded} />}
{mode === "file" && <FileUploadPanel onLoaded={onLoaded} />}
{mode === "create" && <CreateNewPanel onLoaded={onLoaded} />}
</div>
</div>
</div>
);
}
/* ─── styles ─────────────────────────────────────────────────────────── */
const overlayStyle: React.CSSProperties = {
position: "fixed",
inset: 0,
background: "rgba(3,9,18,0.78)",
backdropFilter: "blur(6px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
};
const modalStyle: React.CSSProperties = {
width: "min(620px, 96vw)",
maxHeight: "88vh",
display: "flex",
flexDirection: "column",
borderRadius: 20,
border: "1px solid rgba(127,208,255,0.16)",
background: "linear-gradient(180deg, rgba(11,21,34,0.98), rgba(6,13,22,0.96))",
boxShadow: "0 32px 80px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.06)",
overflow: "hidden",
};
const modalHeaderStyle: React.CSSProperties = {
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
padding: "20px 20px 16px",
};
const modalBodyStyle: React.CSSProperties = {
flex: 1,
overflowY: "auto",
};
const panelBodyStyle: React.CSSProperties = {
padding: "16px 20px 20px",
display: "flex",
flexDirection: "column",
gap: 14,
};
const modalTabBase: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 14px",
border: "none",
borderBottom: "2px solid transparent",
background: "transparent",
cursor: "pointer",
fontSize: 12,
fontWeight: 600,
transition: "160ms ease",
};
const modalTabIdle: React.CSSProperties = {
color: "#8fa8c6",
};
const modalTabActive: React.CSSProperties = {
color: "#4aa3ff",
borderBottomColor: "#4aa3ff",
};
const closeIconBtnStyle: React.CSSProperties = {
background: "transparent",
border: "none",
color: "#8fa8c6",
cursor: "pointer",
padding: 4,
borderRadius: 8,
display: "grid",
placeItems: "center",
};
const fieldLabelStyle: React.CSSProperties = {
color: "#8fa8c6",
fontSize: 11,
fontWeight: 700,
letterSpacing: "0.05em",
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
borderRadius: 8,
border: "1px solid rgba(127,208,255,0.16)",
background: "rgba(0,0,0,0.24)",
color: "#ebf3ff",
fontSize: 13,
outline: "none",
boxSizing: "border-box",
};
const selectStyle: React.CSSProperties = {
...inputStyle,
appearance: "none" as const,
cursor: "pointer",
};
const previewBtnStyle: React.CSSProperties = {
padding: "8px 14px",
borderRadius: 8,
border: "1px solid rgba(127,208,255,0.2)",
background: "rgba(74,163,255,0.08)",
color: "#7fd0ff",
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
whiteSpace: "nowrap",
display: "inline-flex",
alignItems: "center",
gap: 6,
};
const primaryBtnStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 7,
padding: "9px 18px",
borderRadius: 10,
border: "1px solid rgba(74,163,255,0.3)",
background: "linear-gradient(135deg, rgba(74,163,255,0.2), rgba(74,163,255,0.1))",
color: "#7fd0ff",
fontSize: 13,
fontWeight: 700,
cursor: "pointer",
};
const advancedToggleStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
background: "transparent",
border: "none",
color: "#6a7f97",
fontSize: 12,
cursor: "pointer",
padding: 0,
};
const previewCardStyle: React.CSSProperties = {
padding: 14,
borderRadius: 10,
border: "1px solid rgba(76,195,138,0.18)",
background: "rgba(76,195,138,0.04)",
};
const previewTitleStyle: React.CSSProperties = {
color: "#ebf3ff",
fontSize: 15,
fontWeight: 800,
letterSpacing: "-0.03em",
};
const previewGridStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 10,
marginTop: 12,
};
const dropzoneStyle: React.CSSProperties = {
border: "2px dashed",
borderRadius: 12,
padding: "32px 20px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
cursor: "pointer",
transition: "160ms ease",
};
const successBoxStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 12px",
borderRadius: 8,
border: "1px solid rgba(76,195,138,0.22)",
background: "rgba(76,195,138,0.06)",
color: "#4cc38a",
fontSize: 12,
};
const errorBoxStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 12px",
borderRadius: 8,
border: "1px solid rgba(255,157,175,0.22)",
background: "rgba(255,157,175,0.06)",
color: "#ff9daf",
fontSize: 12,
};
const modeTabBase: React.CSSProperties = {
padding: "6px 12px",
borderRadius: 8,
border: "1px solid transparent",
cursor: "pointer",
fontSize: 12,
fontWeight: 600,
};
const modeTabIdle: React.CSSProperties = {
background: "transparent",
color: "#8fa8c6",
borderColor: "rgba(127,208,255,0.1)",
};
const modeTabActive: React.CSSProperties = {
background: "rgba(74,163,255,0.14)",
color: "#ebf3ff",
borderColor: "rgba(127,208,255,0.24)",
};
@@ -0,0 +1,915 @@
import { useCallback, useEffect, useState } from "react";
import {
AlertCircle,
BookOpen,
CheckCircle2,
ExternalLink,
GitMerge,
Layers,
Loader2,
Plus,
RefreshCw,
Search,
Trash2,
ToggleLeft,
ToggleRight,
} from "lucide-react";
import { OntologyLoader } from "./OntologyLoader";
import { OntologySearch } from "./OntologySearch";
import { SKOSVocabularyManager } from "./SKOSVocabularyManager";
interface OntologyEntry {
uri: string;
name: string;
description?: string;
format: string;
status: "published" | "draft" | "external";
source_url?: string;
version?: string;
class_count: number;
concept_count: number;
property_count: number;
loaded_at: string;
enabled: boolean;
tags: string[];
}
type RightPanel = "none" | "search" | "skos";
const STATUS_COLORS: Record<string, string> = {
published: "#4cc38a",
draft: "#f2b66d",
external: "#58a6ff",
};
const FORMAT_COLORS: Record<string, string> = {
turtle: "#9ee8d7",
xml: "#ff9daf",
"json-ld": "#f2b66d",
nt: "#d2a8ff",
unknown: "#6a7f97",
};
function StatusBadge({ status }: { status: string }) {
const color = STATUS_COLORS[status] || "#6a7f97";
return (
<span
style={{
padding: "2px 7px",
borderRadius: 999,
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.07em",
textTransform: "uppercase" as const,
background: `${color}18`,
border: `1px solid ${color}33`,
color,
}}
>
{status}
</span>
);
}
function FormatBadge({ format }: { format: string }) {
const color = FORMAT_COLORS[format] || FORMAT_COLORS.unknown;
return (
<span
style={{
padding: "2px 7px",
borderRadius: 999,
fontSize: 10,
fontWeight: 700,
background: `${color}14`,
border: `1px solid ${color}28`,
color,
}}
>
{format}
</span>
);
}
function Stat({ value, label }: { value: number; label: string }) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
<span style={{ color: "#ebf3ff", fontSize: 14, fontWeight: 800 }}>
{value.toLocaleString()}
</span>
<span style={{ color: "#6a7f97", fontSize: 10 }}>{label}</span>
</div>
);
}
function RegistryRow({
entry,
selected,
onSelect,
onToggle,
onRefresh,
onRemove,
}: {
entry: OntologyEntry;
selected: boolean;
onSelect: (e: OntologyEntry) => void;
onToggle: (uri: string) => void;
onRefresh: (uri: string) => void;
onRemove: (uri: string) => void;
}) {
const [busyToggle, setBusyToggle] = useState(false);
const [busyRefresh, setBusyRefresh] = useState(false);
const [busyRemove, setBusyRemove] = useState(false);
const handleToggle = async (ev: React.MouseEvent) => {
ev.stopPropagation();
setBusyToggle(true);
await onToggle(entry.uri);
setBusyToggle(false);
};
const handleRefresh = async (ev: React.MouseEvent) => {
ev.stopPropagation();
setBusyRefresh(true);
await onRefresh(entry.uri);
setBusyRefresh(false);
};
const handleRemove = async (ev: React.MouseEvent) => {
ev.stopPropagation();
if (!window.confirm(`Remove "${entry.name}" from the registry?`)) return;
setBusyRemove(true);
await onRemove(entry.uri);
setBusyRemove(false);
};
return (
<div
onClick={() => onSelect(entry)}
style={{
...rowStyle,
background: selected
? "rgba(74,163,255,0.1)"
: "rgba(255,255,255,0.02)",
borderColor: selected
? "rgba(127,208,255,0.26)"
: "rgba(127,208,255,0.1)",
opacity: entry.enabled ? 1 : 0.55,
}}
>
<div style={rowMainStyle}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<span style={rowNameStyle}>{entry.name}</span>
<StatusBadge status={entry.status} />
<FormatBadge format={entry.format} />
{!entry.enabled && (
<span style={disabledBadgeStyle}>Disabled</span>
)}
</div>
<div style={rowUriStyle}>{entry.uri}</div>
{entry.source_url && (
<a
href={entry.source_url}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
style={sourceLinkStyle}
>
<ExternalLink size={10} />
{entry.source_url.slice(0, 60)}{entry.source_url.length > 60 ? "…" : ""}
</a>
)}
</div>
<div style={rowStatsStyle}>
<Stat value={entry.class_count} label="Classes" />
<Stat value={entry.concept_count} label="Concepts" />
<Stat value={entry.property_count} label="Props" />
</div>
<div style={rowActionsStyle}>
<button
title={entry.enabled ? "Disable" : "Enable"}
onClick={handleToggle}
disabled={busyToggle}
style={actionBtnStyle}
>
{busyToggle ? (
<Loader2 size={13} style={{ animation: "spin 0.8s linear infinite" }} />
) : entry.enabled ? (
<ToggleRight size={15} color="#4cc38a" />
) : (
<ToggleLeft size={15} color="#6a7f97" />
)}
</button>
{entry.source_url && (
<button
title="Re-fetch from source URL"
onClick={handleRefresh}
disabled={busyRefresh}
style={actionBtnStyle}
>
{busyRefresh ? (
<Loader2 size={13} style={{ animation: "spin 0.8s linear infinite" }} />
) : (
<RefreshCw size={13} color="#58a6ff" />
)}
</button>
)}
<button
title="Remove from registry"
onClick={handleRemove}
disabled={busyRemove}
style={{ ...actionBtnStyle, color: "#ff9daf" }}
>
{busyRemove ? (
<Loader2 size={13} style={{ animation: "spin 0.8s linear infinite" }} />
) : (
<Trash2 size={13} />
)}
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function OntologyManager() {
const [entries, setEntries] = useState<OntologyEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [searchQ, setSearchQ] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [showLoader, setShowLoader] = useState(false);
const [selectedEntry, setSelectedEntry] = useState<OntologyEntry | null>(null);
const [rightPanel, setRightPanel] = useState<RightPanel>("none");
const [actionMsg, setActionMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const fetchRegistry = useCallback(async () => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (searchQ) params.set("q", searchQ);
// format/kind filters (owl/skos/internal/external) are applied client-side
// via filteredEntries; only text search is delegated to the backend
const res = await fetch(`/api/ontology/registry?${params}`);
if (!res.ok) throw new Error("Failed to load registry");
setEntries(await res.json());
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load registry");
} finally {
setLoading(false);
}
}, [searchQ, statusFilter]);
useEffect(() => {
fetchRegistry();
}, [fetchRegistry]);
const flashMsg = (type: "ok" | "err", text: string) => {
setActionMsg({ type, text });
setTimeout(() => setActionMsg(null), 3000);
};
const handleToggle = useCallback(async (uri: string) => {
try {
const res = await fetch(`/api/ontology/${encodeURIComponent(uri)}/toggle`, {
method: "PATCH",
});
if (!res.ok) throw new Error("Toggle failed");
const data = await res.json();
setEntries((prev) =>
prev.map((e) => (e.uri === uri ? { ...e, enabled: data.enabled } : e))
);
} catch {
flashMsg("err", "Could not toggle ontology");
}
}, []);
const handleRefresh = useCallback(async (uri: string) => {
try {
const res = await fetch(`/api/ontology/${encodeURIComponent(uri)}/refresh`, {
method: "POST",
});
if (!res.ok) throw new Error("Refresh failed");
flashMsg("ok", "Ontology refreshed");
fetchRegistry();
} catch {
flashMsg("err", "Refresh failed — check source URL");
}
}, [fetchRegistry]);
const handleRemove = useCallback(async (uri: string) => {
try {
const res = await fetch(`/api/ontology/${encodeURIComponent(uri)}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Remove failed");
setEntries((prev) => prev.filter((e) => e.uri !== uri));
if (selectedEntry?.uri === uri) setSelectedEntry(null);
flashMsg("ok", "Removed from registry");
} catch {
flashMsg("err", "Could not remove ontology");
}
}, [selectedEntry]);
const handleSelect = (entry: OntologyEntry) => {
setSelectedEntry((prev) => (prev?.uri === entry.uri ? null : entry));
setRightPanel("none");
};
const handleLoaded = () => {
setShowLoader(false);
fetchRegistry();
};
const filteredEntries = entries.filter((e) => {
if (statusFilter === "owl") return ["owl:Ontology"].includes(e.format) || e.format === "xml" || e.format === "turtle";
if (statusFilter === "skos") return e.concept_count > 0;
if (statusFilter === "internal") return e.status === "draft" || e.status === "published";
if (statusFilter === "external") return e.status === "external";
return true;
});
const isSKOS = selectedEntry ? selectedEntry.concept_count > 0 : false;
return (
<>
{showLoader && (
<OntologyLoader
onLoaded={handleLoaded}
onClose={() => setShowLoader(false)}
/>
)}
<div style={shellStyle}>
{/* Toolbar */}
<div style={toolbarStyle}>
<div style={searchBoxStyle}>
<Search size={14} color="#6a7f97" />
<input
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
placeholder="Search ontologies by name, URI, or namespace…"
style={searchInputStyle}
/>
</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{(["all", "owl", "skos", "internal", "external"] as const).map((f) => (
<button
key={f}
onClick={() => setStatusFilter(f)}
style={{
...filterPillBase,
...(statusFilter === f ? filterPillActive : filterPillIdle),
}}
>
{f === "all" ? "All" : f.toUpperCase()}
</button>
))}
</div>
<div style={{ display: "flex", gap: 8, marginLeft: "auto" }}>
<button
onClick={() => setRightPanel((p) => (p === "search" ? "none" : "search"))}
style={{
...toolBtnStyle,
...(rightPanel === "search" ? toolBtnActive : {}),
}}
>
<Search size={13} />
Entity Search
</button>
<button
onClick={() => setShowLoader(true)}
style={primaryToolBtnStyle}
>
<Plus size={13} />
Load Ontology
</button>
</div>
</div>
{actionMsg && (
<div
style={{
...actionMsgStyle,
borderColor:
actionMsg.type === "ok"
? "rgba(76,195,138,0.22)"
: "rgba(255,157,175,0.22)",
background:
actionMsg.type === "ok"
? "rgba(76,195,138,0.06)"
: "rgba(255,157,175,0.06)",
color: actionMsg.type === "ok" ? "#4cc38a" : "#ff9daf",
}}
>
{actionMsg.type === "ok" ? (
<CheckCircle2 size={13} />
) : (
<AlertCircle size={13} />
)}
{actionMsg.text}
</div>
)}
{/* Main content area */}
<div style={mainAreaStyle}>
{/* Registry list */}
<div style={listPanelStyle}>
{loading ? (
<div style={centerStyle}>
<Loader2 size={22} color="#4aa3ff" style={{ animation: "spin 1s linear infinite" }} />
<span style={{ color: "#8fa8c6", fontSize: 13, marginTop: 10 }}>Loading registry</span>
</div>
) : error ? (
<div style={centerStyle}>
<AlertCircle size={22} color="#ff9daf" />
<span style={{ color: "#ff9daf", fontSize: 13, marginTop: 8 }}>{error}</span>
<button onClick={fetchRegistry} style={retryBtnStyle}>Retry</button>
</div>
) : filteredEntries.length === 0 ? (
<div style={emptyStateStyle}>
<GitMerge size={36} color="rgba(74,163,255,0.15)" />
<div style={{ color: "#8fa8c6", fontSize: 13, marginTop: 12 }}>
{searchQ ? "No ontologies match your search" : "No ontologies loaded yet"}
</div>
<div style={{ color: "#6a7f97", fontSize: 12, marginTop: 4, textAlign: "center", maxWidth: 300 }}>
Click <strong style={{ color: "#7fd0ff" }}>Load Ontology</strong> to import from a URL, upload a file, or create a new ontology.
</div>
<button onClick={() => setShowLoader(true)} style={{ ...primaryToolBtnStyle, marginTop: 16 }}>
<Plus size={13} />
Load Ontology
</button>
</div>
) : (
<div style={listStyle}>
<div style={listHeaderStyle}>
<span style={listHeaderTextStyle}>
{filteredEntries.length} ontolog{filteredEntries.length === 1 ? "y" : "ies"}
</span>
</div>
{filteredEntries.map((entry) => (
<RegistryRow
key={entry.uri}
entry={entry}
selected={selectedEntry?.uri === entry.uri}
onSelect={handleSelect}
onToggle={handleToggle}
onRefresh={handleRefresh}
onRemove={handleRemove}
/>
))}
</div>
)}
</div>
{/* Right panel */}
{rightPanel === "search" && (
<div style={rightPanelStyle}>
<div style={rightPanelHeaderStyle}>
<span style={rightPanelTitleStyle}>Entity Search</span>
<button onClick={() => setRightPanel("none")} style={closePanelBtnStyle}>×</button>
</div>
<OntologySearch />
</div>
)}
{rightPanel === "none" && selectedEntry && (
<div style={rightPanelStyle}>
<div style={rightPanelHeaderStyle}>
<span style={rightPanelTitleStyle}>{selectedEntry.name}</span>
<div style={{ display: "flex", gap: 6 }}>
{isSKOS && (
<button
onClick={() => setRightPanel("skos")}
style={browseBtnStyle}
>
<BookOpen size={12} />
Browse SKOS
</button>
)}
<button onClick={() => setSelectedEntry(null)} style={closePanelBtnStyle}>×</button>
</div>
</div>
<div style={detailBodyStyle}>
<DetailSection label="URI">
<span style={{ fontFamily: "monospace", fontSize: 11, wordBreak: "break-all", color: "#c6d4e3" }}>
{selectedEntry.uri}
</span>
</DetailSection>
{selectedEntry.description && (
<DetailSection label="Description">
<span style={{ color: "#c6d4e3", fontSize: 13, lineHeight: 1.6 }}>
{selectedEntry.description}
</span>
</DetailSection>
)}
{selectedEntry.source_url && (
<DetailSection label="Source URL">
<a
href={selectedEntry.source_url}
target="_blank"
rel="noreferrer"
style={{ color: "#58a6ff", fontSize: 11, wordBreak: "break-all" }}
>
{selectedEntry.source_url}
</a>
</DetailSection>
)}
{selectedEntry.version && (
<DetailSection label="Version">
<span style={{ color: "#c6d4e3", fontSize: 12 }}>{selectedEntry.version}</span>
</DetailSection>
)}
{selectedEntry.loaded_at && (
<DetailSection label="Loaded at">
<span style={{ color: "#c6d4e3", fontSize: 12 }}>
{new Date(selectedEntry.loaded_at).toLocaleString()}
</span>
</DetailSection>
)}
<div style={statRowStyle}>
<StatBlock value={selectedEntry.class_count} label="Classes" color="#d2a8ff" />
<StatBlock value={selectedEntry.concept_count} label="Concepts" color="#9ee8d7" />
<StatBlock value={selectedEntry.property_count} label="Properties" color="#f2b66d" />
</div>
{selectedEntry.tags.length > 0 && (
<DetailSection label="Tags">
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{selectedEntry.tags.map((tag) => (
<span key={tag} style={tagChipStyle}>{tag}</span>
))}
</div>
</DetailSection>
)}
</div>
</div>
)}
{rightPanel === "skos" && selectedEntry && (
<div style={rightPanelStyle}>
<div style={rightPanelHeaderStyle}>
<span style={rightPanelTitleStyle}>SKOS {selectedEntry.name}</span>
<div style={{ display: "flex", gap: 6 }}>
<button onClick={() => setRightPanel("none")} style={browseBtnStyle}>
<Layers size={12} />
Registry Detail
</button>
<button onClick={() => setRightPanel("none")} style={closePanelBtnStyle}>×</button>
</div>
</div>
<SKOSVocabularyManager schemeUri={selectedEntry.uri} />
</div>
)}
</div>
</div>
</>
);
}
/* ─── sub-components ─────────────────────────────────────────────────── */
function DetailSection({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ borderTop: "1px solid rgba(255,255,255,0.05)", paddingTop: 10, paddingBottom: 2 }}>
<div style={{ color: "#6a7f97", fontSize: 10, fontWeight: 700, textTransform: "uppercase" as const, letterSpacing: "0.07em", marginBottom: 4 }}>
{label}
</div>
{children}
</div>
);
}
function StatBlock({ value, label, color }: { value: number; label: string; color: string }) {
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 2, padding: "10px 6px", background: "rgba(255,255,255,0.02)", borderRadius: 8, border: "1px solid rgba(255,255,255,0.05)" }}>
<span style={{ color, fontSize: 18, fontWeight: 800 }}>{value.toLocaleString()}</span>
<span style={{ color: "#6a7f97", fontSize: 10 }}>{label}</span>
</div>
);
}
/* ─── styles ─────────────────────────────────────────────────────────── */
const shellStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
background: "#0a1525",
overflow: "hidden",
};
const toolbarStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "12px 18px",
borderBottom: "1px solid rgba(127,208,255,0.1)",
background: "rgba(5,12,22,0.72)",
flexWrap: "wrap",
flexShrink: 0,
};
const searchBoxStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
padding: "7px 12px",
borderRadius: 10,
border: "1px solid rgba(127,208,255,0.14)",
background: "rgba(0,0,0,0.24)",
flex: "0 0 280px",
};
const searchInputStyle: React.CSSProperties = {
background: "transparent",
border: "none",
outline: "none",
color: "#ebf3ff",
fontSize: 12,
width: "100%",
};
const filterPillBase: React.CSSProperties = {
padding: "5px 11px",
borderRadius: 999,
border: "1px solid transparent",
fontSize: 11,
fontWeight: 700,
cursor: "pointer",
transition: "160ms ease",
};
const filterPillIdle: React.CSSProperties = {
background: "transparent",
color: "#8fa8c6",
borderColor: "rgba(127,208,255,0.1)",
};
const filterPillActive: React.CSSProperties = {
background: "rgba(74,163,255,0.14)",
color: "#ebf3ff",
borderColor: "rgba(127,208,255,0.26)",
};
const toolBtnStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "7px 12px",
borderRadius: 8,
border: "1px solid rgba(127,208,255,0.16)",
background: "rgba(74,163,255,0.06)",
color: "#8fa8c6",
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
};
const toolBtnActive: React.CSSProperties = {
background: "rgba(74,163,255,0.16)",
color: "#ebf3ff",
borderColor: "rgba(127,208,255,0.28)",
};
const primaryToolBtnStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 7,
padding: "7px 14px",
borderRadius: 9,
border: "1px solid rgba(74,163,255,0.3)",
background: "linear-gradient(135deg, rgba(74,163,255,0.2), rgba(74,163,255,0.08))",
color: "#7fd0ff",
fontSize: 12,
fontWeight: 700,
cursor: "pointer",
};
const actionMsgStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 18px",
fontSize: 12,
borderBottom: "1px solid",
flexShrink: 0,
};
const mainAreaStyle: React.CSSProperties = {
flex: 1,
minHeight: 0,
display: "flex",
overflow: "hidden",
};
const listPanelStyle: React.CSSProperties = {
flex: 1,
minWidth: 0,
overflowY: "auto",
borderRight: "1px solid rgba(127,208,255,0.08)",
};
const listStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
padding: "12px 14px",
gap: 8,
};
const listHeaderStyle: React.CSSProperties = {
paddingBottom: 6,
};
const listHeaderTextStyle: React.CSSProperties = {
color: "#6a7f97",
fontSize: 11,
fontWeight: 700,
};
const rowStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 14,
padding: "12px 14px",
borderRadius: 12,
border: "1px solid",
cursor: "pointer",
transition: "160ms ease",
};
const rowMainStyle: React.CSSProperties = {
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
gap: 4,
};
const rowNameStyle: React.CSSProperties = {
color: "#ebf3ff",
fontSize: 14,
fontWeight: 700,
};
const rowUriStyle: React.CSSProperties = {
color: "#6a7f97",
fontSize: 11,
fontFamily: "monospace",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
};
const sourceLinkStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 4,
color: "#58a6ff",
fontSize: 11,
textDecoration: "none",
};
const rowStatsStyle: React.CSSProperties = {
display: "flex",
gap: 16,
flexShrink: 0,
};
const rowActionsStyle: React.CSSProperties = {
display: "flex",
gap: 4,
flexShrink: 0,
};
const actionBtnStyle: React.CSSProperties = {
background: "transparent",
border: "none",
cursor: "pointer",
padding: 5,
borderRadius: 6,
display: "grid",
placeItems: "center",
color: "#8fa8c6",
};
const rightPanelStyle: React.CSSProperties = {
width: 360,
flexShrink: 0,
display: "flex",
flexDirection: "column",
borderLeft: "1px solid rgba(127,208,255,0.1)",
background: "rgba(5,12,22,0.6)",
overflow: "hidden",
};
const rightPanelHeaderStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "14px 16px",
borderBottom: "1px solid rgba(127,208,255,0.1)",
flexShrink: 0,
};
const rightPanelTitleStyle: React.CSSProperties = {
color: "#ebf3ff",
fontSize: 13,
fontWeight: 700,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
};
const closePanelBtnStyle: React.CSSProperties = {
background: "transparent",
border: "none",
color: "#8fa8c6",
cursor: "pointer",
fontSize: 18,
lineHeight: 1,
padding: "0 2px",
};
const browseBtnStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "4px 10px",
borderRadius: 7,
border: "1px solid rgba(127,208,255,0.18)",
background: "rgba(74,163,255,0.06)",
color: "#7fd0ff",
fontSize: 11,
fontWeight: 600,
cursor: "pointer",
};
const detailBodyStyle: React.CSSProperties = {
padding: "14px 16px",
overflowY: "auto",
flex: 1,
display: "flex",
flexDirection: "column",
gap: 0,
};
const statRowStyle: React.CSSProperties = {
display: "flex",
gap: 6,
marginTop: 12,
marginBottom: 4,
};
const tagChipStyle: React.CSSProperties = {
padding: "3px 8px",
borderRadius: 999,
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.08)",
color: "#8fa8c6",
fontSize: 11,
};
const disabledBadgeStyle: React.CSSProperties = {
padding: "2px 7px",
borderRadius: 999,
fontSize: 10,
fontWeight: 700,
background: "rgba(106,127,151,0.12)",
border: "1px solid rgba(106,127,151,0.2)",
color: "#6a7f97",
};
const centerStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
padding: 40,
};
const emptyStateStyle: React.CSSProperties = {
...centerStyle,
textAlign: "center",
};
const retryBtnStyle: React.CSSProperties = {
marginTop: 12,
padding: "6px 14px",
borderRadius: 8,
border: "1px solid rgba(127,208,255,0.18)",
background: "transparent",
color: "#7fd0ff",
fontSize: 12,
cursor: "pointer",
};
@@ -0,0 +1,574 @@
import { useEffect, useRef, useState } from "react";
import {
AlertCircle,
BookOpen,
ChevronDown,
ChevronRight,
ExternalLink,
Loader2,
Search,
X,
} from "lucide-react";
interface SearchResult {
uri: string;
label: string;
type: string;
entity_type: string;
definition?: string;
source_ontology?: string;
namespace_prefix?: string;
}
interface EntityDetail {
uri: string;
label: string;
type: string;
entity_type: string;
definition?: string;
source_ontology?: string;
superclasses: string[];
subclasses: string[];
domain: string[];
range: string[];
instance_count: number;
properties: Record<string, unknown>;
}
const ENTITY_TYPE_COLORS: Record<string, string> = {
class: "#d2a8ff",
property: "#f2b66d",
individual: "#9ee8d7",
concept: "#58a6ff",
scheme: "#7fd0ff",
unknown: "#6a7f97",
};
const ENTITY_TYPE_LABELS: Record<string, string> = {
class: "Class",
property: "Property",
individual: "Individual",
concept: "Concept",
scheme: "Scheme",
unknown: "Entity",
};
function TypeBadge({ entityType }: { entityType: string }) {
const color = ENTITY_TYPE_COLORS[entityType] || ENTITY_TYPE_COLORS.unknown;
return (
<span
style={{
padding: "1px 7px",
borderRadius: 999,
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase" as const,
background: `${color}14`,
border: `1px solid ${color}28`,
color,
flexShrink: 0,
}}
>
{ENTITY_TYPE_LABELS[entityType] || entityType}
</span>
);
}
function UriRef({ uri }: { uri: string }) {
const short = uri.includes("#")
? uri.split("#").pop() || uri
: uri.split("/").pop() || uri;
return (
<span
title={uri}
style={{ color: "#58a6ff", fontSize: 11, fontFamily: "monospace", cursor: "help" }}
>
{short}
</span>
);
}
function ResultRow({
result,
selected,
onSelect,
}: {
result: SearchResult;
selected: boolean;
onSelect: () => void;
}) {
return (
<div
onClick={onSelect}
style={{
display: "flex",
flexDirection: "column",
gap: 4,
padding: "10px 14px",
borderRadius: 10,
border: "1px solid",
cursor: "pointer",
transition: "160ms ease",
background: selected ? "rgba(74,163,255,0.1)" : "rgba(255,255,255,0.02)",
borderColor: selected ? "rgba(127,208,255,0.24)" : "rgba(127,208,255,0.08)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<span style={{ color: "#ebf3ff", fontSize: 13, fontWeight: 700, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{result.label || result.uri}
</span>
<TypeBadge entityType={result.entity_type} />
</div>
<div style={{ color: "#6a7f97", fontSize: 10, fontFamily: "monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{result.uri}
</div>
{result.definition && (
<div style={{ color: "#8fa8c6", fontSize: 12, lineHeight: 1.4, overflow: "hidden", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical" as const }}>
{result.definition}
</div>
)}
{result.source_ontology && (
<div style={{ color: "#5a7a9a", fontSize: 10 }}>
From: {result.source_ontology}
</div>
)}
</div>
);
}
function CollapsibleList({ label, items }: { label: string; items: string[] }) {
const [open, setOpen] = useState(false);
if (!items.length) return null;
return (
<div>
<button
onClick={() => setOpen((v) => !v)}
style={collapseHdrStyle}
>
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<span>{label}</span>
<span style={{ color: "#6a7f97", fontSize: 10 }}>({items.length})</span>
</button>
{open && (
<div style={{ marginLeft: 16, marginTop: 4, display: "flex", flexDirection: "column", gap: 3 }}>
{items.slice(0, 12).map((uri) => (
<div key={uri} style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ color: "#6a7f97", fontSize: 10 }}></span>
<UriRef uri={uri} />
</div>
))}
{items.length > 12 && (
<span style={{ color: "#5a7a9a", fontSize: 10 }}>+{items.length - 12} more</span>
)}
</div>
)}
</div>
);
}
function DetailPanel({
uri,
onClose,
}: {
uri: string;
onClose: () => void;
}) {
const [detail, setDetail] = useState<EntityDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
setLoading(true);
setError("");
fetch(`/api/ontology/entity/${encodeURIComponent(uri)}`)
.then((r) => {
if (!r.ok) throw new Error("Not found");
return r.json();
})
.then(setDetail)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [uri]);
return (
<div style={detailPanelStyle}>
<div style={detailHeaderStyle}>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<BookOpen size={14} color="#d2a8ff" />
<span style={{ color: "#ebf3ff", fontSize: 13, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
Entity Detail
</span>
</div>
<button onClick={onClose} style={closeDetailBtnStyle}>
<X size={14} />
</button>
</div>
{loading && (
<div style={centerStyle}>
<Loader2 size={18} color="#4aa3ff" style={{ animation: "spin 1s linear infinite" }} />
</div>
)}
{error && (
<div style={centerStyle}>
<AlertCircle size={16} color="#ff9daf" />
<span style={{ color: "#ff9daf", fontSize: 12, marginTop: 6 }}>{error}</span>
</div>
)}
{detail && !loading && (
<div style={detailBodyStyle}>
<div style={{ marginBottom: 14 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap", marginBottom: 4 }}>
<h3 style={{ margin: 0, color: "#ebf3ff", fontSize: 17, fontWeight: 800, letterSpacing: "-0.03em" }}>
{detail.label || detail.uri.split("/").pop()}
</h3>
<TypeBadge entityType={detail.entity_type} />
</div>
<div style={{ color: "#5a7a9a", fontSize: 10, fontFamily: "monospace", wordBreak: "break-all" }}>
{detail.uri}
</div>
</div>
{detail.definition && (
<DetailSection label="Definition">
<p style={{ margin: 0, color: "#c6d4e3", fontSize: 13, lineHeight: 1.6 }}>
{detail.definition}
</p>
</DetailSection>
)}
{detail.instance_count > 0 && (
<DetailSection label="Instances">
<span style={{ color: "#9ee8d7", fontSize: 14, fontWeight: 800 }}>
{detail.instance_count.toLocaleString()}
</span>
</DetailSection>
)}
<CollapsibleList label="Superclasses / Broader" items={detail.superclasses} />
<CollapsibleList label="Subclasses / Narrower" items={detail.subclasses} />
<CollapsibleList label="Domain" items={detail.domain} />
<CollapsibleList label="Range" items={detail.range} />
{detail.source_ontology && (
<DetailSection label="Source Ontology">
<span style={{ color: "#c6d4e3", fontSize: 12, fontFamily: "monospace" }}>
{detail.source_ontology}
</span>
</DetailSection>
)}
<a
href={detail.uri}
target="_blank"
rel="noreferrer"
style={openUriStyle}
>
<ExternalLink size={11} />
Open URI
</a>
</div>
)}
</div>
);
}
function DetailSection({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ paddingTop: 10, borderTop: "1px solid rgba(255,255,255,0.05)", marginTop: 10 }}>
<div style={{ color: "#6a7f97", fontSize: 10, fontWeight: 700, textTransform: "uppercase" as const, letterSpacing: "0.07em", marginBottom: 5 }}>
{label}
</div>
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// Main OntologySearch component
// ---------------------------------------------------------------------------
export function OntologySearch() {
const [query, setQuery] = useState("");
const [entityType, setEntityType] = useState<string>("all");
const [results, setResults] = useState<SearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [selectedUri, setSelectedUri] = useState<string | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const runSearch = async (q: string, type: string) => {
if (!q.trim()) {
setResults([]);
return;
}
setSearching(true);
try {
const params = new URLSearchParams({ q: q.trim(), limit: "80" });
if (type !== "all") params.set("entity_type", type);
const res = await fetch(`/api/ontology/search?${params}`);
if (!res.ok) throw new Error("Search failed");
setResults(await res.json());
} catch {
setResults([]);
} finally {
setSearching(false);
}
};
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => runSearch(query, entityType), 320);
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
}, [query, entityType]);
return (
<div style={searchShellStyle}>
{/* Search input */}
<div style={searchTopStyle}>
<div style={searchBarStyle}>
<Search size={14} color="#6a7f97" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search classes, properties, concepts…"
style={searchInputStyle}
/>
{searching && <Loader2 size={13} color="#4aa3ff" style={{ animation: "spin 0.8s linear infinite", flexShrink: 0 }} />}
{query && !searching && (
<button onClick={() => { setQuery(""); setResults([]); }} style={clearBtnStyle}>
<X size={12} />
</button>
)}
</div>
<div style={typeFilterStyle}>
{(["all", "class", "property", "individual", "concept", "scheme"] as const).map((t) => (
<button
key={t}
onClick={() => setEntityType(t)}
style={{
...typeFilterBtnBase,
...(entityType === t ? typeFilterBtnActive : typeFilterBtnIdle),
}}
>
{t === "all" ? "All" : ENTITY_TYPE_LABELS[t] || t}
</button>
))}
</div>
</div>
{/* Results + detail */}
<div style={searchBodyStyle}>
<div style={resultListStyle}>
{!query && (
<div style={hintStyle}>
<Search size={20} color="rgba(74,163,255,0.2)" />
<span style={{ color: "#6a7f97", fontSize: 12, marginTop: 8 }}>
Type to search across all loaded ontologies
</span>
</div>
)}
{query && results.length === 0 && !searching && (
<div style={hintStyle}>
<span style={{ color: "#6a7f97", fontSize: 12 }}>No results for "{query}"</span>
</div>
)}
{results.length > 0 && (
<div style={{ padding: "10px 12px", display: "flex", flexDirection: "column", gap: 6 }}>
<div style={{ color: "#6a7f97", fontSize: 11, fontWeight: 700, marginBottom: 2 }}>
{results.length} result{results.length !== 1 ? "s" : ""}
</div>
{results.map((r) => (
<ResultRow
key={r.uri}
result={r}
selected={selectedUri === r.uri}
onSelect={() => setSelectedUri((prev) => (prev === r.uri ? null : r.uri))}
/>
))}
</div>
)}
</div>
{selectedUri && (
<DetailPanel uri={selectedUri} onClose={() => setSelectedUri(null)} />
)}
</div>
</div>
);
}
/* ─── styles ─────────────────────────────────────────────────────────── */
const searchShellStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
};
const searchTopStyle: React.CSSProperties = {
padding: "12px 14px 10px",
borderBottom: "1px solid rgba(127,208,255,0.08)",
display: "flex",
flexDirection: "column",
gap: 8,
flexShrink: 0,
};
const searchBarStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
padding: "7px 12px",
borderRadius: 10,
border: "1px solid rgba(127,208,255,0.14)",
background: "rgba(0,0,0,0.24)",
};
const searchInputStyle: React.CSSProperties = {
flex: 1,
background: "transparent",
border: "none",
outline: "none",
color: "#ebf3ff",
fontSize: 13,
};
const clearBtnStyle: React.CSSProperties = {
background: "transparent",
border: "none",
color: "#6a7f97",
cursor: "pointer",
padding: 2,
display: "grid",
placeItems: "center",
};
const typeFilterStyle: React.CSSProperties = {
display: "flex",
gap: 5,
flexWrap: "wrap",
};
const typeFilterBtnBase: React.CSSProperties = {
padding: "4px 10px",
borderRadius: 999,
border: "1px solid transparent",
fontSize: 11,
fontWeight: 600,
cursor: "pointer",
transition: "160ms ease",
};
const typeFilterBtnIdle: React.CSSProperties = {
background: "transparent",
color: "#8fa8c6",
borderColor: "rgba(127,208,255,0.1)",
};
const typeFilterBtnActive: React.CSSProperties = {
background: "rgba(74,163,255,0.14)",
color: "#ebf3ff",
borderColor: "rgba(127,208,255,0.26)",
};
const searchBodyStyle: React.CSSProperties = {
flex: 1,
minHeight: 0,
display: "flex",
overflow: "hidden",
};
const resultListStyle: React.CSSProperties = {
flex: 1,
overflowY: "auto",
minWidth: 0,
};
const hintStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
padding: 32,
};
const detailPanelStyle: React.CSSProperties = {
width: 320,
flexShrink: 0,
display: "flex",
flexDirection: "column",
borderLeft: "1px solid rgba(127,208,255,0.1)",
background: "rgba(3,9,18,0.5)",
overflow: "hidden",
};
const detailHeaderStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 14px",
borderBottom: "1px solid rgba(127,208,255,0.08)",
flexShrink: 0,
};
const closeDetailBtnStyle: React.CSSProperties = {
background: "transparent",
border: "none",
color: "#8fa8c6",
cursor: "pointer",
padding: 2,
display: "grid",
placeItems: "center",
};
const detailBodyStyle: React.CSSProperties = {
flex: 1,
overflowY: "auto",
padding: "14px",
display: "flex",
flexDirection: "column",
gap: 0,
};
const centerStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
padding: 24,
};
const collapseHdrStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 5,
background: "transparent",
border: "none",
color: "#8fa8c6",
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
padding: "6px 0",
width: "100%",
textAlign: "left",
};
const openUriStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 5,
marginTop: 14,
color: "#58a6ff",
fontSize: 11,
textDecoration: "none",
};
@@ -0,0 +1,638 @@
import { useEffect, useState } from "react";
import {
AlertCircle,
BookOpen,
ChevronDown,
ChevronRight,
Loader2,
Search,
X,
} from "lucide-react";
interface SKOSScheme {
uri: string;
title: string;
description?: string;
concept_count: number;
}
interface ConceptNode {
uri: string;
pref_label: string;
alt_labels?: string[];
description?: string;
notation?: string;
scheme_uri?: string;
parent_uri?: string;
children?: ConceptNode[];
}
interface SKOSConceptDetail {
uri: string;
pref_label: string;
alt_labels: string[];
hidden_labels: string[];
definition?: string;
scope_note?: string;
editorial_note?: string;
broader: string[];
narrower: string[];
related: string[];
exact_match: string[];
close_match: string[];
broad_match: string[];
narrow_match: string[];
scheme_uri?: string;
}
function countConcepts(nodes: ConceptNode[]): number {
return nodes.reduce((acc, n) => acc + 1 + countConcepts(n.children ?? []), 0);
}
function LabelChip({ label }: { label: string }) {
return (
<span style={chipStyle}>{label}</span>
);
}
function UriLink({ uri }: { uri: string }) {
const short = uri.includes("#") ? uri.split("#").pop() : uri.split("/").pop();
return (
<span title={uri} style={{ color: "#58a6ff", fontSize: 11, fontFamily: "monospace", cursor: "help" }}>
{short || uri}
</span>
);
}
function ConceptDetailPanel({
uri,
onClose,
onNavigate,
}: {
uri: string;
onClose: () => void;
onNavigate: (uri: string) => void;
}) {
const [detail, setDetail] = useState<SKOSConceptDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
setLoading(true);
setError("");
fetch(`/api/ontology/skos/concept/${encodeURIComponent(uri)}`)
.then((r) => {
if (!r.ok) throw new Error("Concept not found");
return r.json();
})
.then(setDetail)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [uri]);
const renderUriList = (label: string, uris: string[]) => {
if (!uris.length) return null;
return (
<PropSection label={label}>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{uris.map((u) => (
<button
key={u}
onClick={() => onNavigate(u)}
style={navLinkStyle}
>
<ChevronRight size={10} />
<UriLink uri={u} />
</button>
))}
</div>
</PropSection>
);
};
return (
<div style={detailPanelStyle}>
<div style={detailHeaderStyle}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<BookOpen size={13} color="#9ee8d7" />
<span style={{ color: "#ebf3ff", fontSize: 13, fontWeight: 700 }}>Concept Detail</span>
</div>
<button onClick={onClose} style={iconBtnStyle}>
<X size={14} />
</button>
</div>
{loading && (
<div style={centerStyle}>
<Loader2 size={18} color="#4aa3ff" style={{ animation: "spin 1s linear infinite" }} />
</div>
)}
{error && (
<div style={centerStyle}>
<AlertCircle size={16} color="#ff9daf" />
<span style={{ color: "#ff9daf", fontSize: 12, marginTop: 6 }}>{error}</span>
</div>
)}
{detail && !loading && (
<div style={detailBodyStyle}>
<div style={{ marginBottom: 14 }}>
<h3 style={{ margin: "0 0 4px", color: "#ebf3ff", fontSize: 17, fontWeight: 800, letterSpacing: "-0.03em" }}>
{detail.pref_label}
</h3>
{detail.alt_labels.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 6 }}>
{detail.alt_labels.map((l) => <LabelChip key={l} label={l} />)}
</div>
)}
{detail.hidden_labels.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 6 }}>
{detail.hidden_labels.map((l) => (
<span key={l} style={{ ...chipStyle, opacity: 0.5, fontStyle: "italic" }}>{l}</span>
))}
</div>
)}
<div style={{ color: "#5a7a9a", fontSize: 10, fontFamily: "monospace", wordBreak: "break-all" }}>
{uri}
</div>
</div>
{detail.definition && (
<PropSection label="Definition">
<p style={{ margin: 0, color: "#c6d4e3", fontSize: 13, lineHeight: 1.6 }}>
{detail.definition}
</p>
</PropSection>
)}
{detail.scope_note && (
<PropSection label="Scope Note">
<p style={{ margin: 0, color: "#8fa8c6", fontSize: 12, lineHeight: 1.5 }}>
{detail.scope_note}
</p>
</PropSection>
)}
{detail.editorial_note && (
<PropSection label="Editorial Note">
<p style={{ margin: 0, color: "#8fa8c6", fontSize: 12, lineHeight: 1.5 }}>
{detail.editorial_note}
</p>
</PropSection>
)}
{renderUriList("Broader", detail.broader)}
{renderUriList("Narrower", detail.narrower)}
{renderUriList("Related", detail.related)}
{renderUriList("Exact Match", detail.exact_match)}
{renderUriList("Close Match", detail.close_match)}
{renderUriList("Broad Match", detail.broad_match)}
{renderUriList("Narrow Match", detail.narrow_match)}
{detail.scheme_uri && (
<PropSection label="Concept Scheme">
<span style={{ color: "#c6d4e3", fontSize: 11, fontFamily: "monospace", wordBreak: "break-all" }}>
{detail.scheme_uri}
</span>
</PropSection>
)}
</div>
)}
</div>
);
}
function PropSection({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ paddingTop: 10, borderTop: "1px solid rgba(255,255,255,0.05)", marginTop: 10 }}>
<div style={{ color: "#6a7f97", fontSize: 10, fontWeight: 700, textTransform: "uppercase" as const, letterSpacing: "0.07em", marginBottom: 5 }}>
{label}
</div>
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// Concept tree node
// ---------------------------------------------------------------------------
function ConceptTreeNode({
concept,
depth,
selectedUri,
onSelect,
}: {
concept: ConceptNode;
depth: number;
selectedUri: string | null;
onSelect: (uri: string) => void;
}) {
const [expanded, setExpanded] = useState(depth === 0);
const children = concept.children ?? [];
const hasChildren = children.length > 0;
const isSelected = selectedUri === concept.uri;
return (
<>
<div
style={{
display: "flex",
alignItems: "center",
gap: 4,
paddingLeft: 10 + depth * 14,
paddingRight: 10,
paddingTop: 5,
paddingBottom: 5,
borderRadius: 7,
cursor: "pointer",
background: isSelected ? "rgba(74,163,255,0.12)" : "transparent",
transition: "120ms ease",
}}
onMouseEnter={(e) => {
if (!isSelected)
(e.currentTarget as HTMLDivElement).style.background = "rgba(74,163,255,0.06)";
}}
onMouseLeave={(e) => {
if (!isSelected)
(e.currentTarget as HTMLDivElement).style.background = "transparent";
}}
>
{hasChildren ? (
<button
onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }}
style={expandBtnStyle}
>
{expanded ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
</button>
) : (
<span style={{ width: 18, display: "inline-block", flexShrink: 0 }} />
)}
<span
onClick={() => onSelect(concept.uri)}
style={{
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: isSelected ? "#ebf3ff" : depth === 0 ? "#c6d4e3" : "#8fa8c6",
fontSize: depth === 0 ? 13 : 12,
fontWeight: depth === 0 ? 600 : 400,
}}
>
{concept.pref_label || concept.uri}
</span>
{hasChildren && (
<span style={{ color: "#5a7a9a", fontSize: 10, flexShrink: 0 }}>
{children.length}
</span>
)}
</div>
{expanded && hasChildren && children.map((child) => (
<ConceptTreeNode
key={child.uri}
concept={child}
depth={depth + 1}
selectedUri={selectedUri}
onSelect={onSelect}
/>
))}
</>
);
}
// ---------------------------------------------------------------------------
// Scheme panel
// ---------------------------------------------------------------------------
function SchemePanel({
scheme,
selectedUri,
onSelectConcept,
searchQuery,
}: {
scheme: SKOSScheme;
selectedUri: string | null;
onSelectConcept: (uri: string) => void;
searchQuery: string;
}) {
const [expanded, setExpanded] = useState(true);
const [hierarchy, setHierarchy] = useState<ConceptNode[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!expanded) return;
setLoading(true);
fetch(`/api/vocabulary/hierarchy?scheme=${encodeURIComponent(scheme.uri)}`)
.then((r) => (r.ok ? r.json() : []))
.then(setHierarchy)
.catch(() => setHierarchy([]))
.finally(() => setLoading(false));
}, [scheme.uri, expanded]);
const totalConcepts = countConcepts(hierarchy);
const filterConcepts = (nodes: ConceptNode[], q: string): ConceptNode[] => {
if (!q) return nodes;
return nodes.flatMap((n) => {
const match = (n.pref_label + " " + (n.alt_labels?.join(" ") ?? "") + " " + (n.description ?? ""))
.toLowerCase()
.includes(q.toLowerCase());
const filteredChildren = filterConcepts(n.children ?? [], q);
if (match || filteredChildren.length > 0) {
return [{ ...n, children: filteredChildren }];
}
return [];
});
};
const displayedConcepts = filterConcepts(hierarchy, searchQuery);
return (
<div style={schemePanelStyle}>
<button onClick={() => setExpanded((v) => !v)} style={schemeHeaderBtnStyle}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{expanded ? <ChevronDown size={13} color="#8fa8c6" /> : <ChevronRight size={13} color="#8fa8c6" />}
<span style={{ color: "#e6edf3", fontSize: 14, fontWeight: 700 }}>{scheme.title}</span>
</div>
<span style={{ color: "#6a7f97", fontSize: 11 }}>
{loading ? "…" : `${totalConcepts} concept${totalConcepts !== 1 ? "s" : ""}`}
</span>
</button>
{expanded && (
<div style={{ paddingBottom: 8 }}>
{loading ? (
<div style={{ padding: "10px 20px", display: "flex", alignItems: "center", gap: 8 }}>
<Loader2 size={12} color="#4aa3ff" style={{ animation: "spin 0.8s linear infinite" }} />
<span style={{ color: "#6a7f97", fontSize: 12 }}>Loading concepts</span>
</div>
) : displayedConcepts.length === 0 ? (
<div style={{ padding: "8px 24px", color: "#6a7f97", fontSize: 12, fontStyle: "italic" }}>
{searchQuery ? "No matching concepts" : "No concepts in this scheme"}
</div>
) : (
<div style={{ paddingTop: 2 }}>
{displayedConcepts.map((concept) => (
<ConceptTreeNode
key={concept.uri}
concept={concept}
depth={0}
selectedUri={selectedUri}
onSelect={onSelectConcept}
/>
))}
</div>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Main SKOSVocabularyManager
// ---------------------------------------------------------------------------
interface Props {
schemeUri?: string;
}
export function SKOSVocabularyManager({ schemeUri }: Props) {
const [schemes, setSchemes] = useState<SKOSScheme[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [searchQ, setSearchQ] = useState("");
const [selectedUri, setSelectedUri] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
fetch("/api/ontology/skos/schemes")
.then((r) => (r.ok ? r.json() : []))
.then(setSchemes)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, []);
const displayedSchemes = schemeUri
? schemes.filter((s) => s.uri === schemeUri)
: schemes;
return (
<div style={managerShellStyle}>
{/* Search bar */}
<div style={skosToolbarStyle}>
<div style={skosSearchBarStyle}>
<Search size={13} color="#6a7f97" />
<input
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
placeholder="Search labels and definitions…"
style={skosSearchInputStyle}
/>
{searchQ && (
<button onClick={() => setSearchQ("")} style={iconBtnStyle}>
<X size={11} />
</button>
)}
</div>
</div>
<div style={skosBodyStyle}>
{/* Scheme tree column */}
<div style={treeColStyle}>
{loading && (
<div style={centerStyle}>
<Loader2 size={18} color="#4aa3ff" style={{ animation: "spin 1s linear infinite" }} />
</div>
)}
{error && (
<div style={centerStyle}>
<AlertCircle size={16} color="#ff9daf" />
<span style={{ color: "#ff9daf", fontSize: 12, marginTop: 6 }}>{error}</span>
</div>
)}
{!loading && !error && displayedSchemes.length === 0 && (
<div style={{ ...centerStyle, textAlign: "center", padding: 28 }}>
<BookOpen size={28} color="rgba(158,232,215,0.15)" />
<span style={{ color: "#8fa8c6", fontSize: 12, marginTop: 10 }}>
No SKOS concept schemes found
</span>
<span style={{ color: "#6a7f97", fontSize: 11, marginTop: 4, maxWidth: 220 }}>
Import a SKOS vocabulary to browse concepts here
</span>
</div>
)}
{!loading && displayedSchemes.map((scheme) => (
<SchemePanel
key={scheme.uri}
scheme={scheme}
selectedUri={selectedUri}
onSelectConcept={setSelectedUri}
searchQuery={searchQ}
/>
))}
</div>
{/* Concept detail panel */}
{selectedUri && (
<ConceptDetailPanel
uri={selectedUri}
onClose={() => setSelectedUri(null)}
onNavigate={setSelectedUri}
/>
)}
</div>
</div>
);
}
/* ─── styles ─────────────────────────────────────────────────────────── */
const managerShellStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
};
const skosToolbarStyle: React.CSSProperties = {
padding: "10px 12px",
borderBottom: "1px solid rgba(127,208,255,0.08)",
flexShrink: 0,
};
const skosSearchBarStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 7,
padding: "6px 10px",
borderRadius: 8,
border: "1px solid rgba(127,208,255,0.12)",
background: "rgba(0,0,0,0.22)",
};
const skosSearchInputStyle: React.CSSProperties = {
flex: 1,
background: "transparent",
border: "none",
outline: "none",
color: "#ebf3ff",
fontSize: 12,
};
const skosBodyStyle: React.CSSProperties = {
flex: 1,
minHeight: 0,
display: "flex",
overflow: "hidden",
};
const treeColStyle: React.CSSProperties = {
flex: 1,
overflowY: "auto",
padding: "8px 6px",
};
const detailPanelStyle: React.CSSProperties = {
width: 300,
flexShrink: 0,
display: "flex",
flexDirection: "column",
borderLeft: "1px solid rgba(127,208,255,0.1)",
background: "rgba(3,9,18,0.5)",
overflow: "hidden",
};
const detailHeaderStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 14px",
borderBottom: "1px solid rgba(127,208,255,0.08)",
flexShrink: 0,
};
const detailBodyStyle: React.CSSProperties = {
flex: 1,
overflowY: "auto",
padding: "14px",
};
const schemePanelStyle: React.CSSProperties = {
borderRadius: 10,
border: "1px solid rgba(127,208,255,0.1)",
background: "rgba(255,255,255,0.02)",
overflow: "hidden",
marginBottom: 8,
};
const schemeHeaderBtnStyle: React.CSSProperties = {
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 12px",
background: "transparent",
border: "none",
cursor: "pointer",
borderBottom: "1px solid rgba(255,255,255,0.05)",
};
const expandBtnStyle: React.CSSProperties = {
background: "transparent",
border: "none",
color: "#8fa8c6",
cursor: "pointer",
padding: 0,
display: "flex",
alignItems: "center",
flexShrink: 0,
width: 18,
};
const chipStyle: React.CSSProperties = {
padding: "2px 8px",
borderRadius: 999,
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.08)",
color: "#8fa8c6",
fontSize: 11,
};
const iconBtnStyle: React.CSSProperties = {
background: "transparent",
border: "none",
color: "#8fa8c6",
cursor: "pointer",
padding: 2,
display: "grid",
placeItems: "center",
};
const navLinkStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 5,
background: "transparent",
border: "none",
cursor: "pointer",
padding: "2px 0",
textAlign: "left",
};
const centerStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
padding: 24,
};
@@ -0,0 +1,289 @@
import { useCallback, useEffect, useState } from "react";
import {
BookMarked,
GitMerge,
HeartPulse,
Layers,
Shield,
Sliders,
} from "lucide-react";
import { OntologyManager } from "./OntologyManager";
export type OntologyHubTab =
| "registry"
| "editor"
| "versions"
| "alignments"
| "health"
| "shacl";
const TAB_PARAM = "ontologyTab";
const TABS: { id: OntologyHubTab; label: string; icon: typeof GitMerge }[] = [
{ id: "registry", label: "Registry", icon: BookMarked },
{ id: "editor", label: "Editor", icon: Sliders },
{ id: "versions", label: "Versions", icon: Layers },
{ id: "alignments", label: "Alignments", icon: GitMerge },
{ id: "health", label: "Health", icon: HeartPulse },
{ id: "shacl", label: "SHACL", icon: Shield },
];
function readTabParam(): OntologyHubTab {
try {
const params = new URLSearchParams(window.location.search);
const raw = params.get(TAB_PARAM);
if (raw && TABS.some((t) => t.id === raw)) return raw as OntologyHubTab;
} catch {
// ignore
}
return "registry";
}
function writeTabParam(tab: OntologyHubTab) {
try {
const params = new URLSearchParams(window.location.search);
params.set(TAB_PARAM, tab);
window.history.replaceState(null, "", `?${params.toString()}`);
} catch {
// ignore
}
}
function ComingSoonStub({
icon: Icon,
title,
description,
badge,
}: {
icon: typeof GitMerge;
title: string;
description: string;
badge: string;
}) {
return (
<div style={stubShellStyle}>
<div style={stubCardStyle}>
<div style={stubIconRingStyle}>
<Icon size={28} color="#7fd0ff" />
</div>
<div style={stubBadgeStyle}>{badge}</div>
<h2 style={stubTitleStyle}>{title}</h2>
<p style={stubDescStyle}>{description}</p>
<div style={stubDividerStyle} />
<p style={stubSubnoteStyle}>Coming in Subissue 2 / 3 of Ontology Hub</p>
</div>
</div>
);
}
export function OntologyWorkspace() {
const [activeTab, setActiveTab] = useState<OntologyHubTab>(readTabParam);
useEffect(() => {
writeTabParam(activeTab);
}, [activeTab]);
const handleTabChange = useCallback((tab: OntologyHubTab) => {
setActiveTab(tab);
}, []);
const renderTab = () => {
switch (activeTab) {
case "registry":
return <OntologyManager />;
case "editor":
return (
<ComingSoonStub
icon={Sliders}
title="Visual Ontology Editor"
description="Visually edit classes, properties, individuals, restrictions, axioms, and SKOS metadata. Create and propose schema changes through a governed draft workflow."
badge="Subissue 2"
/>
);
case "versions":
return (
<ComingSoonStub
icon={Layers}
title="Versions & Change Proposals"
description="View version history, compare schema diffs, submit change proposals, and manage the review-to-publish lifecycle."
badge="Subissue 2"
/>
);
case "alignments":
return (
<ComingSoonStub
icon={GitMerge}
title="Cross-Ontology Alignments"
description="Manage mappings between ontologies, review suggested alignments from embedding-assisted similarity, and publish alignment sets."
badge="Subissue 3"
/>
);
case "health":
return (
<ComingSoonStub
icon={HeartPulse}
title="Ontology Health Dashboard"
description="Score completeness, consistency, SHACL conformance, alignment coverage, and documentation quality across all loaded ontologies."
badge="Subissue 3"
/>
);
case "shacl":
return (
<ComingSoonStub
icon={Shield}
title="SHACL Studio"
description="Generate, edit, and validate SHACL shapes. Preview constraint violations against the active graph before publishing."
badge="Subissue 3"
/>
);
}
};
return (
<div style={shellStyle}>
<div style={tabBarStyle}>
{TABS.map(({ id, label, icon: Icon }) => (
<button
key={id}
style={{
...tabBtnBase,
...(activeTab === id ? tabBtnActive : tabBtnIdle),
}}
onClick={() => handleTabChange(id)}
>
<Icon size={14} />
<span>{label}</span>
</button>
))}
</div>
<div style={contentStyle}>{renderTab()}</div>
</div>
);
}
/* ─── styles ─────────────────────────────────────────────────────────── */
const shellStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
background: "#07111f",
overflow: "hidden",
};
const tabBarStyle: React.CSSProperties = {
display: "flex",
gap: 6,
padding: "10px 18px",
borderBottom: "1px solid rgba(140,192,255,0.12)",
background: "rgba(3,9,18,0.72)",
flexShrink: 0,
flexWrap: "wrap",
};
const tabBtnBase: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "7px 13px",
borderRadius: 999,
border: "1px solid transparent",
cursor: "pointer",
fontSize: 12,
fontWeight: 600,
transition: "160ms ease",
background: "transparent",
};
const tabBtnIdle: React.CSSProperties = {
color: "#8fa8c6",
borderColor: "rgba(127,208,255,0.1)",
};
const tabBtnActive: React.CSSProperties = {
color: "#ebf3ff",
background: "rgba(74,163,255,0.16)",
borderColor: "rgba(127,208,255,0.3)",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.05)",
};
const contentStyle: React.CSSProperties = {
flex: 1,
minHeight: 0,
overflow: "hidden",
};
const stubShellStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
background: "linear-gradient(180deg, rgba(7,17,31,0.8), rgba(5,11,21,0.95))",
};
const stubCardStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 12,
padding: "48px 52px",
borderRadius: 28,
border: "1px solid rgba(127,208,255,0.12)",
background: "rgba(9,19,34,0.82)",
boxShadow: "0 24px 64px rgba(0,0,0,0.32), inset 0 1px 0 rgba(255,255,255,0.06)",
maxWidth: 480,
textAlign: "center",
};
const stubIconRingStyle: React.CSSProperties = {
width: 64,
height: 64,
borderRadius: "50%",
display: "grid",
placeItems: "center",
background: "rgba(74,163,255,0.1)",
border: "1px solid rgba(127,208,255,0.18)",
marginBottom: 4,
};
const stubBadgeStyle: React.CSSProperties = {
padding: "4px 10px",
borderRadius: 999,
background: "rgba(242,182,109,0.1)",
border: "1px solid rgba(242,182,109,0.22)",
color: "#f2b66d",
fontSize: 10,
fontWeight: 800,
letterSpacing: "0.1em",
textTransform: "uppercase",
};
const stubTitleStyle: React.CSSProperties = {
margin: 0,
color: "#ebf3ff",
fontSize: 22,
fontWeight: 800,
letterSpacing: "-0.04em",
};
const stubDescStyle: React.CSSProperties = {
margin: 0,
color: "#8fa8c6",
fontSize: 14,
lineHeight: 1.65,
maxWidth: 360,
};
const stubDividerStyle: React.CSSProperties = {
width: "100%",
height: 1,
background: "rgba(127,208,255,0.08)",
};
const stubSubnoteStyle: React.CSSProperties = {
margin: 0,
color: "#5a7a9a",
fontSize: 12,
};
+2
View File
@@ -98,6 +98,7 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
from .routes.enrich import router as enrich_router
from .routes.export_import import router as export_import_router
from .routes.graph import router as graph_router
from .routes.ontology import router as ontology_router
from .routes.provenance import router as provenance_router
from .routes.sparql import router as sparql_router
from .routes.temporal import router as temporal_router
@@ -113,6 +114,7 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
app.include_router(sparql_router)
app.include_router(provenance_router)
app.include_router(vocabulary_router)
app.include_router(ontology_router)
_WS_MAX_MESSAGE_BYTES = 64 * 1024 # 64 KB — control messages only
+894
View File
@@ -0,0 +1,894 @@
"""
Ontology Hub routes: registry, URL/file loading, preview, creation, entity search, and SKOS.
"""
import asyncio
import ipaddress
import logging
import socket
import uuid
from datetime import UTC, datetime
from typing import Any, Dict, List, Literal, Optional
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from ..dependencies import get_session
from ..session import GraphSession
from ..utils.rdf_parser import _safe_parse_rdf
router = APIRouter(prefix="/api/ontology", tags=["Ontology"])
logger = logging.getLogger(__name__)
_MAX_FETCH_BYTES = 20 * 1024 * 1024 # 20 MB
_CLASS_TYPES = frozenset({
"owl:Class", "rdfs:Class",
"http://www.w3.org/2002/07/owl#Class",
"http://www.w3.org/2000/01/rdf-schema#Class",
})
_PROPERTY_TYPES = frozenset({
"owl:ObjectProperty", "owl:DatatypeProperty", "owl:AnnotationProperty",
"rdfs:Property",
"http://www.w3.org/2002/07/owl#ObjectProperty",
"http://www.w3.org/2002/07/owl#DatatypeProperty",
"http://www.w3.org/2002/07/owl#AnnotationProperty",
})
_INDIVIDUAL_TYPES = frozenset({
"owl:NamedIndividual",
"http://www.w3.org/2002/07/owl#NamedIndividual",
})
_CONCEPT_TYPES = frozenset({
"skos:Concept",
"http://www.w3.org/2004/02/skos/core#Concept",
})
_SCHEME_TYPES = frozenset({
"skos:ConceptScheme",
"http://www.w3.org/2004/02/skos/core#ConceptScheme",
})
_ONTOLOGY_TYPES = frozenset({
"owl:Ontology",
"http://www.w3.org/2002/07/owl#Ontology",
}) | _SCHEME_TYPES
_SEARCHABLE_TYPES = _CLASS_TYPES | _PROPERTY_TYPES | _INDIVIDUAL_TYPES | _CONCEPT_TYPES | _SCHEME_TYPES
_URI_PREFIX_MAP = {
"http://www.w3.org/2002/07/owl#": "owl:",
"http://www.w3.org/2000/01/rdf-schema#": "rdfs:",
"http://www.w3.org/1999/02/22-rdf-syntax-ns#": "rdf:",
"http://www.w3.org/2004/02/skos/core#": "skos:",
"http://purl.org/dc/terms/": "dcterms:",
"http://purl.org/dc/elements/1.1/": "dc:",
"http://schema.org/": "schema:",
"http://www.w3.org/ns/shacl#": "sh:",
}
_FORMAT_ALIASES: Dict[str, str] = {
"ttl": "turtle",
"rdf": "xml",
"owl": "xml",
"jsonld": "json-ld",
"json": "json-ld",
}
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class OntologyEntry(BaseModel):
uri: str
name: str
description: Optional[str] = None
format: str = "unknown"
status: Literal["published", "draft", "external"] = "external"
source_url: Optional[str] = None
version: Optional[str] = None
class_count: int = 0
concept_count: int = 0
property_count: int = 0
loaded_at: str = ""
enabled: bool = True
tags: List[str] = Field(default_factory=list)
class OntologyPreview(BaseModel):
uri: str
name: str
description: Optional[str] = None
namespace: Optional[str] = None
version: Optional[str] = None
license: Optional[str] = None
format: str
estimated_triples: int = 0
source_url: Optional[str] = None
class LoadOntologyRequest(BaseModel):
url: Optional[str] = None
content: Optional[str] = None
format: Optional[str] = None
name: Optional[str] = None
description: Optional[str] = None
tags: List[str] = Field(default_factory=list)
class PreviewOntologyRequest(BaseModel):
url: Optional[str] = None
content: Optional[str] = None
format: Optional[str] = None
class CreateOntologyRequest(BaseModel):
mode: Literal["scratch", "data", "text"] = "scratch"
namespace: str
name: str
description: Optional[str] = None
tags: List[str] = Field(default_factory=list)
sample_data: Optional[str] = None
schema_text: Optional[str] = None
provider: Optional[str] = None
model: Optional[str] = None
class OntologySearchResult(BaseModel):
uri: str
label: str
type: str
entity_type: str
definition: Optional[str] = None
source_ontology: Optional[str] = None
namespace_prefix: Optional[str] = None
class EntityDetailResponse(BaseModel):
uri: str
label: str
type: str
entity_type: str
definition: Optional[str] = None
source_ontology: Optional[str] = None
superclasses: List[str] = Field(default_factory=list)
subclasses: List[str] = Field(default_factory=list)
domain: List[str] = Field(default_factory=list)
range: List[str] = Field(default_factory=list)
instance_count: int = 0
properties: Dict[str, Any] = Field(default_factory=dict)
class SKOSScheme(BaseModel):
uri: str
title: str
description: Optional[str] = None
concept_count: int = 0
class SKOSConceptDetail(BaseModel):
uri: str
pref_label: str
alt_labels: List[str] = Field(default_factory=list)
hidden_labels: List[str] = Field(default_factory=list)
definition: Optional[str] = None
scope_note: Optional[str] = None
editorial_note: Optional[str] = None
broader: List[str] = Field(default_factory=list)
narrower: List[str] = Field(default_factory=list)
related: List[str] = Field(default_factory=list)
exact_match: List[str] = Field(default_factory=list)
close_match: List[str] = Field(default_factory=list)
broad_match: List[str] = Field(default_factory=list)
narrow_match: List[str] = Field(default_factory=list)
scheme_uri: Optional[str] = None
class LoadOntologyResponse(BaseModel):
status: str = "success"
uri: str
name: str
nodes_added: int = 0
edges_added: int = 0
format: str = "unknown"
class ToggleResponse(BaseModel):
uri: str
enabled: bool
class RefreshResponse(BaseModel):
status: str = "success"
uri: str
nodes_added: int = 0
edges_added: int = 0
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_registry(request: Request) -> Dict[str, OntologyEntry]:
if not hasattr(request.app.state, "ontology_registry"):
request.app.state.ontology_registry = {}
return request.app.state.ontology_registry
def _uri_to_prefix(uri: str) -> str:
for base, prefix in _URI_PREFIX_MAP.items():
if uri.startswith(base):
return prefix + uri[len(base):]
return uri
def _classify_node_type(node_type: str) -> str:
if node_type in _CLASS_TYPES:
return "class"
if node_type in _PROPERTY_TYPES:
return "property"
if node_type in _INDIVIDUAL_TYPES:
return "individual"
if node_type in _CONCEPT_TYPES:
return "concept"
if node_type in _SCHEME_TYPES:
return "scheme"
if node_type in _ONTOLOGY_TYPES:
return "ontology"
return "unknown"
def _node_label(node: Dict[str, Any]) -> str:
props = node.get("properties", {})
return (
props.get("pref_label")
or props.get("rdfs:label")
or props.get("skos:prefLabel")
or props.get("label")
or props.get("content")
or node.get("content", "")
or node.get("id", "")
)
def _extract_namespace(uri: str) -> Optional[str]:
if "#" in uri:
return uri.rsplit("#", 1)[0] + "#"
if "/" in uri:
return uri.rsplit("/", 1)[0] + "/"
return None
def _detect_format(content: str) -> str:
stripped = content.strip()[:500]
if stripped.startswith("{") or stripped.startswith("["):
return "json-ld"
if stripped.startswith("<"):
return "xml"
if "@prefix" in stripped or "@base" in stripped:
return "turtle"
# N-Triples blank-node subject: "_:word <predicate-uri> ..."
# URI-subject N-Triples ("<uri> <uri>") are already caught by the XML
# branch above, so only the blank-node form needs to be checked here.
# Plain string ops avoid the polynomial regex that CodeQL flags (py/polynomial-redos).
if stripped.startswith("_:") and " <" in stripped:
return "nt"
return "turtle"
def _normalize_format(fmt: Optional[str]) -> str:
if not fmt:
return "turtle"
lower = fmt.strip().lower()
return _FORMAT_ALIASES.get(lower, lower)
def _validate_fetch_url(url: str) -> None:
"""Reject non-HTTP(S) schemes and private/loopback/link-local targets."""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise HTTPException(status_code=422, detail="Only http and https URLs are allowed.")
hostname = parsed.hostname
if not hostname:
raise HTTPException(status_code=422, detail="Invalid URL: missing hostname.")
try:
addrinfos = socket.getaddrinfo(hostname, None)
except socket.gaierror as exc:
raise HTTPException(status_code=422, detail=f"Cannot resolve hostname '{hostname}': {exc}") from exc
for _family, _type, _proto, _canonname, sockaddr in addrinfos:
try:
ip = ipaddress.ip_address(sockaddr[0])
except ValueError:
continue
if ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_reserved or ip.is_multicast:
raise HTTPException(
status_code=422,
detail="Fetching from private, loopback, or reserved network addresses is not allowed.",
)
def _fetch_url_sync(url: str) -> bytes:
_validate_fetch_url(url)
import requests as _req
try:
resp = _req.get(
url,
headers={"Accept": "text/turtle, application/rdf+xml, application/ld+json, */*;q=0.1"},
timeout=30,
stream=True,
allow_redirects=True,
)
resp.raise_for_status()
chunks: List[bytes] = []
total = 0
for chunk in resp.iter_content(65536):
total += len(chunk)
if total > _MAX_FETCH_BYTES:
raise HTTPException(status_code=413, detail="Remote resource exceeds 20 MB limit.")
chunks.append(chunk)
return b"".join(chunks)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Could not fetch {url}: {exc}") from exc
def _parse_rdf_sync(content: bytes, fmt: str) -> tuple:
"""Return (nodes, edges, metadata). Raises HTTPException on failure."""
try:
import rdflib
except ImportError:
raise HTTPException(status_code=501, detail="rdflib is not installed.")
fmt_map = {
"turtle": "turtle", "xml": "xml", "nt": "nt",
"json-ld": "json-ld", "n3": "n3",
}
parse_fmt = fmt_map.get(fmt, "turtle")
g = rdflib.Graph()
try:
_safe_parse_rdf(g, content, parse_fmt)
except Exception as exc:
raise HTTPException(status_code=422, detail=f"RDF parse error: {exc}") from exc
OWL = rdflib.Namespace("http://www.w3.org/2002/07/owl#")
RDF = rdflib.RDF
RDFS = rdflib.RDFS
SKOS = rdflib.Namespace("http://www.w3.org/2004/02/skos/core#")
DCT = rdflib.Namespace("http://purl.org/dc/terms/")
DC = rdflib.Namespace("http://purl.org/dc/elements/1.1/")
metadata: Dict[str, Any] = {}
for subj in g.subjects(RDF.type, OWL.Ontology):
metadata["uri"] = str(subj)
for pred, obj in g.predicate_objects(subj):
p = str(pred)
if p in {str(RDFS.label), str(DCT.title), str(DC.title)}:
metadata.setdefault("name", str(obj))
elif p in {str(RDFS.comment), str(DCT.description), str(DC.description)}:
metadata.setdefault("description", str(obj))
elif p == str(OWL.versionInfo):
metadata.setdefault("version", str(obj))
elif p in {str(DCT.license), str(DC.rights)}:
metadata.setdefault("license", str(obj))
break
if "uri" not in metadata:
for subj in g.subjects(RDF.type, SKOS.ConceptScheme):
metadata["uri"] = str(subj)
for pred, obj in g.predicate_objects(subj):
p = str(pred)
if p in {str(SKOS.prefLabel), str(DCT.title), str(DC.title)}:
metadata.setdefault("name", str(obj))
elif p in {str(SKOS.definition), str(DCT.description)}:
metadata.setdefault("description", str(obj))
break
if "uri" not in metadata:
metadata["uri"] = f"urn:semantica:onto:{uuid.uuid4().hex[:8]}"
metadata.setdefault("name", metadata["uri"].rsplit("/", 1)[-1].rsplit("#", 1)[-1] or "Unnamed")
metadata["triple_count"] = len(g)
# Collect literal properties per subject
literal_props: Dict[str, Dict[str, str]] = {}
for subj, pred, obj in g:
if isinstance(subj, rdflib.BNode) or not isinstance(obj, rdflib.Literal):
continue
sid = str(subj)
pk = _uri_to_prefix(str(pred))
literal_props.setdefault(sid, {})[pk] = str(obj)
# Build nodes from rdf:type statements
seen_ids: set = set()
nodes: List[Dict[str, Any]] = []
for subj, _, type_obj in g.triples((None, RDF.type, None)):
if isinstance(subj, rdflib.BNode):
continue
sid = str(subj)
ntype = _uri_to_prefix(str(type_obj))
if sid in seen_ids:
continue
seen_ids.add(sid)
props = dict(literal_props.get(sid, {}))
props["uri"] = sid
label = (
props.get("rdfs:label")
or props.get("skos:prefLabel")
or props.get("dcterms:title")
or sid.rsplit("/", 1)[-1].rsplit("#", 1)[-1]
)
nodes.append({"id": sid, "type": ntype, "content": label, "properties": props})
# Build edges from non-literal object statements
edges: List[Dict[str, Any]] = []
for subj, pred, obj in g:
if isinstance(subj, rdflib.BNode) or isinstance(obj, (rdflib.Literal, rdflib.BNode)):
continue
edges.append({
"source": str(subj),
"target": str(obj),
"type": _uri_to_prefix(str(pred)),
"weight": 1.0,
})
return nodes, edges, metadata
# ---------------------------------------------------------------------------
# Registry endpoints (all specific paths before wildcard)
# ---------------------------------------------------------------------------
@router.get("/registry", response_model=List[OntologyEntry])
async def list_registry(
request: Request,
q: Optional[str] = Query(None),
status: Optional[str] = Query(None),
format: Optional[str] = Query(None),
session: GraphSession = Depends(get_session),
):
registry = _get_registry(request)
# Discover ontology-type nodes from live graph not yet registered
all_nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
# Count entity types per ontology URI via scheme_uri property
class_counts: Dict[str, int] = {}
concept_counts: Dict[str, int] = {}
prop_counts: Dict[str, int] = {}
implicit: Dict[str, Dict[str, Any]] = {}
for node in all_nodes:
ntype = node.get("type", "")
nid = node.get("id", "")
etype = _classify_node_type(ntype)
scheme_uri = node.get("properties", {}).get("scheme_uri") or node.get("properties", {}).get("uri")
if etype == "ontology" or etype == "scheme":
if nid and nid not in registry:
implicit[nid] = node
elif scheme_uri:
if etype == "class":
class_counts[scheme_uri] = class_counts.get(scheme_uri, 0) + 1
elif etype == "concept":
concept_counts[scheme_uri] = concept_counts.get(scheme_uri, 0) + 1
elif etype == "property":
prop_counts[scheme_uri] = prop_counts.get(scheme_uri, 0) + 1
result: List[OntologyEntry] = []
def _matches(name: str, uri: str, desc: str) -> bool:
if not q:
return True
ql = q.lower()
return any(ql in t.lower() for t in [name, uri, desc] if t)
for entry in registry.values():
if status and entry.status != status:
continue
if format and entry.format.lower() != format.lower():
continue
if not _matches(entry.name, entry.uri, entry.description or ""):
continue
updated = entry.model_copy(update={
"class_count": class_counts.get(entry.uri, entry.class_count),
"concept_count": concept_counts.get(entry.uri, entry.concept_count),
"property_count": prop_counts.get(entry.uri, entry.property_count),
})
result.append(updated)
for nid, node in implicit.items():
props = node.get("properties", {})
name = _node_label(node) or nid
if not _matches(name, nid, props.get("description", "")):
continue
result.append(OntologyEntry(
uri=nid,
name=name,
description=props.get("description"),
format=props.get("format", "unknown"),
status="external",
version=props.get("version") or props.get("owl:versionInfo"),
class_count=class_counts.get(nid, 0),
concept_count=concept_counts.get(nid, 0),
property_count=prop_counts.get(nid, 0),
loaded_at=props.get("loaded_at", ""),
enabled=True,
))
return result
@router.post("/preview", response_model=OntologyPreview)
async def preview_ontology(body: PreviewOntologyRequest):
if not body.url and not body.content:
raise HTTPException(status_code=422, detail="Provide either url or content.")
if body.url:
raw = await asyncio.to_thread(_fetch_url_sync, body.url)
content_str = raw.decode("utf-8", errors="replace")
else:
content_str = body.content or ""
fmt = _normalize_format(body.format) if body.format else _detect_format(content_str)
try:
_, _, metadata = await asyncio.to_thread(
_parse_rdf_sync, content_str.encode("utf-8"), fmt
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Could not parse ontology: {exc}") from exc
return OntologyPreview(
uri=metadata.get("uri", ""),
name=metadata.get("name", ""),
description=metadata.get("description"),
namespace=_extract_namespace(metadata.get("uri", "")),
version=metadata.get("version"),
license=metadata.get("license"),
format=fmt,
estimated_triples=metadata.get("triple_count", 0),
source_url=body.url,
)
@router.post("/load", response_model=LoadOntologyResponse)
async def load_ontology(
request: Request,
body: LoadOntologyRequest,
session: GraphSession = Depends(get_session),
):
if not body.url and not body.content:
raise HTTPException(status_code=422, detail="Provide either url or content.")
if body.url:
raw = await asyncio.to_thread(_fetch_url_sync, body.url)
content_str = raw.decode("utf-8", errors="replace")
else:
content_str = body.content or ""
fmt = _normalize_format(body.format) if body.format else _detect_format(content_str)
try:
nodes, edges, metadata = await asyncio.to_thread(
_parse_rdf_sync, content_str.encode("utf-8"), fmt
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Could not parse ontology: {exc}") from exc
onto_uri = metadata.get("uri", f"urn:semantica:onto:{uuid.uuid4().hex[:8]}")
onto_name = body.name or metadata.get("name", "Unnamed Ontology")
nodes_added = await asyncio.to_thread(session.add_nodes, nodes)
edges_added = await asyncio.to_thread(session.add_edges, edges)
registry = _get_registry(request)
registry[onto_uri] = OntologyEntry(
uri=onto_uri,
name=onto_name,
description=body.description or metadata.get("description"),
format=fmt,
status="external",
source_url=body.url,
version=metadata.get("version"),
class_count=sum(1 for n in nodes if _classify_node_type(n.get("type", "")) == "class"),
concept_count=sum(1 for n in nodes if _classify_node_type(n.get("type", "")) in ("concept", "scheme")),
property_count=sum(1 for n in nodes if _classify_node_type(n.get("type", "")) == "property"),
loaded_at=datetime.now(UTC).isoformat(),
enabled=True,
tags=body.tags,
)
return LoadOntologyResponse(
uri=onto_uri, name=onto_name,
nodes_added=nodes_added, edges_added=edges_added, format=fmt,
)
@router.post("/create", response_model=LoadOntologyResponse)
async def create_ontology(
request: Request,
body: CreateOntologyRequest,
session: GraphSession = Depends(get_session),
):
ns = body.namespace.rstrip("/#")
onto_uri = f"{ns}#ontology"
nodes: List[Dict[str, Any]] = [{
"id": onto_uri,
"type": "owl:Ontology",
"content": body.name,
"properties": {
"rdfs:label": body.name,
"rdfs:comment": body.description or "",
"namespace": body.namespace,
},
}]
edges: List[Dict[str, Any]] = []
if body.mode == "data" and body.sample_data:
try:
from ...ontology import OntologyEngine
engine = OntologyEngine()
result = await asyncio.to_thread(engine.from_data, body.sample_data)
for cls in (result.get("classes", []) if isinstance(result, dict) else []):
cls_uri = f"{ns}/{cls.get('name', uuid.uuid4().hex[:6])}"
nodes.append({
"id": cls_uri, "type": "owl:Class",
"content": cls.get("name", ""),
"properties": {"rdfs:label": cls.get("name", "")},
})
except Exception:
logger.exception("Failed to generate ontology from sample data; falling back to minimal ontology.")
elif body.mode == "text" and body.schema_text:
try:
from ...ontology import OntologyEngine
engine = OntologyEngine()
result = await asyncio.to_thread(engine.from_text, body.schema_text)
for cls in (result.get("classes", []) if isinstance(result, dict) else []):
cls_uri = f"{ns}/{cls.get('name', uuid.uuid4().hex[:6])}"
nodes.append({
"id": cls_uri, "type": "owl:Class",
"content": cls.get("name", ""),
"properties": {"rdfs:label": cls.get("name", "")},
})
except Exception:
logger.exception("Failed to generate ontology from schema text; falling back to minimal ontology.")
nodes_added = await asyncio.to_thread(session.add_nodes, nodes)
edges_added = await asyncio.to_thread(session.add_edges, edges)
registry = _get_registry(request)
registry[onto_uri] = OntologyEntry(
uri=onto_uri,
name=body.name,
description=body.description,
format="turtle",
status="draft",
version="0.1.0",
class_count=sum(1 for n in nodes if n.get("type") == "owl:Class"),
loaded_at=datetime.now(UTC).isoformat(),
enabled=True,
tags=body.tags,
)
return LoadOntologyResponse(
uri=onto_uri, name=body.name,
nodes_added=nodes_added, edges_added=edges_added, format="turtle",
)
@router.get("/search", response_model=List[OntologySearchResult])
async def search_entities(
q: str = Query(..., min_length=1),
entity_type: Optional[str] = Query(None),
limit: int = Query(default=50, ge=1, le=200),
session: GraphSession = Depends(get_session),
):
# Use the session's indexed search; over-fetch to allow post-filtering by entity type
raw_hits = await asyncio.to_thread(session.search, q, limit * 6)
results: List[OntologySearchResult] = []
for hit in raw_hits:
node = hit.get("node", hit) # session.search returns {"node": ..., "score": ...}
ntype = node.get("type", "")
if ntype not in _SEARCHABLE_TYPES:
continue
etype = _classify_node_type(ntype)
if entity_type and etype != entity_type:
continue
label = _node_label(node)
props = node.get("properties", {})
definition = (
props.get("rdfs:comment")
or props.get("skos:definition")
or props.get("description")
)
results.append(OntologySearchResult(
uri=node.get("id", ""),
label=label,
type=ntype,
entity_type=etype,
definition=definition,
source_ontology=props.get("scheme_uri"),
namespace_prefix=_extract_namespace(node.get("id", "")),
))
if len(results) >= limit:
break
return results
@router.get("/entity/{entity_uri:path}", response_model=EntityDetailResponse)
async def get_entity_detail(
entity_uri: str,
session: GraphSession = Depends(get_session),
):
node = await asyncio.to_thread(session.get_node, entity_uri)
if node is None:
raise HTTPException(status_code=404, detail="Entity not found.")
props = node.get("properties", {})
ntype = node.get("type", "")
label = _node_label(node)
definition = props.get("rdfs:comment") or props.get("skos:definition") or props.get("description")
out_edges, _ = await asyncio.to_thread(session.get_edges, source=entity_uri, skip=0, limit=9999)
in_edges, _ = await asyncio.to_thread(session.get_edges, target=entity_uri, skip=0, limit=9999)
superclasses = [e["target"] for e in out_edges if e.get("type") in {"rdfs:subClassOf", "skos:broader"}]
subclasses = [e["source"] for e in in_edges if e.get("type") in {"rdfs:subClassOf", "skos:broader"}]
domain = [e["target"] for e in out_edges if e.get("type") == "rdfs:domain"]
range_ = [e["target"] for e in out_edges if e.get("type") == "rdfs:range"]
all_nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
instance_count = sum(1 for n in all_nodes if n.get("type") == entity_uri)
return EntityDetailResponse(
uri=entity_uri, label=label,
type=ntype, entity_type=_classify_node_type(ntype),
definition=definition,
source_ontology=props.get("scheme_uri"),
superclasses=superclasses, subclasses=subclasses,
domain=domain, range=range_,
instance_count=instance_count, properties=props,
)
@router.get("/skos/schemes", response_model=List[SKOSScheme])
async def list_skos_schemes(session: GraphSession = Depends(get_session)):
nodes, _ = await asyncio.to_thread(
session.get_nodes, node_type="skos:ConceptScheme", skip=0, limit=999_999
)
# Count concepts per scheme from edges
all_edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
concept_counts: Dict[str, int] = {}
for edge in all_edges:
if edge.get("type") in {"skos:inScheme", "skos:topConceptOf"}:
concept_counts[edge["target"]] = concept_counts.get(edge["target"], 0) + 1
elif edge.get("type") == "skos:hasTopConcept":
concept_counts[edge["source"]] = concept_counts.get(edge["source"], 0) + 1
result = []
for node in nodes:
props = node.get("properties", {})
nid = node.get("id", "")
result.append(SKOSScheme(
uri=nid,
title=_node_label(node),
description=props.get("description") or props.get("skos:definition"),
concept_count=concept_counts.get(nid, 0),
))
return result
@router.get("/skos/concept/{concept_uri:path}", response_model=SKOSConceptDetail)
async def get_skos_concept(
concept_uri: str,
session: GraphSession = Depends(get_session),
):
node = await asyncio.to_thread(session.get_node, concept_uri)
if node is None:
raise HTTPException(status_code=404, detail="Concept not found.")
props = node.get("properties", {})
out_edges, _ = await asyncio.to_thread(session.get_edges, source=concept_uri, skip=0, limit=9999)
in_edges, _ = await asyncio.to_thread(session.get_edges, target=concept_uri, skip=0, limit=9999)
def collect_out(rel: str) -> List[str]:
return [e["target"] for e in out_edges if e.get("type") == rel]
def collect_in(rel: str) -> List[str]:
return [e["source"] for e in in_edges if e.get("type") == rel]
pref_label = props.get("pref_label") or props.get("skos:prefLabel") or _node_label(node)
alt_labels = props.get("alt_labels") or props.get("skos:altLabel") or []
if isinstance(alt_labels, str):
alt_labels = [alt_labels]
hidden_labels = props.get("skos:hiddenLabel") or []
if isinstance(hidden_labels, str):
hidden_labels = [hidden_labels]
scheme_uri = props.get("scheme_uri")
if not scheme_uri:
candidates = collect_out("skos:inScheme") or collect_out("skos:topConceptOf")
scheme_uri = candidates[0] if candidates else None
return SKOSConceptDetail(
uri=concept_uri,
pref_label=pref_label,
alt_labels=list(alt_labels),
hidden_labels=list(hidden_labels),
definition=props.get("definition") or props.get("skos:definition"),
scope_note=props.get("skos:scopeNote"),
editorial_note=props.get("skos:editorialNote"),
broader=collect_out("skos:broader") + collect_in("skos:narrower"),
narrower=collect_out("skos:narrower") + collect_in("skos:broader"),
related=collect_out("skos:related"),
exact_match=collect_out("skos:exactMatch"),
close_match=collect_out("skos:closeMatch"),
broad_match=collect_out("skos:broadMatch"),
narrow_match=collect_out("skos:narrowMatch"),
scheme_uri=scheme_uri,
)
# ---------------------------------------------------------------------------
# Wildcard management endpoints (must come after specific routes)
# ---------------------------------------------------------------------------
@router.delete("/{ontology_uri:path}")
async def remove_ontology(ontology_uri: str, request: Request):
registry = _get_registry(request)
if ontology_uri not in registry:
raise HTTPException(status_code=404, detail="Ontology not found in registry.")
del registry[ontology_uri]
return {"status": "removed", "uri": ontology_uri}
@router.patch("/{ontology_uri:path}/toggle", response_model=ToggleResponse)
async def toggle_ontology(ontology_uri: str, request: Request):
registry = _get_registry(request)
if ontology_uri not in registry:
raise HTTPException(status_code=404, detail="Ontology not found in registry.")
entry = registry[ontology_uri]
entry.enabled = not entry.enabled
return ToggleResponse(uri=ontology_uri, enabled=entry.enabled)
@router.post("/{ontology_uri:path}/refresh", response_model=RefreshResponse)
async def refresh_ontology(
ontology_uri: str,
request: Request,
session: GraphSession = Depends(get_session),
):
registry = _get_registry(request)
if ontology_uri not in registry:
raise HTTPException(status_code=404, detail="Ontology not found in registry.")
entry = registry[ontology_uri]
if not entry.source_url:
raise HTTPException(status_code=422, detail="No source URL to refresh from.")
raw = await asyncio.to_thread(_fetch_url_sync, entry.source_url)
content_str = raw.decode("utf-8", errors="replace")
try:
nodes, edges, _ = await asyncio.to_thread(
_parse_rdf_sync, content_str.encode("utf-8"), entry.format
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Refresh parse error: {exc}") from exc
nodes_added = await asyncio.to_thread(session.add_nodes, nodes)
edges_added = await asyncio.to_thread(session.add_edges, edges)
entry.loaded_at = datetime.now(UTC).isoformat()
return RefreshResponse(uri=ontology_uri, nodes_added=nodes_added, edges_added=edges_added)