Files
tools/publish/src/app.rs
T
2026-09-02 23:05:29 -04:00

1331 lines
52 KiB
Rust

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<NamedNode, ResourceDescription>),
CacheReadOnlyEntities(HashSet<ReadOnlyEntity>),
PopulateCaches,
LookupResourceDescriptions(HashSet<NamedNode>),
CacheResourceDescriptions(HashMap<NamedNode, ResourceDescription>),
WindowClosed(window::Id),
URLInputChanged(String),
URLInputSubmitted,
NavigateTo(Url),
FetchDocument(Url),
LoadDocument(RdfSource<KeyedDataset<RowState>>),
ResourceNotFound,
ShowNewDocumentButtons,
HideNewDocumentButtons,
ShowError(String),
AddRow(Option<(QuadKey, RowState)>),
DeleteRow(QuadKey),
OpenQueryWindow(SearchResultClickAction),
HoverRow(QuadKey),
UnhoverRow(QuadKey),
QueryUpdated(String),
SetSearchResults(Vec<HashMap<gl_search::Field, gl_search::OwnedValue>>),
UpdateQueryClass(Class),
SearchResultClicked(NamedNode),
DatatypeUpdated(QuadKey, Option<String>),
LanguageUpdated(QuadKey, Option<String>),
SubjectUpdated(QuadKey, String),
PredicateUpdated(QuadKey, String),
ObjectUpdated(QuadKey, String),
DirectionToggled(QuadKey, BaseDirection),
SaveGraph(bool),
ShowOverwriteConfirmationModal,
HideOverwriteConfirmationModal,
ConfirmOverwrite,
DownloadResource,
DownloadLocationSelected(Option<PathBuf>),
DownloadProgress((usize, Option<usize>)),
NewDocument(Class, Option<Url>),
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<String>,
}
#[derive(Debug, Clone)]
pub(crate) enum SearchResultClickAction {
URLInput,
Predicate(QuadKey),
Object(QuadKey),
}
struct SearchState {
window_id: window::Id,
action: SearchResultClickAction,
entity_class_selection: Option<Class>,
query: String,
results: Vec<HashMap<gl_search::Field, gl_search::OwnedValue>>,
}
enum DownloadState {
Idle,
InProgress(FileHandle, usize),
}
pub(crate) struct Publisher {
http_client: ClientWithMiddleware,
curie_helper: CurieHelper,
ontology: Option<ConnectedOntology>,
read_only_entities: HashSet<ReadOnlyEntity>,
abbreviated_datatypes: Vec<String>,
resource_descriptions: HashMap<NamedNode, ResourceDescription>,
window_id: window::Id,
inference_window_id: Option<window::Id>,
read_only_window_id: Option<window::Id>,
url_input: String,
url_input_valid: bool,
navigator: Navigator<Url>,
document: RdfSource<KeyedDataset<RowState>>,
download_state: DownloadState,
inferred_triples: Graph,
hovered_row: Option<QuadKey>,
search_state: Option<SearchState>,
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<TripleRef<'a>>) -> 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<Message>) {
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<Message> {
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<NamedNode> = 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<KeyedDataset<RowState>> = 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<NamedNode> = 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<Message> {
Subscription::batch([
window::close_events().map(Message::WindowClosed),
keyboard::listen().map(Message::Event),
])
}
fn view_inferred_class_table<'a>(
&'a self,
triples: impl IntoIterator<Item = TripleRef<'a>>,
) -> 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<NamedNodeRef>)| {
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::<Vec<_>>()
.join("\n");
text(textual_classes)
},
);
let mut inferred_classes: BTreeMap<NamedNodeRef, BTreeSet<NamedNodeRef>> = 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<Item = TripleRef<'a>>,
) -> 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<gl_search::Field, gl_search::OwnedValue>| {
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<gl_search::Field, gl_search::OwnedValue>| {
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<Message> =
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<Message> = 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<Element<Message>> = self
.document
.dataset()
.iter_both()
.filter(|(_, _, state)| !state.read_only)
.map(|(key, quad, state)| self.view_row(key, quad, state))
.collect();
let body: Element<Message> = 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<Element<'a, Message>>,
content: impl Into<Element<'a, Message>>,
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()
}