use crate::rdf::ontology::Ontology; use crate::rdf::term_helper::{TermHelper, TermHelperMut}; use crate::rdf::vocab::{gl, rda}; use gl_search::{Schema, SearchDocument, SearchIndex, doc}; use http::StatusCode; use iced::alignment::Horizontal; use iced::widget::button::Style; use iced::widget::grid::Sizing; use iced::widget::{ button, center, column, combo_box, container, grid, mouse_area, opaque, row, scrollable, space, stack, table, text, text_input, toggler, }; use iced::window::Settings; use iced::{Background, Color, Element, Length, Subscription, Task, color, window}; use ldp::middleware::BasicAuthMiddleware; use ldp::model::{KeyedDataset, QuadKey}; use ldp::reqwest::{Client, Url}; use ldp::reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; use ldp::traverse::Traverse; use ldp::{RdfSource, RdfSourceUpdateResponse, ResourceRequestBuilder, SerializationOptions}; use oxigraph::io::RdfFormat; use oxigraph::model::vocab::rdf; use oxigraph::model::{BaseDirection, Dataset, NamedNode, Quad, Term, TermRef}; use tracing::{debug, error}; const ANNOTATED_IRI_TYPE: u64 = 0; #[derive(Debug, Clone)] pub(crate) enum Message { None, Traverse, IndexRdfSource(RdfSource), CommitIndex, WindowClosed(window::Id), URLInputChanged(String), URLInputSubmitted, FetchDocument(Url), LoadDocument(RdfSource>), ShowNewDocumentButtons, HideNewDocumentButtons, ShowError(String), AddRow(Option), DeleteRow(QuadKey), DeleteAllRows, OpenQueryWindow(SearchResultClickAction), HoverRow(QuadKey), UnhoverRow(QuadKey), QueryUpdated(String), SearchResultClicked(NamedNode), DatatypeUpdated(QuadKey, Option), LanguageUpdated(QuadKey, Option), ValueUpdated(QuadKey, String), DirectionToggled(QuadKey, BaseDirection), SaveGraph(bool), ShowOverwriteConfirmationModal, HideOverwriteConfirmationModal, ConfirmOverwrite, NewDocument(NamedNode), ResetState, } #[derive(Default, Debug, Clone)] pub(crate) struct RowState { read_only: bool, datatype_state: combo_box::State, } #[derive(Debug, Clone)] pub(crate) enum SearchResultClickAction { URLInput, Predicate(QuadKey), Object(QuadKey), } struct SearchState { window_id: window::Id, action: SearchResultClickAction, query: String, results: Vec, } pub(crate) struct Publisher { http_client: ClientWithMiddleware, ontology: Ontology, abbreviated_datatypes: Vec, window_id: window::Id, url_input: String, document: RdfSource>, hovered_row: Option, search_state: Option, index: SearchIndex, show_overwrite_confirmation: bool, modified: bool, show_new_document_buttons: bool, } impl Publisher { pub(crate) fn new() -> (Self, Task) { let ontology = Ontology::builder() .with_default_ontologies() .build() .expect("Failed to build ontology"); let mut index = SearchIndex::builder() .with_path("/home/alex/Documents/Index") .build() .expect("Failed to build search index"); index.remove_all_of_type(ANNOTATED_IRI_TYPE); ontology.for_each_annotated_iri(|iri, label, comment| { let mut document = doc!( Schema::type_field() => ANNOTATED_IRI_TYPE, Schema::iri_field() => iri, ); if let Some(label) = label { document.add_text(Schema::field("label"), label.to_lowercase()); } if let Some(comment) = comment { document.add_text(Schema::field("comment"), comment.to_lowercase()); } index .add(document) .expect("Unable to add annotated IRI to search index"); }); index.commit().expect("Unable to commit ontology to index"); let mut abbreviated_datatypes = ontology .datatypes() .map(|node| ontology.abbreviate(node)) .collect::>(); abbreviated_datatypes.sort(); let (id, task) = window::open(Settings::default()); let client = Client::new(); let http_client = ClientBuilder::new(client.clone()) .with(BasicAuthMiddleware::new( "fedoraAdmin".to_string(), Some("fedoraAdmin".to_string()), )) .build(); let url_input = "http://fedora.quill.lan/rest/".to_string(); let document = RdfSource::new(Url::parse(&url_input).unwrap()); ( Self { http_client, ontology, abbreviated_datatypes, window_id: id, url_input, document, hovered_row: None, search_state: None, index, show_overwrite_confirmation: false, modified: false, show_new_document_buttons: false, }, task.map(|_| Message::None), ) } pub(crate) fn update(&mut self, message: Message) -> Task { let mut task = Task::none(); debug!(?message); match message { Message::Traverse => { let client = self.http_client.clone(); let root = Url::parse(&self.url_input).expect("Invalid URL"); let stream = Traverse::new(client, root, None); task = Task::run(stream, |result| match result { Ok(rdf_source) => Message::IndexRdfSource(rdf_source), Err(err) => Message::ShowError(format!("Unable to fetch RDF Source: {err}")), }) .chain(Task::done(Message::CommitIndex)); } Message::IndexRdfSource(rdf_source) => { let catalog_id = rdf_source .classes() .filter_map(|class| self.ontology.catalog_id(&class.into_owned())) .next(); if let Some(catalog_id) = catalog_id { let mut document = SearchDocument::new(); document.add_u64(Schema::type_field(), catalog_id); document.add_text(Schema::iri_field(), rdf_source.origin()); for quad in rdf_source.dataset() { if let Some(field_name) = self.ontology.field_name(&quad.predicate.into_owned()) && let TermRef::Literal(literal) = quad.object { let field = Schema::schema() .get_field(&field_name) .expect("Field not found in schema"); document.add_text(field, literal.value().to_lowercase()); } } self.index .add(document) .expect("Unable to add document to search index"); } } Message::CommitIndex => { if let Err(err) = self.index.commit() { task = Task::done(Message::ShowError(format!("Unable to commit index: {err}"))); } } Message::WindowClosed(id) => { if self.window_id == id { task = iced::exit(); } else { self.search_state = None; } } Message::URLInputChanged(value) => { self.url_input = value; } Message::URLInputSubmitted => { let url = Url::parse(&self.url_input).expect("Invalid URL"); task = Task::done(Message::DeleteAllRows) .chain(Task::done(Message::ResetState)) .chain(Task::done(Message::FetchDocument(url))); } Message::FetchDocument(url) => { let client = self.http_client.clone(); task = Task::future(async { let request = ResourceRequestBuilder::with_client_and_url(client, url) .accept_rdf_format(RdfFormat::Turtle) .follow_described_by(true) .build(); match request.send().await { Ok(resource) => match resource.into_rdf_source().await { Ok(rdf_source) => Message::LoadDocument(rdf_source), Err(err) => { Message::ShowError(format!("Unable to parse response: {err}")) } }, Err(ldp::Error::Reqwest(err)) => { if err.status() == Some(StatusCode::NOT_FOUND) { Message::ShowNewDocumentButtons } else { Message::ShowError(err.to_string()) } } Err(err) => Message::ShowError(format!("Request failed: {err}")), } }); } Message::LoadDocument(document) => { let messages = document .dataset() .quads .keys() .map(|key| Message::AddRow(Some(key))); task = Task::batch(messages.map(Task::done)).chain(Task::done(Message::ResetState)); self.document = document; } Message::ShowError(error) => { error!(error); } Message::AddRow(None) => { let quad = self.document.new_quad(); let key = self.document.dataset_mut().quads.insert(quad); task = Task::done(Message::AddRow(Some(key))); } Message::AddRow(Some(key)) => { let quad = self .document .dataset() .quads .get(key) .expect("Failed to get quad from document"); let read_only = self.ontology.is_read_only(quad.as_ref()); let term = TermHelper::new(&quad.object); let datatype = term .datatype() .map(|datatype| self.ontology.abbreviate(datatype)); let datatype_state = combo_box::State::with_selection( self.abbreviated_datatypes.clone(), datatype.as_ref(), ); let state = RowState { read_only, datatype_state, }; self.document .dataset_mut() .associated_data .insert(key, state); self.modified = true; } Message::DeleteRow(key) => { self.document.dataset_mut().remove(key); self.modified = true; } Message::DeleteAllRows => { self.document.dataset_mut().clear(); self.modified = true; } Message::OpenQueryWindow(action) => { if let Some(search_state) = &mut self.search_state { search_state.action = action; } else { let (id, window_task) = window::open(Settings::default()); self.search_state = Some(SearchState { window_id: id, action, query: String::new(), results: Vec::new(), }); task = window_task.map(|_| Message::None) } } Message::QueryUpdated(new_query) => { if let Some(search_state) = &mut self.search_state { search_state.results = self .index .query(0, new_query.as_str(), Schema::all_fields()) .expect("Error encountered while querying index") .iter() .map(NamedNode::new_unchecked) .collect(); search_state.query = new_query; }; } Message::SearchResultClicked(node) => { if let Some(search_state) = &self.search_state { match search_state.action { SearchResultClickAction::URLInput => { task = Task::done(Message::URLInputChanged(node.as_str().to_string())) .chain(Task::done(Message::URLInputSubmitted)); } SearchResultClickAction::Predicate(key) => { if let Some(quad) = self.document.dataset_mut().quads.get_mut(key) { quad.predicate = node; } } SearchResultClickAction::Object(key) => { if let Some(quad) = self.document.dataset_mut().quads.get_mut(key) { quad.object = Term::NamedNode(node); } } } task = task.chain(window::close(search_state.window_id)); self.modified = true; } } Message::HoverRow(index) => { self.hovered_row = Some(index); } Message::UnhoverRow(index) if self.hovered_row == Some(index) => { self.hovered_row = None; } Message::DatatypeUpdated(key, Some(maybe_prefixed_iri)) => { let node = self .ontology .expand(&maybe_prefixed_iri) .unwrap_or(NamedNode::new_unchecked(&maybe_prefixed_iri)); if let Some(quad) = self.document.dataset_mut().quads.get_mut(key) { let mut term = TermHelperMut::new(&mut quad.object); term.set_datatype(Some(node)); self.modified = true; } } Message::DatatypeUpdated(key, None) => { if let Some(quad) = self.document.dataset_mut().quads.get_mut(key) { let mut term = TermHelperMut::new(&mut quad.object); term.set_datatype(None); self.modified = true; } } Message::LanguageUpdated(key, Some(language)) => { if let Some(quad) = self.document.dataset_mut().quads.get_mut(key) { let mut term = TermHelperMut::new(&mut quad.object); term.set_language(language.as_str()); self.modified = true; } } Message::LanguageUpdated(key, None) => { if let Some(quad) = self.document.dataset_mut().quads.get_mut(key) { let mut term = TermHelperMut::new(&mut quad.object); term.set_language("en"); self.modified = true; } } Message::ValueUpdated(key, value) => { if let Some(quad) = self.document.dataset_mut().quads.get_mut(key) { let mut term = TermHelperMut::new(&mut quad.object); term.set_value(value); self.modified = true; } } Message::DirectionToggled(key, direction) => { if let Some(quad) = self.document.dataset_mut().quads.get_mut(key) { let mut term = TermHelperMut::new(&mut quad.object); term.set_direction(direction); self.modified = true; } } Message::SaveGraph(overwrite) => { let client = self.http_client.clone(); let options = SerializationOptions::from_format(RdfFormat::Turtle) .with_filter(self.ontology.exclude_read_only_predicate()); let request = self .document .to_update(options) .expect("Failed to generate PUT request"); let url = self.document.origin().clone(); task = Task::future(async move { match request.send(client, overwrite).await { Ok(RdfSourceUpdateResponse::Success) => Message::FetchDocument(url), Ok(RdfSourceUpdateResponse::DocumentModified(_)) => { Message::ShowOverwriteConfirmationModal } Err(err) => Message::ShowError(format!("Failed to save graph: {err}")), } }); } Message::ShowOverwriteConfirmationModal => { self.show_overwrite_confirmation = true; } Message::HideOverwriteConfirmationModal => { self.show_overwrite_confirmation = false; } Message::ConfirmOverwrite => { task = Task::done(Message::SaveGraph(true)) .chain(Task::done(Message::HideOverwriteConfirmationModal)); } Message::ShowNewDocumentButtons => { self.show_new_document_buttons = true; } Message::HideNewDocumentButtons => { self.show_new_document_buttons = false; } Message::NewDocument(class) => { let url = Url::parse(self.url_input.as_str()).expect("Invalid URL"); let subject = NamedNode::new_unchecked(url.as_str()); self.document = RdfSource::new(url); let quads = self .ontology .template_triples(class.as_ref(), subject.as_ref()) .map(|triples| { triples .map(|triple| self.document.quad_from_triple(triple)) .collect::>() }); if let Some(quads) = quads { let new_keys = self.document.dataset_mut().extend(quads.into_iter()); let messages = new_keys.map(|triple| Message::AddRow(Some(triple))); task = Task::done(Message::HideNewDocumentButtons) .chain(Task::batch(messages.map(Task::done))); } } Message::ResetState => { self.modified = false; self.show_new_document_buttons = false; self.show_overwrite_confirmation = false; } _ => {} } task } pub(crate) fn title(&self, _window: window::Id) -> String { "Graph of Liberty Publisher".to_string() } pub(crate) fn subscription(&self) -> Subscription { window::close_events().map(Message::WindowClosed) } fn view_row<'a>( &'a self, key: QuadKey, triple: &'a Quad, state: &'a RowState, ) -> Element<'a, Message> { const BUTTON_WIDTH: Length = Length::Fixed(35.0); let delete_button = button("\u{274c}") .style(|_, _| Style::default().with_background(Background::Color(color!(255, 0, 0)))) .on_press(Message::DeleteRow(key)); let button_area = if self.hovered_row == Some(key) && !state.read_only { container(delete_button).width(BUTTON_WIDTH) } else { container(space()).width(BUTTON_WIDTH) }; let property_label = self .ontology .info(triple.predicate.as_ref()) .and_then(|info| info.label.clone()) .unwrap_or(self.ontology.abbreviate(triple.predicate.as_ref())); let property: Element = if state.read_only { text(property_label).into() } else { button(text(property_label)) .on_press(Message::OpenQueryWindow( SearchResultClickAction::Predicate(key), )) .style(button::text) .into() }; let term = TermHelper::new(&triple.object); let value_label = term.value_as_named_node().and_then(|node| { self.ontology .info(node) .and_then(|info| info.label.clone()) .map(|label| container(text(label))) }); let value = term .value_as_named_node() .map(|node| self.ontology.abbreviate(node)) .unwrap_or(term.value().to_string()); let value_alignment = term .direction() .map(|direction| match direction { BaseDirection::Ltr => Horizontal::Left, BaseDirection::Rtl => Horizontal::Right, }) .unwrap_or(Horizontal::Left); let value_input_base = text_input("Value", value.as_str()).align_x(value_alignment); let value_input = if !state.read_only { let is_named_node = term.is_named_node(); value_input_base.on_input(move |input| { let expanded_input = if is_named_node { self.ontology .expand(input.as_str()) .map(|node| node.as_str().to_string()) .unwrap_or(input) } else { input }; Message::ValueUpdated(key, expanded_input) }) } else { value_input_base }; let search_launcher = if term.is_named_node() && !state.read_only { Some(container( button(text("\u{1f50e}")) .on_press(Message::OpenQueryWindow(SearchResultClickAction::Object( key, ))) .style(button::text), )) } else { None }; let selected_datatype = term.datatype().map(|node| self.ontology.abbreviate(node)); let datatype_selector: Element = if state.read_only { selected_datatype.map(text).into() } else { combo_box( &state.datatype_state, "Datatype", selected_datatype.as_ref(), move |selection| Message::DatatypeUpdated(key, Some(selection)), ) .on_input(move |input| { let value = if input.is_empty() { None } else { Some(input) }; Message::DatatypeUpdated(key, value) }) .into() }; let language_input = match term.datatype() { Some(rdf::LANG_STRING) | Some(rdf::DIR_LANG_STRING) => Some(container( text_input("Language", term.language().unwrap_or("en")).on_input(move |input| { let value = if input.is_empty() { None } else { Some(input) }; Message::LanguageUpdated(key, value) }), )), _ => None, }; let direction_slider = term .direction() .map(|direction| match direction { BaseDirection::Ltr => toggler(false), BaseDirection::Rtl => toggler(true), }) .map(|toggler| { container(row![ text("LTR"), toggler.on_toggle(move |new_state| { let direction = if new_state { BaseDirection::Rtl } else { BaseDirection::Ltr }; Message::DirectionToggled(key, direction) }), text("RTL"), ]) }); let row = row![ button_area, property, value_label, value_input, search_launcher, datatype_selector, language_input, direction_slider, ]; mouse_area(row) .on_enter(Message::HoverRow(key)) .on_exit(Message::UnhoverRow(key)) .into() } fn view_new_entity_buttons( &self, entities: impl Iterator, ) -> Element<'_, Message> { let buttons = entities.map(|entity| { let label = self .ontology .info(entity.as_ref()) .and_then(|info| info.label.clone()) .unwrap_or(entity.as_str().to_string()); button(text(label)) .on_press(Message::NewDocument(entity)) .into() }); grid(buttons) .height(Sizing::EvenlyDistribute(Length::Shrink)) .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); let label_column = table::column("Label", |result: &NamedNode| { let header_text = self .ontology .info(result.as_ref()) .and_then(|info| info.label.clone()) .unwrap_or("".to_string()); button(text(header_text)) .on_press(Message::SearchResultClicked(result.clone())) .style(button::text) }); let type_column = table::column("Type", |result: &NamedNode| { let header_text = self .ontology .info(result.as_ref()) .map(|info| info.type_.to_string()) .unwrap_or("Unknown".to_string()); button(text(header_text)) .on_press(Message::SearchResultClicked(result.clone())) .style(button::text) }); /*let description_column = table::column("Description", |result: &NamedNode| { let header_text = self.ontology.info(result.as_ref()) .and_then(|info| info.comment.clone()) .unwrap_or("Unknown".to_string()); button(text(header_text)) .on_press(Message::SearchResultClicked(result.clone())) .style(button::text) });*/ let iri_column = table::column("IRI", |result: &NamedNode| { button(text(result.as_str())) .on_press(Message::SearchResultClicked(result.clone())) .style(button::text) }); let results_table = scrollable(table( [ label_column, //description_column, type_column, iri_column, ], &search_state.results, )); return column![search_input, results_table].into(); } let add_row_button = button("Add row").on_press(Message::AddRow(None)); let index_button = button("Index").on_press(Message::Traverse); let search_launcher = button(text("\u{1f50e}")) .on_press(Message::OpenQueryWindow(SearchResultClickAction::URLInput)) .style(button::text); let mut rows: Vec> = vec![]; rows = self .document .dataset() .iter_both() .map(|(key, quad, state)| self.view_row(key, quad, state)) .collect(); let body: Element = if self.show_new_document_buttons { column![ self.view_new_entity_buttons(self.ontology.subclass_of(gl::ENTITY)), self.view_new_entity_buttons(self.ontology.subclass_of(rda::ENTITY)), ] .into() } else { column(rows).into() }; let save_button_base = button(text("\u{1f4be}")); let save_button = if self.modified { save_button_base.on_press(Message::SaveGraph(false)) } else { save_button_base }; let content = column![ row![ add_row_button, index_button, search_launcher, text_input("URL", &self.url_input) .on_input(Message::URLInputChanged) .on_submit(Message::URLInputSubmitted), save_button, ], scrollable(body), space::vertical(), ]; if self.show_overwrite_confirmation { let confirmation_modal = container(column![ text("The resource has changed since it was fetched. Overwrite?"), row![ button(text("Yes")) .style(button::danger) .on_press(Message::ConfirmOverwrite), space::horizontal(), button(text("No")).on_press(Message::HideOverwriteConfirmationModal), ], ]) .width(Length::Shrink); modal(content, confirmation_modal, Message::None) } else { content.into() } } } fn modal<'a, Message>( base: impl Into>, content: impl Into>, on_blur: Message, ) -> Element<'a, Message> where Message: Clone + 'a, { stack![ base.into(), opaque( mouse_area(center(opaque(content)).style(|_theme| { container::Style { background: Some( Color { a: 0.8, ..Color::BLACK } .into(), ), ..container::Style::default() } })) .on_press(on_blur) ) ] .into() }