609 lines
19 KiB
Rust
609 lines
19 KiB
Rust
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<HistoryItem>,
|
|
pub filtered_items: Vec<HistoryItem>,
|
|
pub list_state: ListState,
|
|
pub input_buffer: String,
|
|
pub should_quit: bool,
|
|
pub undo_stack: Vec<HistoryItem>,
|
|
pub current_image: Option<protocol::StatefulProtocol>,
|
|
pub last_selected_index: Option<usize>,
|
|
pub picker: Picker,
|
|
pub preview_scroll: u16,
|
|
pub crypto: Option<Crypto>,
|
|
pub salt: Vec<u8>,
|
|
pub pending_action: Option<PendingAction>,
|
|
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<HistoryItem> = 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<i64>, Option<i64>, 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<i64> {
|
|
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
|
|
}
|