correction + regexp

This commit is contained in:
2026-05-20 23:26:01 +02:00
parent 8b07e305f0
commit d173db3342
11 changed files with 425 additions and 73 deletions

1
Cargo.lock generated
View File

@ -2464,6 +2464,7 @@ dependencies = [
"image",
"ratatui",
"ratatui-image",
"regex",
"rklipd",
"serde",
"serde_json",

View File

@ -14,6 +14,7 @@ fuzzy-matcher = "0.3.7"
image = "0.25.9"
ratatui = "0.30.0"
ratatui-image = { version = "10.0.6", features = ["crossterm"] }
regex = "1.12.3"
rklipd = {path = "rklipd"}
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"

70
rklipd/src/config.rs Normal file
View File

@ -0,0 +1,70 @@
/// rklipd --max-entries 500 --max-entry-size-kb 512 --expiry-days 30
pub struct Config {
pub max_entries: usize,
pub max_entry_size_kb: usize,
pub expiry_days: Option<u64>,
}
impl Default for Config {
fn default() -> Self {
Self {
max_entries: 500,
max_entry_size_kb: 512,
expiry_days: None,
}
}
}
impl Config {
pub fn from_args() -> Self {
let mut cfg = Self::default();
let args: Vec<String> = std::env::args().skip(1).collect();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--max-entries" => {
if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
cfg.max_entries = v;
i += 1;
} else {
eprintln!("--max-entries requiert une valeur entière positive");
}
}
"--max-entry-size-kb" => {
if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
cfg.max_entry_size_kb = v;
i += 1;
} else {
eprintln!("--max-entry-size-kb requiert une valeur entière positive");
}
}
"--expiry-days" => {
if let Some(v) = args.get(i + 1).and_then(|s| s.parse::<u64>().ok()) {
cfg.expiry_days = Some(v);
i += 1;
} else {
eprintln!("--expiry-days requiert une valeur entière positive");
}
}
"--help" | "-h" => {
println!(
r#"Usage: rklipd [OPTIONS]
Options:
--max-entries <N> Nombre max d'entrées (défaut: 500)
--max-entry-size-kb <N> Taille max d'une entrée en Ko (défaut: 512)
--expiry-days <N> Supprime les entrées > N jours (défaut: désactivé)
--help Affiche cette aide"#
);
std::process::exit(0);
}
unknown => {
eprintln!("Argument inconnu : {unknown}. Utilisez --help.");
}
}
i += 1;
}
cfg
}
}

View File

