use malwaredb_server::State;
use malwaredb_server::db::admin::Source;
use std::collections::HashSet;
use std::sync::Arc;
use anyhow::Result;
use eframe::egui::{self, Id};
const COLUMN_HEADERS: [&str; 7] = [
"ID",
"Name",
"Description",
"Parent",
"URL",
"Files",
"Groups",
];
#[derive(Debug, Clone)]
pub struct SourceTable {
#[allow(dead_code)]
state: Arc<State>,
sources: Vec<Source>,
draft: Vec<Source>,
changed_ids: HashSet<usize>,
}
impl SourceTable {
pub async fn new(state: Arc<State>) -> Result<Self> {
let sources = state.db_type.list_sources().await?;
let draft = sources.clone();
Ok(Self {
state,
sources,
draft,
changed_ids: HashSet::new(),
})
}
fn all_valid(&self) -> bool {
self.draft
.iter()
.all(|s| is_valid_name(&s.name) && s.url.as_deref().is_none_or(is_valid_url))
}
pub fn ui(&mut self, ui: &mut egui::Ui) {
if !self.changed_ids.is_empty() {
ui.horizontal(|ui| {
if ui
.add_enabled(self.all_valid(), egui::Button::new("Save"))
.clicked()
{
eprintln!("Saving source information to database not yet implemented");
self.sources.clone_from(&self.draft);
self.changed_ids.clear();
}
if ui.button("Cancel").clicked() {
self.draft.clone_from(&self.sources);
self.changed_ids.clear();
}
});
}
let id_salt = Id::new("source_table");
let table = egui_table::Table::new()
.id_salt(id_salt)
.auto_size_mode(egui_table::AutoSizeMode::OnParentResize)
.num_rows(self.draft.len() as u64)
.columns(
(0..COLUMN_HEADERS.len())
.map(|_| egui_table::Column::new(100.0).resizable(true))
.collect::<Vec<_>>(),
);
table.show(ui, self);
}
}
fn is_valid_name(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
fn is_valid_url(url: &str) -> bool {
url.starts_with("http://") || url.starts_with("https://")
}
fn field_error(source: &Source, col_nr: usize) -> Option<&'static str> {
match col_nr {
1 if source.name.is_empty() => Some("Name cannot be blank"),
1 if !is_valid_name(&source.name) => {
Some("Name may only contain letters, digits, hyphens, and underscores")
}
4 if source.url.as_deref().is_some_and(|u| !is_valid_url(u)) => {
Some("URL must start with http:// or https://")
}
_ => None,
}
}
impl egui_table::TableDelegate for SourceTable {
fn header_cell_ui(&mut self, ui: &mut egui::Ui, cell: &egui_table::HeaderCellInfo) {
if let Some(header) = COLUMN_HEADERS.get(cell.group_index) {
ui.label(*header);
}
}
fn cell_ui(&mut self, ui: &mut egui::Ui, cell: &egui_table::CellInfo) {
let row = match usize::try_from(cell.row_nr).ok() {
Some(r) if r < self.draft.len() => r,
_ => return,
};
let parent_options: Vec<String> = self
.draft
.iter()
.enumerate()
.filter(|(i, _)| *i != row)
.map(|(_, s)| s.name.clone())
.collect();
let error = field_error(&self.draft[row], cell.col_nr);
let changed = {
let source = &mut self.draft[row];
match cell.col_nr {
0 => {
ui.label(source.id.to_string());
false
}
5 => {
ui.label(source.files.to_string());
false
}
6 => {
ui.label(source.groups.to_string());
false
}
1 => {
if error.is_some() {
ui.style_mut().visuals.extreme_bg_color = super::INVALID_BG;
}
let response = ui.text_edit_singleline(&mut source.name);
let changed = response.changed();
if let Some(msg) = error {
response.on_hover_text(msg);
}
changed
}
2 => {
let mut text = source.description.clone().unwrap_or_default();
let changed = ui.text_edit_singleline(&mut text).changed();
if changed {
source.description = if text.is_empty() { None } else { Some(text) };
}
changed
}
3 => {
let selected = source.parent.as_deref().unwrap_or("(none)");
let inner = egui::ComboBox::from_id_salt(("source_parent", row))
.selected_text(selected)
.show_ui(ui, |ui| {
let mut changed = ui
.selectable_value(&mut source.parent, None, "(none)")
.changed();
for name in &parent_options {
changed |= ui
.selectable_value(
&mut source.parent,
Some(name.clone()),
name.as_str(),
)
.changed();
}
changed
});
inner.inner.unwrap_or(false)
}
4 => {
if error.is_some() {
ui.style_mut().visuals.extreme_bg_color = super::INVALID_BG;
}
let mut text = source.url.clone().unwrap_or_default();
let response = ui.text_edit_singleline(&mut text);
let changed = response.changed();
if changed {
source.url = if text.is_empty() { None } else { Some(text) };
}
if let Some(msg) = error {
response.on_hover_text(msg);
}
changed
}
_ => false,
}
};
if changed {
self.changed_ids.insert(row);
} else if self.draft[row] == self.sources[row] {
self.changed_ids.remove(&row);
}
}
}