Skip to content

Commit 305d8cc

Browse files
committed
feat(note): 手动排序后端——scope 独立表与命令族(REQ-287 地基)
1 parent b6a00a4 commit 305d8cc

4 files changed

Lines changed: 171 additions & 0 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
//! 笔记手动排序命令(REQ-287,v0.19.7)。
2+
//!
3+
//! @ai-context: 命令薄壳——scope 校验(防注入/越界:仅 `g:{数字}`/`none`)、
4+
//! 量级守卫(≤2000/scope)、保存=整表覆写快照(事务单命令);
5+
//! 变更广播 notes 域(列表刷新即时可见)。DB 读写见 db_note_orders.rs。
6+
//! @ai-context: 排序语义互斥:scope 有手动序=该区手排;清空=回自动。前端树
7+
//! 视图默认消费,搜索/标签/非默认排序平铺态不应用(交互矩阵锁定)。
8+
9+
use tauri::State;
10+
11+
use crate::commands::AppState;
12+
use crate::db_note_orders::is_valid_scope;
13+
14+
/// 单 scope 手动序量级上限(防御 IPC/DB 滥用)。
15+
const SCOPE_IDS_MAX: usize = 2000;
16+
17+
fn validate(scope: &str) -> Result<(), String> {
18+
if !is_valid_scope(scope) {
19+
return Err("非法的排序范围(应为 g:{组id} 或 none)".to_string());
20+
}
21+
Ok(())
22+
}
23+
24+
/// 全量手动序(scope 升序;量小直接返回)。
25+
#[tauri::command]
26+
pub fn note_order_list(state: State<'_, AppState>) -> Result<Vec<(String, i64, i64)>, String> {
27+
state.db.load_note_orders().map_err(|e| e.to_string())
28+
}
29+
30+
/// 保存某 scope 完整手动序(整表覆写快照;首启=前端写当前可见序)。
31+
#[tauri::command]
32+
pub fn note_order_save(state: State<'_, AppState>, scope: String, note_ids: Vec<i64>) -> Result<(), String> {
33+
validate(&scope)?;
34+
if note_ids.len() > SCOPE_IDS_MAX {
35+
return Err(format!("笔记数超上限({} > {})", note_ids.len(), SCOPE_IDS_MAX));
36+
}
37+
let mut seen = std::collections::HashSet::new();
38+
if !note_ids.iter().all(|id| *id > 0 && seen.insert(*id)) {
39+
return Err("笔记 id 非法或重复".to_string());
40+
}
41+
state.db.save_note_order(&scope, &note_ids).map_err(|e| e.to_string())?;
42+
// REQ-278:notes 域广播(排序变化 → 列表刷新)
43+
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Notes);
44+
Ok(())
45+
}
46+
47+
/// 清除某 scope 手动序(=回自动排序;幂等)。
48+
#[tauri::command]
49+
pub fn note_order_clear(state: State<'_, AppState>, scope: String) -> Result<(), String> {
50+
validate(&scope)?;
51+
let cleared = state.db.clear_note_order(&scope).map_err(|e| e.to_string())?;
52+
if cleared {
53+
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Notes);
54+
}
55+
Ok(())
56+
}
57+
58+
#[cfg(test)]
59+
mod tests {
60+
use super::validate;
61+
62+
#[test]
63+
fn validate_accepts_only_scoped_values() {
64+
assert!(validate("g:1").is_ok());
65+
assert!(validate("none").is_ok());
66+
assert!(validate("g:1; DROP TABLE notes").is_err());
67+
assert!(validate("").is_err());
68+
}
69+
}

