Skip to content

Commit 483ad4e

Browse files
committed
feat(fragments): 碎片升笔记/升卡升级出口 + feedCapture 默认开启(v0.12.2 Phase 2)
- promote_fragment_to_note:单事务建笔记(source=manual,正文=碎片文本+图 markdown 引用) + 搬运图片 fragments/→notes-images/{nid}/ + 删碎片;图片失败降级纯文本;绑定卡自动解绑保留 - promote_fragment_to_card:复用多句卡规则,card_by_fragment 查重幂等(可重复触发返回 0); 未归组碎片明确报错引导;fragment_upgraded 指标与组生成同口径 - feedCapture 默认改开(收件箱动线转正;旧持久化配置不受影响)+ 设置页文案改快速记录入口 - 单测 +5(升笔记文本/图片搬运/图片缺失降级/碎片不存在 + 升卡幂等判据)、lib.rs 注册两命令
1 parent 551b5e4 commit 483ad4e

9 files changed

Lines changed: 345 additions & 12 deletions

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,54 @@ pub fn generate_group_cards(state: State<'_, AppState>, group_id: i64) -> Result
9393
Ok(generated)
9494
}
9595

96+
/// 碎片升为闪卡(v0.12.2 收件箱动线;REQ-201 升级出口——复用多句卡生成规则)。
97+
///
98+
/// @ai-context: 幂等(可重复触发)——已为该碎片生成过卡返回 0 不重产;单句
99+
/// 碎片诚实不出卡(card_from_fragment 内门控,返回 0);卡绑定
100+
/// 碎片所在组(flashcards.group_id NOT NULL)——未归组碎片无卡
101+
/// 可挂,返回明确错误引导先归组/升笔记(碎片归属=卡分组依据)。
102+
#[tauri::command]
103+
pub fn promote_fragment_to_card(state: State<'_, AppState>, fragment_id: i64) -> Result<usize, String> {
104+
crate::commands_fragments::require_feed_enabled(&state)?;
105+
if fragment_id <= 0 {
106+
return Err("无效的碎片 id".to_string());
107+
}
108+
let frag = state
109+
.db
110+
.get_fragment(fragment_id)
111+
.map_err(|e| e.to_string())?
112+
.ok_or_else(|| format!("碎片不存在: {}", fragment_id))?;
113+
let Some(group_id) = frag.group_id else {
114+
return Err("碎片未归组,不能升闪卡——请先移入组或升为笔记".to_string());
115+
};
116+
// 幂等:已升级过(同碎片已有卡)→ 0(不报错,可重复触发)
117+
if state.db.card_by_fragment(fragment_id).map_err(|e| e.to_string())?.is_some() {
118+
return Ok(0);
119+
}
120+
let Some(cand) = card_from_fragment(&frag.text) else {
121+
return Ok(0); // 单句碎片无卡可出(card_generate 门控——诚实不出卡)
122+
};
123+
let now = now_ms();
124+
let new_state = serde_json::to_string(&CardState::default()).unwrap_or_default();
125+
state
126+
.db
127+
.create_card(&NewFlashcard {
128+
group_id,
129+
note_id: None,
130+
fragment_id: Some(frag.id),
131+
front: cand.front,
132+
back: cand.back,
133+
kind: cand.kind.clone(),
134+
state_json: new_state,
135+
due_at: now as i64,
136+
})
137+
.map_err(|e| e.to_string())?;
138+
// 碎片升级率埋点(与 generate_group_cards 同口径——Phase 4 门控判据)
139+
let payload = serde_json::json!({ "fragmentId": frag.id, "groupId": group_id }).to_string();
140+
let _ = state.db.add_metric_event("fragment_upgraded", &payload);
141+
Ok(1)
142+
}
143+
96144
/// 到期复习队列(组过滤可选;到期最紧在前)。
97145
#[tauri::command]
98146
pub fn list_due_cards(

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

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use tauri::State;
1010

1111
use crate::commands::AppState;
1212
use crate::db_fragments::NewFragment;
13-
use crate::types::{Fragment, NewNoteGroup};
13+
use crate::types::{Fragment, NewNoteGroup, Note};
1414
use crate::video_profile_domain::{detect_domain, DomainSignals};
1515

1616
/// 碎片文本最大长度(防超大 payload 拖垮 IPC/DB;几句话的碎片远用不到)。
@@ -186,6 +186,46 @@ pub fn delete_fragment(state: State<'_, AppState>, fragment_id: i64) -> Result<b
186186
state.db.delete_fragment(fragment_id).map_err(|e| e.to_string())
187187
}
188188

189+
/// 碎片升为笔记(v0.12.2 收件箱动线:原料→沉淀;REQ-201 补升级出口)。
190+
///
191+
/// @ai-context: 事务在数据层(建笔记+删碎片原子——promote_fragment_to_note);
192+
/// 本层只做入参校验:标题必填(前端预填首句可改,后端拒绝空串)、
193+
/// 组存在性校验、开关准入(升笔记是碎片生命周期操作,feed 能力
194+
/// 对称纪律)。图随碎片搬运进 notes-images/(失败降级纯文本)。
195+
#[tauri::command]
196+
pub fn promote_fragment_to_note(
197+
state: State<'_, AppState>,
198+
fragment_id: i64,
199+
title: String,
200+
group_id: Option<i64>,
201+
) -> Result<Note, String> {
202+
require_feed_enabled(&state)?;
203+
if fragment_id <= 0 {
204+
return Err("无效的碎片 id".to_string());
205+
}
206+
// 标题必填(二元论裁决:升笔记需要标题——前端预填首句,后端兜底拒绝)
207+
let title_trim = title.trim();
208+
if title_trim.is_empty() {
209+
return Err("笔记标题不能为空".to_string());
210+
}
211+
let title = crate::commands::normalize_title(title, "碎片笔记");
212+
if state.db.get_fragment(fragment_id).map_err(|e| e.to_string())?.is_none() {
213+
return Err(format!("碎片不存在: {}", fragment_id));
214+
}
215+
if let Some(gid) = group_id {
216+
if gid <= 0 {
217+
return Err("无效的组 id".to_string());
218+
}
219+
if state.db.get_group(gid).map_err(|e| e.to_string())?.is_none() {
220+
return Err(format!("笔记组不存在: {}", gid));
221+
}
222+
}
223+
state
224+
.db
225+
.promote_fragment_to_note(&state.data_dir, fragment_id, &title, group_id)
226+
.map_err(|e| e.to_string())
227+
}
228+
189229
/// 碎片图片 → 本地绝对路径(前端 convertFileSrc 消费;REQ-201 缩略图)。
190230
///
191231
/// @ai-context: resolve 先例同 resolve_note_image——WebView 不能直接读本地文件;
@@ -217,8 +257,10 @@ pub fn resolve_fragment_image(
217257
Ok(Some(abs.to_string_lossy().into_owned()))
218258
}
219259

220-
/// feed 能力开关准入(capture/delete/移组共用——后端不信前端隐藏)。
221-
fn require_feed_enabled(state: &AppState) -> Result<(), String> {
260+
/// feed 能力开关准入(capture/delete/升笔记共用——后端不信前端隐藏)。
261+
/// @ai-context: pub(crate) 供 commands_flashcards::promote_fragment_to_card 复用
262+
/// (碎片生命周期操作对称纪律)。
263+
pub(crate) fn require_feed_enabled(state: &AppState) -> Result<(), String> {
222264
let guard = state
223265
.feature_flags
224266
.lock()

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,23 @@ impl Db {
8181
})
8282
}
8383

84+
/// 按碎片查卡(v0.12.2 升卡幂等判据:已为碎片生成过卡即视为已升级,
85+
/// 可重复触发不再产重卡)。
86+
pub fn card_by_fragment(&self, fragment_id: i64) -> Result<Option<Flashcard>> {
87+
self.with_conn(|conn| {
88+
let mut stmt = conn.prepare(&format!(
89+
"SELECT {} FROM flashcards WHERE fragment_id = ?1 LIMIT 1",
90+
CARD_COLUMNS
91+
))?;
92+
let mut rows = stmt.query_map(params![fragment_id], row_to_card)?;
93+
match rows.next() {
94+
Some(Ok(c)) => Ok(Some(c)),
95+
Some(Err(e)) => Err(e.into()),
96+
None => Ok(None),
97+
}
98+
})
99+
}
100+
84101
/// 到期复习队列(due_at ≤ now;组过滤可选;到期最紧在前——弹性承诺不追债,
85102
/// 只按客观到期时刻排序呈现)。
86103
pub fn list_due_cards(&self, group_id: Option<i64>, now_ms: i64, limit: usize) -> Result<Vec<Flashcard>> {

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,30 @@ fn create_and_get_card_roundtrip() {
5252
assert_eq!(fetched.due_at, 1000);
5353
}
5454

55+
#[test]
56+
fn card_by_fragment_is_idempotency_judge() {
57+
// Arrange:碎片绑定卡(v0.12.2 升卡幂等判据)
58+
let db = mem_db();
59+
let gid = make_group(&db);
60+
let f = db
61+
.create_fragment(&crate::db_fragments::NewFragment {
62+
text: "多句碎片:先晕染再定妆。".to_string(),
63+
image_path: None,
64+
domain_tag: None,
65+
group_id: Some(gid),
66+
source: "manual".to_string(),
67+
})
68+
.expect("frag");
69+
let mut c = card(gid, "晕染步骤", 1000);
70+
c.fragment_id = Some(f.id);
71+
db.create_card(&c).expect("card");
72+
// Act/Assert:命中返回卡;无卡碎片返回 None
73+
let hit = db.card_by_fragment(f.id).expect("hit").expect("应命中");
74+
assert_eq!(hit.front, "晕染步骤");
75+
let miss = db.card_by_fragment(9999).expect("miss");
76+
assert!(miss.is_none());
77+
}
78+
5579
#[test]
5680
fn front_exists_dedup_key() {
5781
// Arrange:同组同 front 幂等查重(生成防重)

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

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use rusqlite::params;
88

99
use crate::db::{unix_seconds, Db};
1010
use crate::error::Result;
11-
use crate::types::Fragment;
11+
use crate::types::{Fragment, Note};
1212

1313
/// fragments 表统一查询列(列顺序与 row_to_fragment 严格对应)。
1414
const FRAGMENT_COLUMNS: &str =
@@ -157,6 +157,113 @@ impl Db {
157157
Ok(affected > 0)
158158
})
159159
}
160+
161+
/// 碎片升为笔记(v0.12.2:REQ-201 补升级出口——碎片是原料,升笔记=沉淀)。
162+
///
163+
/// @ai-context: 单事务(建笔记 + 删碎片)——任一步失败整链回滚,不留
164+
/// "笔记已建但碎片还在"的半态;图片搬运是事务内副作用
165+
/// (复制失败降级纯文本笔记,碎片文本不丢——与
166+
/// capture_fragment 图片降级同纪律,最坏遗留孤儿文件)。
167+
/// @ai-context: 图引用写 `notes-images/{note_id}/{name}`(resolve_note_image
168+
/// 规则 2 可解析);source=manual、rule_version=None(手动沉淀
169+
/// 路径诚实降级);碎片删除后绑定卡经外键 SET NULL 自动解绑
170+
/// 保留(卡是独立资产——升笔记不等于消卡)。
171+
pub fn promote_fragment_to_note(
172+
&self,
173+
data_dir: &std::path::Path,
174+
fragment_id: i64,
175+
title: &str,
176+
group_id: Option<i64>,
177+
) -> Result<Note> {
178+
let now = unix_seconds();
179+
self.with_conn(|conn| {
180+
// ① 读碎片(事务内读取——存在性校验与删除同锁,防竞态双升)
181+
let fragment = {
182+
let mut stmt = conn.prepare(&format!(
183+
"SELECT {} FROM fragments WHERE id = ?1",
184+
FRAGMENT_COLUMNS
185+
))?;
186+
let mut rows = stmt.query_map(params![fragment_id], row_to_fragment)?;
187+
match rows.next() {
188+
Some(Ok(f)) => f,
189+
Some(Err(e)) => return Err(e.into()),
190+
None => {
191+
return Err(crate::error::AppError::Db(format!(
192+
"碎片不存在: {}",
193+
fragment_id
194+
)))
195+
}
196+
}
197+
};
198+
// ② 建笔记(正文先落碎片文本;图片在 ③ 补写)
199+
conn.execute(
200+
"INSERT INTO notes (title, content, source, session_id, rule_version, purify_stats, tags, properties, group_id, created_at, updated_at)
201+
VALUES (?1, ?2, 'manual', NULL, NULL, NULL, '[]', NULL, ?3, ?4, ?4)",
202+
params![title, fragment.text, group_id, now],
203+
)?;
204+
let note_id = conn.last_insert_rowid();
205+
// ③ 图片搬运 fragments/ → notes-images/{note_id}/(失败降级纯文本)
206+
let mut content = fragment.text.clone();
207+
if let Some(rel) = fragment.image_path.as_deref() {
208+
match copy_fragment_image(data_dir, note_id, rel) {
209+
Some(img_ref) => {
210+
content = format!("{}\n\n![]({})\n", content, img_ref);
211+
}
212+
None => {
213+
eprintln!(
214+
"[db_fragments] 碎片 {} 图片搬运失败(降级纯文本笔记): {}",
215+
fragment_id, rel
216+
);
217+
}
218+
}
219+
}
220+
if content != fragment.text {
221+
conn.execute(
222+
"UPDATE notes SET content = ?1 WHERE id = ?2",
223+
params![content, note_id],
224+
)?;
225+
}
226+
// ④ 删碎片(绑定卡自动解绑保留)
227+
conn.execute("DELETE FROM fragments WHERE id = ?1", params![fragment_id])?;
228+
// ⑤ 组装返回(与库内一致)
229+
Ok(Note {
230+
id: note_id,
231+
title: title.to_string(),
232+
content,
233+
source: "manual".to_string(),
234+
session_id: None,
235+
rule_version: None,
236+
purify_stats: None,
237+
tags: "[]".to_string(),
238+
properties: None,
239+
pin: 0,
240+
group_id,
241+
created_at: now,
242+
updated_at: now,
243+
})
244+
})
245+
}
246+
}
247+
248+
/// 搬运碎片图进笔记图目录(返回 notes-images 相对引用;失败返回 None 降级)。
249+
///
250+
/// @ai-context: 只放行 fragments/ 前缀 + 防 `..` 穿越(与 resolve_fragment_image
251+
/// 落盘口径一致);源文件缺失/IO 失败 → None(调用方降级纯文本)。
252+
fn copy_fragment_image(data_dir: &std::path::Path, note_id: i64, rel: &str) -> Option<String> {
253+
let rel_trim = rel.trim_start_matches(['/', '\\']);
254+
if !rel_trim.starts_with("fragments/") || rel_trim.split(['/', '\\']).any(|seg| seg == "..") {
255+
return None;
256+
}
257+
let src = data_dir.join(rel_trim);
258+
if !src.is_file() {
259+
return None;
260+
}
261+
// 文件名沿用碎片存储名(毫秒+随机后缀已防碰撞;同笔记内无冲突)
262+
let name = std::path::Path::new(rel_trim).file_name()?.to_str()?.to_string();
263+
let target_dir = data_dir.join("notes-images").join(note_id.to_string());
264+
std::fs::create_dir_all(&target_dir).ok()?;
265+
std::fs::copy(&src, target_dir.join(&name)).ok()?;
266+
Some(format!("notes-images/{}/{}", note_id, name))
160267
}
161268

