feat: move database logic to new structure

This commit is contained in:
PandaDEV 2024-12-11 14:53:01 +10:00
parent add822073e
commit c60f173e29
No known key found for this signature in database
GPG key ID: 13EFF9BAF70EE75C
10 changed files with 198 additions and 352 deletions

View file

@ -1,37 +1,25 @@
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use image::ImageFormat;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use rand::Rng;
use rdev::{simulate, EventType, Key}; use rdev::{simulate, EventType, Key};
use regex::Regex;
use sha2::{Digest, Sha256};
use sqlx::SqlitePool; use sqlx::SqlitePool;
use uuid::Uuid;
use std::fs;
use std::sync::atomic::{AtomicBool, Ordering}; 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::{AppHandle, Emitter, Listener, Manager, Runtime};
use tauri_plugin_clipboard::Clipboard; use tauri_plugin_clipboard::Clipboard;
use tokio::runtime::Runtime as TokioRuntime; 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! { lazy_static! {
static ref APP_DATA_DIR: Mutex<Option<std::path::PathBuf>> = Mutex::new(None);
static ref IS_PROGRAMMATIC_PASTE: AtomicBool = AtomicBool::new(false); 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<String, String> {
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] #[tauri::command]
pub async fn write_and_paste<R: Runtime>( pub async fn write_and_paste<R: Runtime>(
app_handle: tauri::AppHandle<R>, app_handle: tauri::AppHandle<R>,
@ -93,16 +81,6 @@ pub async fn write_and_paste<R: Runtime>(
Ok(()) 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<R: Runtime>(app: &AppHandle<R>) { pub fn setup<R: Runtime>(app: &AppHandle<R>) {
let app = app.clone(); let app = app.clone();
let runtime = TokioRuntime::new().expect("Failed to create Tokio runtime"); let runtime = TokioRuntime::new().expect("Failed to create Tokio runtime");
@ -124,38 +102,49 @@ pub fn setup<R: Runtime>(app: &AppHandle<R>) {
if available_types.image { if available_types.image {
println!("Handling image change"); println!("Handling image change");
if let Ok(image_data) = clipboard.read_image_base64() { if let Ok(image_data) = clipboard.read_image_base64() {
insert_content_if_not_exists( let file_path = save_image_to_file(&app, &image_data)
app.clone(), .await
pool.clone(), .map_err(|e| e.to_string())
"image", .unwrap_or_else(|e| e);
image_data, let _ = db::history::add_history_item(
) pool,
.await; HistoryItem::new(ContentType::Image, file_path, None),
).await;
} }
let _ = app.emit("plugin:clipboard://image-changed", ()); let _ = app.emit("plugin:clipboard://image-changed", ());
} else if available_types.files { } else if available_types.files {
println!("Handling files change"); println!("Handling files change");
if let Ok(files) = clipboard.read_files() { if let Ok(files) = clipboard.read_files() {
let files_str = files.join(", "); let files_str = files.join(", ");
insert_content_if_not_exists( let _ = db::history::add_history_item(
app.clone(), pool,
pool.clone(), HistoryItem::new(ContentType::File, files_str, None),
"files", ).await;
files_str,
)
.await;
} }
let _ = app.emit("plugin:clipboard://files-changed", ()); let _ = app.emit("plugin:clipboard://files-changed", ());
} else if available_types.text { } else if available_types.text {
println!("Handling text change"); println!("Handling text change");
if let Ok(text) = clipboard.read_text() { if let Ok(text) = clipboard.read_text() {
insert_content_if_not_exists( 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();
app.clone(),
pool.clone(), if url_regex.is_match(&text) {
"text", if let Ok(url) = Url::parse(&text) {
text, let favicon = match fetch_favicon_as_base64(url).await {
) Ok(Some(f)) => Some(f),
.await; _ => 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", ()); let _ = app.emit("plugin:clipboard://text-changed", ());
} else { } else {
@ -173,130 +162,8 @@ pub fn setup<R: Runtime>(app: &AppHandle<R>) {
async fn get_pool<R: Runtime>( async fn get_pool<R: Runtime>(
app_handle: &AppHandle<R>, app_handle: &AppHandle<R>,
) -> Result<SqlitePool, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<tauri::State<'_, SqlitePool>, Box<dyn std::error::Error + Send + Sync>> {
let app_data_dir = app_handle Ok(app_handle.state::<SqlitePool>())
.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<dyn std::error::Error + Send + Sync>)
}
async fn insert_content_if_not_exists<R: Runtime>(
app_handle: AppHandle<R>,
pool: SqlitePool,
content_type: &str,
content: String,
) {
let last_content: Option<String> = 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<R: Runtime>(
app_handle: &AppHandle<R>,
base64_image: &str,
) -> Result<String, Box<dyn std::error::Error>> {
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<Option<String>, Box<dyn std::error::Error>> {
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<u8> = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png_bytes), ImageFormat::Png)?;
Ok(Some(STANDARD.encode(&png_bytes)))
} else {
Ok(None)
}
} }
#[tauri::command] #[tauri::command]
@ -310,3 +177,17 @@ pub fn start_monitor(app_handle: AppHandle) -> Result<(), String> {
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok(()) Ok(())
} }
async fn save_image_to_file<R: Runtime>(app_handle: &AppHandle<R>, base64_data: &str) -> Result<String, Box<dyn std::error::Error>> {
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())
}

View file

@ -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<String>,
}
pub fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
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<String>>(
"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<String>,
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<Vec<String>, String> {
let pool = app_handle.state::<SqlitePool>();
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<String> = 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)
}
}
}

View file

@ -18,7 +18,7 @@ pub fn setup(app_handle: tauri::AppHandle) {
HOTKEY_MANAGER.with(|m| *m.borrow_mut() = Some(manager)); HOTKEY_MANAGER.with(|m| *m.borrow_mut() = Some(manager));
let rt = app_handle.state::<tokio::runtime::Runtime>(); let rt = app_handle.state::<tokio::runtime::Runtime>();
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"); .expect("Failed to get initial keybind");
let initial_shortcut = initial_keybind.join("+"); let initial_shortcut = initial_keybind.join("+");

View file

@ -1,5 +1,4 @@
pub mod updater; pub mod updater;
pub mod clipboard; pub mod clipboard;
pub mod database;
pub mod tray; pub mod tray;
pub mod hotkeys; pub mod hotkeys;

View file

@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>>(())
})?;
app.manage(pool);
app.manage(rt);
Ok(())
}
async fn apply_schema(pool: &SqlitePool) -> Result<(), Box<dyn std::error::Error>> {
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(())
}

3
src-tauri/src/db/mod.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod database;
pub mod history;
pub mod settings;

View file

@ -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
);

View file

@ -4,8 +4,11 @@
)] )]
mod api; mod api;
mod db;
mod utils; mod utils;
use sqlx::sqlite::SqlitePoolOptions;
use std::fs;
use tauri::Manager; use tauri::Manager;
use tauri::WebviewUrl; use tauri::WebviewUrl;
use tauri::WebviewWindow; use tauri::WebviewWindow;
@ -30,8 +33,30 @@ fn main() {
.build(), .build(),
) )
.setup(|app| { .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 = 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") { let main_window = if let Some(window) = app.get_webview_window("main") {
window window
} else { } else {
@ -49,7 +74,7 @@ fn main() {
.build()? .build()?
}; };
let _ = api::database::setup(app); let _ = db::database::setup(app);
api::hotkeys::setup(app_handle.clone()); api::hotkeys::setup(app_handle.clone());
api::tray::setup(app)?; api::tray::setup(app)?;
api::clipboard::setup(app.handle()); api::clipboard::setup(app.handle());
@ -58,12 +83,6 @@ fn main() {
utils::commands::center_window_on_current_monitor(&main_window); utils::commands::center_window_on_current_monitor(&main_window);
main_window.hide()?; 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 { tauri::async_runtime::spawn(async move {
api::updater::check_for_updates(app_handle).await; api::updater::check_for_updates(app_handle).await;
}); });
@ -79,11 +98,18 @@ fn main() {
} }
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
api::clipboard::get_image_path,
api::clipboard::write_and_paste, api::clipboard::write_and_paste,
api::clipboard::read_image, db::history::get_history,
api::database::save_keybind, db::history::add_history_item,
api::database::get_keybind 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!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View file

@ -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<Option<String>, Box<dyn std::error::Error>> {
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<u8> = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png_bytes), ImageFormat::Png)?;
Ok(Some(STANDARD.encode(&png_bytes)))
} else {
Ok(None)
}
}

View file

@ -1 +1,3 @@
pub mod types;
pub mod commands; pub mod commands;
pub mod favicon;