Skip to content

Commit 58a3400

Browse files
committed
feat(db): 标签色表按 notes.tags 幂等回填
1 parent 6509e04 commit 58a3400

3 files changed

Lines changed: 287 additions & 1 deletion

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ impl Db {
3434
crate::db_migrations::init_schema(&conn)?;
3535
// v0.8.0 F2(2026-08-21):AI 任务中心持久化(任务记录/恢复/保留)
3636
let db = Self { conn: Arc::new(Mutex::new(conn)) };
37+
// 批 7 T18(规格 §1 L5 行 35「tag_colors 补种子/迁移」):存量标签幂等回填——
38+
// 每个去重标签取确定性色板 id;已设色的行不吃(`INSERT OR IGNORE`),重跑返回 0。
39+
// 失败警告不阻断启动(同下面 uid 回填的范式:标签着色是增强,不是关键路径)。
40+
if let Err(e) = db.backfill_tag_colors() {
41+
eprintln!("[Db] tag_colors 存量回填失败(可忽略,下次启动重试): {e}");
42+
}
3743
db.init_ai_tasks()?;
3844
// v0.16.0(REQ-224):AI 对话持久化(会话/消息双表)
3945
db.init_ai_chat()?;

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

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,16 @@
33
//! @ai-context: tag_colors 表读写(tag 文本主键——tags 无独立表,规格 tag_id
44
//! 前提不存在,按最小合理偏差用 tag 名称作键)。命令层薄壳调用
55
//! 本层;内存库单测覆盖往返/覆盖/幂等(环境隔离)。
6+
//!
7+
//! @ai-context: 批 7 T18(规格 §1 L5 行 35「`tag_colors` 补种子/迁移」)——本表此前
8+
//! **零种子、零回填**(全仓唯一的 INSERT 是 `set_tag_color` 的 upsert)⇒
9+
//! 存量标签在过滤面板恒无色。补偿形态 = 本层的**幂等回填**
10+
//! `backfill_tag_colors`,由 `db.rs` 的 `Db::open` 每次开库调用一次
11+
//! (失败仅告警不阻断启动,同 uid 回填范式)。
12+
13+
use std::collections::BTreeSet;
614

7-
use rusqlite::params;
15+
use rusqlite::{params, Connection};
816

917
use crate::db::Db;
1018
use crate::error::Result;
@@ -17,6 +25,76 @@ pub struct TagColor {
1725
pub color: String,
1826
}
1927

28+
/// 12 色板 id(**顺序即确定性取色的索引**)。
29+
///
30+
/// @ai-context: 真源 = 前端 `app/src/utils/colorPalette.ts` 的 `COLOR_PALETTE`
31+
/// (`COLOR_IDS = Object.keys(COLOR_PALETTE)` ⇒ 键序 = 本数组顺序)。
32+
/// 本数组是它在 Rust 侧的**唯一副本**,两处必须逐字同序:
33+
/// `color_ids_match_frontend_palette` 单测用 `include_str!` 直读真源比对,
34+
/// 单边漂移即红(否则回填写进去的 id 会落到 `paletteHex` 的默认灰)。
35+
pub(crate) const TAG_COLOR_PALETTE_IDS: [&str; 12] = [
36+
"red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "brown", "gray", "black",
37+
"white",
38+
];
39+
40+
/// 标签 → 确定性色板 id(FNV-1a 64 位纯算术 ⇒ 与机器 / 平台 / 进程无关)。
41+
///
42+
/// @ai-context: 为什么不用 `DefaultHasher` / `RandomState`:两者都是**进程级随机种子** ⇒
43+
/// 同一标签在不同启动会算出不同颜色,「确定性分配」这条硬约束(C2.3)就不成立
44+
/// (`INSERT OR IGNORE` 只挡住覆盖,挡不住规则本身不可复算;测试也无从复算)。
45+
/// FNV-1a 的初值与质数写死在代码里,单测可逐字复算、node 探针可独立对拍。
46+
pub(crate) fn deterministic_tag_color(tag: &str) -> &'static str {
47+
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
48+
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
49+
let mut hash = FNV_OFFSET_BASIS;
50+
for byte in tag.as_bytes() {
51+
hash ^= u64::from(*byte);
52+
hash = hash.wrapping_mul(FNV_PRIME);
53+
}
54+
TAG_COLOR_PALETTE_IDS[(hash % TAG_COLOR_PALETTE_IDS.len() as u64) as usize]
55+
}
56+
57+
/// 解析 `notes.tags` 的 JSON 数组文本(非数组 / 损坏 JSON / 非字符串项一律跳过)。
58+
///
59+
/// @ai-context: 防御性——`tags` 列是 TEXT NOT NULL DEFAULT '[]',历史行可能被手工改坏;
60+
/// 回填是增强路径,遇到坏数据**跳过而不是报错**(不阻断开库,见 `Db::open`)。
61+
fn parse_tag_array(raw: Option<&str>) -> Vec<String> {
62+
let Some(text) = raw else { return Vec::new() };
63+
let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else { return Vec::new() };
64+
let Some(items) = value.as_array() else { return Vec::new() };
65+
items
66+
.iter()
67+
.filter_map(|v| v.as_str())
68+
.map(str::trim)
69+
.filter(|s| !s.is_empty())
70+
.map(str::to_string)
71+
.collect()
72+
}
73+
74+
/// 回填实现(自由函数:不持 `Db`,供数据层方法与单测直调;锁由调用方持有)。
75+
fn backfill_tag_colors(conn: &Connection) -> Result<usize> {
76+
// ① 收集 `notes.tags` 里已出现的**去重**标签(BTreeSet ⇒ 遍历序确定,便于复算)
77+
let mut tags: BTreeSet<String> = BTreeSet::new();
78+
{
79+
let mut stmt = conn.prepare("SELECT tags FROM notes")?;
80+
let rows = stmt.query_map([], |row| row.get::<_, Option<String>>(0))?;
81+
for raw in rows {
82+
for tag in parse_tag_array(raw?.as_deref()) {
83+
tags.insert(tag);
84+
}
85+
}
86+
}
87+
// ② `INSERT OR IGNORE`:已存在的行(用户设过的色)一字不动;重复调用返回 0
88+
let mut inserted = 0usize;
89+
for tag in &tags {
90+
inserted += conn.execute(
91+
"INSERT OR IGNORE INTO tag_colors (tag, color) VALUES (?1, ?2)",
92+
params![tag, deterministic_tag_color(tag)],
93+
)?;
94+
}
95+
Ok(inserted)
96+
}
97+
2098
impl Db {
2199
/// 全部标签颜色(空表返回空数组,非错误)。
22100
pub fn list_tag_colors(&self) -> Result<Vec<TagColor>> {
@@ -71,6 +149,21 @@ impl Db {
71149
Ok(())
72150
})
73151
}
152+
153+
/// 把 `notes.tags` 里已出现的**去重标签**回填成 `tag_colors` 行(**幂等**)。
154+
///
155+
/// @ai-context: 批 7 T18(规格 §1 L5 行 35「`tag_colors` 补种子/迁移」)。三条硬约束
156+
/// (C2.3):① **幂等**(重跑返回 0 且表逐字不变)② **不覆盖用户已设色**
157+
/// (等价 `INSERT OR IGNORE`)③ **不改 schema 主键语义**
158+
/// (`tag TEXT PRIMARY KEY` 一字不动)。
159+
/// @ai-context: 形态为何落在数据层而不是 `db_migrations.rs`:后者 573 行、距 600 硬限仅 27
160+
/// (`docs/standards/line-limit-exemptions.md:25`),且本仓已有「每次开库跑一次
161+
/// 的幂等回填」先例(`db_uid::backfill_table`,`db.rs` 的 `Db::open`)
162+
/// ⇒ 回填住 `db_colors.rs`、由 `Db::open` 调用,不引入迁移版本表。
163+
/// 副作用:写 `tag_colors`(**只增不覆盖**)。返回本次新增行数(幂等重跑 ⇒ 0)。
164+
pub fn backfill_tag_colors(&self) -> Result<usize> {
165+
self.with_conn(backfill_tag_colors)
166+
}
74167
}
75168