@ -7,6 +7,9 @@ use std::error::Error;
use std::fs;
use std::path::Path;
const ENC_PREFIX: &str = "enc:";
const ENC2_PREFIX: &str = "enc2:";
pub struct Crypto {
key: [u8; 32],
}
@ -45,13 +48,13 @@ impl Crypto {
.map_err(|e| format!("Erreur de chiffrement : {e}"))?;
let mut combined = nonce.to_vec();
combined.extend_from_slice(&ciphertext);
Ok(format!("enc:{}", BASE64.encode(combined)))
Ok(format!("{}{}", ENC_PREFIX, BASE64.encode(combined)))
}
pub fn decrypt(&self, encrypted: &str) -> Result<String, Box<dyn Error>> {
let encoded = encrypted
.strip_prefix("enc:")
.ok_or("Pas une entrée chiffrée")?;
.strip_prefix(ENC_PREFIX)
.ok_or("Pas une entrée chiffrée (enc:)")?;
let combined = BASE64.decode(encoded)?;
if combined.len() < 12 {
return Err("Données chiffrées trop courtes".into());
@ -66,7 +69,15 @@ impl Crypto {
Ok(String::from_utf8(plaintext)?)
}
pub fn is_encrypted(content: &str) -> bool {
content.starts_with("enc:")
pub fn is_legacy_encrypted(content: &str) -> bool {
content.starts_with(ENC_PREFIX) && !content.starts_with(ENC2_PREFIX)
}
pub fn is_password_encrypted(content: &str) -> bool {
content.starts_with(ENC2_PREFIX)
}
pub fn is_any_encrypted(content: &str) -> bool {
content.starts_with(ENC_PREFIX) || content.starts_with(ENC2_PREFIX)
}
}

View File

@ -1,3 +1,4 @@
use crate::config::Config;
use crate::models::{ClipboardData, ClipboardEntry, Image};
use image::codecs::jpeg::JpegEncoder;
use image::{ExtendedColorType, ImageEncoder};
@ -5,16 +6,18 @@ use rusqlite::Connection;
use std::error::Error;
use std::fs;
use std::path::Path;
use std::time::{Duration, UNIX_EPOCH};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use uuid::Uuid;
pub struct Database {
conn: Connection,
dir_path: String,
max_entries: usize,
max_entry_size_bytes: usize,
}
impl Database {
pub fn init(dir_path: &str) -> Result<Self, Box<dyn Error>> {
pub fn init(dir_path: &str, config: &Config) -> Result<Self, Box<dyn Error>> {
let base_path = Path::new(dir_path);
fs::create_dir_all(base_path.join("images"))?;
@ -22,7 +25,8 @@ impl Database {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL);
INSERT OR IGNORE INTO schema_version (version) SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM schema_version);",
INSERT OR IGNORE INTO schema_version (version)
SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM schema_version);",
)?;
conn.execute(
@ -35,16 +39,22 @@ impl Database {
[],
)?;
conn.execute_batch(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_history_content ON history(content);
CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp DESC);",
)?;
conn.execute_batch(
"DELETE FROM history WHERE id NOT IN (
SELECT MAX(id) FROM history GROUP BY content
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_history_content ON history(content);",
);",
)?;
Ok(Self {
conn,
dir_path: dir_path.to_string(),
max_entries: config.max_entries,
max_entry_size_bytes: config.max_entry_size_kb * 1024,
})
}
@ -56,10 +66,16 @@ impl Database {
if t.trim().is_empty() {
return Ok(());
}
if t.len() > self.max_entry_size_bytes {
return Ok(());
}
("text", t.clone())
}
ClipboardData::Image(img) => {
if let Some(px) = &img.raw_pixels {
if px.len() > self.max_entry_size_bytes * 4 {
return Ok(());
}
let path = img.file_path(&self.dir_path);
let file = fs::File::create(&path)?;
let rgb: Vec<u8> = px
@ -81,6 +97,45 @@ impl Database {
"INSERT OR REPLACE INTO history (type, content, timestamp) VALUES (?1, ?2, ?3)",
(kind, &content, ts),
)?;
self.trim_to_max()?;
Ok(())
}
fn trim_to_max(&self) -> Result<(), Box<dyn Error>> {
if self.max_entries == 0 {
return Ok(());
}
let mut stmt = self.conn.prepare(
"SELECT content FROM history
WHERE type = 'image'
AND id NOT IN (
SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1
)",
)?;
let to_delete: Vec<String> = stmt
.query_map([self.max_entries as i64], |row| row.get(0))?
.filter_map(|r| r.ok())
.collect();
self.conn.execute(
"DELETE FROM history WHERE id NOT IN (
SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1
)",
[self.max_entries as i64],
)?;
for filename in to_delete {
let path = Path::new(&self.dir_path).join("images").join(&filename);
if path.exists() {
if let Err(e) = fs::remove_file(&path) {
eprintln!("Impossible de supprimer l'image {filename} : {e}");
}
}
}
Ok(())
}
@ -137,14 +192,40 @@ impl Database {
Ok(())
}
pub fn delete_entries_older_than(&self, days: u64) -> Result<usize, Box<dyn Error>> {
let cutoff_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as i64
- (days as i64 * 86_400_000);
let mut stmt = self
.conn
.prepare("SELECT content FROM history WHERE type = 'image' AND timestamp < ?1")?;
let image_files: Vec<String> = stmt
.query_map([cutoff_ms], |row| row.get(0))?
.filter_map(|r| r.ok())
.collect();
let count = self
.conn
.execute("DELETE FROM history WHERE timestamp < ?1", [cutoff_ms])?;
for filename in image_files {
let path = Path::new(&self.dir_path).join("images").join(&filename);
if path.exists() {
if let Err(e) = fs::remove_file(&path) {
eprintln!("Impossible de supprimer l'image expirée {filename} : {e}");
}
}
}
Ok(count)
}
pub fn clear_history(&self) -> Result<(), Box<dyn Error>> {
let images_dir = Path::new(&self.dir_path).join("images");
if images_dir.exists() {
fs::remove_dir_all(&images_dir)?;
}
fs::create_dir_all(&images_dir)?;
self.conn.execute("DELETE FROM history", [])?;
Ok(())
}

View File

