use crate::{
DiagramItem, LibraryModel, MainWindow, gui::library::detail::clear_current_detail_display,
};
use anyhow::Result;
use indicatrix_net::library::DesignSummary;
use indicatrix_vault::{
db::sqlite::{Database, DisplayFilters, SortOrder},
model::{entry::DiagramListItem, filter::RangeFilter},
};
use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel};
use std::{
collections::{BTreeSet, HashMap},
sync::{Arc, Mutex},
};
use tracing::warn;
fn is_local_url(url: &str) -> bool {
url.starts_with("local://")
}
pub(super) fn to_diagram_item(item: &DesignSummary) -> DiagramItem {
DiagramItem {
id: item.entry_id as i32,
title: item.title.clone().into(),
shape: item.shape.clone().unwrap_or_default().into(),
gear: item.index_gear.clone().unwrap_or_default().into(),
facets: item.facets_count.clone().unwrap_or_default().into(),
designer: item.designer_info.clone().unwrap_or_default().into(),
lw_ratio: item.lw_ratio.clone().unwrap_or_default().into(),
ri: item.refractive_index.clone().unwrap_or_default().into(),
ignored: item.ignored,
is_local: is_local_url(&item.url),
planner_excluded: false,
tags: ModelRc::new(VecModel::from(Vec::<slint::SharedString>::new())),
}
}
pub struct DiagramListRow {
pub item: DiagramListItem,
pub ignored: bool,
pub planner_excluded: bool,
pub tags: Vec<String>,
}
pub struct FetchedDiagramList {
pub rows: Vec<DiagramListRow>,
pub total: usize,
pub matched_count: usize,
pub excluded_for_missing_curves: usize,
pub count_unavailable: bool,
}
pub fn fetch_diagram_list(
db_mutex: &Arc<Mutex<Database>>,
search: &str,
shape_filter: &str,
gear_filter: &str,
range: &RangeFilter,
) -> Result<FetchedDiagramList> {
fetch_diagram_list_with_options(
db_mutex,
search,
shape_filter,
gear_filter,
range,
DisplaySearchOptions::default(),
)
}
#[derive(Debug, Clone, Copy, Default)]
pub struct DisplaySearchOptions<'a> {
pub order: SortOrder,
pub local_only: bool,
pub tag_filter: Option<&'a str>,
pub id_filter: Option<&'a [i64]>,
}
pub fn fetch_diagram_list_with_options(
db_mutex: &Arc<Mutex<Database>>,
search: &str,
shape_filter: &str,
gear_filter: &str,
range: &RangeFilter,
options: DisplaySearchOptions<'_>,
) -> Result<FetchedDiagramList> {
let db = db_mutex
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let result = fetch_diagram_list_from_db(&db, search, shape_filter, gear_filter, range, options);
drop(db);
result
}
fn fetch_diagram_list_from_db(
db: &Database,
search: &str,
shape_filter: &str,
gear_filter: &str,
range: &RangeFilter,
options: DisplaySearchOptions<'_>,
) -> Result<FetchedDiagramList> {
let clean_shape = if shape_filter == "All Shapes" {
"All"
} else {
shape_filter
};
let clean_gear = if gear_filter == "All Gears" {
"All"
} else {
gear_filter
};
let tag_filter_id = options
.tag_filter
.and_then(|name| db.tag_id_by_name(name).ok().flatten());
let filters = DisplayFilters {
order: options.order,
local_only: options.local_only,
tag_filter: tag_filter_id,
id_filter: options.id_filter,
};
let (result, matched_count) =
db.search_diagrams_display_with_count(search, clean_shape, clean_gear, range, filters)?;
let mut count_unavailable = false;
let total = db.get_total_count().unwrap_or_else(|e| {
warn!("get_total_count failed: {e}");
count_unavailable = true;
result.items.len()
});
let ids: Vec<i64> = result.items.iter().map(|item| item.id).collect();
let mut tags_by_entry = if ids.is_empty() {
HashMap::new()
} else {
db.tags_for_entries(&ids).unwrap_or_default()
};
let planner_excluded_ids = db.planner_excluded_among(&ids).unwrap_or_else(|e| {
warn!("planner_excluded_among failed: {e}");
BTreeSet::new()
});
let rows: Vec<DiagramListRow> = result
.items
.into_iter()
.map(|item| {
let ignored = item.ignored;
let planner_excluded = planner_excluded_ids.contains(&item.id);
let tags = tags_by_entry.remove(&item.id).unwrap_or_default();
DiagramListRow {
item,
ignored,
planner_excluded,
tags,
}
})
.collect();
Ok(FetchedDiagramList {
rows,
total,
matched_count,
excluded_for_missing_curves: result.excluded_for_missing_curves,
count_unavailable,
})
}
pub(super) fn fetch_diagram_list_with_own_connection(
db_mutex: &Arc<Mutex<Database>>,
search: &str,
shape_filter: &str,
gear_filter: &str,
range: &RangeFilter,
options: DisplaySearchOptions<'_>,
) -> Result<FetchedDiagramList> {
match Database::open_read_only(crate::gui::DB_PATH) {
Ok(read_db) => {
fetch_diagram_list_from_db(&read_db, search, shape_filter, gear_filter, range, options)
}
Err(e) => {
warn!(
"background search: could not open a dedicated read-only connection to \
{:?} ({e}); falling back to the shared database lock for this query",
crate::gui::DB_PATH
);
fetch_diagram_list_with_options(
db_mutex,
search,
shape_filter,
gear_filter,
range,
options,
)
}
}
}
fn row_to_item(row: DiagramListRow) -> DiagramItem {
DiagramItem {
id: row.item.id as i32,
title: row.item.title.into(),
shape: row.item.shape.unwrap_or_default().into(),
gear: row.item.index_gear.unwrap_or_default().into(),
facets: row.item.facets_count.unwrap_or_default().into(),
designer: row.item.designer_info.unwrap_or_default().into(),
lw_ratio: row.item.lw_ratio.unwrap_or_default().into(),
ri: row.item.refractive_index.unwrap_or_default().into(),
ignored: row.ignored,
is_local: is_local_url(&row.item.url),
planner_excluded: row.planner_excluded,
tags: ModelRc::new(VecModel::from(
row.tags
.into_iter()
.map(Into::into)
.collect::<Vec<SharedString>>(),
)),
}
}
fn list_unchanged(model: &ModelRc<DiagramItem>, rows: &[DiagramListRow]) -> bool {
model.row_count() == rows.len()
&& model
.iter()
.zip(rows)
.all(|(shown, row)| row_matches_item(row, &shown))
}
fn row_matches_item(row: &DiagramListRow, shown: &DiagramItem) -> bool {
let item = &row.item;
shown.id == item.id as i32
&& shown.title.as_str() == item.title
&& text_matches(&shown.shape, item.shape.as_deref())
&& text_matches(&shown.gear, item.index_gear.as_deref())
&& text_matches(&shown.facets, item.facets_count.as_deref())
&& text_matches(&shown.designer, item.designer_info.as_deref())
&& text_matches(&shown.lw_ratio, item.lw_ratio.as_deref())
&& text_matches(&shown.ri, item.refractive_index.as_deref())
&& shown.ignored == row.ignored
&& shown.planner_excluded == row.planner_excluded
&& shown.is_local == is_local_url(&item.url)
&& shown.tags.row_count() == row.tags.len()
&& shown
.tags
.iter()
.zip(&row.tags)
.all(|(shown_tag, tag)| shown_tag.as_str() == tag.as_str())
}
fn text_matches(shown: &SharedString, value: Option<&str>) -> bool {
shown.as_str() == value.unwrap_or_default()
}
pub fn apply_diagram_list_to_ui(ui: &MainWindow, fetched: FetchedDiagramList) {
let selected_id = ui.global::<LibraryModel>().get_selected_entry_id();
if selected_id >= 0
&& !fetched
.rows
.iter()
.any(|row| row.item.id == i64::from(selected_id))
{
clear_current_detail_display(ui);
}
let count_unavailable = fetched.count_unavailable;
if !list_unchanged(
&ui.global::<LibraryModel>().get_diagram_list(),
&fetched.rows,
) {
let slint_items: Vec<DiagramItem> = fetched.rows.into_iter().map(row_to_item).collect();
ui.global::<LibraryModel>()
.set_diagram_list(ModelRc::new(VecModel::from(slint_items)));
}
ui.global::<LibraryModel>()
.set_total_count(fetched.total as i32);
ui.global::<LibraryModel>()
.set_matched_count(fetched.matched_count as i32);
ui.global::<LibraryModel>()
.set_performance_excluded_count(fetched.excluded_for_missing_curves as i32);
if count_unavailable {
ui.global::<LibraryModel>()
.set_status_message("Count unavailable.".into());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row(id: i64, title: &str, tags: &[&str]) -> DiagramListRow {
DiagramListRow {
item: DiagramListItem {
id,
title: title.to_string(),
url: format!("local://{id}"),
design_id: None,
shape: Some("Round".to_string()),
index_gear: None,
facets_count: Some("57+8".to_string()),
designer_info: None,
lw_ratio: None,
refractive_index: None,
volume: None,
competition_diagram: None,
ignored: false,
},
ignored: false,
planner_excluded: false,
tags: tags.iter().map(|&tag| tag.to_string()).collect(),
}
}
fn shown(rows: Vec<DiagramListRow>) -> ModelRc<DiagramItem> {
let items: Vec<DiagramItem> = rows.into_iter().map(row_to_item).collect();
ModelRc::new(VecModel::from(items))
}
fn catalogue() -> Vec<DiagramListRow> {
vec![row(1, "Alpha", &[]), row(2, "Beta", &["keep"])]
}
#[test]
fn the_rows_a_model_was_built_from_are_unchanged() {
assert!(list_unchanged(&shown(catalogue()), &catalogue()));
}
#[test]
fn a_renamed_design_with_the_same_id_is_a_change() {
let mut rows = catalogue();
rows[1].item.title = "Beta II".to_string();
assert!(!list_unchanged(&shown(catalogue()), &rows));
}
#[test]
fn a_changed_tag_list_is_a_change() {
let mut rows = catalogue();
rows[0].tags.push("new".to_string());
assert!(!list_unchanged(&shown(catalogue()), &rows));
let mut retagged = catalogue();
retagged[1].tags = vec!["other".to_string()];
assert!(!list_unchanged(&shown(catalogue()), &retagged));
}
#[test]
fn a_flipped_ignored_flag_is_a_change() {
let mut rows = catalogue();
rows[0].ignored = true;
assert!(!list_unchanged(&shown(catalogue()), &rows));
}
#[test]
fn a_flipped_planner_flag_is_a_change() {
let mut rows = catalogue();
rows[0].planner_excluded = true;
assert!(!list_unchanged(&shown(catalogue()), &rows));
assert!(!list_unchanged(&shown(rows), &catalogue()));
}
#[test]
fn a_different_order_or_length_is_a_change() {
let mut reordered = catalogue();
reordered.reverse();
assert!(!list_unchanged(&shown(catalogue()), &reordered));
let mut shorter = catalogue();
shorter.pop();
assert!(!list_unchanged(&shown(catalogue()), &shorter));
assert!(!list_unchanged(&shown(shorter), &catalogue()));
}
}