‎app/src-tauri/src/db_migrations.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,18 @@ CREATE TABLE IF NOT EXISTS contracts (
312312
);
313313
",
314314
)?;
315+
// REQ-287(v0.19.7):笔记手动排序表(scope=g{id} 组 / none 未分组;独立表
316+
// 免改 notes 列与全库行映射涟漪——读写见 db_note_orders.rs)
317+
conn.execute_batch(
318+
"CREATE TABLE IF NOT EXISTS note_orders (
319+
scope TEXT NOT NULL,
320+
note_id INTEGER NOT NULL,
321+
ord INTEGER NOT NULL,
322+
PRIMARY KEY (scope, note_id)
323+
);
324+
CREATE INDEX IF NOT EXISTS idx_note_orders_scope ON note_orders(scope, ord);
325+
",
326+
)?;
315327
// v0.5.0 M1(REQ-043):旧库迁移——sessions 表补 profile 列(兼容既有数据库)
316328
ensure_column(conn, "sessions", "profile", "ALTER TABLE sessions ADD COLUMN profile TEXT")?;
317329
// v0.11.7(图文会话,ADR-020):旧库迁移——sessions 表补 kind 列
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
//! 笔记手动排序数据层(REQ-287,v0.19.7)。
2+
//!
3+
//! @ai-context: 交互矩阵裁决——组内位置移动=该组切"手动排序"(首次拖拽自动
4+
//! 快照);与自动排序(更新时间/固定/创建)互斥,scope 级生效:
5+
//! `g:{groupId}`=具体组,`none`=未分组区。独立表免改 notes 列与
6+
//! 全库行映射(35 表重建涟漪零承担);排序仅在树视图(无搜索/
7+
//! 标签/非手动排序筛选)由前端消费(前端先拉全量再按 scope 排)。
8+
//! 空序=未启用(清表即回自动);数据量 ≤2000/scope(命令层守卫)。
9+
10+
use crate::db::Db;
11+
use crate::error::Result;
12+
13+
/// scope 合法性(纯函数可单测):`g:` + 纯数字,或 `none`。
14+
pub fn is_valid_scope(scope: &str) -> bool {
15+
if scope == "none" {
16+
return true;
17+
}
18+
scope
19+
.strip_prefix("g:")
20+
.is_some_and(|rest| !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()))
21+
}
22+
23+
impl Db {
24+
/// 全量拉取手动序(scope, note_id, ord 升序)——前端树视图消费(量小)。
25+
pub fn load_note_orders(&self) -> Result<Vec<(String, i64, i64)>> {
26+
self.with_conn(|conn| {
27+
let mut stmt =
28+
conn.prepare("SELECT scope, note_id, ord FROM note_orders ORDER BY scope, ord")?;
29+
let rows = stmt.query_map([], |row| {
30+
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?))
31+
})?;
32+
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
33+
})
34+
}
35+
36+
/// 保存某 scope 的完整手动序(事务内先删后插=快照语义;ids 保序编号)。
37+
///
38+
/// @ai-context: 首次启用=前端把当前可见序全量写入;后续移动=前端重算后整
39+
/// 表覆写(单命令单事务,无逐位移动的复杂度)。with_conn 仅给
40+
/// &Connection 无法开事务(db_fragments 同款:直接锁 + tx)。
41+
pub fn save_note_order(&self, scope: &str, note_ids: &[i64]) -> Result<()> {
42+
let mut conn = self
43+
.conn
44+
.lock()
45+
.unwrap_or_else(|poisoned| poisoned.into_inner());
46+
let tx = conn.transaction()?;
47+
tx.execute("DELETE FROM note_orders WHERE scope = ?1", [scope])?;
48+
{
49+
let mut stmt =
50+
tx.prepare("INSERT INTO note_orders (scope, note_id, ord) VALUES (?1, ?2, ?3)")?;
51+
for (i, id) in note_ids.iter().enumerate() {
52+
stmt.execute([scope, &id.to_string(), &(i as i64).to_string()])?;
53+
}
54+
}
55+
tx.commit()?;
56+
Ok(())
57+
}
58+
59+
/// 清除某 scope 手动序(=回自动排序;幂等——无记录时零动作)。
60+
pub fn clear_note_order(&self, scope: &str) -> Result<bool> {
61+
self.with_conn(|conn| {
62+
let affected = conn.execute("DELETE FROM note_orders WHERE scope = ?1", [scope])?;
63+
Ok(affected > 0)
64+
})
65+
}
66+
}
67+
68+
#[cfg(test)]
69+
mod tests {
70+
use super::is_valid_scope;
71+
72+
#[test]
73+
fn scope_validation() {
74+
assert!(is_valid_scope("g:3"));
75+
assert!(is_valid_scope("g:123456789"));
76+
assert!(is_valid_scope("none"));
77+
assert!(!is_valid_scope(""));
78+
assert!(!is_valid_scope("g:"));
79+
assert!(!is_valid_scope("G:1"));
80+
assert!(!is_valid_scope("g:1a"));
81+
assert!(!is_valid_scope("notes"));
82+
}
83+
}

‎app/src-tauri/src/lib.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ mod commands_ai_enrich;
5656
// 记录/纯逻辑/命令层
5757
mod note_version;
5858
mod db_notes_versions;
59+
// REQ-287(v0.19.7):笔记手动排序数据层(scope 独立表)
60+
mod db_note_orders;
61+
mod commands_note_orders;
5962
mod db_ai_usage;
6063
// v0.8.0 F2(2026-08-21):AI 任务中心持久化——任务记录/结果恢复/保留策略
6164
mod db_ai_tasks;
@@ -539,6 +542,10 @@ pub fn run() {
539542
// v0.11.0(REQ-195~198):笔记组——列表/详情/组内笔记/自建主题组/
540543
// 重命名/路由改判(修改即记忆)/移动笔记
541544
commands_groups::list_note_groups,
545+
// REQ-287(v0.19.7):笔记手动排序(scope=g{id}/none)
546+
commands_note_orders::note_order_list,
547+
commands_note_orders::note_order_save,
548+
commands_note_orders::note_order_clear,
542549
commands_groups::get_note_group,
543550
commands_groups::list_group_notes,
544551
commands_groups::create_topic_group,

0 commit comments

Comments
 (0)