diff --git a/public/.well-known/mcp.json b/public/.well-known/mcp.json index e75c24f4..206263b0 100644 --- a/public/.well-known/mcp.json +++ b/public/.well-known/mcp.json @@ -1,6 +1,6 @@ { "name": "Ontosphere", - "description": "Interactive RDF/ontology knowledge graph editor — ABox authoring, OWL-RL reasoning, layout, and export. All client-side, no backend.\n\nABOX vs TBOX — TWO SEPARATE CANVAS VIEWS (critical for AI agents):\nOntosphere maintains a strict ABox/TBox split. Every node you add is classified by its rdf:type and appears in exactly one view:\n • ABox view (\"abox\") — individuals and instance data. Nodes typed as owl:NamedIndividual, skos:Concept, or any non-schema type. This is the default view for authoring instance knowledge.\n • TBox view (\"tbox\") — ontology schema. Nodes typed as owl:Class, owl:ObjectProperty, owl:DatatypeProperty, owl:AnnotationProperty, rdfs:Class, etc. This view shows the schema/vocabulary layer.\n • \"Punned\" resources (typed as both, e.g. owl:Class AND owl:NamedIndividual) appear in both views.\nSwitching views replaces the entire canvas — ABox nodes are invisible in TBox view and vice versa. Use setViewMode before exportImage to capture the right layer. addNode writes triples to the store and the canvas populates automatically in the correct view.\n\nArchitecture for AI agents: The app has two coupled layers. (1) N3 RDF store (urn:vg:data) — source of truth for all triples. addNode/addLink write here first. (2) Reactodia canvas — mirrors the store subset matching the active view as draggable node cards and arrows. Nodes start collapsed; call expandNode or expandAll to reveal annotation property cards. OWL-RL reasoning writes inferred triples back to the store (urn:vg:inferred) and refreshes the canvas.\n\nRecommended workflow: setViewMode(\"tbox\") → loadOntology → addNode ×N (owl:Class etc.) → addLink ×N (subClassOf etc.) → runLayout → setViewMode(\"abox\") → addNode ×N (individuals) → addLink ×N → runLayout → runReasoning → fitCanvas → exportImage(svg).\n\nAgent integration: (1) Claude Code / Playwright — call window.__mcpTools[name](params) via browser_evaluate. (2) AI Relay Bridge — any AI chat (ChatGPT, Claude.ai, Gemini) can control Ontosphere via a bookmarklet relay that intercepts JSON-RPC 2.0 tool calls and injects results back automatically; see docs/relay-bridge.md. Full agent guide: AGENTS.md. Example sessions with SVG snapshots: docs/mcp-demo/.", + "description": "Interactive RDF/ontology knowledge graph editor — ABox authoring, OWL-RL reasoning, layout, and export. All client-side, no backend.\n\nABOX vs TBOX — TWO SEPARATE CANVAS VIEWS (critical for AI agents):\nOntosphere maintains a strict ABox/TBox split. Every node you add is classified by its rdf:type and appears in exactly one view:\n • ABox view (\"abox\") — individuals and instance data. Nodes typed as owl:NamedIndividual, skos:Concept, or any non-schema type. This is the default view for authoring instance knowledge.\n • TBox view (\"tbox\") — ontology schema. Nodes typed as owl:Class, owl:ObjectProperty, owl:DatatypeProperty, owl:AnnotationProperty, rdfs:Class, etc. This view shows the schema/vocabulary layer.\n • \"Punned\" resources (typed as both, e.g. owl:Class AND owl:NamedIndividual) appear in both views.\nSwitching views replaces the entire canvas — ABox nodes are invisible in TBox view and vice versa. Use setViewMode before exportImage to capture the right layer. addNode writes triples to the store and the canvas populates automatically in the correct view.\n\nArchitecture for AI agents: The app has two coupled layers. (1) N3 RDF store (urn:vg:data) — source of truth for all triples. addNode/addLink write here first. (2) Reactodia canvas — mirrors the store subset matching the active view as draggable node cards and arrows. Nodes start collapsed; call expandNode or expandAll to reveal annotation property cards. OWL-RL reasoning writes inferred triples back to the store (urn:vg:inferred) and refreshes the canvas.\n\nONTOLOGY DISCOVERY — always the first step:\nOWL, RDFS, RDF, and XSD are pre-loaded. All other ontologies must be loaded explicitly:\n 1. searchOntologies(\"use case\") — find the right prefix (\"calendar\" → ical, \"music\" → mo, \"building\" → bot, \"e-commerce\" → gr, …)\n 2. loadOntology(\"\") — load into TBox. Repeat for each domain.\n 3. Register a namespace prefix: addNamespace(prefix, namespace) if you want short-form IRIs.\n\nRecommended workflow: searchOntologies → loadOntology ×N → addNamespace ×N → setViewMode(\"tbox\") → addNode ×N (owl:Class etc.) → addLink ×N (subClassOf etc.) → runLayout → setViewMode(\"abox\") → addNode ×N (individuals) → addLink ×N → runLayout → runReasoning → fitCanvas → exportImage(svg).\n\nAgent integration: (1) Claude Code / Playwright — call window.__mcpTools[name](params) via browser_evaluate. (2) AI Relay Bridge — any AI chat (ChatGPT, Claude.ai, Gemini) can control Ontosphere via a bookmarklet relay that intercepts JSON-RPC 2.0 tool calls and injects results back automatically; see docs/relay-bridge.md. Full agent guide: AGENTS.md. Example sessions with SVG snapshots: docs/mcp-demo/.", "tools": [ { "name": "loadRdf", @@ -31,7 +31,7 @@ }, { "name": "loadOntology", - "description": "Load TBox ontology for type hints and reasoning support. Does NOT add canvas nodes. Use for schema/class definitions. To load instance data as canvas nodes, use loadRdf instead.", + "description": "Discover or load well-known ontologies — three modes in one tool. (1) Load: pass url with a prefix name (e.g. \"ical\", \"mo\", \"bot\", \"gr\") or a namespace/file URL — loads the TBox, does NOT add canvas nodes. (2) Search: pass query with a use-case keyword (\"calendar\", \"music\", \"building\", \"e-commerce\", \"spatial\", \"IoT\") — returns matching entries with prefix/description/loadUrl. (3) List all: pass neither — returns all ~55 registered ontologies. If url does not match a known prefix or URL, load fails and suggestions are returned automatically. OWL/RDFS/RDF/XSD are always pre-loaded.", "inputSchema": { "type": "object", "properties": { @@ -595,4 +595,4 @@ } } ] -} \ No newline at end of file +} diff --git a/src/__tests__/fixtures/ont-imported.ttl b/src/__tests__/fixtures/ont-imported.ttl new file mode 100644 index 00000000..e33369d5 --- /dev/null +++ b/src/__tests__/fixtures/ont-imported.ttl @@ -0,0 +1,6 @@ +@prefix owl: . +@prefix rdfs: . + + + a owl:Ontology ; + rdfs:label "Imported Test Ontology" . diff --git a/src/__tests__/fixtures/ont-with-imports.ttl b/src/__tests__/fixtures/ont-with-imports.ttl new file mode 100644 index 00000000..230c66e2 --- /dev/null +++ b/src/__tests__/fixtures/ont-with-imports.ttl @@ -0,0 +1,7 @@ +@prefix owl: . +@prefix rdfs: . + + + a owl:Ontology ; + owl:imports ; + rdfs:label "Main Test Ontology" . diff --git a/src/__tests__/stores/ontologyStore.owlImports.test.ts b/src/__tests__/stores/ontologyStore.owlImports.test.ts new file mode 100644 index 00000000..258553ce --- /dev/null +++ b/src/__tests__/stores/ontologyStore.owlImports.test.ts @@ -0,0 +1,161 @@ +// @vitest-environment node +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { Parser as N3Parser } from "n3"; +import { useOntologyStore } from "../../stores/ontologyStore"; +import { rdfManager } from "../../utils/rdfManager"; + +const MAIN_URL = "https://test.example.org/main-ont.ttl"; +const IMPORTED_URL = "https://test.example.org/imported-ont.ttl"; + +const mainTtl = readFileSync(resolve(__dirname, "../fixtures/ont-with-imports.ttl"), "utf-8"); +const importedTtl = readFileSync(resolve(__dirname, "../fixtures/ont-imported.ttl"), "utf-8"); + +function makeTurtleResponse(body: string): Response { + return new Response(body, { + status: 200, + headers: { "content-type": "text/turtle" }, + }); +} + +/** + * Minimal in-memory RDF manager mock. Parses Turtle on loadRDFIntoGraph/loadRDFFromUrl, + * returns quads via positional fetchQuadsPage(graph, offset, limit, opts) that + * ontologyStore.fetchSerializedQuads expects. + */ +function makeInMemoryRdfManager() { + const store: Map> = new Map(); + + function addQuad(graph: string, s: string, p: string, o: string) { + let quads = store.get(graph); + if (!quads) { quads = []; store.set(graph, quads); } + quads.push({ subject: s, predicate: p, object: o, graph }); + } + + function parseTurtle(text: string, graph: string) { + const parser = new N3Parser({ format: "Turtle" }); + const quads = parser.parse(text); + for (const q of quads) { + addQuad(graph, q.subject.value, q.predicate.value, q.object.value); + } + } + + return { + // loadRDFIntoGraph: parse turtle and store quads + loadRDFIntoGraph: vi.fn().mockImplementation(async (text: string, graphName: string) => { + parseTurtle(text, graphName); + }), + + // loadRDFFromUrl: fetch url and delegate to loadRDFIntoGraph + loadRDFFromUrl: vi.fn().mockImplementation(async (url: string, graphName: string) => { + const resp = await fetch(url, { signal: new AbortController().signal, redirect: "follow", headers: {} }); + if (!resp.ok) throw new Error(`fetch failed: ${resp.status}`); + const text = await resp.text(); + parseTurtle(text, graphName); + }), + + // Positional fetchQuadsPage (matches RdfPageFetcher interface in ontologyStore) + fetchQuadsPage: vi.fn().mockImplementation( + async (graph: string, _offset: number, _limit: number, opts: any) => { + const all = store.get(graph) || []; + const predFilter = opts?.filter?.predicate; + const items = predFilter ? all.filter((q) => q.predicate === predFilter) : all; + return { items, total: items.length, limit: _limit }; + }, + ), + + emitAllSubjects: vi.fn().mockResolvedValue(undefined), + + clear: vi.fn().mockImplementation(() => { store.clear(); }), + }; +} + +describe("loadOntology — owl:imports fire-and-forget discovery", () => { + let savedRdfManager: any; + + beforeEach(() => { + savedRdfManager = useOntologyStore.getState().rdfManager; + useOntologyStore.getState().clearOntologies(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + useOntologyStore.setState({ rdfManager: savedRdfManager } as any); + }); + + it("auto-loads owl:imports after explicit loadOntology, even when autoDiscoverOntologies is false", async () => { + const mockMgr = makeInMemoryRdfManager(); + useOntologyStore.setState({ rdfManager: mockMgr as any } as any); + + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((url: string) => { + if (url === MAIN_URL) return Promise.resolve(makeTurtleResponse(mainTtl)); + if (url === IMPORTED_URL) return Promise.resolve(makeTurtleResponse(importedTtl)); + return Promise.reject(new TypeError(`Unexpected fetch: ${url}`)); + }), + ); + + // Disable autoDiscoverOntologies — fire-and-forget must still run (unconditional:true) + const { useAppConfigStore } = await import("../../stores/appConfigStore"); + const prevConfig = useAppConfigStore.getState().config; + useAppConfigStore.setState({ + config: { ...prevConfig, autoDiscoverOntologies: false }, + } as any); + + try { + await useOntologyStore.getState().loadOntology(MAIN_URL); + + // Wait for fire-and-forget discovery + inner loadOntology to complete + await vi.waitFor( + () => { + const loaded = useOntologyStore.getState().loadedOntologies; + const urls = loaded.map((o: any) => String(o.url)); + if (!urls.some((u: string) => u.includes("imported-ont"))) { + throw new Error(`imported-ont not yet in loadedOntologies: ${JSON.stringify(urls)}`); + } + }, + { timeout: 5000, interval: 50 }, + ); + + const loaded = useOntologyStore.getState().loadedOntologies; + const urls = loaded.map((o: any) => String(o.url)); + expect(urls.some((u: string) => u.includes("main-ont"))).toBe(true); + expect(urls.some((u: string) => u.includes("imported-ont"))).toBe(true); + } finally { + useAppConfigStore.setState({ config: prevConfig } as any); + } + }, 10000); + + it("does not re-trigger discovery for inner loadOntology calls (discovered:true guard)", async () => { + const mockMgr = makeInMemoryRdfManager(); + useOntologyStore.setState({ rdfManager: mockMgr as any } as any); + + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url === MAIN_URL) return Promise.resolve(makeTurtleResponse(mainTtl)); + if (url === IMPORTED_URL) return Promise.resolve(makeTurtleResponse(importedTtl)); + return Promise.reject(new TypeError(`Unexpected fetch: ${url}`)); + }); + vi.stubGlobal("fetch", fetchMock); + + await useOntologyStore.getState().loadOntology(MAIN_URL); + + await vi.waitFor( + () => { + const loaded = useOntologyStore.getState().loadedOntologies; + const urls = loaded.map((o: any) => String(o.url)); + if (!urls.some((u: string) => u.includes("imported-ont"))) { + throw new Error("imported-ont not yet loaded"); + } + }, + { timeout: 5000, interval: 50 }, + ); + + // Each URL should be fetched exactly once — no infinite recursion + const mainFetches = fetchMock.mock.calls.filter(([url]: [string]) => url === MAIN_URL).length; + const importedFetches = fetchMock.mock.calls.filter(([url]: [string]) => url === IMPORTED_URL).length; + expect(mainFetches).toBe(1); + expect(importedFetches).toBe(1); + }, 10000); +}); diff --git a/src/__tests__/stores/ontologyStore.test.ts b/src/__tests__/stores/ontologyStore.test.ts index 90775268..073c5931 100644 --- a/src/__tests__/stores/ontologyStore.test.ts +++ b/src/__tests__/stores/ontologyStore.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { useOntologyStore } from "../../stores/ontologyStore"; +import { rdfManager } from "../../utils/rdfManager"; import validateGraph from "../../utils/graphValidation"; import { WELL_KNOWN } from "../../utils/wellKnownOntologies"; @@ -158,3 +159,66 @@ describe("Ontology Store", () => { }); }); }); + +describe("discoverReferencedOntologies — graph parameter fix", () => { + const ONTOLOGY_GRAPH = "urn:vg:ontologies"; + const IMPORT_URL = "https://example.org/imported-ontology.ttl"; + const SUBJECT = "urn:test:myOntology"; + + const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; + const OWL_ONTOLOGY = "http://www.w3.org/2002/07/owl#Ontology"; + const OWL_IMPORTS = "http://www.w3.org/2002/07/owl#imports"; + + let savedRdfManager: any; + + beforeEach(() => { + savedRdfManager = useOntologyStore.getState().rdfManager; + useOntologyStore.getState().clearOntologies(); + }); + + afterEach(() => { + useOntologyStore.setState({ rdfManager: savedRdfManager } as any); + }); + + it("returns owl:imports candidates from the requested graph, not hardcoded data graph", async () => { + const mockFetchQuadsPage = vi.fn().mockImplementation( + async (graph: string, _offset: number, _limit: number, opts: any) => { + if (graph !== ONTOLOGY_GRAPH) { + return { items: [], total: 0, limit: _limit }; + } + const predicate = opts?.filter?.predicate; + if (predicate === RDF_TYPE) { + return { + items: [{ subject: SUBJECT, predicate: RDF_TYPE, object: OWL_ONTOLOGY, graph }], + total: 1, + limit: _limit, + }; + } + if (predicate === OWL_IMPORTS) { + return { + items: [{ subject: SUBJECT, predicate: OWL_IMPORTS, object: IMPORT_URL, graph }], + total: 1, + limit: _limit, + }; + } + return { items: [], total: 0, limit: _limit }; + }, + ); + + useOntologyStore.setState({ + rdfManager: { fetchQuadsPage: mockFetchQuadsPage } as any, + } as any); + + const store = useOntologyStore.getState(); + const result = await store.discoverReferencedOntologies!({ + graphName: ONTOLOGY_GRAPH, + load: false, + }); + + expect(result.candidates).toContain(IMPORT_URL); + // All fetchQuadsPage calls must target the ontology graph, never "urn:vg:data" + for (const call of mockFetchQuadsPage.mock.calls) { + expect(call[0]).toBe(ONTOLOGY_GRAPH); + } + }); +}); diff --git a/src/__tests__/stores/wellKnownOntologies.network.test.ts b/src/__tests__/stores/wellKnownOntologies.network.test.ts new file mode 100644 index 00000000..4faef8be --- /dev/null +++ b/src/__tests__/stores/wellKnownOntologies.network.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest"; +import { Readable } from "node:stream"; +import { Parser as N3Parser } from "n3"; +import { WELL_KNOWN_PREFIXES, resolveOntologyLoadUrl } from "../../utils/wellKnownOntologies"; + +async function fetchAndParse(url: string): Promise { + const resp = await fetch(url, { + headers: { Accept: "text/turtle, application/rdf+xml, application/n-triples, text/n3, */*;q=0.1" }, + redirect: "follow", + }); + + if (!resp.ok) { + throw new Error(`HTTP ${resp.status} from ${url}`); + } + + const body = await resp.text(); + const contentType = (resp.headers.get("content-type") || "").toLowerCase(); + + const isRdfXml = + contentType.includes("application/rdf+xml") || + contentType.includes("text/xml") || + contentType.includes("application/xml") || + url.endsWith(".rdf") || + url.endsWith(".owl") || + body.trimStart().startsWith("((resolve, reject) => { + const parser = new RdfXmlParser(); + let count = 0; + parser.on("data", () => { count++; }); + parser.on("end", () => resolve(count)); + parser.on("error", (err: Error) => reject(new Error(`RDF/XML parse error from ${url}: ${err.message}`))); + const readable = Readable.from([body]); + readable.pipe(parser); + }); + } + + // Try N3 (Turtle / N-Triples / N3 / TriG) + try { + const parser = new N3Parser(); + const quads = parser.parse(body); + return quads.length; + } catch (n3Err: any) { + throw new Error( + `Unknown RDF format from ${url} (content-type: ${contentType}): ${n3Err.message}`, + ); + } +} + +describe("well-known ontology reachability", () => { + for (const entry of WELL_KNOWN_PREFIXES) { + const loadUrl = resolveOntologyLoadUrl(entry.prefix); + + it( + `${entry.prefix} — ${loadUrl} resolves and parses as RDF`, + { timeout: 30000 }, + async () => { + const count = await fetchAndParse(loadUrl); + expect(count).toBeGreaterThan(0); + }, + ); + } +}); diff --git a/src/__tests__/utils/rdfManager.cors.test.ts b/src/__tests__/utils/rdfManager.cors.test.ts new file mode 100644 index 00000000..f94b2cf4 --- /dev/null +++ b/src/__tests__/utils/rdfManager.cors.test.ts @@ -0,0 +1,84 @@ +// @vitest-environment node +import { describe, it, expect, vi, afterEach } from "vitest"; +import { RDFManagerImpl } from "../../utils/rdfManager.impl"; + +const mockWorker = { + call: vi.fn(), + on: vi.fn(), + off: vi.fn(), +} as any; + +function makeInstance() { + return new RDFManagerImpl({ workerClient: mockWorker }); +} + +const TARGET = "https://example.org/test.ttl"; +const PROXY = "https://corsproxy.io/?url="; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("fetchWithCorsFallback", () => { + it("Scenario A: proxy activates on direct fetch failure", async () => { + const proxyResponse = new Response("ok", { status: 200 }); + const mockFetch = vi.fn() + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValueOnce(proxyResponse); + vi.stubGlobal("fetch", mockFetch); + + const inst = makeInstance(); + const controller = new AbortController(); + const result = await (inst as any).fetchWithCorsFallback( + TARGET, + { Accept: "text/turtle" }, + controller.signal, + undefined, + PROXY, + ); + + expect(result).toBe(proxyResponse); + expect(mockFetch).toHaveBeenCalledTimes(2); + const secondCall = mockFetch.mock.calls[1]; + expect(secondCall[0]).toBe(PROXY + encodeURIComponent(TARGET)); + }); + + it("Scenario B: no proxy configured — error propagates", async () => { + const fetchError = new TypeError("Failed to fetch"); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(fetchError)); + + const inst = makeInstance(); + const controller = new AbortController(); + await expect( + (inst as any).fetchWithCorsFallback( + TARGET, + {}, + controller.signal, + undefined, + "", + ), + ).rejects.toThrow("Failed to fetch"); + + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1); + }); + + it("Scenario C: direct fetch succeeds — proxy never called", async () => { + const directResponse = new Response("ok", { status: 200 }); + const mockFetch = vi.fn().mockResolvedValue(directResponse); + vi.stubGlobal("fetch", mockFetch); + + const inst = makeInstance(); + const controller = new AbortController(); + const result = await (inst as any).fetchWithCorsFallback( + TARGET, + {}, + controller.signal, + undefined, + PROXY, + ); + + expect(result).toBe(directResponse); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0][0]).toBe(TARGET); + }); +}); diff --git a/src/components/Canvas/ReactodiaCanvas.tsx b/src/components/Canvas/ReactodiaCanvas.tsx index 8d3a6be2..35822475 100644 --- a/src/components/Canvas/ReactodiaCanvas.tsx +++ b/src/components/Canvas/ReactodiaCanvas.tsx @@ -38,12 +38,11 @@ import type { AppConfig } from '@/stores/appConfigStore'; import { LayoutPopover } from './LayoutPopover'; import { RdfPropertyEditor } from './rdfPropertyEditor'; import { toast } from 'sonner'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '../ui/dialog'; import { Label } from '../ui/label'; import { Input } from '../ui/input'; import OntologyUrlAutoComplete from '../ui/OntologyUrlAutoComplete'; import { Button } from '../ui/button'; -import { WELL_KNOWN_PREFIXES, resolveOntologyLoadUrl } from '@/utils/wellKnownOntologies'; +import { WELL_KNOWN_BY_PREFIX, resolveOntologyLoadUrl } from '@/utils/wellKnownOntologies'; import { instantiateWorkflowOnCanvas } from '@/utils/workflowInstantiator'; function extractNamespace(iri: string): string { @@ -377,7 +376,9 @@ export default function ReactodiaCanvas() { // Resolved once at mount from ?loadImports URL param. false = imports disabled for this session. const loadImportsEnabledRef = React.useRef(true); - const ontologyCount = useOntologyStore(s => s.loadedOntologies?.length ?? 0); + const ontologyCount = useOntologyStore(s => + (s.loadedOntologies ?? []).filter((o: any) => o.loadStatus !== 'fail').length + ); const namespaces = useOntologyStore(s => Array.isArray(s.namespaceRegistry) ? s.namespaceRegistry : []); const loadKnowledgeGraph = useOntologyStore(s => s.loadKnowledgeGraph); const loadAdditionalOntologies = useOntologyStore(s => s.loadAdditionalOntologies); @@ -854,15 +855,20 @@ export default function ReactodiaCanvas() { } // ?ontology= comma-separated list of well-known prefix names (e.g. "bfo,dcat") or full URIs. - let startupOntologyUrls: string[] = []; + let startupOntologyEntries: { input: string; resolved: string; label: string }[] = []; try { const u = new URL(String(window.location.href)); const ontologyParam = u.searchParams.get('ontology') || u.searchParams.get('ontologies') || ''; if (ontologyParam.trim()) { - startupOntologyUrls = ontologyParam + startupOntologyEntries = ontologyParam .split(',') - .map((s) => resolveOntologyLoadUrl(s.trim())) - .filter(Boolean); + .map((s) => s.trim()) + .filter(Boolean) + .map((s) => ({ + input: s, + resolved: resolveOntologyLoadUrl(s), + label: WELL_KNOWN_BY_PREFIX[s]?.name ?? s, + })); } } catch { /* ignore */ @@ -895,26 +901,34 @@ export default function ReactodiaCanvas() { disableImportDiscovery: !loadImportsEnabledRef.current, ...(startupApiKey ? { apiKey: startupApiKey, apiKeyHeader: startupApiKeyHeader || undefined } : {}), }); - toast.success('Startup knowledge graph loaded'); + const startupLabel = (() => { + try { return new URL(startupUrl).pathname.split('/').filter(Boolean).pop()?.replace(/\.[^.]+$/, '') || startupUrl; } + catch { return startupUrl; } + })(); + toast.success(`Loaded: ${startupLabel}`, { description: '?url= parameter' }); } catch (err) { + toast.error('Failed to load startup graph', { description: startupUrl }); console.error('[ReactodiaCanvas] Startup URL load failed', err); } finally { actions.setLoading(false, 0, ''); } } - // Load ontologies specified via ?ontology= URL param (additive — runs alongside all other mechanisms). - if (startupOntologyUrls.length > 0) { - try { - actions.setLoading(true, 5, 'Loading ontologies from URL parameter...'); - await loadAdditionalOntologies(startupOntologyUrls, (progress: number, message: string) => { - actions.setLoading(true, Math.max(5, progress), message); - }); - } catch (err) { - console.warn('[ReactodiaCanvas] ?ontology= load failed', err); - } finally { - actions.setLoading(false, 0, ''); + // Load ontologies specified via ?ontology= URL param — one toast per entry. + if (startupOntologyEntries.length > 0) { + for (const entry of startupOntologyEntries) { + try { + actions.setLoading(true, 5, `Loading ${entry.label}...`); + await loadAdditionalOntologies([entry.resolved], (progress, message) => { + actions.setLoading(true, Math.max(5, progress), message); + }); + toast.success(entry.label, { description: 'Loaded from ?ontology= parameter' }); + } catch (err) { + console.warn('[ReactodiaCanvas] ?ontology= load failed', entry.input, err); + toast.error(`Failed to load ${entry.label}`, { description: '?ontology= parameter — check the prefix or URL' }); + } } + actions.setLoading(false, 0, ''); } })(); }, [loadKnowledgeGraph, loadAdditionalOntologies, actions]); @@ -1416,53 +1430,69 @@ export default function ReactodiaCanvas() { onOpenChange={setSettingsOpen} /> - - - - Load Ontology - - Enter a URL or type to search well-known ontologies. - - -
-
- - + {loadOntologyOpen && ( +
{ if (e.target === e.currentTarget) setLoadOntologyOpen(false); }} + onKeyDown={(e) => { if (e.key === 'Escape') setLoadOntologyOpen(false); }} + > +
+ +
+

Load Ontology

+

Enter a URL or type to search well-known ontologies.

-
- - +
+
+ + +
+
+ + +
- -
+ + )} = ({
{ont?.name || 'Unknown'}
{ontologyUrl || 'No URI'}
- Loaded + {ont?.loadStatus === 'fail' + ? Failed + : ont?.loadStatus === 'pending' + ? Loading… + : Loaded + } {isAutoloaded && · autoload} {isCore && · core}
diff --git a/src/components/ui/OntologyUrlAutoComplete.tsx b/src/components/ui/OntologyUrlAutoComplete.tsx index 59c16bc1..c47965c0 100644 --- a/src/components/ui/OntologyUrlAutoComplete.tsx +++ b/src/components/ui/OntologyUrlAutoComplete.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useLayoutEffect, useEffect, useMemo } from 'react'; import ReactDOM from 'react-dom'; -import { WELL_KNOWN_PREFIXES } from '../../utils/wellKnownOntologies'; +import { searchWellKnownOntologies, resolveOntologyLoadUrl } from '../../utils/wellKnownOntologies'; import { cn } from '../../lib/utils'; interface Props { @@ -19,15 +19,7 @@ export default function OntologyUrlAutoComplete({ value, onChange, placeholder, const listRef = useRef(null); const closeTimer = useRef | null>(null); - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - if (!q) return WELL_KNOWN_PREFIXES; - return WELL_KNOWN_PREFIXES.filter(e => - e.prefix.toLowerCase().includes(q) || - e.name.toLowerCase().includes(q) || - e.url.toLowerCase().includes(q) - ); - }, [query]); + const filtered = useMemo(() => searchWellKnownOntologies(query), [query]); useEffect(() => { setActiveIndex(-1); }, [filtered]); @@ -82,7 +74,10 @@ export default function OntologyUrlAutoComplete({ value, onChange, placeholder, )} >
{e.prefix} — {e.name}
-
{e.url}
+ {(e as any).description && ( +
{(e as any).description}
+ )} +
{resolveOntologyLoadUrl(e.prefix)}
))} , diff --git a/src/mcp/manifest.ts b/src/mcp/manifest.ts index e44f7a76..7b5e0695 100644 --- a/src/mcp/manifest.ts +++ b/src/mcp/manifest.ts @@ -12,7 +12,12 @@ export const mcpServerDescription = ' • "Punned" resources (typed as both, e.g. owl:Class AND owl:NamedIndividual) appear in both views.\n' + 'Switching views replaces the entire canvas — ABox nodes are invisible in TBox view and vice versa. Use setViewMode before exportImage to capture the right layer. addNode writes triples to the store and the canvas populates automatically in the correct view.\n\n' + 'Architecture for AI agents: The app has two coupled layers. (1) N3 RDF store (urn:vg:data) — source of truth for all triples. addNode/addLink write here first. (2) Reactodia canvas — mirrors the store subset matching the active view as draggable node cards and arrows. Nodes start collapsed; call expandNode or expandAll to reveal annotation property cards. OWL-RL reasoning writes inferred triples back to the store (urn:vg:inferred) and refreshes the canvas.\n\n' + - 'Recommended workflow: setViewMode("tbox") → loadOntology → addNode ×N (owl:Class etc.) → addLink ×N (subClassOf etc.) → runLayout → setViewMode("abox") → addNode ×N (individuals) → addLink ×N → runLayout → runReasoning → fitCanvas → exportImage(svg).\n\n' + + 'ONTOLOGY DISCOVERY — always the first step:\n' + + 'OWL, RDFS, RDF, and XSD are pre-loaded. All other ontologies must be loaded explicitly:\n' + + ' 1. searchOntologies("use case") — find the right prefix ("calendar" → ical, "music" → mo, "building" → bot, "e-commerce" → gr, …)\n' + + ' 2. loadOntology("") — load into TBox. Repeat for each domain.\n' + + ' 3. Register a namespace prefix: addNamespace(prefix, namespace) if you want short-form IRIs.\n\n' + + 'Recommended workflow: searchOntologies → loadOntology ×N → addNamespace ×N → setViewMode("tbox") → addNode ×N (owl:Class etc.) → addLink ×N (subClassOf etc.) → runLayout → setViewMode("abox") → addNode ×N (individuals) → addLink ×N → runLayout → runReasoning → fitCanvas → exportImage(svg).\n\n' + 'Agent integration: (1) Claude Code / Playwright — call window.__mcpTools[name](params) via browser_evaluate. (2) AI Relay Bridge — any AI chat (ChatGPT, Claude.ai, Gemini) can control Ontosphere via a bookmarklet relay that intercepts JSON-RPC 2.0 tool calls and injects results back automatically; see docs/relay-bridge.md. Full agent guide: AGENTS.md. Example sessions with SVG snapshots: docs/mcp-demo/.'; export const mcpManifest: McpToolManifestEntry[] = [ @@ -37,7 +42,13 @@ export const mcpManifest: McpToolManifestEntry[] = [ }, { name: 'loadOntology', - description: 'Load TBox ontology for type hints and reasoning support. Does NOT add canvas nodes. Use for schema/class definitions. To load instance data as canvas nodes, use loadRdf instead.', + description: + 'Discover or load well-known ontologies — three modes in one tool. ' + + '(1) Load: pass url with a prefix name (e.g. "ical", "mo", "bot", "gr") or a namespace/file URL — loads the TBox, does NOT add canvas nodes. ' + + '(2) Search: pass query with a use-case keyword ("calendar", "music", "building", "e-commerce", "spatial", "IoT") — returns matching entries with prefix/description/loadUrl. ' + + '(3) List all: pass neither — returns all ~55 registered ontologies. ' + + 'If url does not match a known prefix or URL, load fails and suggestions are returned automatically. ' + + 'OWL/RDFS/RDF/XSD are always pre-loaded.', inputSchema: { type: 'object', properties: { diff --git a/src/mcp/tools/graph.ts b/src/mcp/tools/graph.ts index 5187b6ad..58e6bb6e 100644 --- a/src/mcp/tools/graph.ts +++ b/src/mcp/tools/graph.ts @@ -5,7 +5,7 @@ import { rdfManager } from '@/utils/rdfManager'; import { getWorkspaceRefs, applyViewMode } from '@/mcp/workspaceContext'; import { mcpManifest, mcpServerDescription } from '@/mcp/manifest'; import { Parser as SparqlParser, Generator as SparqlGenerator } from 'sparqljs'; -import { resolveOntologyLoadUrl, WELL_KNOWN_PREFIXES } from '@/utils/wellKnownOntologies'; +import { resolveOntologyLoadUrl, searchWellKnownOntologies } from '@/utils/wellKnownOntologies'; import { useSettingsStore } from '@/stores/settingsStore'; /** Prepend PREFIX declarations from the namespace map for any prefix not already declared in the query. */ @@ -88,45 +88,55 @@ const loadRdf: McpTool = { const loadOntology: McpTool = { name: 'loadOntology', description: - 'Load a well-known ontology by prefix name (e.g. "bfo", "ro", "iao", "foaf", "pmdco"), ' + - 'by its namespace URL, or by any direct ontology file URL. ' + - 'Call with url="" or omit url to list all available well-known ontologies.', + 'Discover or load well-known ontologies. ' + + 'Pass url to load by prefix name (e.g. "ical", "mo", "bot", "gr") or by namespace/file URL. ' + + 'Pass query to search by use-case keyword (e.g. "calendar", "music", "building", "e-commerce"). ' + + 'Pass neither to list all ~55 registered ontologies. ' + + 'OWL/RDFS/RDF/XSD are always pre-loaded.', inputSchema: { type: 'object', properties: { url: { type: 'string', - description: - 'Prefix name (e.g. "bfo"), namespace IRI, or direct ontology URL. ' + - 'Leave empty to list available well-known ontologies.', + description: 'Prefix name, namespace IRI, or direct ontology URL to load.', + }, + query: { + type: 'string', + description: 'Keyword or use-case phrase to search the registry (e.g. "calendar", "IoT", "spatial").', }, }, }, async handler(params): Promise { - const { url = '' } = (params ?? {}) as { url?: string }; + const { url, query } = (params ?? {}) as { url?: string; query?: string }; - // Empty call — return registry listing - if (!url.trim()) { - const known = WELL_KNOWN_PREFIXES - .filter(p => (p as any).ontologyUrl || p.url) - .map(p => ({ prefix: p.prefix, name: p.name, namespace: p.url, ontologyUrl: (p as any).ontologyUrl ?? p.url })); - return { success: true, data: { availableOntologies: known } }; + // Search mode + if (!url?.trim()) { + const ontologies = searchWellKnownOntologies(query ?? '').map(e => ({ + prefix: e.prefix, + name: e.name, + description: (e as any).description ?? '', + namespace: e.url, + loadUrl: resolveOntologyLoadUrl(e.prefix), + })); + return { + success: true, + data: { query: query || '(all)', count: ontologies.length, ontologies }, + }; } + // Load mode const resolvedUrl = resolveOntologyLoadUrl(url); const corsProxyUrl = useSettingsStore.getState().settings.corsProxyUrl; try { await rdfManager.loadRDFFromUrl(resolvedUrl, { corsProxyUrl }); return { success: true, data: { loaded: resolvedUrl, requestedAs: url !== resolvedUrl ? url : undefined } }; } catch (e) { - // Suggest close matches from the registry - const q = url.toLowerCase(); - const suggestions = WELL_KNOWN_PREFIXES - .filter(p => p.prefix.includes(q) || p.name.toLowerCase().includes(q)) - .map(p => p.prefix); + const suggestions = searchWellKnownOntologies(url) + .map(p => ({ prefix: p.prefix, description: (p as any).description ?? p.name })); return { success: false, error: String(e), + hint: 'Pass query instead of url to search the registry.', ...(suggestions.length ? { suggestions } : {}), }; } @@ -457,6 +467,13 @@ const help: McpTool = { 'error with data.lateResult=true. Do NOT retry — a [Ontosphere — late result for ] follow-up', 'will be injected automatically when the operation completes.', '', + 'ONTOLOGY DISCOVERY', + 'OWL, RDFS, RDF, and XSD axioms are always pre-loaded — you can use owl:, rdfs:, rdf:, xsd: immediately.', + 'All other ontologies must be loaded explicitly before use:', + ' 1. searchOntologies("your use case") — find the right prefix (e.g. "calendar" → ical, "music" → mo, "building" → bot)', + ' 2. loadOntology("") — load into the TBox. Repeat for each domain you need.', + ' 3. Only then start addNode / addLink — types will resolve correctly.', + '', 'GRAPH ARCHITECTURE', 'Asserted triples live in urn:vg:data — all mutation tools (addNode, addLink, updateNode, SPARQL CONSTRUCT, etc.) operate here only.', 'Inferred triples live in urn:vg:inferred — written by runReasoning, cleared by clearInferred, and read-only from all other tools.', diff --git a/src/stores/ontologyStore.ts b/src/stores/ontologyStore.ts index a736497d..5df059e7 100644 --- a/src/stores/ontologyStore.ts +++ b/src/stores/ontologyStore.ts @@ -11,7 +11,7 @@ import { RDFManager, rdfManager } from "../utils/rdfManager"; import { useAppConfigStore } from "./appConfigStore"; import { useSettingsStore } from "./settingsStore"; import { debug, info, warn, error, fallback } from "../utils/startupDebug"; -import { WELL_KNOWN, WELL_KNOWN_PREFIXES } from "../utils/wellKnownOntologies"; +import { WELL_KNOWN, WELL_KNOWN_PREFIXES, resolveOntologyLoadUrl } from "../utils/wellKnownOntologies"; import { DataFactory, Quad } from "n3"; import { toast } from "sonner"; import { buildPaletteMap } from "../components/Canvas/core/namespacePalette"; @@ -494,6 +494,7 @@ interface OntologyStore { concurrency?: number; onProgress?: (p: number, message: string) => void; forceDisabled?: boolean; + unconditional?: boolean; }, ) => Promise<{ candidates: string[]; @@ -618,6 +619,13 @@ export const useOntologyStore = create((set, get) => ({ // normalize requested URL (http -> https, trim) const normRequestedUrl = normalizeOntologyUri(url); + // Resolve to the actual fetch URL (ontologyUrl overrides namespace URL for well-known entries). + // Try both the normalized (https) form and the original http form because WELL_KNOWN urls use http://. + const _httpForm0 = normRequestedUrl.replace(/^https:\/\//i, "http://"); + const _resolved0 = resolveOntologyLoadUrl(normRequestedUrl); + const fetchUrl = normalizeOntologyUri( + _resolved0 !== normRequestedUrl ? _resolved0 : resolveOntologyLoadUrl(_httpForm0) + ); let canonicalNorm: string | undefined = undefined; // If well-known, register a lightweight entry so UI shows it immediately. @@ -662,10 +670,10 @@ export const useOntologyStore = create((set, get) => ({ const corsProxyUrl = useSettingsStore.getState().settings.corsProxyUrl; const loadOpts = { timeoutMs: 15000, corsProxyUrl: corsProxyUrl || undefined }; if (mgr && typeof (mgr as any).loadRDFFromUrl === "function") { - await (mgr as any).loadRDFFromUrl(normRequestedUrl, "urn:vg:ontologies", loadOpts); + await (mgr as any).loadRDFFromUrl(fetchUrl, "urn:vg:ontologies", loadOpts); } else { // fallback to module-level manager - await (rdfManager as any).loadRDFFromUrl(normRequestedUrl, "urn:vg:ontologies", loadOpts); + await (rdfManager as any).loadRDFFromUrl(fetchUrl, "urn:vg:ontologies", loadOpts); } } catch (err) { warn( @@ -843,6 +851,17 @@ export const useOntologyStore = create((set, get) => ({ /* ignore overall emit failures */ } + // Fire-and-forget owl:imports discovery into the ontology graph. + // Guard with !discovered to prevent mutual recursion: inner loadOntology calls + // triggered by discovery set discovered:true and must not re-trigger. + if (!options?.discovered) { + void get().discoverReferencedOntologies!({ + graphName: "urn:vg:ontologies", + load: "async", + unconditional: true, + }).catch(() => { /* ignore discovery errors */ }); + } + return { success: true, url: normRequestedUrl, canonicalUrl: canonicalNorm }; } catch (error: any) { try { @@ -1293,20 +1312,21 @@ export const useOntologyStore = create((set, get) => ({ concurrency?: number; onProgress?: (p: number, message: string) => void; forceDisabled?: boolean; + unconditional?: boolean; }) => { const opts = options || {}; // Session-level override (e.g. ?loadImports=false URL param). if (opts.forceDisabled === true) { return { candidates: [] }; } - // Respect the user setting — skip discovery entirely if disabled. + // Respect the user setting — skip discovery if disabled, unless the caller + // explicitly opts out (unconditional: true) for owl:imports following on explicit loads. const appCfgDiscover = useAppConfigStore.getState(); - if (appCfgDiscover && appCfgDiscover.config && appCfgDiscover.config.autoDiscoverOntologies === false) { + if (!opts.unconditional && appCfgDiscover && appCfgDiscover.config && appCfgDiscover.config.autoDiscoverOntologies === false) { return { candidates: [] }; } const requestedGraphName = typeof opts.graphName === "string" && opts.graphName.trim() ? opts.graphName : "urn:vg:data"; - const graphName = "urn:vg:data"; const loadMode = typeof opts.load === "undefined" ? "async" : opts.load; const timeoutMs = typeof opts.timeoutMs === "number" ? opts.timeoutMs : 15000; const concurrency = typeof opts.concurrency === "number" ? opts.concurrency : 6; @@ -1371,14 +1391,13 @@ export const useOntologyStore = create((set, get) => ({ }; })(); - const vgM = vgMeasureModule("discoverReferencedOntologies", { graphName, loadMode, timeoutMs, concurrency }); + const vgM = vgMeasureModule("discoverReferencedOntologies", { graphName: requestedGraphName, loadMode, timeoutMs, concurrency }); console.debug("[VG_DEBUG] discoverReferencedOntologies.invoked", { - graphName, + graphName: requestedGraphName, loadMode, timeoutMs, concurrency, - requestedGraphName, }); const mgr = get().rdfManager; @@ -1391,13 +1410,13 @@ export const useOntologyStore = create((set, get) => ({ const typeQuads = await fetchSerializedQuads( mgr, - graphName, + requestedGraphName, { predicate: RDF_TYPE }, 2000, ); const importQuads = await fetchSerializedQuads( mgr, - graphName, + requestedGraphName, { predicate: OWL_IMPORTS }, 2000, ); @@ -1460,8 +1479,7 @@ export const useOntologyStore = create((set, get) => ({ } console.debug("[VG_DEBUG] discoverReferencedOntologies.candidates", { - graph: graphName, - requestedGraphName, + graph: requestedGraphName, candidates, sources: candidateSources, }); @@ -1512,7 +1530,7 @@ export const useOntologyStore = create((set, get) => ({ vgM && typeof vgM.end === "function" && vgM.end({ reason: "no_mgr_emit" }); throw new Error("discoverReferencedOntologies: no rdfManager.emitAllSubjects available"); } - await (mgrInst as any).emitAllSubjects("urn:vg:data"); + await (mgrInst as any).emitAllSubjects(requestedGraphName); onProgress && onProgress(100, "Discovery complete"); vgM && typeof vgM.end === "function" && vgM.end({ reason: "complete", resultsCount: results.length }); diff --git a/src/utils/wellKnownOntologies.ts b/src/utils/wellKnownOntologies.ts index ec3c962f..e3f56de1 100644 --- a/src/utils/wellKnownOntologies.ts +++ b/src/utils/wellKnownOntologies.ts @@ -2,7 +2,7 @@ * Centralized well-known ontology and prefix mappings. * * New canonical structure: - * - WELL_KNOWN_PREFIXES: Array of { prefix, url, name } + * - WELL_KNOWN_PREFIXES: Array of { prefix, url, name, description } * - WELL_KNOWN_BY_PREFIX: Record * - WELL_KNOWN_BY_URL: Map * @@ -17,156 +17,405 @@ export const WELL_KNOWN_PREFIXES = [ prefix: "rdf", url: "http://www.w3.org/1999/02/22-rdf-syntax-ns#", name: "RDF - The RDF Concepts Vocabulary", + description: "Fundamental RDF concepts: resources, properties, literals and statements", isCore: true, }, { prefix: "rdfs", url: "http://www.w3.org/2000/01/rdf-schema#", name: "RDFS - The RDF Schema Vocabulary", + description: "RDF Schema: classes, properties, subclass and subproperty hierarchies", isCore: true, }, - { prefix: "owl", url: "http://www.w3.org/2002/07/owl#", name: "OWL", isCore: true }, - { prefix: "xsd", url: "http://www.w3.org/2001/XMLSchema#", name: "XSD", isCore: true }, - { prefix: "skos", url: "http://www.w3.org/2004/02/skos/core#", name: "SKOS" }, + { + prefix: "owl", + url: "http://www.w3.org/2002/07/owl#", + name: "OWL", + description: "Web Ontology Language: expressive class and property axioms, reasoning", + isCore: true, + }, + { + prefix: "xsd", + url: "http://www.w3.org/2001/XMLSchema#", + name: "XSD", + description: "XML Schema Datatypes: string, integer, date, boolean and other primitives", + isCore: true, + }, + { + prefix: "skos", + url: "http://www.w3.org/2004/02/skos/core#", + name: "SKOS", + description: "Simple Knowledge Organization System: thesauri, taxonomies and classification schemes", + }, { prefix: "prov", url: "http://www.w3.org/ns/prov#", name: "PROV-O - The PROV Ontology", + description: "Provenance: entities, activities, agents and their causal relationships", ontologyUrl: "https://www.w3.org/ns/prov-o", }, { prefix: "p-plan", url: "http://purl.org/net/p-plan#", name: "P-Plan - The P-Plan Ontology", + description: "Scientific workflow provenance: plans, steps and entities extending PROV-O", ontologyUrl: "https://purl.org/net/p-plan", }, { prefix: "bfo", url: "http://purl.obolibrary.org/obo/BFO_", name: "BFO 2 - Basic Formal Ontology 2.0", + description: "Upper-level foundational ontology for biomedical and scientific domains", ontologyUrl: "https://purl.obolibrary.org/obo/bfo/2.0/bfo.owl", }, { prefix: "bfo2020", url: "https://basic-formal-ontology.org/2020/formulas/owl/", name: "BFO 2020 - Basic Formal Ontology 2020", + description: "ISO/IEC 21838-compliant update of Basic Formal Ontology", ontologyUrl: "https://raw.githubusercontent.com/BFO-ontology/BFO-2020/master/21838-2/owl/bfo-core.owl", }, { prefix: "dcat", url: "http://www.w3.org/ns/dcat#", name: "DCAT - Data Catalog Vocabulary", + description: "Data catalogs: datasets, distributions, data services and catalog records", ontologyUrl: "https://www.w3.org/ns/dcat2", }, { prefix: "qudt", url: "http://qudt.org/schema/qudt/", name: "QUDT - Quantities, Units, Dimensions and Types", + description: "Measurement schema: quantity kinds, units, dimensions and numeric values", }, { prefix: "unit", url: "http://qudt.org/vocab/unit/", name: "QUDT Units Vocabulary", + description: "Concrete SI and non-SI units of measure (metre, kilogram, second, …)", }, { prefix: "dcterms", url: "http://purl.org/dc/terms/", name: "Dublin Core Terms", + description: "General metadata: titles, creators, dates, subjects and descriptions", }, { prefix: "dc", url: "http://purl.org/dc/elements/1.1/", name: "Dublin Core", + description: "Legacy Dublin Core metadata elements (title, creator, date, format, …)", + }, + { + prefix: "foaf", + url: "http://xmlns.com/foaf/0.1/", + name: "FOAF", + description: "Friend of a Friend: people, organizations, social networks and accounts", + ontologyUrl: "https://xmlns.com/foaf/spec/index.rdf", }, - { prefix: "foaf", url: "http://xmlns.com/foaf/0.1/", name: "FOAF", ontologyUrl: "https://xmlns.com/foaf/spec/index.rdf" }, { prefix: "org", url: "http://www.w3.org/ns/org#", name: "Organization", + description: "Formal organizations, membership, roles, sites and organizational units", }, { prefix: "pmdco", url: "https://w3id.org/pmd/co/", name: "PMD Core", + description: "Materials science and engineering: processes, specimens and characteristics", + ontologyUrl: "https://raw.githubusercontent.com/materialdigital/core-ontology/main/pmdco.ttl", }, { prefix: "tto", url: "https://w3id.org/pmd/ao/tto/", name: "PMD Tensile Test", + description: "Mechanical tensile testing: specimens, force, elongation and test procedures", + ontologyUrl: "https://raw.githubusercontent.com/materialdigital/tensile-test-ontology/main/tto.ttl", }, { prefix: "iof-core", url: "https://spec.industrialontologies.org/ontology/core/Core/", name: "IOF Core", - }, - { - prefix: "iof-mat", - url: "https://spec.industrialontologies.org/ontology/materials/Materials/", - name: "IOF Materials", - }, - { - prefix: "iof-qual", - url: "https://spec.industrialontologies.org/ontology/qualities/", - name: "IOF Qualities", + description: "Industrial ontology: processes, equipment, capabilities and maintenance", }, { prefix: "ro", url: "http://purl.obolibrary.org/obo/RO_", name: "RO - Relations Ontology", + description: "Biological and biomedical relation types: part-of, has-role, regulates, …", ontologyUrl: "https://purl.obolibrary.org/obo/ro.owl", }, { prefix: "iao", url: "http://purl.obolibrary.org/obo/IAO_", name: "IAO - Information Artifact Ontology", + description: "Information entities: documents, data items, measurement data and identifiers", ontologyUrl: "https://purl.obolibrary.org/obo/iao.owl", }, { prefix: "log", url: "https://w3id.org/pmd/log/", name: "PMD LOG - PMD Laboratory Operations Graph", + description: "Laboratory workflows: instruments, measurements, samples and operations", ontologyUrl: "https://w3id.org/pmd/log", }, { prefix: "emmo", url: "https://w3id.org/emmo#", name: "EMMO - European Materials Modelling Ontology", + description: "Physics, chemistry and materials modelling: properties, models and simulations", ontologyUrl: "https://raw.githubusercontent.com/emmo-repo/EMMO/master/emmo.ttl", }, { prefix: "sosa", url: "http://www.w3.org/ns/sosa/", name: "SOSA - Sensor, Observation, Sample and Actuator", + description: "IoT observation framework: sensors, actuators, samples and observations", ontologyUrl: "https://www.w3.org/ns/sosa/", }, { prefix: "ssn", url: "http://www.w3.org/ns/ssn/", name: "SSN - Semantic Sensor Network Ontology", + description: "Semantic sensor networks: systems, platforms, capabilities and deployments", ontologyUrl: "https://www.w3.org/ns/ssn/", }, { prefix: "schema", url: "https://schema.org/", name: "Schema.org", + description: "Events, people, places, products, organizations and web content markup", + ontologyUrl: "https://schema.org/version/latest/schemaorg-current-https.ttl", }, { prefix: "sh", url: "http://www.w3.org/ns/shacl#", name: "SHACL - Shapes Constraint Language", + description: "Shapes and constraints for RDF graph validation and conformance testing", ontologyUrl: "https://www.w3.org/ns/shacl", }, { prefix: "time", url: "http://www.w3.org/2006/time#", name: "OWL-Time - Time Ontology in OWL", + description: "Temporal entities: instants, intervals, durations, time positions and zones", ontologyUrl: "https://www.w3.org/2006/time", }, + { + prefix: "vcard", + url: "http://www.w3.org/2006/vcard/ns#", + name: "vCard Ontology", + description: "Electronic business cards: contact details, addresses, telephones and emails", + ontologyUrl: "https://www.w3.org/2006/vcard/ns", + }, + { + prefix: "ldp", + url: "http://www.w3.org/ns/ldp#", + name: "LDP - Linked Data Platform", + description: "RESTful Linked Data containers, resources and interactions over HTTP", + ontologyUrl: "https://www.w3.org/ns/ldp", + }, + { + prefix: "oa", + url: "http://www.w3.org/ns/oa#", + name: "OA - Web Annotation Vocabulary", + description: "Web annotations: bodies, targets, selectors and annotation motivations", + ontologyUrl: "https://www.w3.org/ns/oa", + }, + { + prefix: "odrl", + url: "http://www.w3.org/ns/odrl/2/", + name: "ODRL - Open Digital Rights Language", + description: "Rights management: policies, permissions, prohibitions and obligations", + ontologyUrl: "https://www.w3.org/ns/odrl/2/", + }, + { + prefix: "as", + url: "https://www.w3.org/ns/activitystreams#", + name: "Activity Streams 2.0", + description: "Social activities: actors, objects, likes, follows and activity feeds", + ontologyUrl: "https://www.w3.org/ns/activitystreams-owl.ttl", + }, + { + prefix: "csvw", + url: "http://www.w3.org/ns/csvw#", + name: "CSVW - CSV on the Web", + description: "Tabular data: CSV file metadata, column definitions and data types", + ontologyUrl: "https://www.w3.org/ns/csvw", + }, + { + prefix: "locn", + url: "http://www.w3.org/ns/locn#", + name: "LOCN - Core Location Vocabulary", + description: "Addresses, postal codes and geographic locations for government data", + ontologyUrl: "https://www.w3.org/ns/locn", + }, + { + prefix: "wgs84", + url: "http://www.w3.org/2003/01/geo/wgs84_pos#", + name: "WGS84 - Basic Geo Vocabulary", + description: "Geographic coordinates: latitude, longitude and altitude in WGS84", + ontologyUrl: "http://www.w3.org/2003/01/geo/wgs84_pos.rdf", + }, + { + prefix: "qb", + url: "http://purl.org/linked-data/cube#", + name: "RDF Data Cube Vocabulary", + description: "Statistical data: observations, measures, dimensions and attributes", + ontologyUrl: "https://raw.githubusercontent.com/UKGovLD/publishing-statistical-data/master/specs/src/main/vocab/cube.ttl", + }, + + // Calendar & events + { + prefix: "ical", + url: "http://www.w3.org/2002/12/cal/ical#", + name: "iCal - iCalendar Vocabulary", + description: "Calendar events: meetings, recurring schedules, alarms and calendar components", + ontologyUrl: "http://www.w3.org/2002/12/cal/ical.rdf", + }, + + // Bibliographic & scholarly + { + prefix: "bibo", + url: "http://purl.org/ontology/bibo/", + name: "BIBO - Bibliographic Ontology", + description: "Bibliographic metadata: books, articles, theses, reports and academic works", + }, + { + prefix: "fabio", + url: "http://purl.org/spar/fabio/", + name: "FABIO - FRBR-aligned Bibliographic Ontology", + description: "Publishing works: journal articles, conference papers, datasets and expressions", + }, + { + prefix: "cito", + url: "http://purl.org/spar/cito/", + name: "CiTO - Citation Typing Ontology", + description: "Scholarly citation types: supports, disputes, extends, documents and cites", + }, + { + prefix: "vann", + url: "http://purl.org/vocab/vann/", + name: "VANN - Vocabulary for Annotating Vocabulary Descriptions", + description: "Vocabulary metadata: preferred namespace prefixes and example usage", + ontologyUrl: "https://lov.linkeddata.es/dataset/lov/vocabs/vann/versions/2010-06-07.n3", + }, + + // Music + { + prefix: "mo", + url: "http://purl.org/ontology/mo/", + name: "MO - Music Ontology", + description: "Music metadata: tracks, albums, artists, performances and recordings", + ontologyUrl: "https://raw.githubusercontent.com/motools/musicontology/master/rdf/musicontology.n3", + }, + + // Media & images + { + prefix: "exif", + url: "http://www.w3.org/2003/12/exif/ns#", + name: "EXIF - Exif Vocabulary", + description: "Image metadata: camera settings, GPS coordinates, exposure and orientation", + ontologyUrl: "http://www.w3.org/2003/12/exif/ns", + }, + + // E-commerce & products + { + prefix: "gr", + url: "http://purl.org/goodrelations/v1#", + name: "GoodRelations - E-commerce Ontology", + description: "E-commerce: products, offerings, prices, businesses and delivery options", + ontologyUrl: "http://purl.org/goodrelations/v1", + }, + + // Licensing + { + prefix: "cc", + url: "http://creativecommons.org/ns#", + name: "CC - Creative Commons Vocabulary", + description: "Creative Commons licensing: permissions, prohibitions and attribution requirements", + ontologyUrl: "https://creativecommons.org/schema.rdf", + }, + + // Social web & discussions + { + prefix: "sioc", + url: "http://rdfs.org/sioc/ns#", + name: "SIOC - Semantically Interlinked Online Communities", + description: "Online communities: blog posts, forum threads, replies and user accounts", + ontologyUrl: "http://rdfs.org/sioc/ns", + }, + + // IoT / smart home + { + prefix: "saref", + url: "https://saref.etsi.org/core/", + name: "SAREF - Smart Appliances REFerence Ontology", + description: "Smart home and IoT: devices, functions, commands, measurements and states", + }, + + // Buildings & architecture + { + prefix: "bot", + url: "https://w3id.org/bot#", + name: "BOT - Building Topology Ontology", + description: "Buildings: sites, buildings, storeys, spaces, zones and construction elements", + ontologyUrl: "https://w3id.org/bot", + }, + + // Geospatial + { + prefix: "geo", + url: "http://www.opengis.net/ont/geosparql#", + name: "GeoSPARQL - OGC Geographic Query Language", + description: "Spatial features: geometries, WKT literals, topological relations and GIS queries", + ontologyUrl: "https://opengeospatial.github.io/ogc-geosparql/geosparql11/geo.ttl", + }, + + // Provenance extensions + { + prefix: "pav", + url: "http://purl.org/pav/", + name: "PAV - Provenance, Authoring and Versioning", + description: "Lightweight provenance: authors, curators, versions and source attribution", + }, + + // Open government data + { + prefix: "adms", + url: "http://www.w3.org/ns/adms#", + name: "ADMS - Asset Description Metadata Schema", + description: "Semantic assets and interoperability resources: specifications, schemas and vocabularies", + }, + { + prefix: "regorg", + url: "http://www.w3.org/ns/regorg#", + name: "RegOrg - Registered Organization Vocabulary", + description: "Legal entities and registered organizations: registration numbers and identifiers", + }, + + // Project metadata & vocabulary annotation + { + prefix: "doap", + url: "http://usefulinc.com/ns/doap#", + name: "DOAP - Description of a Project", + description: "Open-source projects: repositories, releases, maintainers and programming languages", + ontologyUrl: "https://raw.githubusercontent.com/ewilderj/doap/master/schema/doap.rdf", + }, + + // Dublin Core type vocabulary + { + prefix: "dcmitype", + url: "http://purl.org/dc/dcmitype/", + name: "DCMIType - DCMI Type Vocabulary", + description: "Content types: Dataset, Image, Software, Text, Sound, MovingImage and more", + }, ] as const; export const WELL_KNOWN_BY_PREFIX: Record< string, - { prefix: string; url: string; name: string; ontologyUrl?: string } + { prefix: string; url: string; name: string; description?: string; ontologyUrl?: string } > = Object.fromEntries(WELL_KNOWN_PREFIXES.map((p) => [p.prefix, p])) as any; /** @@ -175,6 +424,18 @@ export const WELL_KNOWN_BY_PREFIX: Record< * (e.g. BFO, DCAT) that URL is returned; otherwise the namespace `url` is used. * Unrecognised strings are returned as-is so callers can pass raw URIs directly. */ +/** Filter the registry by keyword or use-case phrase. Empty query returns all entries. */ +export function searchWellKnownOntologies(query: string): typeof WELL_KNOWN_PREFIXES[number][] { + const q = query.trim().toLowerCase(); + if (!q) return [...WELL_KNOWN_PREFIXES]; + return WELL_KNOWN_PREFIXES.filter(e => + e.prefix.toLowerCase().includes(q) || + e.name.toLowerCase().includes(q) || + e.url.toLowerCase().includes(q) || + ((e as any).description as string | undefined)?.toLowerCase().includes(q) + ); +} + export function resolveOntologyLoadUrl(prefixOrUri: string): string { // Match by prefix name first const byPrefix = WELL_KNOWN_BY_PREFIX[prefixOrUri]; diff --git a/vitest.config.ts b/vitest.config.ts index 6ed1dedd..444f2ae0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -22,7 +22,12 @@ export default defineConfig({ test: { environment: 'jsdom', setupFiles: ['src/test-setup.ts'], - exclude: ['.trunk/**', 'node_modules/**', 'e2e/**'], + exclude: [ + '.trunk/**', + 'node_modules/**', + 'e2e/**', + ...(process.env.NETWORK_TESTS ? [] : ['**/*.network.test.ts']), + ], server: { deps: { inline: ['@reactodia/workspace'],