@ -119,9 +119,20 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
}
IpcRequest::SetClipboard { content } => {
let actual =
if content.starts_with("enc:") || content.starts_with("enc2:") {
crypto_clone.decrypt(&content).unwrap_or(content)
let actual = if Crypto::is_legacy_encrypted(&content) {
crypto_clone.decrypt(&content).unwrap_or_else(|e| {
eprintln!("Impossible de déchiffrer l'entrée enc: : {e}");
content.clone()
})
} else if Crypto::is_password_encrypted(&content) {
reply(
&mut stream,
IpcResponse::Error(
"Entrée chiffrée par mot de passe : déchiffrez-la côté client avant de coller"
.to_string(),
),
);
return;
} else {
content
};
@ -142,6 +153,14 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
height: h,
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
});
} else {
reply(
&mut stream,
IpcResponse::Error(format!(
"Image introuvable : {actual}"
)),
);
return;
}
}
} else {

View File

@ -1,3 +1,4 @@
mod config;
mod crypto;
mod database;
mod ipc;
@ -5,13 +6,27 @@ mod models;
mod monitor;
mod ws;
use crate::config::Config;
use crate::crypto::Crypto;
use crate::database::Database;
use arboard::Clipboard;
use directories::ProjectDirs;
use std::sync::{Arc, Mutex};
use std::time::Duration;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = Config::from_args();
println!(
"rklipd démarrage — max_entries={}, max_entry_size_kb={}, expiry_days={}",
config.max_entries,
config.max_entry_size_kb,
config
.expiry_days
.map(|d| d.to_string())
.unwrap_or_else(|| "désactivé".to_string())
);
let clipboard = Clipboard::new()?;
let proj_dirs =
@ -19,7 +34,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let dir_path = proj_dirs.data_dir();
let dir_path_str = dir_path.to_str().expect("Chemin invalide").to_string();
let db = Arc::new(Mutex::new(Database::init(&dir_path_str)?));
let db = Arc::new(Mutex::new(Database::init(&dir_path_str, &config)?));
let crypto = Arc::new(Crypto::load_or_create(dir_path)?);
let socket_path = dir_path.join("rklip.sock");
@ -29,8 +44,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
crate::ipc::start_server(db_for_ipc, crypto_for_ipc, &socket_path);
});
println!("rklipd démarrage...");
monitor::start(db, clipboard)?;
if let Some(days) = config.expiry_days {
let db_for_expiry = Arc::clone(&db);
std::thread::spawn(move || {
loop {
std::thread::sleep(Duration::from_secs(3600));
let lock = db_for_expiry.lock().unwrap();
match lock.delete_entries_older_than(days) {
Ok(0) => {}
Ok(n) => println!("Expiration : {n} entrée(s) supprimée(s) (> {days} jours)"),
Err(e) => eprintln!("Erreur expiration : {e}"),
}
}
});
}
monitor::start(db, clipboard)?;
Ok(())
}

View File

@ -4,6 +4,7 @@ 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;
@ -43,6 +44,32 @@ pub struct App {
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 {
@ -76,6 +103,7 @@ impl App {
status_message: None,
syntax_set: SyntaxSet::load_defaults_newlines(),
theme_set: ThemeSet::load_defaults(),
type_filter: TypeFilter::All,
};
app.update_preview();
app
@ -99,7 +127,14 @@ impl App {
}
}
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);
@ -123,22 +158,45 @@ impl App {
.cloned()
.collect();
self.filtered_items = if text_query.is_empty() {
base
} else {
let matcher = SkimMatcherV2::default();
let mut matched: Vec<(i64, HistoryItem)> = base
.into_iter()
.filter_map(|item| {
let search_str = if Crypto::is_any_encrypted(&item.content) {
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, &text_query)
.fuzzy_match(&search_str(&item), &text_query)
.map(|s| (s, item))
})
.collect();
@ -146,11 +204,18 @@ impl App {
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();
}
@ -208,21 +273,14 @@ impl App {
return;
}
self.crypto = None;
if Crypto::is_password_encrypted(&content) {
if self.crypto.is_none() {
self.pending_action = Some(PendingAction::DecryptSelected);
self.enter_password_mode();
} else {
self.do_decrypt_selected();
}
} else {
if self.crypto.is_none() {
self.pending_action = Some(PendingAction::EncryptSelected);
}
self.enter_password_mode();
} else {
self.do_encrypt_selected();
}
}
}
fn enter_password_mode(&mut self) {
@ -260,6 +318,8 @@ impl App {
None => return,
};
self.crypto = None;
match encrypt_result {
Ok(enc) => {
if ipc::update_entry(content.clone(), enc.clone()) {
@ -284,6 +344,8 @@ impl App {
None => return,
};
self.crypto = None;
match decrypt_result {
Ok(plain) => {
if ipc::update_entry(content.clone(), plain.clone()) {
@ -293,7 +355,11 @@ impl App {
self.set_error("Erreur mise à jour BDD".into());
}
}
Err(e) => self.set_error(format!("{e}")),
Err(_) => {
self.set_error(
"Déchiffrement échoué — mauvais mot de passe. Réessayez avec [e].".into(),
);
}
}
}
@ -308,12 +374,21 @@ impl App {
None => return,
};
self.crypto = None;
match decrypt_result {
Ok(plain) => {
ipc::set_clipboard(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(),
);
}
Err(e) => self.set_error(format!("{e}")),
}
}
@ -334,15 +409,17 @@ impl App {
None => return,
};
if Crypto::is_password_encrypted(&content) {
if self.crypto.is_none() {
self.crypto = None;
self.pending_action = Some(PendingAction::PasteEncrypted);
self.enter_password_mode();
} else {
self.do_paste_encrypted();
}
} else {
ipc::set_clipboard(content);
if ipc::set_clipboard(content) {
self.should_quit = true;
} else {
self.set_error(
"Impossible de définir le presse-papier (daemon injoignable ?)".into(),
);
}
}
}
@ -436,9 +513,24 @@ impl App {
.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();
}
}
}
}
}

