This commit is contained in:
2026-08-26 19:44:18 -04:00
parent 35477a3658
commit 948b415d5a
6 changed files with 184 additions and 84 deletions
+2 -2
View File
@@ -8,14 +8,14 @@ pub enum Category {
impl Category { impl Category {
pub fn to_named_node(&self) -> NamedNodeRef<'_> { pub fn to_named_node(&self) -> NamedNodeRef<'_> {
match self { match self {
Category::AudioBook => vocab::gl::AUDIO_BOOK, Category::AudioBook => vocab::glo::AUDIO_BOOK,
} }
} }
pub fn try_from_named_node<'a>(node: impl Into<NamedNodeRef<'a>>) -> Option<Self> { pub fn try_from_named_node<'a>(node: impl Into<NamedNodeRef<'a>>) -> Option<Self> {
let node = node.into(); let node = node.into();
match node { match node {
vocab::gl::AUDIO_BOOK => Some(Category::AudioBook), vocab::glo::AUDIO_BOOK => Some(Category::AudioBook),
_ => None, _ => None,
} }
} }
+39 -1
View File
@@ -1,7 +1,8 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt::Display; use std::fmt::Display;
use oxigraph::model::{NamedNode, NamedNodeRef}; use oxigraph::model::{Graph, Literal, NamedNode, NamedNodeRef, NamedOrBlankNode, Term, Triple};
use oxilangtag::LanguageTag; use oxilangtag::LanguageTag;
use url::Url;
use gl_search::Schema; use gl_search::Schema;
use crate::language::LanguageCondition; use crate::language::LanguageCondition;
use crate::vocab; use crate::vocab;
@@ -45,6 +46,14 @@ impl Class {
Class::Manifestation, Class::Manifestation,
]; ];
pub const CREATABLE: &[Class] = &[
Class::Work,
Class::Person,
Class::CorporateBody,
Class::Expression,
Class::Manifestation,
];
pub fn to_named_node(&self) -> NamedNodeRef<'_> { pub fn to_named_node(&self) -> NamedNodeRef<'_> {
match self { match self {
Class::RdfProperty => vocab::rdf::PROPERTY, Class::RdfProperty => vocab::rdf::PROPERTY,
@@ -105,4 +114,33 @@ impl Class {
], ],
} }
} }
pub fn next_url(&self) -> Option<Url> {
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(),
}
}
} }
+11 -1
View File
@@ -1,7 +1,7 @@
pub use oxigraph::model::vocab::rdf; pub use oxigraph::model::vocab::rdf;
pub use oxigraph::model::vocab::rdfs; pub use oxigraph::model::vocab::rdfs;
pub mod gl { pub mod glo {
use oxigraph::model::NamedNodeRef; use oxigraph::model::NamedNodeRef;
pub const AUDIO_BOOK: NamedNodeRef = pub const AUDIO_BOOK: NamedNodeRef =
@@ -9,6 +9,9 @@ pub mod gl {
pub const READ_ONLY: NamedNodeRef = pub const READ_ONLY: NamedNodeRef =
NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont/readOnly"); 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 { pub mod skos {
@@ -57,6 +60,13 @@ pub mod rdaad {
NamedNodeRef::new_unchecked("http://rdaregistry.info/Elements/a/datatype/P50032"); 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 { pub mod rdawd {
use oxigraph::model::NamedNodeRef; use oxigraph::model::NamedNodeRef;
+16
View File
@@ -95,6 +95,12 @@ xsd:unsignedShort rdf:type rdfs:Datatype ;
rdfs:label "derived with"@en . 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 # Data properties
################################################################# #################################################################
@@ -135,6 +141,11 @@ fedora:Container rdf:type owl:NamedIndividual ;
:readOnly "true"^^xsd:boolean . :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 ### http://fedora.info/definitions/v4/repository#Resource
fedora:Resource rdf:type owl:NamedIndividual ; fedora:Resource rdf:type owl:NamedIndividual ;
:readOnly "true"^^xsd:boolean . :readOnly "true"^^xsd:boolean .
@@ -160,6 +171,11 @@ fedora:hasParent rdf:type owl:NamedIndividual ;
:readOnly "true"^^xsd:boolean . :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 ### http://fedora.info/definitions/v4/repository#lastModified
fedora:lastModified rdf:type owl:NamedIndividual ; fedora:lastModified rdf:type owl:NamedIndividual ;
:readOnly "true"^^xsd:boolean . :readOnly "true"^^xsd:boolean .
+79 -79
View File
@@ -45,12 +45,12 @@ pub(crate) enum Message {
NavigateTo(Url), NavigateTo(Url),
FetchDocument(Url), FetchDocument(Url),
LoadDocument(RdfSource<KeyedDataset<RowState>>), LoadDocument(RdfSource<KeyedDataset<RowState>>),
ResourceNotFound,
ShowNewDocumentButtons, ShowNewDocumentButtons,
HideNewDocumentButtons, HideNewDocumentButtons,
ShowError(String), ShowError(String),
AddRow(Option<(QuadKey, RowState)>), AddRow(Option<(QuadKey, RowState)>),
DeleteRow(QuadKey), DeleteRow(QuadKey),
DeleteAllRows,
OpenQueryWindow(SearchResultClickAction), OpenQueryWindow(SearchResultClickAction),
HoverRow(QuadKey), HoverRow(QuadKey),
UnhoverRow(QuadKey), UnhoverRow(QuadKey),
@@ -68,7 +68,7 @@ pub(crate) enum Message {
ShowOverwriteConfirmationModal, ShowOverwriteConfirmationModal,
HideOverwriteConfirmationModal, HideOverwriteConfirmationModal,
ConfirmOverwrite, ConfirmOverwrite,
NewDocument(NamedNode), NewDocument(Class, Option<Url>),
NavigateToPredicate(QuadKey), NavigateToPredicate(QuadKey),
NavigateToObject(QuadKey), NavigateToObject(QuadKey),
NavigateBack, NavigateBack,
@@ -123,6 +123,7 @@ pub(crate) struct Publisher {
show_overwrite_confirmation: bool, show_overwrite_confirmation: bool,
modified: bool, modified: bool,
show_new_document_buttons: bool, show_new_document_buttons: bool,
resource_not_found: bool,
} }
impl Publisher { impl Publisher {
@@ -180,6 +181,7 @@ impl Publisher {
show_overwrite_confirmation: false, show_overwrite_confirmation: false,
modified: false, modified: false,
show_new_document_buttons: false, show_new_document_buttons: false,
resource_not_found: false,
}, },
task.map(|_| Message::ConnectToOntologyService("http://[::1]:3000".to_string())), task.map(|_| Message::ConnectToOntologyService("http://[::1]:3000".to_string())),
) )
@@ -259,8 +261,7 @@ impl Publisher {
} }
} }
Message::NavigateTo(url) => { Message::NavigateTo(url) => {
task = Task::done(Message::DeleteAllRows) task = Task::done(Message::ResetState)
.chain(Task::done(Message::ResetState))
.chain(Task::done(Message::FetchDocument(url))); .chain(Task::done(Message::FetchDocument(url)));
} }
Message::FetchDocument(url) => { Message::FetchDocument(url) => {
@@ -280,7 +281,7 @@ impl Publisher {
}, },
Err(ldp::Error::Reqwest(err)) => { Err(ldp::Error::Reqwest(err)) => {
if err.status() == Some(StatusCode::NOT_FOUND) { if err.status() == Some(StatusCode::NOT_FOUND) {
Message::ShowNewDocumentButtons Message::ResourceNotFound
} else { } else {
Message::ShowError(err.to_string()) 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) => { Message::LoadDocument(document) => {
let nodes_to_look_up = document.dataset().quads.iter().map(|(_, quad)| { let nodes_to_look_up = document.dataset().quads.iter().map(|(_, quad)| {
let mut nodes = vec![ quad.predicate.clone() ]; let mut nodes = vec![ quad.predicate.clone() ];
@@ -342,10 +347,6 @@ impl Publisher {
self.document.dataset_mut().remove(key); self.document.dataset_mut().remove(key);
self.modified = true; self.modified = true;
} }
Message::DeleteAllRows => {
self.document.dataset_mut().clear();
self.modified = true;
}
Message::OpenQueryWindow(action) => { Message::OpenQueryWindow(action) => {
let selected_entity_class = match action { let selected_entity_class = match action {
SearchResultClickAction::Predicate(_) => Some(vocab::rdf::PROPERTY), SearchResultClickAction::Predicate(_) => Some(vocab::rdf::PROPERTY),
@@ -538,21 +539,26 @@ impl Publisher {
Message::HideNewDocumentButtons => { Message::HideNewDocumentButtons => {
self.show_new_document_buttons = false; self.show_new_document_buttons = false;
} }
Message::NewDocument(class) => { Message::NewDocument(class, None) => {
let url = Url::parse(self.url_input.as_str()).expect("Invalid URL"); let client = self.http_client.clone();
let mut document: RdfSource<KeyedDataset<RowState>> = RdfSource::new(url); task = tasks::navigate_to_next_url(client, class);
}
Message::NewDocument(class, Some(url)) => {
let mut document: RdfSource<KeyedDataset<RowState>> = RdfSource::new(url.clone());
let mut quad = document.new_quad(); let mut quad = document.new_quad();
quad.predicate = vocab::rdf::TYPE.into_owned(); 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); document.dataset_mut().quads.insert(quad);
task = Task::done(Message::HideNewDocumentButtons) task = Task::done(Message::HideNewDocumentButtons)
.chain(Task::done(Message::URLInputChanged(url.to_string())))
.chain(Task::done(Message::LoadDocument(document))); .chain(Task::done(Message::LoadDocument(document)));
} }
Message::ResetState => { Message::ResetState => {
self.modified = false; self.modified = false;
self.show_new_document_buttons = false; self.show_new_document_buttons = false;
self.show_overwrite_confirmation = false; self.show_overwrite_confirmation = false;
self.resource_not_found = false;
} }
Message::NavigateToPredicate(key) => { Message::NavigateToPredicate(key) => {
if let Some(quad) = self.document.dataset_mut().quads.get_mut(key) { if let Some(quad) = self.document.dataset_mut().quads.get_mut(key) {
@@ -811,72 +817,76 @@ impl Publisher {
.into() .into()
} }
pub(crate) fn view(&self, window: window::Id) -> Element<'_, Message> { fn view_search_window<'a>(&'a self, search_state: &'a SearchState) -> Element<'a, Message> {
if let Some(search_state) = &self.search_state let search_input = text_input("Query", &search_state.query)
&& search_state.window_id == window .on_input(Message::QueryUpdated)
{ .id("query");
let search_input = text_input("Query", &search_state.query)
.on_input(Message::QueryUpdated)
.id("query");
let type_selector = pick_list( let type_selector = pick_list(
search_state.entity_class_selection.as_ref(), search_state.entity_class_selection.as_ref(),
Class::ALL, Class::ALL,
ToString::to_string, ToString::to_string,
).on_select(|selection| Message::UpdateQueryClass(selection)); ).on_select(|selection| Message::UpdateQueryClass(selection));
let mut columns = vec![table::column(text("CURIE"), |document: &HashMap<gl_search::Field, gl_search::OwnedValue>| { let mut columns = vec![table::column(text("CURIE"), |document: &HashMap<gl_search::Field, gl_search::OwnedValue>| {
let iri = document.get(&Schema::iri_field()) let iri = document.get(&Schema::iri_field())
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
.unwrap_or_default(); .unwrap_or_default();
let abbreviated_iri = document.get(&Schema::curie_field()) let abbreviated_iri = document.get(&Schema::curie_field())
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
.unwrap_or_default(); .unwrap_or_default();
button(text(abbreviated_iri).wrapping(Wrapping::Word)) button(text(abbreviated_iri).wrapping(Wrapping::Word))
.on_press(Message::SearchResultClicked(NamedNode::new_unchecked(iri))) .on_press(Message::SearchResultClicked(NamedNode::new_unchecked(iri)))
.style(button::text) .style(button::text)
})]; })];
let fields = search_state.entity_class_selection let fields = search_state.entity_class_selection
.as_ref() .as_ref()
.map(|class| { .map(|class| {
class.fields(language::ENGLISH_TAG.clone()) class.fields(language::ENGLISH_TAG.clone())
}).unwrap_or_default(); }).unwrap_or_default();
for (field, label) in fields { for (field, label) in fields {
columns.push( columns.push(
table::column(text(label), move |document: &HashMap<gl_search::Field, gl_search::OwnedValue>| { table::column(text(label), move |document: &HashMap<gl_search::Field, gl_search::OwnedValue>| {
let iri = document.get(&Schema::iri_field()) let iri = document.get(&Schema::iri_field())
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
.unwrap_or_default(); .unwrap_or_default();
let value = document let value = document
.get(&field) .get(&field)
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
.unwrap_or_default(); .unwrap_or_default();
button(text(value).wrapping(Wrapping::Word)) button(text(value).wrapping(Wrapping::Word))
.on_press(Message::SearchResultClicked(NamedNode::new_unchecked(iri))) .on_press(Message::SearchResultClicked(NamedNode::new_unchecked(iri)))
.style(button::text) .style(button::text)
}) })
.width(Length::Fixed(256.0)), .width(Length::Fixed(256.0)),
); );
} }
let results_table = if columns.is_empty() { let results_table = if columns.is_empty() {
None None
} else { } else {
Some(scrollable(table(columns, &search_state.results))) 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 back_button = button("\u{1f870}").on_press(Message::NavigateBack);
let forward_button = button("\u{1f872}").on_press(Message::NavigateForward); 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) let address_input = iri_input(&self.curie_helper, "URL", None, &self.url_input)
.on_input(Message::URLInputChanged) .on_input(Message::URLInputChanged)
@@ -895,25 +905,14 @@ impl Publisher {
.collect(); .collect();
let body: Element<Message> = if self.show_new_document_buttons { let body: Element<Message> = if self.show_new_document_buttons {
let buttons = Class::ALL.iter() let buttons = Class::CREATABLE.iter()
.map(|class| { .map(|class| {
let node = class.to_named_node().into_owned(); let url = if self.resource_not_found {
let abbreviation = self.curie_helper Url::parse(&self.url_input).ok()
.abbreviate(None, node.as_str()) } else { None };
.unwrap_or(node.to_string());
let description = self.resource_descriptions button(text(class.to_string()))
.get(&node); .on_press(Message::NewDocument(class.clone(), url))
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))
.into() .into()
}); });
@@ -1004,6 +1003,7 @@ impl Publisher {
let content = navigation_area(column![ let content = navigation_area(column![
row![ row![
new_document_button,
add_row_button, add_row_button,
back_button, back_button,
forward_button, forward_button,
+37 -1
View File
@@ -1,5 +1,11 @@
use iced::Task; 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::ontology::{ConnectedOntology, OntologyBuilder, ResourceSelector};
use gl_graph::vocab; use gl_graph::vocab;
use crate::app::Message; use crate::app::Message;
@@ -48,3 +54,33 @@ pub(crate) fn run_inference(mut ontology: ConnectedOntology, graph: Graph) -> Ta
} }
}) })
} }
pub(crate) fn navigate_to_next_url(client: ClientWithMiddleware, class: Class) -> Task<Message> {
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::<Dataset>().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() }
}