418 lines
15 KiB
Rust
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()
|
|
}
|