diff --git a/graph/src/category.rs b/graph/src/category.rs index 6998eb1..a196812 100644 --- a/graph/src/category.rs +++ b/graph/src/category.rs @@ -8,14 +8,14 @@ pub enum Category { impl Category { pub fn to_named_node(&self) -> NamedNodeRef<'_> { match self { - Category::AudioBook => vocab::gl::AUDIO_BOOK, + Category::AudioBook => vocab::glo::AUDIO_BOOK, } } pub fn try_from_named_node<'a>(node: impl Into>) -> Option { let node = node.into(); match node { - vocab::gl::AUDIO_BOOK => Some(Category::AudioBook), + vocab::glo::AUDIO_BOOK => Some(Category::AudioBook), _ => None, } } diff --git a/graph/src/class.rs b/graph/src/class.rs index 34b2ea8..7bbb811 100644 --- a/graph/src/class.rs +++ b/graph/src/class.rs @@ -1,7 +1,8 @@ use std::collections::HashMap; use std::fmt::Display; -use oxigraph::model::{NamedNode, NamedNodeRef}; +use oxigraph::model::{Graph, Literal, NamedNode, NamedNodeRef, NamedOrBlankNode, Term, Triple}; use oxilangtag::LanguageTag; +use url::Url; use gl_search::Schema; use crate::language::LanguageCondition; use crate::vocab; @@ -45,6 +46,14 @@ impl Class { Class::Manifestation, ]; + pub const CREATABLE: &[Class] = &[ + Class::Work, + Class::Person, + Class::CorporateBody, + Class::Expression, + Class::Manifestation, + ]; + pub fn to_named_node(&self) -> NamedNodeRef<'_> { match self { Class::RdfProperty => vocab::rdf::PROPERTY, @@ -105,4 +114,33 @@ impl Class { ], } } + + pub fn next_url(&self) -> Option { + match self { + Class::Work => Some(Url::parse("http://fedora.quill.lan/rest/#Work").unwrap()), + _ => None, + } + } + + pub fn template(&self, subject: NamedNode) -> Graph { + let empty_english_string = Term::Literal(Literal::new_language_tagged_literal_unchecked("", "en")); + let empty_node = Term::NamedNode(NamedNode::new_unchecked("#")); + + match self { + Class::RdfProperty => Graph::new(), + Class::RdfsClass => Graph::new(), + Class::SkosConcept => Graph::new(), + Class::Work => Graph::from_iter([ + Triple::new(NamedOrBlankNode::NamedNode(subject), vocab::rdawd::TITLE_OF_WORK, empty_english_string) + ]), + Class::Person => Graph::from_iter([ + Triple::new(NamedOrBlankNode::NamedNode(subject.clone()), vocab::rdaao::IDENTIFIER_FOR_PERSON, empty_node), + Triple::new(NamedOrBlankNode::NamedNode(subject.clone()), vocab::rdaad::GIVEN_NAME, empty_english_string.clone()), + Triple::new(NamedOrBlankNode::NamedNode(subject), vocab::rdaad::SURNAME, empty_english_string), + ]), + Class::CorporateBody => Graph::new(), + Class::Expression => Graph::new(), + Class::Manifestation => Graph::new(), + } + } } \ No newline at end of file diff --git a/graph/src/vocab.rs b/graph/src/vocab.rs index 6341306..a3b1b11 100644 --- a/graph/src/vocab.rs +++ b/graph/src/vocab.rs @@ -1,7 +1,7 @@ pub use oxigraph::model::vocab::rdf; pub use oxigraph::model::vocab::rdfs; -pub mod gl { +pub mod glo { use oxigraph::model::NamedNodeRef; pub const AUDIO_BOOK: NamedNodeRef = @@ -9,6 +9,9 @@ pub mod gl { pub const READ_ONLY: NamedNodeRef = NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont/readOnly"); + + pub const NEXT_URL: NamedNodeRef = + NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont/nextUrl"); } pub mod skos { @@ -57,6 +60,13 @@ pub mod rdaad { NamedNodeRef::new_unchecked("http://rdaregistry.info/Elements/a/datatype/P50032"); } +pub mod rdaao { + use oxigraph::model::NamedNodeRef; + + pub const IDENTIFIER_FOR_PERSON: NamedNodeRef = + NamedNodeRef::new_unchecked("http://rdaregistry.info/Elements/a/object/P50094"); +} + pub mod rdawd { use oxigraph::model::NamedNodeRef; diff --git a/ontology/graphofliberty.ttl b/ontology/graphofliberty.ttl index dbfa6ed..39547d5 100644 --- a/ontology/graphofliberty.ttl +++ b/ontology/graphofliberty.ttl @@ -95,6 +95,12 @@ xsd:unsignedShort rdf:type rdfs:Datatype ; rdfs:label "derived with"@en . +### https://graphofliberty.org/2026/04/ont/nextUrl +:nextUrl rdf:type owl:ObjectProperty ; + rdfs:comment "The next URL to use when creating a new document."@en ; + rdfs:label "Next URL"@en . + + ################################################################# # Data properties ################################################################# @@ -135,6 +141,11 @@ fedora:Container rdf:type owl:NamedIndividual ; :readOnly "true"^^xsd:boolean . +### http://fedora.info/definitions/v4/repository#RepositoryRoot +fedora:RepositoryRoot rdf:type owl:NamedIndividual ; + :readOnly "true"^^xsd:boolean . + + ### http://fedora.info/definitions/v4/repository#Resource fedora:Resource rdf:type owl:NamedIndividual ; :readOnly "true"^^xsd:boolean . @@ -160,6 +171,11 @@ fedora:hasParent rdf:type owl:NamedIndividual ; :readOnly "true"^^xsd:boolean . +### http://fedora.info/definitions/v4/repository#hasTransactionProvider +fedora:hasTransactionProvider rdf:type owl:NamedIndividual ; + :readOnly "true"^^xsd:boolean . + + ### http://fedora.info/definitions/v4/repository#lastModified fedora:lastModified rdf:type owl:NamedIndividual ; :readOnly "true"^^xsd:boolean . diff --git a/publish/src/app.rs b/publish/src/app.rs index ed102b7..1e1439b 100644 --- a/publish/src/app.rs +++ b/publish/src/app.rs @@ -45,12 +45,12 @@ pub(crate) enum Message { NavigateTo(Url), FetchDocument(Url), LoadDocument(RdfSource>), + ResourceNotFound, ShowNewDocumentButtons, HideNewDocumentButtons, ShowError(String), AddRow(Option<(QuadKey, RowState)>), DeleteRow(QuadKey), - DeleteAllRows, OpenQueryWindow(SearchResultClickAction), HoverRow(QuadKey), UnhoverRow(QuadKey), @@ -68,7 +68,7 @@ pub(crate) enum Message { ShowOverwriteConfirmationModal, HideOverwriteConfirmationModal, ConfirmOverwrite, - NewDocument(NamedNode), + NewDocument(Class, Option), NavigateToPredicate(QuadKey), NavigateToObject(QuadKey), NavigateBack, @@ -123,6 +123,7 @@ pub(crate) struct Publisher { show_overwrite_confirmation: bool, modified: bool, show_new_document_buttons: bool, + resource_not_found: bool, } impl Publisher { @@ -180,6 +181,7 @@ impl Publisher { show_overwrite_confirmation: false, modified: false, show_new_document_buttons: false, + resource_not_found: false, }, task.map(|_| Message::ConnectToOntologyService("http://[::1]:3000".to_string())), ) @@ -259,8 +261,7 @@ impl Publisher { } } Message::NavigateTo(url) => { - task = Task::done(Message::DeleteAllRows) - .chain(Task::done(Message::ResetState)) + task = Task::done(Message::ResetState) .chain(Task::done(Message::FetchDocument(url))); } Message::FetchDocument(url) => { @@ -280,7 +281,7 @@ impl Publisher { }, Err(ldp::Error::Reqwest(err)) => { if err.status() == Some(StatusCode::NOT_FOUND) { - Message::ShowNewDocumentButtons + Message::ResourceNotFound } else { Message::ShowError(err.to_string()) } @@ -289,6 +290,10 @@ impl Publisher { } }); } + Message::ResourceNotFound => { + self.resource_not_found = true; + task = Task::done(Message::ShowNewDocumentButtons); + } Message::LoadDocument(document) => { let nodes_to_look_up = document.dataset().quads.iter().map(|(_, quad)| { let mut nodes = vec![ quad.predicate.clone() ]; @@ -342,10 +347,6 @@ impl Publisher { self.document.dataset_mut().remove(key); self.modified = true; } - Message::DeleteAllRows => { - self.document.dataset_mut().clear(); - self.modified = true; - } Message::OpenQueryWindow(action) => { let selected_entity_class = match action { SearchResultClickAction::Predicate(_) => Some(vocab::rdf::PROPERTY), @@ -538,21 +539,26 @@ impl Publisher { Message::HideNewDocumentButtons => { self.show_new_document_buttons = false; } - Message::NewDocument(class) => { - let url = Url::parse(self.url_input.as_str()).expect("Invalid URL"); - let mut document: RdfSource> = RdfSource::new(url); + Message::NewDocument(class, None) => { + let client = self.http_client.clone(); + task = tasks::navigate_to_next_url(client, class); + } + Message::NewDocument(class, Some(url)) => { + let mut document: RdfSource> = RdfSource::new(url.clone()); let mut quad = document.new_quad(); quad.predicate = vocab::rdf::TYPE.into_owned(); - quad.object = Term::NamedNode(class); + quad.object = Term::NamedNode(class.to_named_node().into_owned()); document.dataset_mut().quads.insert(quad); task = Task::done(Message::HideNewDocumentButtons) + .chain(Task::done(Message::URLInputChanged(url.to_string()))) .chain(Task::done(Message::LoadDocument(document))); } Message::ResetState => { self.modified = false; self.show_new_document_buttons = false; self.show_overwrite_confirmation = false; + self.resource_not_found = false; } Message::NavigateToPredicate(key) => { if let Some(quad) = self.document.dataset_mut().quads.get_mut(key) { @@ -811,72 +817,76 @@ impl Publisher { .into() } - pub(crate) fn view(&self, window: window::Id) -> Element<'_, Message> { - if let Some(search_state) = &self.search_state - && search_state.window_id == window - { - let search_input = text_input("Query", &search_state.query) - .on_input(Message::QueryUpdated) - .id("query"); + fn view_search_window<'a>(&'a self, search_state: &'a SearchState) -> Element<'a, Message> { + let search_input = text_input("Query", &search_state.query) + .on_input(Message::QueryUpdated) + .id("query"); - let type_selector = pick_list( - search_state.entity_class_selection.as_ref(), - Class::ALL, - ToString::to_string, - ).on_select(|selection| Message::UpdateQueryClass(selection)); + let type_selector = pick_list( + search_state.entity_class_selection.as_ref(), + Class::ALL, + ToString::to_string, + ).on_select(|selection| Message::UpdateQueryClass(selection)); - let mut columns = vec![table::column(text("CURIE"), |document: &HashMap| { - let iri = document.get(&Schema::iri_field()) - .and_then(|value| value.as_str()) - .unwrap_or_default(); + let mut columns = vec![table::column(text("CURIE"), |document: &HashMap| { + let iri = document.get(&Schema::iri_field()) + .and_then(|value| value.as_str()) + .unwrap_or_default(); - let abbreviated_iri = document.get(&Schema::curie_field()) - .and_then(|value| value.as_str()) - .unwrap_or_default(); + let abbreviated_iri = document.get(&Schema::curie_field()) + .and_then(|value| value.as_str()) + .unwrap_or_default(); - button(text(abbreviated_iri).wrapping(Wrapping::Word)) - .on_press(Message::SearchResultClicked(NamedNode::new_unchecked(iri))) - .style(button::text) - })]; + button(text(abbreviated_iri).wrapping(Wrapping::Word)) + .on_press(Message::SearchResultClicked(NamedNode::new_unchecked(iri))) + .style(button::text) + })]; - let fields = search_state.entity_class_selection - .as_ref() - .map(|class| { + let fields = search_state.entity_class_selection + .as_ref() + .map(|class| { class.fields(language::ENGLISH_TAG.clone()) }).unwrap_or_default(); - for (field, label) in fields { - columns.push( - table::column(text(label), move |document: &HashMap| { - let iri = document.get(&Schema::iri_field()) - .and_then(|value| value.as_str()) - .unwrap_or_default(); + for (field, label) in fields { + columns.push( + table::column(text(label), move |document: &HashMap| { + let iri = document.get(&Schema::iri_field()) + .and_then(|value| value.as_str()) + .unwrap_or_default(); - let value = document - .get(&field) - .and_then(|value| value.as_str()) - .unwrap_or_default(); + let value = document + .get(&field) + .and_then(|value| value.as_str()) + .unwrap_or_default(); - button(text(value).wrapping(Wrapping::Word)) - .on_press(Message::SearchResultClicked(NamedNode::new_unchecked(iri))) - .style(button::text) - }) + button(text(value).wrapping(Wrapping::Word)) + .on_press(Message::SearchResultClicked(NamedNode::new_unchecked(iri))) + .style(button::text) + }) .width(Length::Fixed(256.0)), - ); - } + ); + } - let results_table = if columns.is_empty() { - None - } else { - Some(scrollable(table(columns, &search_state.results))) - }; + let results_table = if columns.is_empty() { + None + } else { + Some(scrollable(table(columns, &search_state.results))) + }; - return column![row![search_input, type_selector], results_table].into(); + column![row![search_input, type_selector], results_table].into() + } + + pub(crate) fn view(&self, window: window::Id) -> Element<'_, Message> { + if let Some(search_state) = &self.search_state + && search_state.window_id == window { + return self.view_search_window(search_state); } let back_button = button("\u{1f870}").on_press(Message::NavigateBack); let forward_button = button("\u{1f872}").on_press(Message::NavigateForward); - let add_row_button = button("Add row").on_press(Message::AddRow(None)); + let new_document_button = button("New Document").on_press(Message::ShowNewDocumentButtons); + let add_row_button = button("Add Row").on_press(Message::AddRow(None)); let address_input = iri_input(&self.curie_helper, "URL", None, &self.url_input) .on_input(Message::URLInputChanged) @@ -895,25 +905,14 @@ impl Publisher { .collect(); let body: Element = if self.show_new_document_buttons { - let buttons = Class::ALL.iter() + let buttons = Class::CREATABLE.iter() .map(|class| { - let node = class.to_named_node().into_owned(); - let abbreviation = self.curie_helper - .abbreviate(None, node.as_str()) - .unwrap_or(node.to_string()); + let url = if self.resource_not_found { + Url::parse(&self.url_input).ok() + } else { None }; - let description = self.resource_descriptions - .get(&node); - - let button_text = if let Some(description) = description && - let Some(label) = &description.label { - format!("{label} ({abbreviation})") - } else { - abbreviation - }; - - button(text(button_text)) - .on_press(Message::NewDocument(node)) + button(text(class.to_string())) + .on_press(Message::NewDocument(class.clone(), url)) .into() }); @@ -1004,6 +1003,7 @@ impl Publisher { let content = navigation_area(column![ row![ + new_document_button, add_row_button, back_button, forward_button, diff --git a/publish/src/tasks.rs b/publish/src/tasks.rs index 1bf088a..ffdf812 100644 --- a/publish/src/tasks.rs +++ b/publish/src/tasks.rs @@ -1,5 +1,11 @@ use iced::Task; -use oxigraph::model::{Graph, NamedNode}; +use ldp::reqwest_middleware::ClientWithMiddleware; +use ldp::ResourceRequestBuilder; +use oxigraph::io::RdfFormat; +use oxigraph::model::{Dataset, Graph, NamedNode, NamedNodeRef, NamedOrBlankNode, NamedOrBlankNodeRef, TermRef}; +use tracing::debug; +use url::Url; +use gl_graph::class::Class; use gl_graph::ontology::{ConnectedOntology, OntologyBuilder, ResourceSelector}; use gl_graph::vocab; use crate::app::Message; @@ -47,4 +53,34 @@ pub(crate) fn run_inference(mut ontology: ConnectedOntology, graph: Graph) -> Ta Err(err) => Message::ShowError(err.to_string()), } }) +} + +pub(crate) fn navigate_to_next_url(client: ClientWithMiddleware, class: Class) -> Task { + if let Some(lookup_url) = class.next_url() { + Task::future(async move { + let request = ResourceRequestBuilder::with_client_and_url(client, lookup_url.clone()) + .accept_rdf_format(RdfFormat::Turtle) + .follow_described_by(true) + .build(); + + match request.send().await { + Ok(resource) => match resource.into_rdf_source::().await { + Ok(document) => { + let node = NamedNodeRef::new_unchecked(lookup_url.as_str()); + let result = document.dataset() + .quads_for_pattern(Some(NamedOrBlankNodeRef::NamedNode(node)), Some(vocab::glo::NEXT_URL), None, None) + .next(); + if let Some(next_url_quad) = result { + if let TermRef::NamedNode(next_url) = next_url_quad.object { + let url = Url::parse(next_url.as_str()).unwrap(); + Message::NewDocument(class, Some(url)) + } else { Message::None } + } else { Message::None } + } + _ => Message::None + } + _ => Message::None + } + }) + } else { Task::none() } } \ No newline at end of file