diff --git a/frontend/src/lib/ontology/ontology-loader.ts b/frontend/src/lib/ontology/ontology-loader.ts
index 30cc12ad5a..eb44db458f 100644
--- a/frontend/src/lib/ontology/ontology-loader.ts
+++ b/frontend/src/lib/ontology/ontology-loader.ts
@@ -623,8 +623,9 @@ function parseTurtleOntology(content: string): ParsedOntology {
let blankNodeDepth = 0; // Track depth of blank node blocks to skip
let inMultiLineString = false; // Track if we're inside a multi-line triple-quoted string
let multiLineQuoteChar = ''; // The quote character(s) for the multi-line string
+ let baseUri: string | null = null; // @base directive for relative URI resolution
- // First pass: extract prefixes
+ // First pass: extract prefixes and @base
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('@prefix') || trimmed.startsWith('PREFIX')) {
@@ -632,9 +633,58 @@ function parseTurtleOntology(content: string): ParsedOntology {
if (match) {
prefixes[match[1] || ''] = match[2];
}
+ } else if (trimmed.startsWith('@base') || trimmed.startsWith('BASE')) {
+ // Extract @base directive: @base or BASE
+ const match = trimmed.match(/@?base\s+<([^>]+)>/i);
+ if (match) {
+ baseUri = match[1];
+ }
}
}
+ // Helper to expand URIs with base URI support for relative URIs
+ const expand = (uri: string): string => {
+ if (!uri) return uri;
+
+ // Remove angle brackets if present
+ let cleaned = uri.trim().replace(/^, '').replace(/>$/, '');
+
+ // Handle empty relative URI <> - refers to the base URI itself
+ if (cleaned === '' && baseUri) {
+ return baseUri;
+ }
+
+ // Handle relative URIs starting with # (like #Address)
+ // These resolve against the base URI
+ if (cleaned.startsWith('#') && baseUri) {
+ return baseUri + cleaned;
+ }
+
+ // Check if it's already a full URI
+ if (cleaned.startsWith('http://') || cleaned.startsWith('https://')) {
+ return cleaned;
+ }
+
+ // Handle prefixed URIs (like vcard:Address, owl:Class)
+ const colonIndex = cleaned.indexOf(':');
+ if (colonIndex > 0) {
+ const prefix = cleaned.substring(0, colonIndex);
+ const localName = cleaned.substring(colonIndex + 1);
+
+ if (prefixes[prefix]) {
+ return prefixes[prefix] + localName;
+ }
+ }
+
+ // Handle unprefixed URIs with default prefix (empty prefix ":")
+ // e.g., ":Address" with PREFIX :
+ if (cleaned.startsWith(':') && prefixes['']) {
+ return prefixes[''] + cleaned.substring(1);
+ }
+
+ return cleaned;
+ };
+
// Second pass: parse triples
let lineNum = 0;
for (const line of lines) {
@@ -734,16 +784,16 @@ function parseTurtleOntology(content: string): ParsedOntology {
if (!trimmed.startsWith(';') && !trimmed.startsWith(',') && !isIndentedLine) {
// Process previous subject if exists
if (currentSubject && currentTriples.length > 0) {
- processSubject(currentSubject, currentTriples, prefixes, classes, properties, individuals);
+ processSubject(currentSubject, currentTriples, prefixes, classes, properties, individuals, baseUri);
}
// Start new subject
const parts = splitTurtleLine(trimmed, prefixes);
if (parts.length >= 3) {
// Full triple on one line: subject predicate object(s)
- currentSubject = expandUri(parts[0], prefixes);
+ currentSubject = expand(parts[0]);
// Handle 'a' shorthand for rdf:type
- const predicate = parts[1] === 'a' ? NAMESPACES.rdf + 'type' : expandUri(parts[1], prefixes);
+ const predicate = parts[1] === 'a' ? NAMESPACES.rdf + 'type' : expand(parts[1]);
lastPredicate = predicate; // Track for comma continuations
// Handle comma-separated values (e.g., "Subject a Class1, Class2")
currentTriples = [];
@@ -752,7 +802,7 @@ function parseTurtleOntology(content: string): ParsedOntology {
}
} else if (parts.length === 1 && !trimmed.endsWith('.')) {
// Subject alone on a line (DCAT3 style): dcat:Catalog
- currentSubject = expandUri(parts[0], prefixes);
+ currentSubject = expand(parts[0]);
currentTriples = [];
lastPredicate = null;
}
@@ -761,7 +811,7 @@ function parseTurtleOntology(content: string): ParsedOntology {
const parts = splitTurtleLine(trimmed.substring(1).trim(), prefixes);
if (parts.length >= 2) {
// Handle 'a' shorthand for rdf:type
- const predicate = parts[0] === 'a' ? NAMESPACES.rdf + 'type' : expandUri(parts[0], prefixes);
+ const predicate = parts[0] === 'a' ? NAMESPACES.rdf + 'type' : expand(parts[0]);
lastPredicate = predicate; // Track for comma continuations
// Handle comma-separated values
for (let i = 1; i < parts.length; i++) {
@@ -774,7 +824,7 @@ function parseTurtleOntology(content: string): ParsedOntology {
const parts = splitTurtleLine(trimmed, prefixes);
if (parts.length >= 2) {
// Handle 'a' shorthand for rdf:type
- const predicate = parts[0] === 'a' ? NAMESPACES.rdf + 'type' : expandUri(parts[0], prefixes);
+ const predicate = parts[0] === 'a' ? NAMESPACES.rdf + 'type' : expand(parts[0]);
lastPredicate = predicate; // Track for comma continuations
// Handle comma-separated values (e.g., "a rdfs:Class, owl:Class")
// Each part after predicate is a separate object
@@ -894,7 +944,8 @@ function processSubject(
prefixes: Record,
classes: Map,
properties: Map,
- individuals: Map
+ individuals: Map,
+ baseUri?: string | null
): void {
const types: string[] = [];
const labels: LangString[] = [];
@@ -915,7 +966,7 @@ function processSubject(
for (const triple of triples) {
const { predicate, object } = triple;
- const expandedObject = expandUri(object, prefixes);
+ const expandedObject = expandUri(object, prefixes, baseUri);
if (predicate === NAMESPACES.rdf + 'type') {
types.push(expandedObject);
@@ -1506,19 +1557,33 @@ function parseJsonLdOntology(content: string): ParsedOntology {
/**
* Expand prefixed URI to full URI
+ * @param uri - The URI to expand (can be prefixed like "owl:Class", relative like "#Address", or full)
+ * @param prefixes - Map of prefix to namespace
+ * @param baseUri - Optional base URI for resolving relative URIs (from @base directive)
*/
-export function expandUri(uri: string, prefixes: Record): string {
+export function expandUri(uri: string, prefixes: Record, baseUri?: string | null): string {
if (!uri) return uri;
// Remove angle brackets if present
let cleaned = uri.trim().replace(/^, '').replace(/>$/, '');
+ // Handle empty relative URI <> - refers to the base URI itself
+ if (cleaned === '' && baseUri) {
+ return baseUri;
+ }
+
+ // Handle relative URIs starting with # (like #Address)
+ // These resolve against the base URI
+ if (cleaned.startsWith('#') && baseUri) {
+ return baseUri + cleaned;
+ }
+
// Check if it's already a full URI
if (cleaned.startsWith('http://') || cleaned.startsWith('https://')) {
return cleaned;
}
- // Handle prefixed URIs
+ // Handle prefixed URIs (like vcard:Address, owl:Class)
const colonIndex = cleaned.indexOf(':');
if (colonIndex > 0) {
const prefix = cleaned.substring(0, colonIndex);
@@ -1529,6 +1594,12 @@ export function expandUri(uri: string, prefixes: Record): string
}
}
+ // Handle unprefixed URIs with default prefix (empty prefix ":")
+ // e.g., ":Address" with PREFIX :
+ if (cleaned.startsWith(':') && prefixes['']) {
+ return prefixes[''] + cleaned.substring(1);
+ }
+
return cleaned;
}