View File

@ -56,8 +56,11 @@ pub fn fetch_history(limit: usize) -> Option<Vec<HistoryItem>> {
}
}
pub fn set_clipboard(content: String) {
let _ = send_request(&IpcRequest::SetClipboard { content });
pub fn set_clipboard(content: String) -> bool {
matches!(
send_request(&IpcRequest::SetClipboard { content }),
Some(IpcResponse::Ok)
)
}
pub fn delete_entry(content: String) {

View File

@ -58,14 +58,10 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
}
match app.mode {
// Dans la boucle, Mode::Normal :
Mode::Normal => {
match key.code {
KeyCode::Char('d') | KeyCode::Char('g') => {}
_ => {
last_d = false;
last_g = false;
}
}
// FIX: les deux blocs match étaient redondants et mal nommés.
// On fusionne la logique en un seul bloc clair.
match key.code {
KeyCode::Enter => app.paste_selected(),
KeyCode::Char('j') | KeyCode::Down => app.next(),
@ -76,8 +72,11 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
app.list_state.select(Some(l));
app.update_preview();
}
last_d = false;
last_g = false;
}
KeyCode::Char('g') => {
last_d = false;
if last_g {
if !app.filtered_items.is_empty() {
app.list_state.select(Some(0));
@ -89,6 +88,7 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
}
}
KeyCode::Char('d') => {
last_g = false;
if last_d {
app.mode = Mode::ConfirmDelete;
last_d = false;
@ -96,19 +96,39 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
last_d = true;
}
}
KeyCode::Char('u') => app.undo_delete(),
KeyCode::Char('e') => app.toggle_encrypt(),
KeyCode::Char('u') => {
app.undo_delete();
last_d = false;
last_g = false;
}
KeyCode::Char('e') => {
app.toggle_encrypt();
last_d = false;
last_g = false;
}
KeyCode::Char('t') => {
app.cycle_type_filter();
last_d = false;
last_g = false;
}
KeyCode::Char('/') => {
app.mode = Mode::Search;
app.input_buffer.clear();
app.update_search();
last_d = false;
last_g = false;
}
KeyCode::Char(':') => {
app.mode = Mode::Command;
app.input_buffer.clear();
last_d = false;
last_g = false;
}
KeyCode::Char('q') => app.should_quit = true,
_ => {}
_ => {
last_d = false;
last_g = false;
}
}
}

View File

@ -197,11 +197,37 @@ pub fn render(f: &mut Frame, app: &mut App) {
_ => String::new(),
};
// let msg_span = if let Some((msg, _)) = &app.error_message {
// Span::styled(format!(" ⚠ {msg}"), Style::default().fg(Color::Red))
// } else if let Some((msg, _)) = &app.status_message {
// Span::styled(format!(" ✓ {msg}"), Style::default().fg(Color::Green))
// } else {
// Span::raw(extra)
// };
let filter_hint = match app.type_filter {
crate::app::TypeFilter::All => String::new(),
f => format!(" [{}]", f.label()),
};
let msg_span = if let Some((msg, _)) = &app.error_message {
Span::styled(format!("{msg}"), Style::default().fg(Color::Red))
} else if let Some((msg, _)) = &app.status_message {
Span::styled(format!("{msg}"), Style::default().fg(Color::Green))
} else {
let extra = match &app.mode {
Mode::Search => {
// Indicateur visuel du mode de recherche actif (fuzzy vs regexp)
let mode_hint = if app.input_buffer.trim_start().starts_with('/') {
"re"
} else {
"~"
};
format!(" [{}] /{}{}", mode_hint, app.input_buffer, filter_hint)
}
Mode::Command => format!(" :{}", app.input_buffer),
Mode::PasswordInput => format!(" {}", "".repeat(app.input_buffer.len())),
_ => filter_hint,
};
Span::raw(extra)
};