This commit is contained in:
2026-08-05 11:21:25 -04:00
parent 921d56f2b3
commit 7ce5ed118d
18 changed files with 1144 additions and 213 deletions
+31 -111
View File
@@ -1,40 +1,34 @@
use std::collections::HashMap;
use crate::rdf::ontology::{IndexEntry, LabeledIri, Ontology};
use crate::navigator::Navigator;
use crate::rdf::curie::CurieHelper;
use crate::rdf::ontology::{LabeledIri, Ontology};
use crate::rdf::term_helper::{TermHelper, TermHelperMut};
use gl_search::{Schema, SearchDocument, SearchIndex, doc, Value};
use crate::rdf::vocab::rda;
use crate::widget::iri_input::iri_input;
use crate::widget::navigation_area::navigation_area;
use gl_search::language;
use gl_search::{IndexWriter, Schema, SearchDocument, SearchIndex, Value};
use http::StatusCode;
use iced::alignment::Horizontal;
use iced::keyboard::{Event, key};
use iced::widget::button::Style;
use iced::widget::grid::Sizing;
use iced::widget::text::Wrapping;
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, Length, Subscription, Task, color, window};
use iced::widget::text::Wrapping;
use iced::{Background, Color, Element, Length, Subscription, Task, color, keyboard, window};
use ldp::middleware::BasicAuthMiddleware;
use ldp::model::{KeyedDataset, QuadKey};
use ldp::reqwest::{Client, Url};
use ldp::reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
use ldp::traverse::Traverse;
use ldp::{RdfSource, RdfSourceUpdateResponse, ResourceRequestBuilder, SerializationOptions};
use oxigraph::io::RdfFormat;
use oxigraph::model::vocab::{rdf, rdfs};
use oxigraph::model::{BaseDirection, Dataset, NamedNode, NamedOrBlankNode, Quad, Term};
use tracing::{debug_span, error, field, trace};
use crate::navigator::Navigator;
use crate::rdf::curie::CurieHelper;
use crate::rdf::language;
use crate::rdf::vocab::rda;
use crate::widget::iri_input::iri_input;
use crate::widget::navigation_area::navigation_area;
use tracing::{debug_span, error, trace};
#[derive(Clone, Debug)]
pub(crate) enum Message {
None,
RebuildIndex,
Traverse,
AddRdfSource(RdfSource<Dataset>),
ConcludeTraversal,
IndexQueryResults(HashMap<NamedNode, IndexEntry>),
WindowClosed(window::Id),
URLInputChanged(String),
URLInputSubmitted,
@@ -71,6 +65,7 @@ pub(crate) enum Message {
NavigateForward,
ResetState,
SetReadOnly(bool),
Event(Event),
}
#[derive(Default, Debug, Clone)]
@@ -181,90 +176,6 @@ impl Publisher {
trace!(?message);
match message {
Message::RebuildIndex => {
let mut writer = self.index.writer().expect("Unable to obtain index writer");
let clear_index_task = Task::perform(tokio::task::spawn_blocking(move || {
let _span = debug_span!("Clear Index").entered();
writer.delete_all_documents().expect("Unable to clear index");
writer.commit().expect("Unable to commit index operation");
}), |_| Message::None);
let index_future = self.ontology
.query_for_indexable_triples(&*language::ENGLISH_OR_UNTAGGED, None);
let index_task = Task::perform(index_future, |task_result| {
match task_result {
Ok(results) => Message::IndexQueryResults(results),
Err(err) => Message::ShowError(err.to_string()),
}
});
task = clear_index_task.chain(Task::batch([
index_task,
Task::done(Message::Traverse),
]));
}
Message::Traverse => {
let client = self.http_client.clone();
let root = Url::parse(&self.url_input).expect("Invalid URL");
let stream = Traverse::new(client, root, None);
task = Task::run(stream, |result| match result {
Ok(rdf_source) => Message::AddRdfSource(rdf_source),
Err(err) => Message::ShowError(format!("Unable to fetch RDF Source: {err}")),
}).chain(Task::done(Message::ConcludeTraversal));
self.traversal = Some(self.ontology.to_dataset());
}
Message::AddRdfSource(rdf_source) => {
if let Some(traversal) = &mut self.traversal {
traversal.extend(rdf_source.dataset());
}
}
Message::ConcludeTraversal => {
if let Some(traversal) = self.traversal.take() {
let index_task = self.ontology
.query_for_indexable_triples(&*language::ENGLISH_OR_UNTAGGED, Some(traversal));
task = Task::perform(index_task, |task_result| {
match task_result {
Ok(results) => Message::IndexQueryResults(results),
Err(err) => Message::ShowError(err.to_string()),
}
});
}
}
Message::IndexQueryResults(results) => {
let mut writer = self.index.writer().expect("Unable to obtain index writer");
let curie_helper = self.curie_helper.clone();
task = Task::perform(tokio::task::spawn_blocking(move || {
let span = debug_span!("Index Query Results", documents = field::Empty).entered();
let mut counter = 0usize;
for (individual, entry) in results {
let mut document = doc!(
Schema::type_field() => entry.category_id,
Schema::iri_field() => individual.as_str(),
);
if let Some(curie) = curie_helper.abbreviate(None, individual.as_str()) {
document.add_text(Schema::curie_field(), curie);
}
for (key, value) in entry.fields {
document.add_text(Schema::field(&key, language::ENGLISH_PRIMARY), value.as_str());
}
writer.add_document(document).expect("Failed to add document to search index");
counter += 1;
}
span.record("documents", counter);
writer.commit()
}), |result| {
if let Err(err) = result {
Message::ShowError(err.to_string())
} else {
Message::None
}
});
}
Message::WindowClosed(id) => {
if self.window_id == id {
task = iced::exit();
@@ -355,9 +266,8 @@ impl Publisher {
.map(|datatype| self.curie_helper.abbreviate(None, datatype.as_str())
.unwrap_or(datatype.as_str().to_string()));
let datatype_state = combo_box::State::with_selection(
let datatype_state = combo_box::State::new(
self.abbreviated_datatypes.clone(),
datatype.as_ref(),
);
let state = RowState {
read_only,
@@ -425,7 +335,7 @@ impl Publisher {
let query = new_query.clone();
search_state.query = new_query;
let results = self.index.query(category_id, query.as_str(), Schema::all_fields(), 10)
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));
};
@@ -616,6 +526,17 @@ impl Publisher {
Message::SetReadOnly(value) => {
self.show_read_only = value;
}
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
@@ -628,6 +549,7 @@ impl Publisher {
pub(crate) fn subscription(&self) -> Subscription<Message> {
Subscription::batch([
window::close_events().map(Message::WindowClosed),
keyboard::listen().map(Message::Event),
])
}
@@ -697,7 +619,7 @@ impl Publisher {
// If the datatype is something, then it's not a named node, and we should use a plain
// text_input as opposed to an iri_input.
let object_input: Element<Message> = if term.datatype().is_some() {
let base = text_input("Object", value.as_str());
let base = text_input("Object", value.clone());
if self.ontology.is_read_only(triple.as_ref()) {
base.into()
} else {
@@ -734,9 +656,10 @@ impl Publisher {
}).into()
};
let language = term.language().unwrap_or("en").to_owned();
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| {
text_input("Language", language).on_input(move |input| {
let value = if input.is_empty() { None } else { Some(input) };
Message::LanguageUpdated(key, value)
}),
@@ -848,7 +771,7 @@ impl Publisher {
.and_then(|value| value.as_str())
.unwrap_or_default();
let value = document.get_first(Schema::field(&field_name, language::ENGLISH_PRIMARY))
let value = document.get_first(Schema::field(&field_name, Some(language::ENGLISH_PRIMARY)))
.and_then(|value| value.as_str())
.unwrap_or_default();
@@ -877,8 +800,6 @@ impl Publisher {
let add_row_button = button("Add row").on_press(Message::AddRow(None));
let reindex_ontology_button = button("Rebuild index").on_press(Message::RebuildIndex);
let address_input = iri_input(&self.curie_helper, "URL", None, &self.url_input)
.on_input(Message::URLInputChanged)
.on_submit(Message::URLInputSubmitted)
@@ -920,7 +841,6 @@ impl Publisher {
let content = navigation_area(column![
row![
reindex_ontology_button,
add_row_button,
back_button,
forward_button,
+23
View File
@@ -0,0 +1,23 @@
use clap::{Args, Parser, Subcommand};
#[derive(Args)]
pub(crate) struct SearchArgs {
#[arg(short, long, value_name = "DOC TYPE")]
pub(crate) discriminant: Option<u64>,
#[arg(value_name = "QUERY")]
pub(crate) query: String,
}
#[derive(Subcommand)]
pub(crate) enum Command {
Search(SearchArgs),
Reindex,
}
#[derive(Parser)]
#[clap(version, long_about = None)]
pub(crate) struct AppArgs {
#[command(subcommand)]
pub(crate) command: Option<Command>,
}
+122 -5
View File
@@ -5,12 +5,29 @@ mod windows;
mod widget;
mod navigator;
mod theme;
mod args;
use crate::app::Publisher;
use std::sync::{Arc, RwLock};
use std::thread;
use clap::Parser;
use color_eyre::eyre::eyre;
use iced::futures::StreamExt;
use iced::Task;
use ldp::middleware::BasicAuthMiddleware;
use ldp::reqwest::{Client, Url};
use ldp::reqwest_middleware::ClientBuilder;
use ldp::traverse::Traverse;
use oxigraph::model::Dataset;
use tracing::{debug, debug_span, error, field};
use crate::app::{Message, Publisher};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, fmt};
use tracing_subscriber::fmt::format::FmtSpan;
use gl_search::{DocType, Document, Schema, SearchIndex};
use crate::args::{AppArgs, Command};
use crate::rdf::curie::CurieHelper;
use crate::rdf::ontology::Ontology;
fn main() -> color_eyre::Result<()> {
let appender = tracing_appender::rolling::never("/tmp", "publisher-log");
@@ -20,10 +37,110 @@ fn main() -> color_eyre::Result<()> {
.init();
color_eyre::install()?;
iced::daemon(Publisher::new, Publisher::update, Publisher::view)
.title(Publisher::title)
.subscription(Publisher::subscription)
.run()?;
let ontology = Ontology::builder()
.with_path("/home/alex/.local/share/org.graphofliberty.desktop/ontology")
.build()
.expect("Failed to build ontology");
let mut index = SearchIndex::builder()
.with_path("/home/alex/.local/share/org.graphofliberty.desktop/index")
.build()
.expect("Failed to build search index");
let args = AppArgs::parse();
match args.command {
Some(Command::Search(args)) => {
for doc in index.query(args.discriminant, &args.query, Schema::all_fields(), 5)? {
println!("{}", doc.to_json(Schema::schema()));
}
}
Some(Command::Reindex) => {
let curie_helper = CurieHelper::new(Ontology::prefixes().clone());
let writer = Arc::new(RwLock::new(index.writer()?));
debug_span!("Clear Index").in_scope(|| {
let mut writer = writer.write()?;
writer.delete_all_documents()?;
writer.commit()
})?;
let store = ontology.store();
let writer_clone = writer.clone();
let index_classes_thread = thread::spawn(move || {
let span = debug_span!("Index Classes", documents = field::Empty).entered();
let writer = writer_clone.read().unwrap();
let count = gl_search::rdf::index_triples(DocType::RdfsClass, store, &*gl_search::language::ENGLISH_OR_UNTAGGED, &*writer)?;
span.record("documents", count);
Ok::<(), gl_search::SearchError>(())
});
let store = ontology.store();
let writer_clone = writer.clone();
let index_properties_thread = thread::spawn(move || {
let span = debug_span!("Index Properties", documents = field::Empty).entered();
let writer = writer_clone.read().unwrap();
let count = gl_search::rdf::index_triples(DocType::RdfProperty, store, &*gl_search::language::ENGLISH_OR_UNTAGGED, &writer)?;
span.record("documents", count);
Ok::<(), gl_search::SearchError>(())
});
let store = ontology.store();
let writer_clone = writer.clone();
let index_concepts_thread = thread::spawn(move || {
let span = debug_span!("Index Concepts", documents = field::Empty).entered();
let writer = writer_clone.read().unwrap();
let count = gl_search::rdf::index_triples(DocType::SkosConcept, store, &*gl_search::language::ENGLISH_OR_UNTAGGED, &writer)?;
span.record("documents", count);
Ok::<(), gl_search::SearchError>(())
});
let starting_url = Url::parse("http://fedora.quill.lan/rest/")?;
let writer_clone = writer.clone();
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.name("repository-traversal")
.build()?;
let traversal_task = runtime.spawn(async move {
let http_client = ClientBuilder::new(Client::new())
.with(BasicAuthMiddleware::new(
"fedoraAdmin".to_string(),
Some("fedoraAdmin".to_string()),
))
.build();
let mut documents = 0usize;
let mut traversal = Traverse::new(http_client, starting_url, None);
while let Some(result) = traversal.next().await {
match result {
Ok(rdf_source) => {
let writer = writer_clone.read().unwrap();
documents += gl_search::rdf::index_entity(rdf_source.dataset(), &*gl_search::language::ENGLISH_OR_UNTAGGED, &writer)?;
}
Err(err) => error!(?err),
}
}
debug!(documents, "Repository Traversal");
Ok::<(), gl_search::SearchError>(())
});
index_classes_thread.join().unwrap()?;
index_properties_thread.join().unwrap()?;
index_concepts_thread.join().unwrap()?;
runtime.block_on(traversal_task)??;
debug_span!("Commit").in_scope(|| {
let mut writer = writer.write()?;
writer.commit()
})?;
}
None => {
iced::daemon(Publisher::new, Publisher::update, Publisher::view)
.title(Publisher::title)
.subscription(Publisher::subscription)
.run()?;
}
}
Ok(())
}
-46
View File
@@ -1,46 +0,0 @@
use std::sync::LazyLock;
use oxigraph::model::Term;
use oxilangtag::LanguageTag;
pub const ENGLISH_PRIMARY: &str = "en";
pub static ENGLISH_OR_UNTAGGED: LazyLock<LanguageCondition> = LazyLock::new(|| {
LanguageCondition::ExactMatchOrUntagged(LanguageTag::parse(ENGLISH_PRIMARY.to_string()).unwrap())
});
pub enum LanguageCondition {
ExactMatchOnly(LanguageTag<String>),
ExactMatchOrUntagged(LanguageTag<String>),
UntaggedOnly,
AnyOrNone,
}
impl LanguageCondition {
pub fn primary_matches_term(&self, term: &Term) -> bool {
if let Term::Literal(literal) = term {
let tag = literal.language()
.map(LanguageTag::parse_and_normalize)
.and_then(Result::ok);
match (tag, self) {
(Some(language), LanguageCondition::ExactMatchOnly(expectation)) |
(Some(language), LanguageCondition::ExactMatchOrUntagged(expectation)) => language.primary_language() == expectation.primary_language(),
(None, LanguageCondition::ExactMatchOrUntagged(_)) => true,
(None, LanguageCondition::UntaggedOnly) => true,
(_, LanguageCondition::AnyOrNone) => true,
_ => false,
}
} else {
false
}
}
pub fn to_filter_expression(&self, var: &str) -> String {
match self {
LanguageCondition::ExactMatchOnly(language) => format!(r#"FILTER(langMATCHES(LANG(?{var}), "{language}"))"#),
LanguageCondition::ExactMatchOrUntagged(language) => format!(r#"FILTER(langMATCHES(LANG(?{var}), "{language}") || !hasLANG(?{var}))"#),
LanguageCondition::UntaggedOnly => format!("FILTER(!hasLANG(?{var}))"),
LanguageCondition::AnyOrNone => "".to_string(),
}
}
}
-1
View File
@@ -3,5 +3,4 @@ pub(crate) mod term_helper;
pub mod vocab;
pub(crate) mod materialize;
pub(crate) mod conversion;
pub(crate) mod language;
pub(crate) mod curie;
+9 -4
View File
@@ -1,17 +1,17 @@
use crate::error;
use crate::rdf::vocab::gl;
use crate::rdf::{conversion, materialize};
use gl_search::language::LanguageCondition;
use iced::futures::TryFutureExt;
use oxigraph::model::vocab::{rdf, rdfs, xsd};
use oxigraph::model::{Dataset, Graph, GraphNameRef, LiteralRef, NamedNode, NamedNodeRef, NamedOrBlankNode, NamedOrBlankNodeRef, Term, TermRef, Triple, TripleRef};
use oxigraph::sparql::{QueryResults, SparqlEvaluator};
use oxigraph::store::Store;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fmt::Display;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use iced::futures::TryFutureExt;
use oxigraph::store::Store;
use tracing::{debug_span, field};
use crate::rdf::{conversion, materialize};
use crate::rdf::language::LanguageCondition;
const ONTOLOGY_PREFIX: &str = "https://graphofliberty.org/2026/04/ont/";
const ONTOLOGY_GRAPH_NAME: GraphNameRef = GraphNameRef::NamedNode(NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont"));
@@ -50,6 +50,7 @@ static PREFIXES: LazyLock<BTreeMap<String, String>> = LazyLock::new(|| {
("rdax", "http://rdaregistry.info/Elements/x/"),
("rdaco", "http://rdaregistry.info/termList/RDAContentType/"),
("rdact", "http://rdaregistry.info/termList/RDACarrierType/"),
("rdamt", "http://rdaregistry.info/termList/RDAMediaType/"),
("rdaft", "http://rdaregistry.info/termList/fileType/"),
("schema", "https://schema.org/"),
("gl", "http://fedora.quill.lan/rest/"),
@@ -179,6 +180,10 @@ impl Ontology {
.collect::<Dataset>()
}
pub fn store(&self) -> Store {
self.store.clone()
}
pub fn query_for_indexable_triples(&self, language: &LanguageCondition, source: Option<Dataset>) -> impl Future<Output = error::Result<HashMap<NamedNode, IndexEntry>>> + 'static {
let language_filter = language.to_filter_expression("fieldValue");
let query = format!(r#"SELECT ?individual ?categoryId ?fieldName ?fieldValue
+24 -8
View File
@@ -1,8 +1,8 @@
use iced::{alignment, keyboard, widget, Element, Event, Length, Rectangle, Size, Theme, Background, Border};
use iced::{alignment, keyboard, widget, Element, Event, Length, Rectangle, Size};
use iced::advanced::{mouse, text, Shell, renderer};
use iced::advanced::layout::{Limits, Node};
use iced::advanced::{Layout, Widget};
use iced::advanced::widget::{tree, Tree};
use iced::advanced::widget::{tree, Tree, Operation};
use iced::clipboard::Content;
use iced::mouse::{Cursor, Interaction};
use iced::widget::text_input::{Catalog, Status, Style, StyleFn};
@@ -31,9 +31,9 @@ where
Theme: Catalog,
Renderer: text::Renderer,
{
pub fn new(curie_helper: &'a CurieHelper, placeholder: &str, base: Option<&str>, iri: &str) -> Self {
pub fn new(curie_helper: &'a CurieHelper, placeholder: &'a str, base: Option<&str>, iri: &str) -> Self {
let display_value = curie_helper.abbreviate(base, iri).unwrap_or_else(|| iri.to_string());
let text_input = widget::TextInput::new(placeholder, &display_value);
let text_input = widget::TextInput::new(placeholder, display_value);
Self {
curie_helper,
base: base.map(String::from),
@@ -44,7 +44,7 @@ where
}
#[must_use]
pub fn align_x(mut self, alignment: impl Into<alignment::Horizontal>) -> Self {
pub fn align_x(mut self, alignment: impl Into<text::Alignment>) -> Self {
self.text_input = self.text_input.align_x(alignment);
self
}
@@ -87,6 +87,12 @@ where
self.text_input = self.text_input.style(style);
self
}
#[must_use]
pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
self.text_input = self.text_input.id(id);
self
}
}
impl<'a, Message, Theme, Renderer> From<IriInput<'a, Message, Theme, Renderer>>
@@ -94,14 +100,14 @@ for Element<'a, Message, Theme, Renderer>
where
Message: Clone + 'a,
Theme: Catalog + 'a,
Renderer: text::Renderer + 'a,
Renderer: text::Renderer + 'static,
{
fn from(value: IriInput<'a, Message, Theme, Renderer>) -> Self {
Element::new(value)
}
}
pub fn iri_input<'a, Message, Theme, Renderer>(curie_helper: &'a CurieHelper, placeholder: &str, base: Option<&str>, iri: &str) -> IriInput<'a, Message, Theme, Renderer>
pub fn iri_input<'a, Message, Theme, Renderer>(curie_helper: &'a CurieHelper, placeholder: &'a str, base: Option<&str>, iri: &str) -> IriInput<'a, Message, Theme, Renderer>
where
Message: Clone,
Theme: Catalog,
@@ -114,7 +120,7 @@ impl <Message, Theme, Renderer> Widget<Message, Theme, Renderer> for IriInput<'_
where
Message: Clone,
Theme: Catalog,
Renderer: text::Renderer,
Renderer: text::Renderer + 'static,
{
fn size(&self) -> Size<Length> {
Widget::size(&self.text_input)
@@ -143,6 +149,16 @@ where
tree.diff_children(&mut [&mut self.text_input as &mut dyn Widget<_, _, _>]);
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn Operation,
) {
self.text_input.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(&mut self, tree: &mut Tree, event: &Event, layout: Layout<'_>, cursor: Cursor, renderer: &Renderer, shell: &mut Shell<'_, Message>, viewport: &Rectangle) {
let state = tree.state.downcast_mut::<State>();