Files
Thing/src-tauri/src/clipboard/storage.rs
T

418 lines
15 KiB
Rust

//! SQLite 持久化:剪贴板历史记录
//!
//! 表结构见 `init_db`。所有方法线程安全(内部 Mutex 包裹 Connection)。
use base64::Engine as _;
use rusqlite::{params, Connection, OptionalExtension};
use specta::Type;
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
/// 列表项(不含大字段,用于历史/搜索结果)
#[derive(Debug, Clone, serde::Serialize, Type)]
#[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, Type)]
#[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>>,
/// 图片缩略图 PNG(仅 image 类型,供弹窗悬停预览)
pub thumb: 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);",
);
// 迁移:老库无 thumb 列(图片缩略图,供弹窗悬停预览),补列
let has_thumb = conn
.prepare("PRAGMA table_info(clipboard_history)")
.ok()
.map(|mut stmt| {
stmt.query_map([], |r| r.get::<_, String>(1))
.map(|rows| rows.filter_map(|c| c.ok()).any(|c| c == "thumb"))
.unwrap_or(false)
})
.unwrap_or(false);
if !has_thumb {
let _ = conn.execute("ALTER TABLE clipboard_history ADD COLUMN thumb BLOB", []);
}
}
/// 插入新条目;若 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, thumb, preview, size, hash, pinned, pinned_order, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0, NULL, ?8)",
params![
item.kind,
item.content,
item.blob.as_deref(),
item.thumb.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)
/// 先锁内查询数据,释放锁后再执行耗时编码(DIB→PNG + base64),避免长时间占用连接锁
pub fn get_detail(&self, id: i64, image_to_base64: impl Fn(&[u8]) -> Option<String>) -> Option<ClipboardItemDetail> {
let (item, content, blob) = {
let conn = self.conn.lock().ok()?;
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()?
};
// conn 已在此处释放,以下编码不占用连接锁
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,
})
}
/// 获取图片缩略图 PNG base64(弹窗悬停预览用)。
/// 老数据无缩略图时回退为 `thumb_from_dib` 现场生成。
pub fn get_thumb(&self, id: i64, thumb_from_dib: impl Fn(&[u8]) -> Option<Vec<u8>>) -> Option<String> {
let (thumb, blob) = {
let conn = self.conn.lock().ok()?;
conn.query_row(
"SELECT thumb, blob FROM clipboard_history WHERE id = ?1 AND kind = 'image'",
params![id],
|r| Ok((r.get::<_, Option<Vec<u8>>>(0)?, r.get::<_, Option<Vec<u8>>>(1)?)),
)
.ok()?
};
// conn 已在此处释放
let png = match thumb {
Some(t) if !t.is_empty() => Some(t),
_ => blob.as_deref().and_then(|b| thumb_from_dib(b)),
};
png.map(|p| base64::engine::general_purpose::STANDARD.encode(&p))
}
/// 获取原始字段供 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)
}
}
/// 按搜索关键词统计匹配的非固定条目总数
/// 与 `search` 保持一致的匹配字段(preview + content),避免分页总数错误
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 OR content 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()
}