162269
/// 把 rusqlite 行映射为 Fragment。

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

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,92 @@ fn delete_missing_fragment_returns_false() {
191191
let db = mem_db();
192192
assert!(!db.delete_fragment(9999).expect("delete"));
193193
}
194+
195+
#[test]
196+
fn promote_fragment_to_note_creates_note_and_deletes_fragment() {
197+
// Arrange
198+
let db = mem_db();
199+
let f = db.create_fragment(&frag("碎片文本:眼影要晕染", None, None)).expect("f");
200+
// Act:升为未归组笔记
201+
let note = db.promote_fragment_to_note(std::path::Path::new("."), f.id, "眼影晕染", None).expect("promote");
202+
// Assert:笔记成立(正文=碎片文本、source=manual、未归组);碎片已删
203+
assert_eq!(note.content, "碎片文本:眼影要晕染");
204+
assert_eq!(note.source, "manual");
205+
assert_eq!(note.group_id, None);
206+
assert!(db.get_fragment(f.id).expect("get").is_none());
207+
let fetched = db.get_note(note.id).expect("get").expect("note 应存在");
208+
assert_eq!(fetched.content, "碎片文本:眼影要晕染");
209+
}
210+
211+
#[test]
212+
fn promote_fragment_copies_image_and_embeds_ref() {
213+
// Arrange:临时目录伪造碎片图(promote 只做 fs::copy,字节内容无关)
214+
let dir = std::env::temp_dir().join(format!("dsh_promote_img_{}", std::process::id()));
215+
let _ = std::fs::remove_dir_all(&dir);
216+
std::fs::create_dir_all(dir.join("fragments")).expect("dir");
217+
std::fs::write(dir.join("fragments").join("1-abc.png"), b"fake").expect("img");
218+
let db = mem_db();
219+
let group = db
220+
.create_group(&NewNoteGroup {
221+
name: "目标组".to_string(),
222+
terrain: "container".to_string(),
223+
kind: "topic".to_string(),
224+
domain_tag: Some("beauty".to_string()),
225+
source: "route".to_string(),
226+
series_key: None,
227+
route_reason: None,
228+
})
229+
.expect("group");
230+
let f = db
231+
.create_fragment(&NewFragment {
232+
text: "带图碎片".to_string(),
233+
image_path: Some("fragments/1-abc.png".to_string()),
234+
domain_tag: None,
235+
group_id: Some(group.id),
236+
source: "manual".to_string(),
237+
})
238+
.expect("f");
239+
// Act:升入指定组
240+
let note = db
241+
.promote_fragment_to_note(&dir, f.id, "带图笔记", Some(group.id))
242+
.expect("promote");
243+
// Assert:图片已搬运入 notes-images/{nid}/ 且正文含引用;碎片已删;归组生效
244+
let img_ref = format!("![](notes-images/{}/1-abc.png)", note.id);
245+
assert!(note.content.contains(&img_ref), "正文应含图片引用: {}", note.content);
246+
assert!(dir.join("notes-images").join(note.id.to_string()).join("1-abc.png").is_file());
247+
assert_eq!(note.group_id, Some(group.id));
248+
assert!(db.get_fragment(f.id).expect("get").is_none());
249+
let _ = std::fs::remove_dir_all(&dir);
250+
}
251+
252+
#[test]
253+
fn promote_fragment_image_missing_degrades_to_text() {
254+
// Arrange:碎片记录引用不存在图片(外部清理等)
255+
let db = mem_db();
256+
let f = db
257+
.create_fragment(&NewFragment {
258+
text: "图丢了也要升".to_string(),
259+
image_path: Some("fragments/ghost.png".to_string()),
260+
domain_tag: None,
261+
group_id: None,
262+
source: "manual".to_string(),
263+
})
264+
.expect("f");
265+
// Act:升笔记(临时目录无 fragments/ghost.png)
266+
let note = db
267+
.promote_fragment_to_note(&std::env::temp_dir(), f.id, "降级笔记", None)
268+
.expect("promote");
269+
// Assert:图片缺失降级纯文本(碎片文本不丢——诚实降级纪律)
270+
assert_eq!(note.content, "图丢了也要升");
271+
assert!(db.get_fragment(f.id).expect("get").is_none());
272+
}
273+
274+
#[test]
275+
fn promote_fragment_missing_returns_error() {
276+
// Arrange/Act/Assert:升不存在的碎片返回明确错误(不 panic 不静默)
277+
let db = mem_db();
278+
let err = db
279+
.promote_fragment_to_note(std::path::Path::new("."), 99999, "标题", None)
280+
.expect_err("应报错");
281+
assert!(err.to_string().contains("碎片不存在"));
282+
}

0 commit comments

Comments
 (0)