correction + regexp
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -2464,6 +2464,7 @@ dependencies = [
|
|||||||
"image",
|
"image",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"ratatui-image",
|
"ratatui-image",
|
||||||
|
"regex",
|
||||||
"rklipd",
|
"rklipd",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@ -14,6 +14,7 @@ fuzzy-matcher = "0.3.7"
|
|||||||
image = "0.25.9"
|
image = "0.25.9"
|
||||||
ratatui = "0.30.0"
|
ratatui = "0.30.0"
|
||||||
ratatui-image = { version = "10.0.6", features = ["crossterm"] }
|
ratatui-image = { version = "10.0.6", features = ["crossterm"] }
|
||||||
|
regex = "1.12.3"
|
||||||
rklipd = {path = "rklipd"}
|
rklipd = {path = "rklipd"}
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
|
|||||||
70
rklipd/src/config.rs
Normal file
70
rklipd/src/config.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,9 @@ use std::error::Error;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
const ENC_PREFIX: &str = "enc:";
|
||||||
|
const ENC2_PREFIX: &str = "enc2:";
|
||||||
|
|
||||||
pub struct Crypto {
|
pub struct Crypto {
|
||||||
key: [u8; 32],
|
key: [u8; 32],
|
||||||
}
|
}
|
||||||
@ -45,13 +48,13 @@ impl Crypto {
|
|||||||
.map_err(|e| format!("Erreur de chiffrement : {e}"))?;
|
.map_err(|e| format!("Erreur de chiffrement : {e}"))?;
|
||||||
let mut combined = nonce.to_vec();
|
let mut combined = nonce.to_vec();
|
||||||
combined.extend_from_slice(&ciphertext);
|
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>> {
|
pub fn decrypt(&self, encrypted: &str) -> Result<String, Box<dyn Error>> {
|
||||||
let encoded = encrypted
|
let encoded = encrypted
|
||||||
.strip_prefix("enc:")
|
.strip_prefix(ENC_PREFIX)
|
||||||
.ok_or("Pas une entrée chiffrée")?;
|
.ok_or("Pas une entrée chiffrée (enc:)")?;
|
||||||
let combined = BASE64.decode(encoded)?;
|
let combined = BASE64.decode(encoded)?;
|
||||||
if combined.len() < 12 {
|
if combined.len() < 12 {
|
||||||
return Err("Données chiffrées trop courtes".into());
|
return Err("Données chiffrées trop courtes".into());
|
||||||
@ -66,7 +69,15 @@ impl Crypto {
|
|||||||
Ok(String::from_utf8(plaintext)?)
|
Ok(String::from_utf8(plaintext)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_encrypted(content: &str) -> bool {
|
pub fn is_legacy_encrypted(content: &str) -> bool {
|
||||||
content.starts_with("enc:")
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
use crate::config::Config;
|
||||||
use crate::models::{ClipboardData, ClipboardEntry, Image};
|
use crate::models::{ClipboardData, ClipboardEntry, Image};
|
||||||
use image::codecs::jpeg::JpegEncoder;
|
use image::codecs::jpeg::JpegEncoder;
|
||||||
use image::{ExtendedColorType, ImageEncoder};
|
use image::{ExtendedColorType, ImageEncoder};
|
||||||
@ -5,46 +6,55 @@ use rusqlite::Connection;
|
|||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::{Duration, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub struct Database {
|
pub struct Database {
|
||||||
conn: Connection,
|
conn: Connection,
|
||||||
dir_path: String,
|
dir_path: String,
|
||||||
|
max_entries: usize,
|
||||||
|
max_entry_size_bytes: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Database {
|
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);
|
let base_path = Path::new(dir_path);
|
||||||
fs::create_dir_all(base_path.join("images"))?;
|
fs::create_dir_all(base_path.join("images"))?;
|
||||||
|
|
||||||
let conn = Connection::open(base_path.join("clipboard.db"))?;
|
let conn = Connection::open(base_path.join("clipboard.db"))?;
|
||||||
|
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL);
|
"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(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS history (
|
"CREATE TABLE IF NOT EXISTS history (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
type TEXT NOT NULL,
|
type TEXT NOT NULL,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
timestamp INTEGER NOT NULL
|
timestamp INTEGER NOT NULL
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
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(
|
conn.execute_batch(
|
||||||
"DELETE FROM history WHERE id NOT IN (
|
"DELETE FROM history WHERE id NOT IN (
|
||||||
SELECT MAX(id) FROM history GROUP BY content
|
SELECT MAX(id) FROM history GROUP BY content
|
||||||
);
|
);",
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_history_content ON history(content);",
|
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
conn,
|
conn,
|
||||||
dir_path: dir_path.to_string(),
|
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() {
|
if t.trim().is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
if t.len() > self.max_entry_size_bytes {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
("text", t.clone())
|
("text", t.clone())
|
||||||
}
|
}
|
||||||
ClipboardData::Image(img) => {
|
ClipboardData::Image(img) => {
|
||||||
if let Some(px) = &img.raw_pixels {
|
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 path = img.file_path(&self.dir_path);
|
||||||
let file = fs::File::create(&path)?;
|
let file = fs::File::create(&path)?;
|
||||||
let rgb: Vec<u8> = px
|
let rgb: Vec<u8> = px
|
||||||
@ -81,6 +97,45 @@ impl Database {
|
|||||||
"INSERT OR REPLACE INTO history (type, content, timestamp) VALUES (?1, ?2, ?3)",
|
"INSERT OR REPLACE INTO history (type, content, timestamp) VALUES (?1, ?2, ?3)",
|
||||||
(kind, &content, ts),
|
(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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,14 +192,40 @@ impl Database {
|
|||||||
Ok(())
|
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>> {
|
pub fn clear_history(&self) -> Result<(), Box<dyn Error>> {
|
||||||
let images_dir = Path::new(&self.dir_path).join("images");
|
let images_dir = Path::new(&self.dir_path).join("images");
|
||||||
|
|
||||||
if images_dir.exists() {
|
if images_dir.exists() {
|
||||||
fs::remove_dir_all(&images_dir)?;
|
fs::remove_dir_all(&images_dir)?;
|
||||||
}
|
}
|
||||||
fs::create_dir_all(&images_dir)?;
|
fs::create_dir_all(&images_dir)?;
|
||||||
|
|
||||||
self.conn.execute("DELETE FROM history", [])?;
|
self.conn.execute("DELETE FROM history", [])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -119,12 +119,23 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
|
|||||||
}
|
}
|
||||||
|
|
||||||
IpcRequest::SetClipboard { content } => {
|
IpcRequest::SetClipboard { content } => {
|
||||||
let actual =
|
let actual = if Crypto::is_legacy_encrypted(&content) {
|
||||||
if content.starts_with("enc:") || content.starts_with("enc2:") {
|
crypto_clone.decrypt(&content).unwrap_or_else(|e| {
|
||||||
crypto_clone.decrypt(&content).unwrap_or(content)
|
eprintln!("Impossible de déchiffrer l'entrée enc: : {e}");
|
||||||
} else {
|
content.clone()
|
||||||
content
|
})
|
||||||
};
|
} 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
|
||||||
|
};
|
||||||
|
|
||||||
match arboard::Clipboard::new() {
|
match arboard::Clipboard::new() {
|
||||||
Ok(mut cb) => {
|
Ok(mut cb) => {
|
||||||
@ -142,6 +153,14 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
|
|||||||
height: h,
|
height: h,
|
||||||
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
|
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
reply(
|
||||||
|
&mut stream,
|
||||||
|
IpcResponse::Error(format!(
|
||||||
|
"Image introuvable : {actual}"
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
mod config;
|
||||||
mod crypto;
|
mod crypto;
|
||||||
mod database;
|
mod database;
|
||||||
mod ipc;
|
mod ipc;
|
||||||
@ -5,13 +6,27 @@ mod models;
|
|||||||
mod monitor;
|
mod monitor;
|
||||||
mod ws;
|
mod ws;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
use crate::crypto::Crypto;
|
use crate::crypto::Crypto;
|
||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use arboard::Clipboard;
|
use arboard::Clipboard;
|
||||||
use directories::ProjectDirs;
|
use directories::ProjectDirs;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
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 clipboard = Clipboard::new()?;
|
||||||
|
|
||||||
let proj_dirs =
|
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 = proj_dirs.data_dir();
|
||||||
let dir_path_str = dir_path.to_str().expect("Chemin invalide").to_string();
|
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 crypto = Arc::new(Crypto::load_or_create(dir_path)?);
|
||||||
|
|
||||||
let socket_path = dir_path.join("rklip.sock");
|
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);
|
crate::ipc::start_server(db_for_ipc, crypto_for_ipc, &socket_path);
|
||||||
});
|
});
|
||||||
|
|
||||||
println!("rklipd démarrage...");
|
if let Some(days) = config.expiry_days {
|
||||||
monitor::start(db, clipboard)?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
156
src/app.rs
156
src/app.rs
@ -4,6 +4,7 @@ use chrono::{Local, NaiveDate, TimeZone};
|
|||||||
use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
|
use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
|
||||||
use ratatui::widgets::ListState;
|
use ratatui::widgets::ListState;
|
||||||
use ratatui_image::{picker::Picker, protocol};
|
use ratatui_image::{picker::Picker, protocol};
|
||||||
|
use regex::Regex;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use syntect::highlighting::ThemeSet;
|
use syntect::highlighting::ThemeSet;
|
||||||
use syntect::parsing::SyntaxSet;
|
use syntect::parsing::SyntaxSet;
|
||||||
@ -43,6 +44,32 @@ pub struct App {
|
|||||||
pub status_message: Option<(String, Instant)>,
|
pub status_message: Option<(String, Instant)>,
|
||||||
pub syntax_set: SyntaxSet,
|
pub syntax_set: SyntaxSet,
|
||||||
pub theme_set: ThemeSet,
|
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 {
|
impl App {
|
||||||
@ -76,6 +103,7 @@ impl App {
|
|||||||
status_message: None,
|
status_message: None,
|
||||||
syntax_set: SyntaxSet::load_defaults_newlines(),
|
syntax_set: SyntaxSet::load_defaults_newlines(),
|
||||||
theme_set: ThemeSet::load_defaults(),
|
theme_set: ThemeSet::load_defaults(),
|
||||||
|
type_filter: TypeFilter::All,
|
||||||
};
|
};
|
||||||
app.update_preview();
|
app.update_preview();
|
||||||
app
|
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) {
|
pub fn update_search(&mut self) {
|
||||||
|
self.last_selected_index = None;
|
||||||
|
|
||||||
let query = self.input_buffer.trim().to_string();
|
let query = self.input_buffer.trim().to_string();
|
||||||
let (date_before, date_after, text_query) = parse_date_filters(&query);
|
let (date_before, date_after, text_query) = parse_date_filters(&query);
|
||||||
|
|
||||||
@ -123,22 +158,45 @@ impl App {
|
|||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.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() {
|
self.filtered_items = if text_query.is_empty() {
|
||||||
base
|
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 {
|
} else {
|
||||||
let matcher = SkimMatcherV2::default();
|
let matcher = SkimMatcherV2::default();
|
||||||
let mut matched: Vec<(i64, HistoryItem)> = base
|
let mut matched: Vec<(i64, HistoryItem)> = base
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|item| {
|
.filter_map(|item| {
|
||||||
let search_str = 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()
|
|
||||||
};
|
|
||||||
matcher
|
matcher
|
||||||
.fuzzy_match(&search_str, &text_query)
|
.fuzzy_match(&search_str(&item), &text_query)
|
||||||
.map(|s| (s, item))
|
.map(|s| (s, item))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@ -146,11 +204,18 @@ impl App {
|
|||||||
matched.into_iter().map(|(_, i)| i).collect()
|
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() {
|
self.list_state.select(if self.filtered_items.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(0)
|
Some(0)
|
||||||
});
|
});
|
||||||
|
|
||||||
self.update_preview();
|
self.update_preview();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,21 +273,14 @@ impl App {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.crypto = None;
|
||||||
|
|
||||||
if Crypto::is_password_encrypted(&content) {
|
if Crypto::is_password_encrypted(&content) {
|
||||||
if self.crypto.is_none() {
|
self.pending_action = Some(PendingAction::DecryptSelected);
|
||||||
self.pending_action = Some(PendingAction::DecryptSelected);
|
|
||||||
self.enter_password_mode();
|
|
||||||
} else {
|
|
||||||
self.do_decrypt_selected();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if self.crypto.is_none() {
|
self.pending_action = Some(PendingAction::EncryptSelected);
|
||||||
self.pending_action = Some(PendingAction::EncryptSelected);
|
|
||||||
self.enter_password_mode();
|
|
||||||
} else {
|
|
||||||
self.do_encrypt_selected();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
self.enter_password_mode();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enter_password_mode(&mut self) {
|
fn enter_password_mode(&mut self) {
|
||||||
@ -260,6 +318,8 @@ impl App {
|
|||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
self.crypto = None;
|
||||||
|
|
||||||
match encrypt_result {
|
match encrypt_result {
|
||||||
Ok(enc) => {
|
Ok(enc) => {
|
||||||
if ipc::update_entry(content.clone(), enc.clone()) {
|
if ipc::update_entry(content.clone(), enc.clone()) {
|
||||||
@ -284,6 +344,8 @@ impl App {
|
|||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
self.crypto = None;
|
||||||
|
|
||||||
match decrypt_result {
|
match decrypt_result {
|
||||||
Ok(plain) => {
|
Ok(plain) => {
|
||||||
if ipc::update_entry(content.clone(), plain.clone()) {
|
if ipc::update_entry(content.clone(), plain.clone()) {
|
||||||
@ -293,7 +355,11 @@ impl App {
|
|||||||
self.set_error("Erreur mise à jour BDD".into());
|
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,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
self.crypto = None;
|
||||||
|
|
||||||
match decrypt_result {
|
match decrypt_result {
|
||||||
Ok(plain) => {
|
Ok(plain) => {
|
||||||
ipc::set_clipboard(plain);
|
if ipc::set_clipboard(plain) {
|
||||||
self.should_quit = true;
|
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,
|
None => return,
|
||||||
};
|
};
|
||||||
if Crypto::is_password_encrypted(&content) {
|
if Crypto::is_password_encrypted(&content) {
|
||||||
if self.crypto.is_none() {
|
self.crypto = None;
|
||||||
self.pending_action = Some(PendingAction::PasteEncrypted);
|
self.pending_action = Some(PendingAction::PasteEncrypted);
|
||||||
self.enter_password_mode();
|
self.enter_password_mode();
|
||||||
} else {
|
|
||||||
self.do_paste_encrypted();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
ipc::set_clipboard(content);
|
if ipc::set_clipboard(content) {
|
||||||
self.should_quit = true;
|
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()
|
.iter()
|
||||||
.zip(&new)
|
.zip(&new)
|
||||||
.any(|(a, b)| a.content != b.content);
|
.any(|(a, b)| a.content != b.content);
|
||||||
|
|
||||||
if changed {
|
if changed {
|
||||||
|
let selected_content = self.get_selected_item().map(|i| i.content.clone());
|
||||||
|
|
||||||
self.all_items = new;
|
self.all_items = new;
|
||||||
self.update_search();
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,8 +56,11 @@ pub fn fetch_history(limit: usize) -> Option<Vec<HistoryItem>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_clipboard(content: String) {
|
pub fn set_clipboard(content: String) -> bool {
|
||||||
let _ = send_request(&IpcRequest::SetClipboard { content });
|
matches!(
|
||||||
|
send_request(&IpcRequest::SetClipboard { content }),
|
||||||
|
Some(IpcResponse::Ok)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_entry(content: String) {
|
pub fn delete_entry(content: String) {
|
||||||
|
|||||||
40
src/main.rs
40
src/main.rs
@ -58,14 +58,10 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
|
|||||||
}
|
}
|
||||||
|
|
||||||
match app.mode {
|
match app.mode {
|
||||||
|
// Dans la boucle, Mode::Normal :
|
||||||
Mode::Normal => {
|
Mode::Normal => {
|
||||||
match key.code {
|
// FIX: les deux blocs match étaient redondants et mal nommés.
|
||||||
KeyCode::Char('d') | KeyCode::Char('g') => {}
|
// On fusionne la logique en un seul bloc clair.
|
||||||
_ => {
|
|
||||||
last_d = false;
|
|
||||||
last_g = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Enter => app.paste_selected(),
|
KeyCode::Enter => app.paste_selected(),
|
||||||
KeyCode::Char('j') | KeyCode::Down => app.next(),
|
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.list_state.select(Some(l));
|
||||||
app.update_preview();
|
app.update_preview();
|
||||||
}
|
}
|
||||||
|
last_d = false;
|
||||||
|
last_g = false;
|
||||||
}
|
}
|
||||||
KeyCode::Char('g') => {
|
KeyCode::Char('g') => {
|
||||||
|
last_d = false;
|
||||||
if last_g {
|
if last_g {
|
||||||
if !app.filtered_items.is_empty() {
|
if !app.filtered_items.is_empty() {
|
||||||
app.list_state.select(Some(0));
|
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') => {
|
KeyCode::Char('d') => {
|
||||||
|
last_g = false;
|
||||||
if last_d {
|
if last_d {
|
||||||
app.mode = Mode::ConfirmDelete;
|
app.mode = Mode::ConfirmDelete;
|
||||||
last_d = false;
|
last_d = false;
|
||||||
@ -96,19 +96,39 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
|
|||||||
last_d = true;
|
last_d = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('u') => app.undo_delete(),
|
KeyCode::Char('u') => {
|
||||||
KeyCode::Char('e') => app.toggle_encrypt(),
|
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('/') => {
|
KeyCode::Char('/') => {
|
||||||
app.mode = Mode::Search;
|
app.mode = Mode::Search;
|
||||||
app.input_buffer.clear();
|
app.input_buffer.clear();
|
||||||
app.update_search();
|
app.update_search();
|
||||||
|
last_d = false;
|
||||||
|
last_g = false;
|
||||||
}
|
}
|
||||||
KeyCode::Char(':') => {
|
KeyCode::Char(':') => {
|
||||||
app.mode = Mode::Command;
|
app.mode = Mode::Command;
|
||||||
app.input_buffer.clear();
|
app.input_buffer.clear();
|
||||||
|
last_d = false;
|
||||||
|
last_g = false;
|
||||||
}
|
}
|
||||||
KeyCode::Char('q') => app.should_quit = true,
|
KeyCode::Char('q') => app.should_quit = true,
|
||||||
_ => {}
|
_ => {
|
||||||
|
last_d = false;
|
||||||
|
last_g = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
src/ui.rs
26
src/ui.rs
@ -197,11 +197,37 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
|||||||
_ => String::new(),
|
_ => 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 {
|
let msg_span = if let Some((msg, _)) = &app.error_message {
|
||||||
Span::styled(format!(" ⚠ {msg}"), Style::default().fg(Color::Red))
|
Span::styled(format!(" ⚠ {msg}"), Style::default().fg(Color::Red))
|
||||||
} else if let Some((msg, _)) = &app.status_message {
|
} else if let Some((msg, _)) = &app.status_message {
|
||||||
Span::styled(format!(" ✓ {msg}"), Style::default().fg(Color::Green))
|
Span::styled(format!(" ✓ {msg}"), Style::default().fg(Color::Green))
|
||||||
} else {
|
} 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)
|
Span::raw(extra)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user