use crate::navigator::Navigator; use crate::rdf::conversion::term_to_named_node; use crate::rdf::term_helper::{TermHelper, TermHelperMut}; use crate::tasks; use crate::widget::iri_input::iri_input; use crate::widget::navigation_area::navigation_area; use gl_graph::class::Class; use gl_graph::ontology::{ConnectedOntology, OntologyBuilder, ReadOnlyEntity, ResourceDescription}; use gl_graph::{CurieHelper, language, vocab}; use gl_search::tantivy::schema::Value; use gl_search::{Schema, SearchIndex}; use http::StatusCode; use iced::advanced::text::Wrapping; use iced::alignment::Horizontal; use iced::keyboard::{Event, key}; use iced::task::sipper; use iced::widget::button::Style; use iced::widget::grid::Sizing; use iced::widget::{ button, center, column, combo_box, container, grid, mouse_area, opaque, operation, pick_list, row, scrollable, space, stack, table, text, text_input, toggler, }; use iced::window::Settings; use iced::{ Background, Color, Element, Font, Length, Subscription, Task, color, font, keyboard, window, }; use ldp::middleware::BasicAuthMiddleware; use ldp::model::{KeyedDataset, QuadKey}; use ldp::reqwest::{Client, Url}; use ldp::reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; use ldp::{RdfSource, RdfSourceUpdateResponse, ResourceRequestBuilder, SerializationOptions}; use oxigraph::io::RdfFormat; use oxigraph::model::{ BaseDirection, Graph, NamedNode, NamedNodeRef, NamedOrBlankNode, NamedOrBlankNodeRef, Quad, Term, TermRef, Triple, TripleRef, }; use rfd::FileHandle; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::ops::Sub; use std::path::PathBuf; use tokio::io::AsyncWriteExt; use tonic::codegen::tokio_stream::StreamExt; use tracing::{debug, error, trace}; #[derive(Clone, Debug)] pub(crate) enum Message { None, ConnectToOntologyService(String), ConnectedToOntologyService(ConnectedOntology), CacheDatatypes(HashMap), CacheReadOnlyEntities(HashSet), PopulateCaches, LookupResourceDescriptions(HashSet), CacheResourceDescriptions(HashMap), WindowClosed(window::Id), URLInputChanged(String), URLInputSubmitted, NavigateTo(Url), FetchDocument(Url), LoadDocument(RdfSource>), ResourceNotFound, ShowNewDocumentButtons, HideNewDocumentButtons, ShowError(String), AddRow(Option<(QuadKey, RowState)>), DeleteRow(QuadKey), OpenQueryWindow(SearchResultClickAction), HoverRow(QuadKey), UnhoverRow(QuadKey), QueryUpdated(String), SetSearchResults(Vec>), UpdateQueryClass(Class), SearchResultClicked(NamedNode), DatatypeUpdated(QuadKey, Option), LanguageUpdated(QuadKey, Option), SubjectUpdated(QuadKey, String), PredicateUpdated(QuadKey, String), ObjectUpdated(QuadKey, String), DirectionToggled(QuadKey, BaseDirection), SaveGraph(bool), ShowOverwriteConfirmationModal, HideOverwriteConfirmationModal, ConfirmOverwrite, DownloadResource, DownloadLocationSelected(Option), DownloadProgress((usize, Option)), NewDocument(Class, Option), NavigateToPredicate(QuadKey), NavigateToObject(QuadKey), NavigateBack, NavigateForward, ResetState, RunInference, SetInferredTriples(Graph), ShowInferencesWindow, ShowReadOnlyWindow, Event(Event), } #[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, entity_class_selection: Option, query: String, results: Vec>, } enum DownloadState { Idle, InProgress(FileHandle, usize), } pub(crate) struct Publisher { http_client: ClientWithMiddleware, curie_helper: CurieHelper, ontology: Option, read_only_entities: HashSet, abbreviated_datatypes: Vec, resource_descriptions: HashMap, window_id: window::Id, inference_window_id: Option, read_only_window_id: Option, url_input: String, url_input_valid: bool, navigator: Navigator, document: RdfSource>, download_state: DownloadState, inferred_triples: Graph, hovered_row: Option, search_state: Option, index: SearchIndex, show_overwrite_confirmation: bool, modified: bool, show_new_document_buttons: bool, resource_not_found: bool, } impl Publisher { fn is_read_only<'a>(&'a self, triple: impl Into>) -> bool { let triple = triple.into(); if triple.predicate == vocab::rdf::TYPE && let TermRef::NamedNode(node) = triple.object { self.read_only_entities .contains(&ReadOnlyEntity::Class(node.into_owned())) } else { self.read_only_entities .contains(&ReadOnlyEntity::Property(triple.predicate.into_owned())) } } pub(crate) fn new() -> (Self, Task) { let curie_helper = CurieHelper::new(gl_graph::PREFIXES.clone()); let index = SearchIndex::builder() .with_path("/home/alex/.local/share/org.graphofliberty.desktop/index") .build() .expect("Failed to build search index"); 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 starting_url = Url::parse("http://fedora.quill.lan/rest/").unwrap(); let navigator = Navigator::new(starting_url.clone()); let document = RdfSource::new(starting_url.clone()); ( Self { http_client, curie_helper, ontology: None, read_only_entities: HashSet::new(), abbreviated_datatypes: Vec::new(), resource_descriptions: HashMap::new(), window_id: id, inference_window_id: None, read_only_window_id: None, url_input: starting_url.to_string(), url_input_valid: true, navigator, document, download_state: DownloadState::Idle, inferred_triples: Graph::new(), hovered_row: None, search_state: None, index, show_overwrite_confirmation: false, modified: false, show_new_document_buttons: false, resource_not_found: false, }, task.map(|_| Message::ConnectToOntologyService("http://[::1]:3000".to_string())), ) } pub(crate) fn update(&mut self, message: Message) -> Task { let mut task = Task::none(); trace!(?message); match message { Message::ConnectToOntologyService(endpoint) => { let builder = OntologyBuilder::from_string(&endpoint, language::ENGLISH_OR_UNTAGGED.clone()) .expect("Unable to parse endpoint of ontology service"); task = tasks::connect_to_ontology_service(builder) .chain(Task::done(Message::PopulateCaches)); } Message::ConnectedToOntologyService(ontology) => { self.ontology = Some(ontology); } Message::PopulateCaches => { if let Some(ontology) = &mut self.ontology { let classes = Class::ALL .iter() .map(|class| class.to_named_node().into_owned()); task = Task::batch([ tasks::list_datatypes(ontology.clone()), tasks::list_read_only_entities(ontology.clone()), tasks::lookup_resource_descriptions(ontology.clone(), classes), ]) } } Message::CacheDatatypes(datatypes) => { self.abbreviated_datatypes = datatypes .keys() .map(|node| { self.curie_helper .abbreviate(None, node.as_str()) .unwrap_or(node.as_str().to_string()) }) .collect(); } Message::CacheReadOnlyEntities(entities) => { self.read_only_entities = entities; } Message::LookupResourceDescriptions(nodes) => { let provided_nodes: HashSet = HashSet::from_iter(nodes); let nodes_with_known_descriptions = HashSet::from_iter(self.resource_descriptions.keys().cloned()); let nodes_to_look_up = provided_nodes.sub(&nodes_with_known_descriptions); if let Some(ontology) = &mut self.ontology { task = tasks::lookup_resource_descriptions(ontology.clone(), nodes_to_look_up) } } Message::CacheResourceDescriptions(descriptions) => { self.resource_descriptions.extend(descriptions); } Message::WindowClosed(id) => { if self.window_id == id { task = iced::exit(); } else if let Some(search_state) = &self.search_state && search_state.window_id == id { self.search_state = None; } else if let Some(infrence_window_id) = self.inference_window_id && infrence_window_id == id { self.inference_window_id = None; } else if let Some(read_only_window_id) = self.read_only_window_id && read_only_window_id == id { self.read_only_window_id = None; } } Message::URLInputChanged(value) => { self.url_input_valid = Url::parse(&value).is_ok(); self.url_input = value; } Message::URLInputSubmitted => { if let Ok(url) = Url::parse(&self.url_input) { if &url != self.navigator.current() { self.navigator.goto(url.clone()); } task = Task::done(Message::NavigateTo(url)); } } Message::NavigateTo(url) => { task = Task::done(Message::ResetState).chain(Task::done(Message::FetchDocument(url))); } Message::FetchDocument(url) => { let client = self.http_client.clone(); task = Task::perform( async { let request = ResourceRequestBuilder::with_client_and_url(client, url) .accept_rdf_format(RdfFormat::Turtle) .follow_described_by(true) .build(); let response = request.send().await?; response.into_rdf_source().await }, |result| match result { Ok(document) => Message::LoadDocument(document), Err(ldp::Error::Reqwest(err)) if err.status() == Some(StatusCode::NOT_FOUND) => { Message::ResourceNotFound } Err(err) => Message::ShowError(err.to_string()), }, ); } Message::ResourceNotFound => { task = Task::done(Message::ShowNewDocumentButtons); self.resource_not_found = true; } Message::LoadDocument(document) => { let nodes_to_look_up = document .dataset() .quads .iter() .flat_map(|(_, quad)| { let mut nodes = vec![quad.predicate.clone()]; if let Some(object) = term_to_named_node(&quad.object) { nodes.push(object.clone()); } nodes }) .collect(); let add_row_tasks = document.dataset().quads.iter().map(|(key, quad)| { let datatype_state = combo_box::State::new(self.abbreviated_datatypes.clone()); let state = RowState { read_only: self.is_read_only(quad.as_ref()), datatype_state, }; Task::done(Message::AddRow(Some((key, state)))) }); task = Task::done(Message::LookupResourceDescriptions(nodes_to_look_up)) .chain(Task::batch(add_row_tasks)) .chain(Task::done(Message::RunInference)) .chain(Task::done(Message::ResetState)); self.document = document; } Message::ShowError(error) => { error!(error); } Message::AddRow(None) => { let mut quad = self.document.new_quad(); let empty_node = NamedNode::new_unchecked(""); quad.predicate = empty_node.clone(); quad.object = Term::NamedNode(empty_node); let key = self.document.dataset_mut().quads.insert(quad); let datatype_state = combo_box::State::new(self.abbreviated_datatypes.clone()); let state = RowState { read_only: false, datatype_state, }; task = Task::done(Message::AddRow(Some((key, state)))); } Message::AddRow(Some((key, 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::OpenQueryWindow(action) => { let selected_entity_class = match action { SearchResultClickAction::Predicate(_) => Some(vocab::rdf::PROPERTY), SearchResultClickAction::Object(key) => { self.document.dataset().quads.get(key).and_then(|quad| { if quad.predicate == vocab::rdf::TYPE { Some(vocab::rdfs::CLASS) } else { None } }) } SearchResultClickAction::URLInput => None, }; 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, entity_class_selection: None, query: String::new(), results: Vec::new(), }); task = window_task.then(|_| operation::focus("query")); } if let Some(selected_entity_class) = selected_entity_class && let Some(class) = Class::try_from_named_node(selected_entity_class) { task = task.chain(Task::done(Message::UpdateQueryClass(class))) } } Message::UpdateQueryClass(type_) => { if let Some(search_state) = &mut self.search_state { search_state.entity_class_selection = Some(type_); task = Task::done(Message::QueryUpdated(search_state.query.clone())); } } Message::QueryUpdated(new_query) => { if let Some(search_state) = &mut self.search_state { let category_id = search_state .entity_class_selection .as_ref() .map(|selection| selection.to_owned() as u64); let query = new_query.clone(); search_state.query = new_query; let results = self .index .query(category_id, query.as_str(), Schema::all_fields(), 25) .expect("Unable to complete search"); task = Task::done(Message::SetSearchResults(results)); }; } Message::SetSearchResults(results) => { if let Some(search_state) = &mut self.search_state { search_state.results = results; } } Message::SearchResultClicked(node) => { if let Some(search_state) = &self.search_state { task = task.chain(Task::done(Message::LookupResourceDescriptions( HashSet::from_iter([node.clone()]), ))); 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 .curie_helper .expand(None, &maybe_prefixed_iri) .unwrap_or(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(NamedNode::new_unchecked(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::SubjectUpdated(key, value) => { if let Some(quad) = self.document.dataset_mut().quads.get_mut(key) { quad.subject = NamedOrBlankNode::NamedNode(NamedNode::new_unchecked(value)); self.modified = true; } } Message::PredicateUpdated(key, value) => { if let Some(quad) = self.document.dataset_mut().quads.get_mut(key) { quad.predicate = NamedNode::new_unchecked(value); self.modified = true; } } Message::ObjectUpdated(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 read_only_entities = self.read_only_entities.clone(); let options = SerializationOptions::from_format(RdfFormat::Turtle).with_filter( move |triple| { if triple.predicate == vocab::rdf::TYPE && let TermRef::NamedNode(node) = triple.object { !read_only_entities.contains(&ReadOnlyEntity::Class(node.into_owned())) } else { !read_only_entities .contains(&ReadOnlyEntity::Property(triple.predicate.into_owned())) } }, ); 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::DownloadResource => { let suggested_file_name = self.document.origin_file_name().unwrap_or_default(); let destination_selector = rfd::AsyncFileDialog::new().set_file_name(suggested_file_name); task = Task::perform(destination_selector.save_file(), |result| { Message::DownloadLocationSelected(result.map(|fh| fh.path().to_owned())) }); } Message::DownloadLocationSelected(None) => { debug!("canceled"); } Message::DownloadLocationSelected(Some(path)) => { if let Ok(url) = Url::parse(&self.url_input) { let client = self.http_client.clone(); task = Task::sip( sipper(async move |mut sender| { let request = ResourceRequestBuilder::with_client_and_url(client, url) .follow_described_by(false) .build(); let resource = request.send().await?; let mut handle = tokio::fs::File::create(path).await?; let mut progress = 0usize; let total_size = resource.size(); let mut stream = resource.into_stream(); while let Some(chunk_result) = stream.next().await { let chunk = chunk_result?; handle.write_all(&chunk).await?; progress += chunk.len(); sender.send((progress, total_size)).await; } Ok(()) }), Message::DownloadProgress, |result: crate::error::Result<()>| { if let Err(err) = result { Message::ShowError(err.to_string()) } else { Message::None } }, ); } } Message::DownloadProgress((bytes_written, Some(bytes_total))) => { let percentage = bytes_written as f32 / bytes_total as f32; debug!(bytes_written, bytes_total, percentage); } Message::ShowNewDocumentButtons => { self.show_new_document_buttons = true; } Message::HideNewDocumentButtons => { self.show_new_document_buttons = false; } 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 nodes_to_look_up = HashSet::new(); let document_node = NamedNode::new_unchecked(url.as_str()); for triple in &class.template(document_node) { let quad = document.quad_from_triple(triple.into_owned()); document.dataset_mut().quads.insert(quad); if triple.predicate == vocab::rdf::TYPE && let TermRef::NamedNode(node) = triple.object { nodes_to_look_up.insert(node.into_owned()); } else { nodes_to_look_up.insert(triple.predicate.into_owned()); } } task = Task::done(Message::LookupResourceDescriptions(nodes_to_look_up)) .chain(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) { let url = Url::parse(quad.predicate.as_str()).expect("Invalid URL"); task = Task::done(Message::URLInputChanged(url.to_string())) .chain(Task::done(Message::URLInputSubmitted)); } } Message::NavigateToObject(key) => { if let Some(quad) = self.document.dataset_mut().quads.get_mut(key) { let url = if let Term::NamedNode(node) = &quad.object { Url::parse(node.as_str()).ok() } else if let Term::Literal(literal) = &quad.object { Url::parse(literal.value()).ok() } else { None }; if let Some(url) = url { task = Task::done(Message::URLInputChanged(url.to_string())) .chain(Task::done(Message::URLInputSubmitted)); } } } Message::NavigateBack => { let url = self.navigator.back().clone(); task = Task::done(Message::URLInputChanged(url.to_string())) .chain(Task::done(Message::NavigateTo(url))); } Message::NavigateForward => { let url = self.navigator.forward().clone(); task = Task::done(Message::URLInputChanged(url.to_string())) .chain(Task::done(Message::NavigateTo(url))); } Message::RunInference => { if let Some(ontology) = &mut self.ontology { let graph = self .document .dataset() .quads .iter() .map(|(_, quad)| Triple::from(quad.clone())) .collect(); task = tasks::run_inference(ontology.clone(), graph); } } Message::SetInferredTriples(graph) => { if let Some(ontology) = &mut self.ontology { let predicates = graph.iter().map(|triple| triple.predicate.into_owned()); let objects = graph.iter().filter_map(|triple| match triple.object { TermRef::NamedNode(node) => Some(node.into_owned()), _ => None, }); let mut nodes_to_look_up: HashSet = HashSet::from_iter(predicates); nodes_to_look_up.extend(objects); task = tasks::lookup_resource_descriptions(ontology.clone(), nodes_to_look_up); } self.inferred_triples = graph; } Message::ShowInferencesWindow => { if let Some(id) = self.inference_window_id { task = window::gain_focus(id); } else { let (id, window_task) = window::open(Settings::default()); self.inference_window_id = Some(id); task = window_task.map(|_| Message::None); } } Message::ShowReadOnlyWindow => { if let Some(id) = self.read_only_window_id { task = window::gain_focus(id); } else { let (id, window_task) = window::open(Settings::default()); self.read_only_window_id = Some(id); task = window_task.map(|_| Message::None); } } Message::Event(Event::KeyPressed { key: keyboard::Key::Named(key::Named::Tab), modifiers, .. }) => { if modifiers.shift() { task = operation::focus_previous(); } else { task = operation::focus_next(); } } _ => {} } task } pub(crate) fn title(&self, window: window::Id) -> String { if let Some(inference_window_id) = self.inference_window_id && window == inference_window_id { "Graph of Liberty Publisher - Inferences".to_string() } else if let Some(search_state) = &self.search_state && window == search_state.window_id { "Graph of Liberty Publisher - Search".to_string() } else if let Some(read_only_window_id) = self.read_only_window_id && window == read_only_window_id { "Graph of Liberty Publisher - Read Only Triples".to_string() } else { "Graph of Liberty Publisher".to_string() } } pub(crate) fn subscription(&self) -> Subscription { Subscription::batch([ window::close_events().map(Message::WindowClosed), keyboard::listen().map(Message::Event), ]) } fn view_inferred_class_table<'a>( &'a self, triples: impl IntoIterator>, ) -> Element<'a, Message> { let bold = |t| { text(t).font(Font { weight: font::Weight::Bold, ..Font::default() }) }; let subject_column = table::column(bold("Subject"), |(subject, _): (NamedNodeRef, _)| { text( self.curie_helper .abbreviate(Some(self.document.origin()), subject.as_str()) .unwrap_or_else(|| subject.as_str().to_string()), ) }); let class_column = table::column( bold("Classes"), |(_, classes): (_, BTreeSet)| { let textual_classes = classes .iter() .map(|class| { let curie = self .curie_helper .abbreviate(Some(self.document.origin()), class.as_str()) .unwrap_or_else(|| class.as_str().to_string()); let label = self .resource_descriptions .get(&class.into_owned()) .and_then(|description| description.label.clone()); if let Some(label) = label { format!("{curie} ({label})") } else { curie } }) .collect::>() .join("\n"); text(textual_classes) }, ); let mut inferred_classes: BTreeMap> = BTreeMap::new(); for triple in triples { if let NamedOrBlankNodeRef::NamedNode(subject) = triple.subject && let TermRef::NamedNode(class) = triple.object { inferred_classes .entry(subject) .and_modify(|classes| { classes.insert(class); }) .or_insert(BTreeSet::from_iter([class])); } } table([subject_column, class_column], inferred_classes).into() } fn view_triple_table<'a>( &'a self, triples: impl IntoIterator>, ) -> Element<'a, Message> { let bold = |t| { text(t).font(Font { weight: font::Weight::Bold, ..Font::default() }) }; let subject_column = table::column(bold("Subject"), |triple: TripleRef| { let curie = if let NamedOrBlankNodeRef::NamedNode(subject) = triple.subject { Some( self.curie_helper .abbreviate(Some(self.document.origin()), subject.as_str()) .unwrap_or_else(|| subject.as_str().to_string()), ) } else { None }; curie.map(text) }); let predicate_column = table::column(bold("Predicate"), |triple: TripleRef| { let curie = self .curie_helper .abbreviate(None, triple.predicate.as_str()) .unwrap_or_else(|| triple.predicate.as_str().to_string()); let label = self .resource_descriptions .get(&triple.predicate.into_owned()) .and_then(|description| description.label.clone()); if let Some(label) = label { text(format!("{curie} ({label})")) } else { text(curie) } }); let object_column = table::column(bold("Object"), |triple: TripleRef| match triple.object { TermRef::NamedNode(node) => { let curie = self .curie_helper .abbreviate(Some(self.document.origin()), node.as_str()) .unwrap_or_else(|| node.as_str().to_string()); let label = self .resource_descriptions .get(&node.into_owned()) .and_then(|description| description.label.clone()); if let Some(label) = label { text(format!("{curie} ({label})")) } else { text(curie) } } _ => text(triple.object.to_string()), }); table([subject_column, predicate_column, object_column], triples).into() } 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(Message::UpdateQueryClass); 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(); 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| 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(); 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) }, ) .width(Length::Fixed(256.0)), ); } let results_table = if columns.is_empty() { None } else { Some(scrollable(table(columns, &search_state.results))) }; column![row![search_input, type_selector], results_table].into() } fn view_inference_window(&self) -> Element<'_, Message> { let classes = self .inferred_triples .triples_for_predicate(vocab::rdf::TYPE); column![ self.view_inferred_class_table(classes), scrollable(self.view_triple_table(&self.inferred_triples)), ] .into() } fn view_read_only_window(&self) -> Element<'_, Message> { let read_only_triples = self .document .dataset() .iter_both() .filter_map(|(_, quad, state)| if state.read_only { Some(quad) } else { None }) .map(|quad| TripleRef::from(quad.as_ref())); column![scrollable(self.view_triple_table(read_only_triples)),].into() } fn view_row<'a>( &'a self, key: QuadKey, quad: &'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 base = self.document.origin(); let subject = match &quad.subject { NamedOrBlankNode::NamedNode(subject) => subject.as_str(), _ => "", }; let subject_input_base = iri_input(&self.curie_helper, "Subject", Some(base), subject); let subject_input = if state.read_only { subject_input_base } else { subject_input_base.on_input(move |value| Message::SubjectUpdated(key, value)) }; let predicate_input_base = iri_input( &self.curie_helper, "Predicate", Some(base), quad.predicate.as_str(), ); let predicate_input = if state.read_only { predicate_input_base } else { predicate_input_base .on_input(move |value| Message::PredicateUpdated(key, value)) .on_control_click(Message::OpenQueryWindow( SearchResultClickAction::Predicate(key), )) .on_shift_click(Message::NavigateToPredicate(key)) }; let predicate_label = self .resource_descriptions .get(&quad.predicate) .and_then(|description| description.label.clone()) .map(|label| container(text(label))); let term = TermHelper::new(&quad.object); let value_label = term .value_as_named_node() .and_then(|node| self.resource_descriptions.get(&node.into_owned())) .and_then(|description| description.label.clone()) .map(|label| container(text(label))); let value = term .value_as_named_node() .map(|node| node.as_str().to_string()) .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); /* If the object is not a valid URL (either as a literal string or as a named node), then * use a normal text input instead of an iri_input. */ let object_input: Element = if term.is_named_node() || Url::parse(term.value()).is_ok() { let base = iri_input(&self.curie_helper, "Object", Some(base), value.as_str()) .on_shift_click(Message::NavigateToObject(key)); if state.read_only { base.into() } else { base.align_x(value_alignment) .on_input(move |value| Message::ObjectUpdated(key, value)) .on_control_click(Message::OpenQueryWindow( SearchResultClickAction::Object(key), )) .into() } } else { let base = text_input("Object", value.clone()); if state.read_only { base.into() } else { base.on_input(move |value| Message::ObjectUpdated(key, value)) .into() } }; let selected_datatype = term .datatype() .and_then(|node| self.curie_helper.abbreviate(None, node.as_str())); 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 = term.language().unwrap_or("en").to_owned(); let language_input = match term.datatype() { Some(vocab::rdf::LANG_STRING) | Some(vocab::rdf::DIR_LANG_STRING) => Some(container( text_input("Language", language).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, subject_input, predicate_input, predicate_label, object_input, value_label, datatype_selector, language_input, direction_slider, ]; mouse_area(row) .on_enter(Message::HoverRow(key)) .on_exit(Message::UnhoverRow(key)) .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); } if let Some(inference_window_id) = self.inference_window_id && window == inference_window_id { return self.view_inference_window(); } if let Some(read_only_window_id) = self.read_only_window_id && window == read_only_window_id { return self.view_read_only_window(); } let show_inferences_button = button("\u{1f9e0}").on_press(Message::ShowInferencesWindow); let show_read_only_button = button("\u{1f6ab}").on_press(Message::ShowReadOnlyWindow); let back_button = button("\u{1f870}").on_press(Message::NavigateBack); let forward_button = button("\u{1f872}").on_press(Message::NavigateForward); 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) .on_submit(Message::URLInputSubmitted) .on_control_click(Message::OpenQueryWindow(SearchResultClickAction::URLInput)) .style(crate::theme::styles::text_input::required( !self.url_input_valid, )); let rows: Vec> = self .document .dataset() .iter_both() .filter(|(_, _, state)| !state.read_only) .map(|(key, quad, state)| self.view_row(key, quad, state)) .collect(); let body: Element = if self.show_new_document_buttons { let buttons = Class::CREATABLE.iter().map(|class| { let url = if self.resource_not_found { Url::parse(&self.url_input).ok() } else { None }; button(text(class.to_string())) .on_press(Message::NewDocument(class.clone(), url)) .into() }); column![grid(buttons).height(Sizing::EvenlyDistribute(Length::Shrink))].into() } else { column(rows).into() }; let download_button_base = button("\u{2b73}"); let download_button = if self.document.origin_type() == Some(&ldp::ResourceType::NonRdfSource) { download_button_base.on_press(Message::DownloadResource) } else { download_button_base }; 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 = navigation_area(column![ row![ show_inferences_button, show_read_only_button, new_document_button, add_row_button, back_button, forward_button, address_input, download_button, save_button, ], scrollable(body), ]) .on_back_click(Message::NavigateBack) .on_forward_click(Message::NavigateForward); 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() }