use crate::rdf::ontology::{LabeledIri, Ontology}; use crate::rdf::term_helper::{TermHelper, TermHelperMut}; use gl_search::{Schema, SearchDocument, SearchIndex, doc, IndexWriter, Value}; 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, pick_list, row, scrollable, space, stack, table, text, text_input, toggler}; use iced::window::Settings; use iced::{Background, Color, Element, Length, Subscription, Task, color, window}; use iced::widget::text::Wrapping; 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, rdfs}; use oxigraph::model::{BaseDirection, Dataset, NamedNode, NamedNodeRef, Quad, Term}; use tracing::{debug, debug_span, error, trace}; use crate::rdf::language; use crate::rdf::vocab::rda; use crate::widget::iri_input::iri_input; #[derive(Clone, Debug)] 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), SetSearchResults(Vec), QueryTypeUpdated(LabeledIri), 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, type_: LabeledIri, 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, index_writer: Option, show_overwrite_confirmation: bool, modified: bool, show_new_document_buttons: bool, } impl Publisher { pub(crate) fn new() -> (Self, Task) { let ontology = debug_span!("Ontology Creation").in_scope(|| { Ontology::builder() .with_path("/home/alex/.local/share/org.graphofliberty.desktop/ontology") .build() .expect("Failed to build ontology") }); let index = SearchIndex::builder() .with_path("/home/alex/.local/share/org.graphofliberty.desktop/index") .build() .expect("Failed to build search index"); /*debug_span!("Ontology Indexing").in_scope(|| { let mut counter = 0; let mut writer = index.writer().expect("Failed to build index writer"); let index = ontology.index(&*language::ENGLISH_OR_UNTAGGED).expect("Unable to generate index of entities"); for (individual, entry) in index { let mut document = doc!( Schema::type_field() => entry.catalog_id, Schema::iri_field() => individual.as_str(), Schema::curie_field() => ontology.abbreviate(individual.as_ref()), ); for (key, value) in entry.fields { document.add_text(Schema::field(&key, language::ENGLISH_PRIMARY), value.as_str()); } writer.add(document).expect("Failed to add document to search index"); counter += 1; } writer.commit().expect("Failed to commit changes to search index"); debug!("Added {counter} documents to search index"); });*/ let mut abbreviated_datatypes = ontology .datatypes() .map(|node| ontology.abbreviate(node.as_ref())) .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, index_writer: None, 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(); trace!(?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); self.index_writer = Some(self.index.writer().expect("Unable to create search index writer")); 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) => { for catalog_id in rdf_source.classes() .filter_map(|class| self.ontology.catalog_id(&class.into_owned())) { 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) = self.ontology.field_for_property(&quad.predicate.into_owned()) && let TermRef::Literal(literal) = quad.object { let field = Schema::schema() .get_field(field.name.as_str()) .expect("Field not found in schema"); document.add_text(field, literal.value()); } }*/ if let Some(writer) = &self.index_writer { writer.add(document).expect("Unable to add document to search index"); } } } Message::CommitIndex => { if let Some(writer) = &mut self.index_writer { writer.commit().expect("Unable to commit updates to search index"); self.index_writer = None; } } 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 { if let Some(entity) = self.ontology.searchable_classes(&*language::ENGLISH_OR_UNTAGGED).next() { let (id, window_task) = window::open(Settings::default()); self.search_state = Some(SearchState { window_id: id, action, query: String::new(), type_: entity, results: Vec::new(), }); task = window_task.map(|_| Message::None) }; } } Message::QueryTypeUpdated(type_) => { if let Some(search_state) = &mut self.search_state { search_state.type_ = type_; task = Task::done(Message::QueryUpdated(search_state.query.clone())); } } Message::QueryUpdated(new_query) => { if let Some(search_state) = &mut self.search_state { let selected_entity_class = &search_state.type_.iri; let catalog_id = match search_state.action { SearchResultClickAction::Predicate(_) => self.ontology.catalog_id(&rdf::PROPERTY.into_owned()), SearchResultClickAction::Object(key) => { self.document .dataset() .quads .get(key) .and_then(|quad| if quad.predicate == rdf::TYPE { self.ontology.catalog_id(&rdfs::CLASS.into_owned()) } else { self.ontology.catalog_id(selected_entity_class) }) }, SearchResultClickAction::URLInput => self.ontology.catalog_id(selected_entity_class), }; let index = self.index.clone(); let query = new_query.clone(); let search_task = tokio::task::spawn_blocking(move || { index.query(catalog_id, query.as_str(), Schema::all_fields(), 25) }); task = Task::future(async { match search_task.await { Ok(Ok(results)) => Message::SetSearchResults(results), Ok(Err(err)) => Message::ShowError(err.to_string()), Err(err) => Message::ShowError(err.to_string()), } }); search_state.query = new_query; }; } 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 { 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()); 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 { Subscription::batch([ 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 predicate_input = iri_input(self.ontology.prefixes(), "Predicate", triple.predicate.as_str()) .on_input(move |value| Message::ValueUpdated(key, value)) .on_control_click(Message::OpenQueryWindow(SearchResultClickAction::Predicate(key))); let term = TermHelper::new(&triple.object); let value_label = term.value_as_named_node().and_then(|node| { let info = self.ontology.info(&node.into_owned(), &*language::ENGLISH_OR_UNTAGGED); Some(container(text(info.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); let object_input = iri_input(self.ontology.prefixes(), "Object", value.as_str()) .align_x(value_alignment) .on_input(move |value| Message::ValueUpdated(key, value)) .on_control_click(Message::OpenQueryWindow(SearchResultClickAction::Object(key))); 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, predicate_input, value_label, object_input, 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 IntoIterator, ) -> Element<'_, Message> { let buttons = entities.into_iter().map(|entity| { let label = format!("{} ({})", entity.label, self.ontology.abbreviate(entity.iri.as_ref())); button(text(label)) .on_press(Message::NewDocument(entity.iri)) .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 mut entities = self.ontology .searchable_classes(&*language::ENGLISH_OR_UNTAGGED) .collect::>(); entities.sort_by(|a, b| Ord::cmp(&a.label, &b.label)); let type_selector = pick_list(Some(&search_state.type_), entities, ToString::to_string) .on_select(|selection| Message::QueryTypeUpdated(selection)); let fields = self.ontology.fields_for_class(&search_state.type_.iri, &*language::ENGLISH_OR_UNTAGGED) .expect("Unable to load fields for class"); let mut columns = vec![ table::column(text("CURIE"), |document: &SearchDocument| { let iri = document.get_first(Schema::iri_field()) .and_then(|value| value.as_str()) .unwrap_or_default(); let abbreviated_iri = self.ontology.abbreviate(NamedNodeRef::new_unchecked(iri)); button(text(abbreviated_iri).wrapping(Wrapping::Word)) .on_press(Message::SearchResultClicked(NamedNode::new_unchecked(iri))) .style(button::text) }) ]; for field in fields.into_iter() { let field_name = field.name.clone(); columns.push( table::column(text(field.label), move |document: &SearchDocument| { let iri = document.get_first(Schema::iri_field()) .and_then(|value| value.as_str()) .unwrap_or_default(); let value = document.get_first(Schema::field(&field_name, language::ENGLISH_PRIMARY)) .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))) }; return column![ row![search_input, type_selector], 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 address_input = iri_input(self.ontology.prefixes(), "URL", &self.url_input) .on_input(Message::URLInputChanged) .on_submit(Message::URLInputSubmitted) .on_control_click(Message::OpenQueryWindow(SearchResultClickAction::URLInput)); 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 { let subclasses = self.ontology.subclasses_of(&rda::ENTITY.into_owned()); let labeled_subclasses = subclasses.iter().map(|iri| self.ontology.info(iri, &*language::ENGLISH_OR_UNTAGGED)); column![ //self.view_new_entity_buttons(self.ontology.subclass_of(gl::ENTITY)), self.view_new_entity_buttons(labeled_subclasses), ].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, address_input, 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() }