托盘
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
//! SQLite 持久化:剪贴板历史记录
|
||||
//!
|
||||
//! 表结构见 `init_db`。所有方法线程安全(内部 Mutex 包裹 Connection)。
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// 列表项(不含大字段,用于历史/搜索结果)
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClipboardItem {
|
||||
pub id: i64,
|
||||
pub kind: String,
|
||||
pub preview: String,
|
||||
pub size: i64,
|
||||
pub pinned: bool,
|
||||
pub pinned_order: Option<i64>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// 详情(含文本内容或图片 base64)
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClipboardItemDetail {
|
||||
#[serde(flatten)]
|
||||
pub item: ClipboardItem,
|
||||
/// 文本内容 / 文件列表 JSON
|
||||
pub content: Option<String>,
|
||||
/// 图片 PNG base64(仅 image 类型)
|
||||
pub image_base64: Option<String>,
|
||||
}
|
||||
|
||||
/// 新捕获的剪贴板数据
|
||||
pub struct NewItem {
|
||||
pub kind: String,
|
||||
pub content: Option<String>,
|
||||
pub blob: Option<Vec<u8>>,
|
||||
pub preview: String,
|
||||
pub size: i64,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
pub struct Storage {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub fn new(data_dir: &PathBuf) -> std::io::Result<Self> {
|
||||
fs::create_dir_all(data_dir)?;
|
||||
let db_path = data_dir.join("history.db");
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
||||
conn.busy_timeout(std::time::Duration::from_secs(3)).ok();
|
||||
Self::init_db(&conn);
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
/// 内存数据库回退(磁盘初始化失败时使用,数据不持久化)
|
||||
pub fn new_in_memory() -> Self {
|
||||
let conn = Connection::open_in_memory().expect("open_in_memory");
|
||||
Self::init_db(&conn);
|
||||
Self {
|
||||
conn: Mutex::new(conn),
|
||||
}
|
||||
}
|
||||
|
||||
fn init_db(conn: &Connection) {
|
||||
let _ = conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS clipboard_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
content TEXT,
|
||||
blob BLOB,
|
||||
preview TEXT NOT NULL DEFAULT '',
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
hash TEXT NOT NULL DEFAULT '',
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
pinned_order INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_created_at ON clipboard_history(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_pinned ON clipboard_history(pinned, pinned_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_hash ON clipboard_history(hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_kind ON clipboard_history(kind);",
|
||||
);
|
||||
}
|
||||
|
||||
/// 插入新条目;若 dedup 为 true 且 hash 已存在则仅更新 created_at,返回条目 id。
|
||||
/// 返回 None 表示未插入(内容为空)。
|
||||
pub fn insert_or_touch(&self, item: NewItem, dedup: bool) -> Option<i64> {
|
||||
if item.preview.is_empty() && item.content.is_none() && item.blob.is_none() {
|
||||
return None;
|
||||
}
|
||||
let conn = self.conn.lock().ok()?;
|
||||
if dedup {
|
||||
let existing: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT id FROM clipboard_history WHERE hash = ?1",
|
||||
params![item.hash],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
.ok()
|
||||
.flatten();
|
||||
if let Some(id) = existing {
|
||||
// 更新时间戳到当前(置顶到列表最前)
|
||||
let now = now_ms();
|
||||
let _ = conn.execute(
|
||||
"UPDATE clipboard_history SET created_at = ?1 WHERE id = ?2",
|
||||
params![now, id],
|
||||
);
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
let now = now_ms();
|
||||
let res = conn.execute(
|
||||
"INSERT INTO clipboard_history
|
||||
(kind, content, blob, preview, size, hash, pinned, pinned_order, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, NULL, ?7)",
|
||||
params![
|
||||
item.kind,
|
||||
item.content,
|
||||
item.blob.as_deref(),
|
||||
item.preview,
|
||||
item.size,
|
||||
item.hash,
|
||||
now,
|
||||
],
|
||||
);
|
||||
if res.is_err() {
|
||||
return None;
|
||||
}
|
||||
Some(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// 获取非固定的历史记录(按时间倒序)
|
||||
pub fn get_history(&self, limit: i64, offset: i64, kind: &str) -> Vec<ClipboardItem> {
|
||||
let conn = match self.conn.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let sql = if kind == "all" {
|
||||
"SELECT id, kind, preview, size, pinned, pinned_order, created_at
|
||||
FROM clipboard_history WHERE pinned = 0
|
||||
ORDER BY created_at DESC LIMIT ?1 OFFSET ?2"
|
||||
} else {
|
||||
"SELECT id, kind, preview, size, pinned, pinned_order, created_at
|
||||
FROM clipboard_history WHERE pinned = 0 AND kind = ?1
|
||||
ORDER BY created_at DESC LIMIT ?2 OFFSET ?3"
|
||||
};
|
||||
let mut stmt = match conn.prepare(sql) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let rows = if kind == "all" {
|
||||
stmt.query_map(params![limit, offset], row_to_item)
|
||||
} else {
|
||||
stmt.query_map(params![kind, limit, offset], row_to_item)
|
||||
};
|
||||
rows.map(|r| r.filter_map(|i| i.ok()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 获取固定条目(按 pinned_order,未设置则按时间倒序)
|
||||
pub fn get_pinned(&self) -> Vec<ClipboardItem> {
|
||||
let conn = match self.conn.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let mut stmt = match conn.prepare(
|
||||
"SELECT id, kind, preview, size, pinned, pinned_order, created_at
|
||||
FROM clipboard_history WHERE pinned = 1
|
||||
ORDER BY pinned_order ASC NULLS LAST, created_at DESC",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
stmt.query_map([], row_to_item)
|
||||
.map(|r| r.filter_map(|i| i.ok()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 搜索(跨固定/非固定,按时间倒序)
|
||||
pub fn search(&self, query: &str, limit: i64, offset: i64) -> Vec<ClipboardItem> {
|
||||
let conn = match self.conn.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let like = format!("%{}%", query);
|
||||
let mut stmt = match conn.prepare(
|
||||
"SELECT id, kind, preview, size, pinned, pinned_order, created_at
|
||||
FROM clipboard_history
|
||||
WHERE pinned = 0 AND (preview LIKE ?1 OR content LIKE ?1)
|
||||
ORDER BY created_at DESC LIMIT ?2 OFFSET ?3",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
stmt.query_map(params![like, limit, offset], row_to_item)
|
||||
.map(|r| r.filter_map(|i| i.ok()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 获取详情(含文本/图片预览 base64)
|
||||
pub fn get_detail(&self, id: i64, image_to_base64: impl Fn(&[u8]) -> Option<String>) -> Option<ClipboardItemDetail> {
|
||||
let conn = self.conn.lock().ok()?;
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT id, kind, preview, size, pinned, pinned_order, created_at, content, blob
|
||||
FROM clipboard_history WHERE id = ?1",
|
||||
params![id],
|
||||
|r| {
|
||||
let kind: String = r.get(1)?;
|
||||
let content: Option<String> = r.get(7)?;
|
||||
let blob: Option<Vec<u8>> = r.get(8)?;
|
||||
Ok((
|
||||
ClipboardItem {
|
||||
id: r.get(0)?,
|
||||
kind,
|
||||
preview: r.get(2)?,
|
||||
size: r.get(3)?,
|
||||
pinned: r.get::<_, i64>(4)? != 0,
|
||||
pinned_order: r.get(5)?,
|
||||
created_at: r.get(6)?,
|
||||
},
|
||||
content,
|
||||
blob,
|
||||
))
|
||||
},
|
||||
)
|
||||
.ok()?;
|
||||
let (item, content, blob) = row;
|
||||
let image_base64 = if item.kind == "image" {
|
||||
blob.as_deref().and_then(|b| image_to_base64(b))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(ClipboardItemDetail {
|
||||
item,
|
||||
content,
|
||||
image_base64,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取原始字段供 copy_back 写回(避免 base64 转换开销)
|
||||
pub fn get_raw_for_copy(&self, id: i64) -> Option<(String, Option<String>, Option<Vec<u8>>)> {
|
||||
let conn = self.conn.lock().ok()?;
|
||||
conn.query_row(
|
||||
"SELECT kind, content, blob FROM clipboard_history WHERE id = ?1",
|
||||
params![id],
|
||||
|r| Ok((r.get::<_, String>(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// 切换固定状态。设为固定时分配一个递减的 pinned_order(靠前)。
|
||||
pub fn set_pinned(&self, id: i64, pinned: bool) -> bool {
|
||||
let conn = match self.conn.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if pinned {
|
||||
// 取当前最小 pinned_order,再 -1 使其排到最前
|
||||
let min_order: Option<i64> = conn
|
||||
.query_row("SELECT MIN(pinned_order) FROM clipboard_history WHERE pinned = 1", [], |r| r.get(0))
|
||||
.ok()
|
||||
.flatten();
|
||||
let new_order = min_order.unwrap_or(0) - 1;
|
||||
conn.execute(
|
||||
"UPDATE clipboard_history SET pinned = 1, pinned_order = ?1 WHERE id = ?2",
|
||||
params![new_order, id],
|
||||
)
|
||||
.is_ok()
|
||||
} else {
|
||||
conn.execute(
|
||||
"UPDATE clipboard_history SET pinned = 0, pinned_order = NULL WHERE id = ?1",
|
||||
params![id],
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(&self, id: i64) -> bool {
|
||||
self.conn
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|c| c.execute("DELETE FROM clipboard_history WHERE id = ?1", params![id]).is_ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 清空所有非固定条目
|
||||
pub fn clear_non_pinned(&self) -> bool {
|
||||
self.conn
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|c| c.execute("DELETE FROM clipboard_history WHERE pinned = 0", []).is_ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 超过 max 时自动清理最旧的非固定条目
|
||||
pub fn prune_to_max(&self, max_items: i64) {
|
||||
let conn = match self.conn.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
let _ = conn.execute(
|
||||
"DELETE FROM clipboard_history
|
||||
WHERE pinned = 0 AND id IN (
|
||||
SELECT id FROM clipboard_history
|
||||
WHERE pinned = 0
|
||||
ORDER BY created_at DESC
|
||||
LIMIT -1 OFFSET ?1
|
||||
)",
|
||||
params![max_items],
|
||||
);
|
||||
}
|
||||
|
||||
pub fn count(&self) -> i64 {
|
||||
self.count_kind("all")
|
||||
}
|
||||
|
||||
/// 按类型统计非固定条目总数(kind="all" 表示全部)
|
||||
pub fn count_kind(&self, kind: &str) -> i64 {
|
||||
let conn = match self.conn.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
let sql = if kind == "all" {
|
||||
"SELECT COUNT(*) FROM clipboard_history WHERE pinned = 0"
|
||||
} else {
|
||||
"SELECT COUNT(*) FROM clipboard_history WHERE pinned = 0 AND kind = ?1"
|
||||
};
|
||||
if kind == "all" {
|
||||
conn.query_row(sql, [], |r| r.get(0)).ok().unwrap_or(0)
|
||||
} else {
|
||||
conn.query_row(sql, params![kind], |r| r.get(0)).ok().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 按搜索关键词统计匹配的非固定条目总数
|
||||
pub fn count_search(&self, query: &str) -> i64 {
|
||||
let conn = match self.conn.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
let pattern = format!("%{}%", query);
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM clipboard_history WHERE pinned = 0 AND preview LIKE ?1",
|
||||
params![pattern],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_item(r: &rusqlite::Row<'_>) -> rusqlite::Result<ClipboardItem> {
|
||||
Ok(ClipboardItem {
|
||||
id: r.get(0)?,
|
||||
kind: r.get(1)?,
|
||||
preview: r.get(2)?,
|
||||
size: r.get(3)?,
|
||||
pinned: r.get::<_, i64>(4)? != 0,
|
||||
pinned_order: r.get(5)?,
|
||||
created_at: r.get(6)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn now_ms() -> i64 {
|
||||
chrono::Local::now().timestamp_millis()
|
||||
}
|
||||
Reference in New Issue
Block a user