diff --git a/src-tauri/src/api/clipboard.rs b/src-tauri/src/api/clipboard.rs index 3150c58..b4bd276 100644 --- a/src-tauri/src/api/clipboard.rs +++ b/src-tauri/src/api/clipboard.rs @@ -1,37 +1,25 @@ -use base64::engine::general_purpose::STANDARD; -use base64::Engine; -use image::ImageFormat; use lazy_static::lazy_static; -use rand::Rng; use rdev::{simulate, EventType, Key}; -use regex::Regex; -use sha2::{Digest, Sha256}; use sqlx::SqlitePool; +use uuid::Uuid; +use std::fs; use std::sync::atomic::{AtomicBool, Ordering}; -use std::{fs, sync::Mutex, thread, time::Duration}; +use std::{thread, time::Duration}; use tauri::{AppHandle, Emitter, Listener, Manager, Runtime}; use tauri_plugin_clipboard::Clipboard; use tokio::runtime::Runtime as TokioRuntime; +use regex::Regex; +use url::Url; +use base64::{Engine, engine::general_purpose::STANDARD}; + +use crate::utils::favicon::fetch_favicon_as_base64; +use crate::db; +use crate::utils::types::{ContentType, HistoryItem}; lazy_static! { - static ref APP_DATA_DIR: Mutex> = Mutex::new(None); static ref IS_PROGRAMMATIC_PASTE: AtomicBool = AtomicBool::new(false); } -pub fn set_app_data_dir(path: std::path::PathBuf) { - let mut dir = APP_DATA_DIR.lock().unwrap(); - *dir = Some(path); -} - -#[tauri::command] -pub fn read_image(filename: String) -> Result { - let app_data_dir = APP_DATA_DIR.lock().unwrap(); - let app_data_dir = app_data_dir.as_ref().expect("App data directory not set"); - let image_path = app_data_dir.join("images").join(filename); - let image_data = fs::read(image_path).map_err(|e| e.to_string())?; - Ok(STANDARD.encode(image_data)) -} - #[tauri::command] pub async fn write_and_paste( app_handle: tauri::AppHandle, @@ -93,16 +81,6 @@ pub async fn write_and_paste( Ok(()) } -#[tauri::command] -pub fn get_image_path(app_handle: tauri::AppHandle, filename: String) -> String { - let app_data_dir = app_handle - .path() - .app_data_dir() - .expect("Failed to get app data directory"); - let image_path = app_data_dir.join("images").join(filename); - image_path.to_str().unwrap_or("").to_string() -} - pub fn setup(app: &AppHandle) { let app = app.clone(); let runtime = TokioRuntime::new().expect("Failed to create Tokio runtime"); @@ -124,38 +102,49 @@ pub fn setup(app: &AppHandle) { if available_types.image { println!("Handling image change"); if let Ok(image_data) = clipboard.read_image_base64() { - insert_content_if_not_exists( - app.clone(), - pool.clone(), - "image", - image_data, - ) - .await; + let file_path = save_image_to_file(&app, &image_data) + .await + .map_err(|e| e.to_string()) + .unwrap_or_else(|e| e); + let _ = db::history::add_history_item( + pool, + HistoryItem::new(ContentType::Image, file_path, None), + ).await; } let _ = app.emit("plugin:clipboard://image-changed", ()); } else if available_types.files { println!("Handling files change"); if let Ok(files) = clipboard.read_files() { let files_str = files.join(", "); - insert_content_if_not_exists( - app.clone(), - pool.clone(), - "files", - files_str, - ) - .await; + let _ = db::history::add_history_item( + pool, + HistoryItem::new(ContentType::File, files_str, None), + ).await; } let _ = app.emit("plugin:clipboard://files-changed", ()); } else if available_types.text { println!("Handling text change"); if let Ok(text) = clipboard.read_text() { - insert_content_if_not_exists( - app.clone(), - pool.clone(), - "text", - text, - ) - .await; + let url_regex = Regex::new(r"^https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)$").unwrap(); + + if url_regex.is_match(&text) { + if let Ok(url) = Url::parse(&text) { + let favicon = match fetch_favicon_as_base64(url).await { + Ok(Some(f)) => Some(f), + _ => None, + }; + + let _ = db::history::add_history_item( + pool, + HistoryItem::new(ContentType::Link, text, favicon) + ).await; + } + } else { + let _ = db::history::add_history_item( + pool, + HistoryItem::new(ContentType::Text, text, None) + ).await; + } } let _ = app.emit("plugin:clipboard://text-changed", ()); } else { @@ -173,130 +162,8 @@ pub fn setup(app: &AppHandle) { async fn get_pool( app_handle: &AppHandle, -) -> Result> { - let app_data_dir = app_handle - .path() - .app_data_dir() - .expect("Failed to get app data directory"); - let db_path = app_data_dir.join("data.db"); - let database_url = format!("sqlite:{}", db_path.to_str().unwrap()); - SqlitePool::connect(&database_url) - .await - .map_err(|e| Box::new(e) as Box) -} - -async fn insert_content_if_not_exists( - app_handle: AppHandle, - pool: SqlitePool, - content_type: &str, - content: String, -) { - let last_content: Option = sqlx::query_scalar( - "SELECT content FROM history WHERE content_type = ? ORDER BY timestamp DESC LIMIT 1", - ) - .bind(content_type) - .fetch_one(&pool) - .await - .unwrap_or(None); - - let content = if content_type == "image" { - match save_image(&app_handle, &content).await { - Ok(path) => path, - Err(e) => { - println!("Failed to save image: {}", e); - content - } - } - } else { - content - }; - - if last_content.as_deref() != Some(&content) { - let id: String = rand::thread_rng() - .sample_iter(&rand::distributions::Alphanumeric) - .take(16) - .map(char::from) - .collect(); - - let favicon_base64 = if content_type == "text" { - let url_regex = Regex::new(r"^https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)$").unwrap(); - if url_regex.is_match(&content) { - match url::Url::parse(&content) { - Ok(url) => match fetch_favicon_as_base64(url).await { - Ok(Some(favicon)) => Some(favicon), - Ok(None) => None, - Err(e) => { - println!("Failed to fetch favicon: {}", e); - None - } - }, - Err(e) => { - println!("Failed to parse URL: {}", e); - None - } - } - } else { - None - } - } else { - None - }; - - let _ = sqlx::query( - "INSERT INTO history (id, content_type, content, favicon) VALUES (?, ?, ?, ?)", - ) - .bind(id) - .bind(content_type) - .bind(&content) - .bind(favicon_base64) - .execute(&pool) - .await; - - let _ = app_handle.emit("clipboard-content-updated", ()); - } -} - -async fn save_image( - app_handle: &AppHandle, - base64_image: &str, -) -> Result> { - let image_data = STANDARD.decode(base64_image)?; - let mut hasher = Sha256::new(); - hasher.update(&image_data); - let hash = hasher.finalize(); - let filename = format!("{:x}.png", hash); - - let app_data_dir = app_handle - .path() - .app_data_dir() - .expect("Failed to get app data directory"); - let images_dir = app_data_dir.join("images"); - let path = images_dir.join(&filename); - - if !path.exists() { - fs::create_dir_all(&images_dir)?; - fs::write(&path, &image_data)?; - } - - Ok(path.to_str().unwrap().to_string()) -} - -async fn fetch_favicon_as_base64( - url: url::Url, -) -> Result, Box> { - let client = reqwest::Client::new(); - let favicon_url = format!("https://favicone.com/{}", url.host_str().unwrap()); - let response = client.get(&favicon_url).send().await?; - - if response.status().is_success() { - let bytes = response.bytes().await?; - let img = image::load_from_memory(&bytes)?; - let mut png_bytes: Vec = Vec::new(); - img.write_to(&mut std::io::Cursor::new(&mut png_bytes), ImageFormat::Png)?; - Ok(Some(STANDARD.encode(&png_bytes))) - } else { - Ok(None) - } +) -> Result, Box> { + Ok(app_handle.state::()) } #[tauri::command] @@ -310,3 +177,17 @@ pub fn start_monitor(app_handle: AppHandle) -> Result<(), String> { .map_err(|e| e.to_string())?; Ok(()) } + +async fn save_image_to_file(app_handle: &AppHandle, base64_data: &str) -> Result> { + let app_data_dir = app_handle.path().app_data_dir().unwrap(); + let images_dir = app_data_dir.join("images"); + fs::create_dir_all(&images_dir)?; + + let file_name = format!("{}.png", Uuid::new_v4()); + let file_path = images_dir.join(&file_name); + + let bytes = STANDARD.decode(base64_data)?; + fs::write(&file_path, bytes)?; + + Ok(file_path.to_string_lossy().into_owned()) +} diff --git a/src-tauri/src/api/database.rs b/src-tauri/src/api/database.rs deleted file mode 100644 index 06e6391..0000000 --- a/src-tauri/src/api/database.rs +++ /dev/null @@ -1,161 +0,0 @@ -use rand::distributions::Alphanumeric; -use rand::{thread_rng, Rng}; -use serde::{Deserialize, Serialize}; -use serde_json; -use sqlx::sqlite::{SqlitePool, SqlitePoolOptions}; -use std::fs; -use tauri::{Manager, Emitter}; -use tokio::runtime::Runtime; - -#[derive(Deserialize, Serialize)] -struct KeybindSetting { - keybind: Vec, -} - -pub fn setup(app: &mut tauri::App) -> Result<(), Box> { - let rt = Runtime::new().expect("Failed to create Tokio runtime"); - - let app_data_dir = app.path().app_data_dir().unwrap(); - fs::create_dir_all(&app_data_dir).expect("Failed to create app data directory"); - - let db_path = app_data_dir.join("data.db"); - let is_new_db = !db_path.exists(); - if is_new_db { - fs::File::create(&db_path).expect("Failed to create database file"); - } - - let db_url = format!("sqlite:{}", db_path.to_str().unwrap()); - let pool = rt.block_on(async { - SqlitePoolOptions::new() - .max_connections(5) - .connect(&db_url) - .await - .expect("Failed to create pool") - }); - - rt.block_on(async { - // Setup settings table - sqlx::query( - "CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - )" - ) - .execute(&pool) - .await - .expect("Failed to create settings table"); - - let existing_keybind = sqlx::query_scalar::<_, Option>( - "SELECT value FROM settings WHERE key = 'keybind'" - ) - .fetch_one(&pool) - .await; - - match existing_keybind { - Ok(Some(_)) => { - }, - Ok(None) => { - let default_keybind = KeybindSetting { - keybind: vec!["Meta".to_string(), "V".to_string()], - }; - let json = serde_json::to_string(&default_keybind).unwrap(); - - sqlx::query( - "INSERT INTO settings (key, value) VALUES ('keybind', ?)" - ) - .bind(json) - .execute(&pool) - .await - .expect("Failed to insert default keybind"); - }, - Err(e) => { - eprintln!("Failed to check existing keybind: {}", e); - } - } - - // Setup history table - sqlx::query( - "CREATE TABLE IF NOT EXISTS history ( - id TEXT PRIMARY KEY, - content_type TEXT NOT NULL, - content TEXT NOT NULL, - favicon TEXT, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP - )" - ) - .execute(&pool) - .await - .expect("Failed to create history table"); - - sqlx::query( - "CREATE INDEX IF NOT EXISTS idx_timestamp ON history (timestamp)" - ) - .execute(&pool) - .await - .expect("Failed to create index"); - - if is_new_db { - let id: String = thread_rng() - .sample_iter(&Alphanumeric) - .take(16) - .map(char::from) - .collect(); - sqlx::query("INSERT INTO history (id, content_type, content, timestamp) VALUES (?, ?, ?, CURRENT_TIMESTAMP)") - .bind(id) - .bind("text") - .bind("Welcome to your clipboard history!") - .execute(&pool) - .await - .expect("Failed to insert welcome message"); - } - }); - - app.manage(pool); - app.manage(rt); - - Ok(()) -} - -#[tauri::command] -pub async fn save_keybind( - app_handle: tauri::AppHandle, - keybind: Vec, - pool: tauri::State<'_, SqlitePool>, -) -> Result<(), String> { - let json = serde_json::to_string(&keybind).map_err(|e| e.to_string())?; - - sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES ('keybind', ?)") - .bind(json) - .execute(&*pool) - .await - .map_err(|e| e.to_string())?; - - let keybind_str = keybind.join("+"); - app_handle - .emit("update-shortcut", keybind_str) - .map_err(|e| e.to_string())?; - - Ok(()) -} - -#[tauri::command] -pub async fn get_keybind(app_handle: tauri::AppHandle) -> Result, String> { - let pool = app_handle.state::(); - - let result = - sqlx::query_scalar::<_, String>("SELECT value FROM settings WHERE key = 'keybind'") - .fetch_optional(&*pool) - .await - .map_err(|e| e.to_string())?; - - match result { - Some(json) => { - let keybind: Vec = serde_json::from_str(&json).map_err(|e| e.to_string())?; - Ok(keybind) - } - None => { - let default_keybind = vec!["Meta".to_string(), "V".to_string()]; - Ok(default_keybind) - } - } -} diff --git a/src-tauri/src/api/hotkeys.rs b/src-tauri/src/api/hotkeys.rs index fc33e73..f35d9c8 100644 --- a/src-tauri/src/api/hotkeys.rs +++ b/src-tauri/src/api/hotkeys.rs @@ -18,7 +18,7 @@ pub fn setup(app_handle: tauri::AppHandle) { HOTKEY_MANAGER.with(|m| *m.borrow_mut() = Some(manager)); let rt = app_handle.state::(); - let initial_keybind = rt.block_on(crate::api::database::get_keybind(app_handle_clone.clone())) + let initial_keybind = rt.block_on(crate::db::settings::get_keybind(app_handle_clone.clone())) .expect("Failed to get initial keybind"); let initial_shortcut = initial_keybind.join("+"); diff --git a/src-tauri/src/api/mod.rs b/src-tauri/src/api/mod.rs index b513b8d..e6643f9 100644 --- a/src-tauri/src/api/mod.rs +++ b/src-tauri/src/api/mod.rs @@ -1,5 +1,4 @@ pub mod updater; pub mod clipboard; -pub mod database; pub mod tray; pub mod hotkeys; diff --git a/src-tauri/src/db/database.rs b/src-tauri/src/db/database.rs new file mode 100644 index 0000000..f846d24 --- /dev/null +++ b/src-tauri/src/db/database.rs @@ -0,0 +1,63 @@ +use sqlx::sqlite::{SqlitePool, SqlitePoolOptions}; +use std::fs; +use tauri::Manager; +use tokio::runtime::Runtime as TokioRuntime; + +pub fn setup(app: &mut tauri::App) -> Result<(), Box> { + let rt = TokioRuntime::new().expect("Failed to create Tokio runtime"); + + let app_data_dir = app.path().app_data_dir().unwrap(); + fs::create_dir_all(&app_data_dir).expect("Failed to create app data directory"); + + let db_path = app_data_dir.join("data.db"); + let is_new_db = !db_path.exists(); + if is_new_db { + fs::File::create(&db_path).expect("Failed to create database file"); + } + + let db_url = format!("sqlite:{}", db_path.to_str().unwrap()); + let pool = rt.block_on(async { + SqlitePoolOptions::new() + .max_connections(5) + .connect(&db_url) + .await + .expect("Failed to create pool") + }); + + rt.block_on(async { + apply_schema(&pool).await?; + if is_new_db { + if let Err(e) = super::history::initialize_history(&pool).await { + eprintln!("Failed to initialize history: {}", e); + } + if let Err(e) = super::settings::initialize_settings(&pool).await { + eprintln!("Failed to initialize settings: {}", e); + } + } + Ok::<(), Box>(()) + })?; + + app.manage(pool); + app.manage(rt); + + Ok(()) +} + +async fn apply_schema(pool: &SqlitePool) -> Result<(), Box> { + let schema = include_str!("scheme.sql"); + + let statements: Vec<&str> = schema + .split(';') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); + + for statement in statements { + sqlx::query(statement) + .execute(pool) + .await + .map_err(|e| format!("Failed to execute schema statement: {}", e))?; + } + + Ok(()) +} diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs new file mode 100644 index 0000000..ce02d97 --- /dev/null +++ b/src-tauri/src/db/mod.rs @@ -0,0 +1,3 @@ +pub mod database; +pub mod history; +pub mod settings; \ No newline at end of file diff --git a/src-tauri/src/db/scheme.sql b/src-tauri/src/db/scheme.sql new file mode 100644 index 0000000..e2028ae --- /dev/null +++ b/src-tauri/src/db/scheme.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS history ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + content_type TEXT NOT NULL, + content TEXT NOT NULL, + favicon TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 19c975b..36a3072 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,8 +4,11 @@ )] mod api; +mod db; mod utils; +use sqlx::sqlite::SqlitePoolOptions; +use std::fs; use tauri::Manager; use tauri::WebviewUrl; use tauri::WebviewWindow; @@ -30,8 +33,30 @@ fn main() { .build(), ) .setup(|app| { + let app_data_dir = app.path().app_data_dir().unwrap(); + fs::create_dir_all(&app_data_dir).expect("Failed to create app data directory"); + + let db_path = app_data_dir.join("data.db"); + let is_new_db = !db_path.exists(); + if is_new_db { + fs::File::create(&db_path).expect("Failed to create database file"); + } + + let db_url = format!("sqlite:{}", db_path.to_str().unwrap()); + let app_handle = app.handle().clone(); + let app_handle_clone = app_handle.clone(); + tauri::async_runtime::spawn(async move { + let pool = SqlitePoolOptions::new() + .max_connections(5) + .connect(&db_url) + .await + .expect("Failed to create pool"); + + app_handle_clone.manage(pool); + }); + let main_window = if let Some(window) = app.get_webview_window("main") { window } else { @@ -49,7 +74,7 @@ fn main() { .build()? }; - let _ = api::database::setup(app); + let _ = db::database::setup(app); api::hotkeys::setup(app_handle.clone()); api::tray::setup(app)?; api::clipboard::setup(app.handle()); @@ -58,12 +83,6 @@ fn main() { utils::commands::center_window_on_current_monitor(&main_window); main_window.hide()?; - let app_data_dir = app - .path() - .app_data_dir() - .expect("Failed to get app data directory"); - api::clipboard::set_app_data_dir(app_data_dir); - tauri::async_runtime::spawn(async move { api::updater::check_for_updates(app_handle).await; }); @@ -79,11 +98,18 @@ fn main() { } }) .invoke_handler(tauri::generate_handler![ - api::clipboard::get_image_path, api::clipboard::write_and_paste, - api::clipboard::read_image, - api::database::save_keybind, - api::database::get_keybind + db::history::get_history, + db::history::add_history_item, + db::history::search_history, + db::history::load_history_chunk, + db::history::delete_history_item, + db::history::clear_history, + db::history::read_image, + db::settings::get_setting, + db::settings::save_setting, + db::settings::save_keybind, + db::settings::get_keybind, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/utils/favicon.rs b/src-tauri/src/utils/favicon.rs new file mode 100644 index 0000000..0091313 --- /dev/null +++ b/src-tauri/src/utils/favicon.rs @@ -0,0 +1,21 @@ +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use image::ImageFormat; +use reqwest; +use url::Url; + +pub async fn fetch_favicon_as_base64(url: Url) -> Result, Box> { + let client = reqwest::Client::new(); + let favicon_url = format!("https://favicone.com/{}", url.host_str().unwrap()); + let response = client.get(&favicon_url).send().await?; + + if response.status().is_success() { + let bytes = response.bytes().await?; + let img = image::load_from_memory(&bytes)?; + let mut png_bytes: Vec = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png_bytes), ImageFormat::Png)?; + Ok(Some(STANDARD.encode(&png_bytes))) + } else { + Ok(None) + } +} \ No newline at end of file diff --git a/src-tauri/src/utils/mod.rs b/src-tauri/src/utils/mod.rs index 6be336e..35b6d67 100644 --- a/src-tauri/src/utils/mod.rs +++ b/src-tauri/src/utils/mod.rs @@ -1 +1,3 @@ -pub mod commands; \ No newline at end of file +pub mod types; +pub mod commands; +pub mod favicon; \ No newline at end of file