use crate::error; use oxigraph::io::{RdfFormat, RdfParser}; use oxigraph::model::vocab::{rdf, rdfs, xsd}; use oxigraph::model::{ Dataset, NamedNode, NamedNodeRef, NamedOrBlankNode, NamedOrBlankNodeRef, Term, TermRef, Triple, TripleRef, }; use oxigraph::sparql::{QueryResults, SparqlEvaluator}; use std::collections::{HashMap, HashSet}; const PREFIXES: &[(&str, &str)] = &[ ("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"), ("rdfs", "http://www.w3.org/2000/01/rdf-schema#"), ("owl", "http://www.w3.org/2002/07/owl#"), ("xsd", "http://www.w3.org/2001/XMLSchema#"), ("ldp", "http://www.w3.org/ns/ldp#"), ("dc", "http://purl.org/dc/elements/1.1/"), ("posix", "http://www.w3.org/ns/posix/stat#"), ( "ebucore", "http://www.ebu.ch/metadata/ontologies/ebucore/ebucore#", ), ("premis", "http://www.loc.gov/premis/rdf/v1#"), ("premis3", "http://www.loc.gov/premis/rdf/v3/"), ("dcterms", "http://purl.org/dc/terms/"), ("fedora", "http://fedora.info/definitions/v4/repository#"), ("rdaa", "http://rdaregistry.info/Elements/a/"), ("rdac", "http://rdaregistry.info/Elements/c/"), ("rdae", "http://rdaregistry.info/Elements/e/"), ("rdai", "http://rdaregistry.info/Elements/i/"), ("rdam", "http://rdaregistry.info/Elements/m/"), ("rdan", "http://rdaregistry.info/Elements/n/"), ("rdap", "http://rdaregistry.info/Elements/p/"), ("rdat", "http://rdaregistry.info/Elements/t/"), ("rdaw", "http://rdaregistry.info/Elements/w/"), ("rdax", "http://rdaregistry.info/Elements/x/"), ("schema", "https://schema.org/"), ("quill", "http://fedora.quill.lan/rest/"), ("gl", "https://graphofliberty.org/2026/04/ont/"), ]; const RDF_ONT: &[u8] = include_bytes!("ontologies/22-rdf-syntax-ns.ttl"); const RDFS_ONT: &[u8] = include_bytes!("ontologies/rdf-schema.ttl"); const LDP_ONT: &[u8] = include_bytes!("ontologies/ldp.ttl"); const FEDORA_ONT: &[u8] = include_bytes!("ontologies/fedora.xml"); const GL_ONT: &[u8] = include_bytes!("ontologies/ontology.ttl"); const GL_READ_ONLY: NamedNodeRef = NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont/ReadOnly"); const GL_TEMPLATE: NamedNodeRef = NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont/template"); const GL_INDEXED_BY_FIELD: NamedNodeRef = NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont/indexedByField"); const GL_CATALOG_ID: NamedNodeRef = NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont/catalogId"); const OWL_SAME_AS: NamedNodeRef = NamedNodeRef::new_unchecked("http://www.w3.org/2002/07/owl#sameAs"); pub const RDA_ENTITY: NamedNodeRef = NamedNodeRef::new_unchecked("http://rdaregistry.info/Elements/c/C10013"); pub const GL_ENTITY: NamedNodeRef = NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont/Entity"); pub struct OntologyBuilder<'a> { ontologies: Vec<(RdfFormat, &'a [u8])>, } impl<'a> OntologyBuilder<'a> { pub fn with_ontology_bytes(mut self, format: RdfFormat, bytes: &'a [u8]) -> Self { self.ontologies.push((format, bytes)); self } pub fn with_default_ontologies(self) -> Self { self.with_ontology_bytes(RdfFormat::Turtle, RDF_ONT) .with_ontology_bytes(RdfFormat::Turtle, RDFS_ONT) .with_ontology_bytes(RdfFormat::Turtle, LDP_ONT) .with_ontology_bytes(RdfFormat::RdfXml, FEDORA_ONT) .with_ontology_bytes(RdfFormat::Turtle, GL_ONT) } fn materialize_same_as(dataset: &mut Dataset) { let additional_quads = dataset .quads_for_pattern(None, Some(OWL_SAME_AS), None, None) .fold(Dataset::new(), |mut new_dataset, alias| { if let NamedOrBlankNodeRef::NamedNode(x) = alias.subject && let TermRef::NamedNode(y) = alias.object { for mut quad in dataset.quads_for_pattern( Some(NamedOrBlankNodeRef::NamedNode(x)), None, None, None, ) { quad.subject = NamedOrBlankNodeRef::NamedNode(y); new_dataset.insert(quad); } for mut quad in dataset.quads_for_pattern( Some(NamedOrBlankNodeRef::NamedNode(y)), None, None, None, ) { quad.subject = NamedOrBlankNodeRef::NamedNode(x); new_dataset.insert(quad); } } new_dataset }); let old_size = dataset.len(); dataset.extend(&additional_quads); let new_size = dataset.len(); if new_size > old_size { Self::materialize_same_as(dataset) } } fn memoize(ontology: &mut Ontology) { // Read-only properties for quad in ontology.dataset.quads_for_pattern( None, Some(rdf::TYPE), Some(TermRef::NamedNode(GL_READ_ONLY)), None, ) { if let NamedOrBlankNodeRef::NamedNode(subject) = quad.subject { ontology.read_only_properties.insert(subject.into_owned()); } } // Read-only classes for quad in ontology.dataset.quads_for_pattern( None, Some(rdfs::SUB_CLASS_OF), Some(TermRef::NamedNode(GL_READ_ONLY)), None, ) { if let NamedOrBlankNodeRef::NamedNode(subject) = quad.subject { ontology.read_only_classes.insert(subject.into_owned()); } } // Full-text search index field names for quad in ontology .dataset .quads_for_pattern(None, Some(GL_INDEXED_BY_FIELD), None, None) { if let NamedOrBlankNodeRef::NamedNode(subject) = quad.subject && let TermRef::Literal(literal) = quad.object { ontology .field_map .insert(subject.into_owned(), literal.value().to_string()); } } // Catalog IDs (used to quickly filter full-text search results) for quad in ontology .dataset .quads_for_pattern(None, Some(GL_CATALOG_ID), None, None) { if let NamedOrBlankNodeRef::NamedNode(subject) = quad.subject && let TermRef::Literal(literal) = quad.object { if literal.datatype() == xsd::POSITIVE_INTEGER { let value: u64 = literal.value().parse().expect("Failed to parse catalog ID from ontology. It ought to be a positive integer."); ontology.catalog_ids.insert(subject.into_owned(), value); } } } } pub fn build(&mut self) -> error::Result { let prefixes = PREFIXES .iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect::>(); let mut dataset = Dataset::new(); for (format, bytes) in &self.ontologies { let quads = RdfParser::from_format(*format) .for_slice(bytes) .filter_map(Result::ok); dataset.extend(quads); } Self::materialize_same_as(&mut dataset); let mut ontology = Ontology { dataset, prefixes, read_only_properties: HashSet::new(), read_only_classes: HashSet::new(), field_map: HashMap::new(), catalog_ids: HashMap::new(), }; Self::memoize(&mut ontology); Ok(ontology) } } pub struct Ontology { dataset: Dataset, prefixes: HashMap, read_only_properties: HashSet, read_only_classes: HashSet, field_map: HashMap, catalog_ids: HashMap, } fn term_to_string(term: &Term) -> Option { match term { Term::Literal(literal) => Some(literal.value().to_string()), _ => None, } } impl Ontology { pub fn builder<'a>() -> OntologyBuilder<'a> { OntologyBuilder { ontologies: Vec::new(), } } pub fn label<'a>(&'a self, node: NamedNodeRef<'a>) -> Option<&'a str> { let quads = self.dataset.quads_for_pattern( Some(NamedOrBlankNodeRef::NamedNode(node)), Some(rdfs::LABEL), None, None, ); for quad in quads { if let TermRef::Literal(literal) = quad.object { match literal.language() { None | Some("en") => return Some(literal.value()), _ => {} } } } None } pub fn abbreviate(&self, node: NamedNodeRef<'_>) -> String { for (prefix_name, prefix_iri) in &self.prefixes { if let Some(local_name) = node.as_str().strip_prefix(prefix_iri) { return if local_name.is_empty() { format!("{prefix_name}:") } else { format!("{prefix_name}:{local_name}") }; } } node.as_str().to_string() } pub fn expand(&self, prefixed_iri: &str) -> Option { let (prefix, name) = prefixed_iri.split_once(':')?; self.prefixes .get(prefix) .map(|base| NamedNode::new_unchecked(format!("{base}{name}"))) } pub fn field_name(&self, property: &NamedNode) -> Option<&String> { self.field_map.get(property) } pub fn catalog_id(&self, class: &NamedNode) -> Option { self.catalog_ids.get(class).copied() } pub fn for_each_annotated_iri(&self, f: F) where F: Fn(&str, Option<&str>, Option<&str>), { let query = SparqlEvaluator::new() .parse_query( r"PREFIX rdfs: SELECT ?subject ?label ?comment WHERE { OPTIONAL { ?subject rdfs:label ?label . FILTER (LANG(?label) = 'en' || LANG(?label) = '') } OPTIONAL { ?subject rdfs:comment ?comment . FILTER (LANG(?comment) = 'en' || LANG(?comment) = '') } }", ) .expect("Unable to parse annotation query"); if let QueryResults::Solutions(solutions) = query.on_queryable_dataset(&self.dataset).execute().unwrap() { for solution in solutions.filter_map(Result::ok) { let label = solution.get("label").and_then(term_to_string); let comment = solution.get("comment").and_then(term_to_string); if let Some(Term::NamedNode(subject)) = solution.get("subject") { f(subject.as_str(), label.as_deref(), comment.as_deref()); } } } } pub fn datatypes(&self) -> impl Iterator> { self.dataset .quads_for_pattern( None, Some(rdf::TYPE), Some(TermRef::NamedNode(rdfs::DATATYPE)), None, ) .filter_map(|quad| match quad.subject { NamedOrBlankNodeRef::NamedNode(subject) => Some(subject), _ => None, }) } pub fn is_read_only<'a>(&self, triple: impl Into>) -> bool { let triple = triple.into(); let read_only_property = self .read_only_properties .contains(&triple.predicate.into_owned()); let read_only_class = match (triple.predicate, triple.object) { (rdf::TYPE, TermRef::NamedNode(node)) => { self.read_only_classes.contains(&node.into_owned()) } _ => false, }; read_only_property || read_only_class } pub fn exclude_read_only_predicate(&self) -> impl Fn(TripleRef<'_>) -> bool + 'static { let classes = self.read_only_classes.clone(); let properties = self.read_only_properties.clone(); move |triple| { let read_only_property = properties.contains(&triple.predicate.into_owned()); let read_only_class = match (triple.predicate, triple.object) { (rdf::TYPE, TermRef::NamedNode(node)) => classes.contains(&node.into_owned()), _ => false, }; !(read_only_property || read_only_class) } } pub fn subclass_of(&self, class: NamedNodeRef<'_>) -> impl Iterator { let query = SparqlEvaluator::new() .parse_query( format!( "PREFIX rdfs: SELECT ?class {{ ?class (rdfs:subClassOf|^rdfs:subClassOf)* {class} . }}" ) .as_str(), ) .expect("Unable to parse subclass_of query"); if let QueryResults::Solutions(solutions) = query.on_queryable_dataset(&self.dataset).execute().unwrap() { solutions.filter_map(Result::ok).filter_map(|solution| { if let Some(Term::NamedNode(class)) = solution.get("class") { Some(class.clone()) } else { None } }) } else { unreachable!() } } pub fn template_triples<'a>( &'a self, class: NamedNodeRef<'a>, subject: NamedNodeRef<'_>, ) -> Option> { if let Some(quad) = self .dataset .quads_for_pattern( Some(NamedOrBlankNodeRef::NamedNode(class)), Some(GL_TEMPLATE), None, None, ) .next() { if let TermRef::BlankNode(blank_node) = quad.object { let iter = self .dataset .quads_for_subject(blank_node) .map(|quad| quad.into_owned()) .map(Triple::from) .map(move |mut triple| { triple.subject = NamedOrBlankNode::NamedNode(subject.into_owned()); triple }); Some(iter) } else { None } } else { None } } }