mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
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:
@@ -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.
|
||||
|
||||
Generated
+45
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user