253 lines
8.3 KiB
Rust
253 lines
8.3 KiB
Rust
use crate::config::Config;
|
|
use crate::models::{ClipboardData, ClipboardEntry, Image};
|
|
use image::codecs::jpeg::JpegEncoder;
|
|
use image::{ExtendedColorType, ImageEncoder};
|
|
use rusqlite::Connection;
|
|
use std::error::Error;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
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, config: &Config) -> Result<Self, Box<dyn Error>> {
|
|
let base_path = Path::new(dir_path);
|
|
fs::create_dir_all(base_path.join("images"))?;
|
|
|
|
let conn = Connection::open(base_path.join("clipboard.db"))?;
|
|
|
|
conn.execute_batch(
|
|
"PRAGMA journal_mode=WAL;
|
|
PRAGMA synchronous=NORMAL;
|
|
PRAGMA foreign_keys=ON;",
|
|
)?;
|
|
|
|
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);",
|
|
)?;
|
|
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS history (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
type TEXT NOT NULL,
|
|
content TEXT 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(
|
|
"DELETE FROM history WHERE id NOT IN (
|
|
SELECT MAX(id) FROM history GROUP BY 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,
|
|
})
|
|
}
|
|
|
|
pub fn append(&self, entry: ClipboardEntry) -> Result<(), Box<dyn Error>> {
|
|
let ts = entry.timestamp.duration_since(UNIX_EPOCH)?.as_millis() as i64;
|
|
|
|
let (kind, content) = match &entry.content {
|
|
ClipboardData::Text(t) => {
|
|
if t.trim().is_empty() {
|
|
return Ok(());
|
|
}
|
|
if t.len() > self.max_entry_size_bytes {
|
|
return Ok(());
|
|
}
|
|
("text", t.clone())
|
|
}
|
|
ClipboardData::Image(img) => {
|
|
match &img.raw_pixels {
|
|
Some(px) => {
|
|
if px.len() > self.max_entry_size_bytes * 4 {
|
|
eprintln!(
|
|
"Image rejetée dans DB : {} Mo > limite {} Mo",
|
|
px.len() / 1_048_576,
|
|
(self.max_entry_size_bytes * 4) / 1_048_576
|
|
);
|
|
return Ok(());
|
|
}
|
|
let path = img.file_path(&self.dir_path);
|
|
let file = fs::File::create(&path)?;
|
|
let rgb: Vec<u8> = px
|
|
.chunks_exact(4)
|
|
.flat_map(|rgba| [rgba[0], rgba[1], rgba[2]])
|
|
.collect();
|
|
JpegEncoder::new_with_quality(file, 70).write_image(
|
|
&rgb,
|
|
img.width,
|
|
img.height,
|
|
ExtendedColorType::Rgb8,
|
|
)?;
|
|
}
|
|
None => return Ok(()),
|
|
}
|
|
("image", format!("{}.jpg", img.id))
|
|
}
|
|
};
|
|
|
|
self.conn.execute(
|
|
"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 tx = self.conn.unchecked_transaction()?;
|
|
|
|
let image_files: Vec<String> = {
|
|
let mut stmt = tx.prepare(
|
|
"SELECT content FROM history
|
|
WHERE type = 'image'
|
|
AND id NOT IN (
|
|
SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1
|
|
)",
|
|
)?;
|
|
stmt.query_map([self.max_entries as i64], |row| row.get(0))?
|
|
.filter_map(|r| r.ok())
|
|
.collect()
|
|
};
|
|
|
|
tx.execute(
|
|
"DELETE FROM history WHERE id NOT IN (
|
|
SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1
|
|
)",
|
|
[self.max_entries as i64],
|
|
)?;
|
|
|
|
tx.commit()?;
|
|
|
|
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 {filename} : {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn read_history(&self, limit: usize) -> Result<Vec<ClipboardEntry>, Box<dyn Error>> {
|
|
let mut stmt = self.conn.prepare(
|
|
"SELECT type, content, timestamp FROM history ORDER BY timestamp DESC LIMIT ?1",
|
|
)?;
|
|
|
|
let rows = stmt.query_map([limit as i64], |row| {
|
|
Ok((
|
|
row.get::<_, String>(0)?,
|
|
row.get::<_, String>(1)?,
|
|
row.get::<_, i64>(2)?,
|
|
))
|
|
})?;
|
|
|
|
let mut entries = Vec::new();
|
|
for row in rows {
|
|
let (ty, content, ts_ms) = row?;
|
|
let timestamp = UNIX_EPOCH + Duration::from_millis(ts_ms as u64);
|
|
let data = if ty == "text" {
|
|
ClipboardData::Text(content)
|
|
} else {
|
|
let id = Uuid::parse_str(content.trim_end_matches(".jpg"))?;
|
|
ClipboardData::Image(Image {
|
|
id,
|
|
raw_pixels: None,
|
|
width: 0,
|
|
height: 0,
|
|
})
|
|
};
|
|
entries.push(ClipboardEntry {
|
|
content: data,
|
|
timestamp,
|
|
});
|
|
}
|
|
Ok(entries)
|
|
}
|
|
|
|
pub fn delete_entry_by_content(&self, content: &str) -> Result<(), Box<dyn Error>> {
|
|
self.conn
|
|
.execute("DELETE FROM history WHERE content = ?1", [content])?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn update_entry_content(&self, old: &str, new: &str) -> Result<(), Box<dyn Error>> {
|
|
let rows_affected = self.conn.execute(
|
|
"UPDATE history SET content = ?1 WHERE content = ?2",
|
|
[new, old],
|
|
)?;
|
|
if rows_affected == 0 {
|
|
return Err(format!("Entrée introuvable : {old}").into());
|
|
}
|
|
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 tx = self.conn.unchecked_transaction()?;
|
|
|
|
let image_files: Vec<String> = {
|
|
let mut stmt =
|
|
tx.prepare("SELECT content FROM history WHERE type = 'image' AND timestamp < ?1")?;
|
|
stmt.query_map([cutoff_ms], |row| row.get(0))?
|
|
.filter_map(|r| r.ok())
|
|
.collect()
|
|
};
|
|
|
|
let count = tx.execute("DELETE FROM history WHERE timestamp < ?1", [cutoff_ms])?;
|
|
|
|
tx.commit()?;
|
|
|
|
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(())
|
|
}
|
|
}
|