use crate::crypto::Crypto; use crate::ipc::{self, HistoryItem}; use chrono::{Local, NaiveDate, TimeZone}; use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2}; use ratatui::widgets::ListState; use ratatui_image::{picker::Picker, protocol}; use regex::Regex; use std::time::{Duration, Instant}; use syntect::highlighting::ThemeSet; use syntect::parsing::SyntaxSet; const PREVIEW_MAX_WIDTH: u32 = 1280; const PREVIEW_MAX_HEIGHT: u32 = 720; #[derive(PartialEq, Clone)] pub enum Mode { Normal, Command, Search, ConfirmDelete, PasswordInput, } #[derive(Clone)] pub enum PendingAction { EncryptSelected, DecryptSelected, PasteEncrypted, } pub struct App { pub mode: Mode, pub all_items: Vec, pub filtered_items: Vec, pub list_state: ListState, pub input_buffer: String, pub should_quit: bool, pub undo_stack: Vec, pub current_image: Option, pub last_selected_index: Option, pub picker: Picker, pub preview_scroll: u16, pub crypto: Option, pub salt: Vec, pub pending_action: Option, pub error_message: Option<(String, Instant)>, pub status_message: Option<(String, Instant)>, pub syntax_set: SyntaxSet, pub theme_set: ThemeSet, pub type_filter: TypeFilter, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TypeFilter { All, Text, Image, } impl TypeFilter { pub fn next(self) -> Self { match self { Self::All => Self::Text, Self::Text => Self::Image, Self::Image => Self::All, } } pub fn label(self) -> &'static str { match self { Self::All => "Tous", Self::Text => "Texte", Self::Image => "Image", } } } impl App { pub fn new() -> Self { let items = ipc::fetch_history(200).unwrap_or_default(); let mut list_state = ListState::default(); list_state.select(if items.is_empty() { None } else { Some(0) }); let picker = Picker::from_query_stdio().unwrap_or_else(|_| Picker::halfblocks()); let salt = directories::ProjectDirs::from("com", "zefad", "rklipd") .and_then(|d| Crypto::load_or_create_salt(d.data_dir()).ok()) .unwrap_or_else(|| vec![0u8; 32]); let mut app = Self { mode: Mode::Normal, filtered_items: items.clone(), all_items: items, list_state, input_buffer: String::new(), should_quit: false, undo_stack: Vec::new(), current_image: None, last_selected_index: None, picker, preview_scroll: 0, crypto: None, salt, pending_action: None, error_message: None, status_message: None, syntax_set: SyntaxSet::load_defaults_newlines(), theme_set: ThemeSet::load_defaults(), type_filter: TypeFilter::All, }; app.update_preview(); app } pub fn format_timestamp(ts_ms: i64) -> String { let secs = ts_ms / 1000; let nsecs = ((ts_ms % 1000) * 1_000_000) as u32; match Local.timestamp_opt(secs, nsecs) { chrono::LocalResult::Single(dt) => { let diff = Local::now().signed_duration_since(dt); if diff.num_days() == 0 { dt.format("%H:%M:%S").to_string() } else if diff.num_days() < 365 { dt.format("%d %b %H:%M").to_string() } else { dt.format("%d/%m/%Y").to_string() } } _ => "?".to_string(), } } pub fn cycle_type_filter(&mut self) { self.type_filter = self.type_filter.next(); self.update_search(); } pub fn update_search(&mut self) { self.last_selected_index = None; let query = self.input_buffer.trim().to_string(); let (date_before, date_after, text_query) = parse_date_filters(&query); let base: Vec = self .all_items .iter() .filter(|item| { let ts_s = item.timestamp / 1000; if let Some(before) = date_before { if ts_s >= before { return false; } } if let Some(after) = date_after { if ts_s < after { return false; } } true }) .cloned() .collect(); let search_str = |item: &HistoryItem| -> String { if Crypto::is_any_encrypted(&item.content) { "[chiffré]".to_string() } else if item.content.ends_with(".jpg") || item.content.ends_with(".png") { format!("image {}", item.content) } else { item.content.clone() } }; let is_regex = text_query.starts_with('/') && text_query.len() > 1; self.filtered_items = if text_query.is_empty() { base } else if is_regex { let pattern = &text_query[1..]; match Regex::new(pattern) { Ok(re) => base .into_iter() .filter(|item| re.is_match(&search_str(item))) .collect(), Err(e) => { self.error_message = Some(( format!( "Regex invalide : {}", e.to_string().lines().next().unwrap_or("") ), Instant::now(), )); base } } } else { let matcher = SkimMatcherV2::default(); let mut matched: Vec<(i64, HistoryItem)> = base .into_iter() .filter_map(|item| { matcher .fuzzy_match(&search_str(&item), &text_query) .map(|s| (s, item)) }) .collect(); matched.sort_by(|a, b| b.0.cmp(&a.0)); matched.into_iter().map(|(_, i)| i).collect() }; self.filtered_items.retain(|item| match self.type_filter { TypeFilter::All => true, TypeFilter::Text => !item.content.ends_with(".jpg") && !item.content.ends_with(".png"), TypeFilter::Image => item.content.ends_with(".jpg") || item.content.ends_with(".png"), }); self.list_state.select(if self.filtered_items.is_empty() { None } else { Some(0) }); self.update_preview(); } pub fn delete_selected(&mut self) { if let Some(i) = self.list_state.selected() { if i < self.filtered_items.len() { let item = self.filtered_items.remove(i); // On stocke juste l'item (plus l'index filtré qui était faux) self.undo_stack.push(item.clone()); self.all_items.retain(|x| x.content != item.content); let new_sel = if self.filtered_items.is_empty() { None } else if i >= self.filtered_items.len() { Some(self.filtered_items.len() - 1) } else { Some(i) }; self.list_state.select(new_sel); } } self.update_preview(); } pub fn undo_delete(&mut self) { if let Some(item) = self.undo_stack.pop() { ipc::add_entry(item.content.clone()); if let Some(new_items) = ipc::fetch_history(200) { self.all_items = new_items; } else { self.all_items.insert(0, item.clone()); } self.update_search(); if let Some(pos) = self .filtered_items .iter() .position(|x| x.content == item.content) { self.list_state.select(Some(pos)); self.last_selected_index = None; } } self.update_preview(); } pub fn toggle_encrypt(&mut self) { let content = match self.get_selected_item() { Some(i) => i.content.clone(), None => return, }; if Crypto::is_legacy_encrypted(&content) { self.set_error( "Entrée chiffrée avec l'ancienne clé machine — non modifiable ici".into(), ); return; } self.crypto = None; if Crypto::is_password_encrypted(&content) { self.pending_action = Some(PendingAction::DecryptSelected); } else { self.pending_action = Some(PendingAction::EncryptSelected); } self.enter_password_mode(); } fn enter_password_mode(&mut self) { self.mode = Mode::PasswordInput; self.input_buffer.clear(); } pub fn apply_password(&mut self, password: String) { if password.is_empty() { self.set_error("Mot de passe vide".into()); return; } match Crypto::from_password(&password, &self.salt) { Ok(crypto) => { self.crypto = Some(crypto); match self.pending_action.take() { Some(PendingAction::EncryptSelected) => self.do_encrypt_selected(), Some(PendingAction::DecryptSelected) => self.do_decrypt_selected(), Some(PendingAction::PasteEncrypted) => self.do_paste_encrypted(), None => self.set_status("Mot de passe défini pour la session".into()), } } Err(e) => self.set_error(format!("Erreur crypto : {e}")), } } fn do_encrypt_selected(&mut self) { let content = match self.get_selected_item() { Some(i) => i.content.clone(), None => return, }; let encrypt_result = match &self.crypto { Some(key) => key.encrypt(&content), None => return, }; self.crypto = None; match encrypt_result { Ok(enc) => { if ipc::update_entry(content.clone(), enc.clone()) { self.replace_content(&content, enc); self.set_status("Entrée chiffrée 🔒".into()); } else { self.set_error("Erreur mise à jour BDD".into()); } } Err(e) => self.set_error(format!("Chiffrement : {e}")), } } fn do_decrypt_selected(&mut self) { let content = match self.get_selected_item() { Some(i) => i.content.clone(), None => return, }; let decrypt_result = match &self.crypto { Some(key) => key.decrypt(&content), None => return, }; self.crypto = None; match decrypt_result { Ok(plain) => { if ipc::update_entry(content.clone(), plain.clone()) { self.replace_content(&content, plain); self.set_status("Entrée déchiffrée".into()); } else { self.set_error("Erreur mise à jour BDD".into()); } } Err(_) => { self.set_error( "Déchiffrement échoué — mauvais mot de passe. Réessayez avec [e].".into(), ); } } } fn do_paste_encrypted(&mut self) { let content = match self.get_selected_item() { Some(i) => i.content.clone(), None => return, }; let decrypt_result = match &self.crypto { Some(key) => key.decrypt(&content), None => return, }; self.crypto = None; match decrypt_result { Ok(plain) => { if ipc::set_clipboard(plain) { self.should_quit = true; } else { self.set_error("Erreur : impossible de définir le presse-papier".into()); } } Err(_) => { self.set_error( "Déchiffrement échoué — mauvais mot de passe. Réessayez avec Entrée.".into(), ); } } } fn replace_content(&mut self, old: &str, new: String) { if let Some(p) = self.all_items.iter().position(|x| x.content == old) { self.all_items[p].content = new.clone(); } if let Some(p) = self.filtered_items.iter().position(|x| x.content == old) { self.filtered_items[p].content = new; } self.last_selected_index = None; self.update_preview(); } pub fn paste_selected(&mut self) { let content = match self.get_selected_item().map(|i| i.content.clone()) { Some(c) => c, None => return, }; if Crypto::is_password_encrypted(&content) { self.crypto = None; self.pending_action = Some(PendingAction::PasteEncrypted); self.enter_password_mode(); } else if ipc::set_clipboard(content) { self.should_quit = true; } else { self.set_error("Impossible de définir le presse-papier (daemon injoignable ?)".into()); } } pub fn clear_history(&mut self) { if ipc::clear_history() { self.all_items.clear(); self.filtered_items.clear(); self.undo_stack.clear(); self.list_state.select(None); self.current_image = None; self.set_status("Historique effacé".into()); } else { self.set_error("Erreur lors de l'effacement".into()); } } pub fn update_preview(&mut self) { let idx = self.list_state.selected(); if self.last_selected_index == idx { return; } self.last_selected_index = idx; self.current_image = None; self.preview_scroll = 0; let content = match self.get_selected_item().map(|i| i.content.clone()) { Some(c) => c, None => return, }; if content.ends_with(".jpg") || content.ends_with(".png") { if let Some(dirs) = directories::ProjectDirs::from("com", "zefad", "rklipd") { let path = dirs.data_dir().join("images").join(&content); if path.exists() { if let Ok(img) = image::open(&path) { let img = if img.width() > PREVIEW_MAX_WIDTH || img.height() > PREVIEW_MAX_HEIGHT { img.thumbnail(PREVIEW_MAX_WIDTH, PREVIEW_MAX_HEIGHT) } else { img }; self.current_image = Some(self.picker.new_resize_protocol(img)); } } } } } pub fn scroll_preview_down(&mut self) { self.preview_scroll = self.preview_scroll.saturating_add(3); } pub fn scroll_preview_up(&mut self) { self.preview_scroll = self.preview_scroll.saturating_sub(3); } pub fn next(&mut self) { if self.filtered_items.is_empty() { return; } let i = self.list_state.selected().map_or(0, |i| { if i >= self.filtered_items.len() - 1 { 0 } else { i + 1 } }); self.list_state.select(Some(i)); self.update_preview(); } pub fn previous(&mut self) { if self.filtered_items.is_empty() { return; } let i = self.list_state.selected().map_or(0, |i| { if i == 0 { self.filtered_items.len() - 1 } else { i - 1 } }); self.list_state.select(Some(i)); self.update_preview(); } pub fn get_selected_item(&self) -> Option<&HistoryItem> { self.list_state .selected() .and_then(|i| self.filtered_items.get(i)) } pub fn sync_with_daemon(&mut self) { if let Some(new) = ipc::fetch_history(200) { let changed = self.all_items.len() != new.len() || self .all_items .iter() .zip(&new) .any(|(a, b)| a.content != b.content); if changed { let selected_content = self.get_selected_item().map(|i| i.content.clone()); self.all_items = new; self.update_search(); if let Some(content) = selected_content { if let Some(pos) = self .filtered_items .iter() .position(|x| x.content == content) { self.list_state.select(Some(pos)); self.last_selected_index = None; self.update_preview(); } } } } } pub fn set_error(&mut self, msg: String) { self.error_message = Some((msg, Instant::now())); } pub fn set_status(&mut self, msg: String) { self.status_message = Some((msg, Instant::now())); } pub fn tick_messages(&mut self) { let ttl = Duration::from_secs(3); if self .error_message .as_ref() .map_or(false, |(_, t)| t.elapsed() > ttl) { self.error_message = None; } if self .status_message .as_ref() .map_or(false, |(_, t)| t.elapsed() > ttl) { self.status_message = None; } } } fn parse_date_filters(query: &str) -> (Option, Option, String) { let mut before = None; let mut after = None; let mut rest = Vec::new(); for token in query.split_whitespace() { if let Some(d) = token.strip_prefix("before:") { if let Some(ts) = parse_date(d) { before = Some(ts); continue; } } if let Some(d) = token.strip_prefix("after:") { if let Some(ts) = parse_date(d) { after = Some(ts); continue; } } rest.push(token); } (before, after, rest.join(" ")) } fn parse_date(s: &str) -> Option { if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") { let dt = d.and_hms_opt(0, 0, 0)?; return Some(Local.from_local_datetime(&dt).single()?.timestamp()); } if let Ok(d) = NaiveDate::parse_from_str(&format!("{s}-01"), "%Y-%m-%d") { let dt = d.and_hms_opt(0, 0, 0)?; return Some(Local.from_local_datetime(&dt).single()?.timestamp()); } if let Ok(d) = NaiveDate::parse_from_str(&format!("{s}-01-01"), "%Y-%m-%d") { let dt = d.and_hms_opt(0, 0, 0)?; return Some(Local.from_local_datetime(&dt).single()?.timestamp()); } None }