76169
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3;同 db_notes 模式)。

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

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,61 @@
11
//! db_colors.rs 单测(内存库,环境隔离;v0.14 B 视觉系统)。
2+
//!
3+
//! @ai-context: 批 7 T18 追加三组 —— ① `tag_colors` 幂等回填的四点(C2.3 逐字)② 12 色 id
4+
//! 与前端真源 `colorPalette.ts` 的跨语言对拍 ③ 真 SQLite 文件库上的端到端
5+
//! 「加标签 / 设色 / 清色 → 重新读库」往返(唯一未覆盖的一跳是 Tauri IPC 壳)。
26
37
use crate::db::Db;
8+
use crate::types::NewNote;
9+
10+
use super::{deterministic_tag_color, TAG_COLOR_PALETTE_IDS};
411

512
fn mem_db() -> Db {
613
Db::open(":memory:").expect("open in-memory db")
714
}
815

16+
/// 前端真源(**编译期嵌入**):`app/src/utils/colorPalette.ts`。
17+
/// 单边漂移(只有一侧改了色板)在这里直接红,不需要任何外部探针。
18+
const FRONTEND_PALETTE_TS: &str = include_str!("../../src/utils/colorPalette.ts");
19+
20+
/// 从真源 TS 的 `COLOR_PALETTE` 字面量里读键序(独立解析,不复用生产代码)。
21+
fn frontend_color_ids() -> Vec<String> {
22+
let start = FRONTEND_PALETTE_TS
23+
.find("export const COLOR_PALETTE")
24+
.expect("真源里找不到 COLOR_PALETTE");
25+
let block = &FRONTEND_PALETTE_TS[start..];
26+
let end = block.find("};").expect("COLOR_PALETTE 字面量没有收尾");
27+
block[..end]
28+
.lines()
29+
.filter_map(|line| {
30+
let (key, rest) = line.trim().split_once(':')?;
31+
if !rest.trim_start().starts_with('{') {
32+
return None;
33+
}
34+
let key = key.trim();
35+
let ok = !key.is_empty() && key.chars().all(|c| c.is_ascii_lowercase());
36+
ok.then(|| key.to_string())
37+
})
38+
.collect()
39+
}
40+
41+
/// 造一条带标签的笔记(`notes.tags` = JSON 数组文本,即回填的输入面)。
42+
fn seed_note(db: &Db, title: &str, tags: &[&str]) -> i64 {
43+
let note = db
44+
.create_note(&NewNote {
45+
title: title.to_string(),
46+
content: String::new(),
47+
source: "manual".to_string(),
48+
session_id: None,
49+
rule_version: None,
50+
purify_stats: None,
51+
tags: Some(serde_json::to_string(tags).expect("tags json")),
52+
properties: None,
53+
group_id: None,
54+
})
55+
.expect("create note");
56+
note.id
57+
}
58+
959
#[test]
1060
fn set_get_roundtrip() {
1161
// Arrange
@@ -62,3 +112,140 @@ fn list_orders_by_tag() {
62112
// Assert:按 tag 字典序
63113
assert_eq!(all.iter().map(|t| t.tag.as_str()).collect::<Vec<_>>(), vec!["a", "b"]);
64114
}
115+
116+
// ── 批 7 T18 · `tag_colors` 幂等回填(C2.3 的四点)─────────────────────────────
117+
118+
/// ① 空库:`notes.tags` 全空 ⇒ 回填 0 行、表仍空(不许凭空造行)。
119+
#[test]
120+
fn backfill_empty_db_inserts_nothing() {
121+
// Arrange
122+
let db = mem_db();
123+
// Act
124+
let inserted = db.backfill_tag_colors().expect("backfill");
125+
// Assert
126+
assert_eq!(inserted, 0);
127+
assert!(db.list_tag_colors().expect("list").is_empty());
128+
}
129+
130+
/// ② 已有标签:2 条笔记共 3 个去重标签 ⇒ 回填 3 行,颜色 = 确定性规则值。
131+
#[test]
132+
fn backfill_seeds_distinct_tags_of_existing_notes() {
133+
// Arrange:重复标签只算一个("编程" 出现在两条笔记上)
134+
let db = mem_db();
135+
seed_note(&db, "n1", &["化妆", "编程"]);
136+
seed_note(&db, "n2", &["编程", "乐理"]);
137+
// Act
138+
let inserted = db.backfill_tag_colors().expect("backfill");
139+
// Assert:恰 3 行、逐个标签的颜色等于确定性规则
140+
assert_eq!(inserted, 3);
141+
let all = db.list_tag_colors().expect("list");
142+
assert_eq!(all.len(), 3);
143+
for row in &all {
144+
assert_eq!(row.color, deterministic_tag_color(&row.tag), "标签 {} 的颜色不是确定性值", row.tag);
145+
}
146+
let mut names: Vec<&str> = all.iter().map(|t| t.tag.as_str()).collect();
147+
names.sort_unstable();
148+
assert_eq!(names, vec!["乐理", "化妆", "编程"]);
149+
// 交叉自证:另一个库(另一条连接)对同一标签给出同一颜色 ⇒ 规则不含进程随机源
150+
let other = mem_db();
151+
seed_note(&other, "n1", &["化妆"]);
152+
other.backfill_tag_colors().expect("backfill other");
153+
assert_eq!(other.get_tag_color("化妆").expect("get").as_deref(), Some(deterministic_tag_color("化妆")));
154+
}
155+
156+
/// ③ 用户已设色不被覆盖(等价 `INSERT OR IGNORE`)。
157+
#[test]
158+
fn backfill_does_not_override_user_color() {
159+
// Arrange:用户先显式设色
160+
let db = mem_db();
161+
seed_note(&db, "n1", &["化妆"]);
162+
db.set_tag_color("化妆", "red").expect("user set");
163+
// 非空真自证:确定性规则给出的默认色**不是** red(否则本用例红不出差别)
164+
assert_ne!(deterministic_tag_color("化妆"), "red");
165+
// Act
166+
let inserted = db.backfill_tag_colors().expect("backfill");
167+
// Assert:该标签已有行 ⇒ 新增 0,且用户的 red 一字未动
168+
assert_eq!(inserted, 0);
169+
assert_eq!(db.get_tag_color("化妆").expect("get").as_deref(), Some("red"));
170+
}
171+
172+
/// ④ 重跑幂等:连续两次 ⇒ 第二次返回 0,且表**逐字**不变。
173+
#[test]
174+
fn backfill_is_idempotent_on_rerun() {
175+
// Arrange
176+
let db = mem_db();
177+
seed_note(&db, "n1", &["化妆", "编程"]);
178+
assert_eq!(db.backfill_tag_colors().expect("first"), 2);
179+
let before = db.list_tag_colors().expect("list");
180+
// Act:第二次
181+
let second = db.backfill_tag_colors().expect("second");
182+
// Assert
183+
assert_eq!(second, 0);
184+
assert_eq!(db.list_tag_colors().expect("list"), before);
185+
}
186+
187+
// ── 批 7 T18 · 12 色 id 的跨语言对拍(C2.3 第 ②③ 条)────────────────────────
188+
189+
/// Rust 侧 12 色 id 与前端真源 `COLOR_IDS` **同序同集**(`include_str!` 直读真源)。
190+
#[test]
191+
fn color_ids_match_frontend_palette() {
192+
// Act
193+
let frontend = frontend_color_ids();
194+
let rust: Vec<String> = TAG_COLOR_PALETTE_IDS.iter().map(|s| s.to_string()).collect();
195+
// Assert:先证明解析器没瞎(两侧都得是 12),再逐字比序
196+
assert_eq!(frontend.len(), 12, "前端真源解析出 {} 个 id(解析器或真源变了): {frontend:?}", frontend.len());
197+
assert_eq!(rust.len(), 12, "Rust 侧色板不是 12 个: {rust:?}");
198+
assert_eq!(rust, frontend, "Rust 侧 12 色 id 与前端真源 COLOR_IDS 不同序 / 不同集");
199+
}
200+
201+
/// 确定性取色的**跨实现**钉住:期望值由本仓 node 探针 `tmp/t18/fnv.mjs` 独立算出
202+
/// (BigInt FNV-1a 64;与 Rust 侧实现各自独立写),逐字抄在这里。
203+
#[test]
204+
fn deterministic_color_matches_independent_probe() {
205+
for (tag, expected) in [("化妆", "blue"), ("编程", "red"), ("乐理", "gray"), ("x", "white")] {
206+
assert_eq!(deterministic_tag_color(tag), expected, "标签 {tag} 的确定性取色漂了");
207+
}
208+
// 边界:空标签 / 单字符也落在色板内(不 panic、不越界)
209+
for tag in ["", "a", "标签标签标签标签标签标签标签标签标签标签"] {
210+
assert!(TAG_COLOR_PALETTE_IDS.contains(&deterministic_tag_color(tag)), "标签 {tag} 取色越界");
211+
}
212+
}
213+
214+
// ── 批 7 T18 · 端到端(真 SQLite 文件库 + 重开)─────────────────────────────
215+
216+
/// 验收「标签能写进去」的**真库往返**:加标签 → 关闭并重新读库 → 标签仍在(且开库回填
217+
/// 把这两个标签变成 `tag_colors` 行);设色 → 读回 → 色在;清色 → 读回 → 色无。
218+
///
219+
/// @ai-context: 这是本任务**最强**的一层证据 —— 走的是真实 SQLite 文件(不是 mock),
220+
/// 唯一未覆盖的一跳是 Tauri IPC 壳与 WebView(真机不可达,C6.4)。
221+
#[test]
222+
fn e2e_tag_writes_survive_reopen() {
223+
// Arrange:临时文件库(不触碰真实数据目录)
224+
let path = std::env::temp_dir().join(format!("entropy_t18_tags_{}.db", std::process::id()));
225+
let _ = std::fs::remove_file(&path);
226+
let id = {
227+
let db = Db::open(path.to_str().expect("path utf8")).expect("open file db");
228+
let note_id = seed_note(&db, "端到端", &[]);
229+
// Act ①:加标签(生产写端路径 = `update_note_tags` 命令背后的同一数据层方法)
230+
assert!(db.update_note_tags(note_id, r#"["化妆","编程"]"#).expect("update tags"));
231+
note_id
232+
}; // ← db 在此 drop:连接关闭(等价「关掉应用」)
233+
// Act ②:重新读库
234+
let db = Db::open(path.to_str().expect("path utf8")).expect("reopen file db");
235+
let note = db.get_note(id).expect("get note").expect("note 仍在");
236+
// Assert ①:标签仍在(逐字)
237+
assert_eq!(note.tags, r#"["化妆","编程"]"#);
238+
// Assert ②:重开时的幂等回填把这两个标签变成 tag_colors 行(此前该表零种子)
239+
let seeded = db.list_tag_colors().expect("list after reopen");
240+
assert_eq!(seeded.len(), 2, "重开未按 notes.tags 回填: {seeded:?}");
241+
// Act/Assert ③:设色 → 读回 → 色在
242+
db.set_tag_color("化妆", "purple").expect("set color");
243+
assert_eq!(db.get_tag_color("化妆").expect("get").as_deref(), Some("purple"));
244+
// Act/Assert ④:清色 → 读回 → 色无
245+
db.reset_tag_color("化妆").expect("reset color");
246+
assert_eq!(db.get_tag_color("化妆").expect("get"), None);
247+
// 清理
248+
drop(db);
249+
let _ = std::fs::remove_file(&path);
250+
}
251+

0 commit comments

Comments
 (0)