Skip to content

Commit 0867749

Browse files
committed
feat(sessions): notes.session_id 关联与批量转笔记命令
1 parent 9615465 commit 0867749

11 files changed

Lines changed: 555 additions & 55 deletions

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ pub async fn save_draft_as_note(state: State<'_, AppState>, draft: NoteDraft) ->
169169
title: normalize_title(draft.title, "未命名笔记"),
170170
content: truncate_chars(draft.markdown, CONTENT_MAX_CHARS),
171171
source: "classroom".to_string(),
172+
session_id: None,
172173
};
173174
state.db.create_note(&new).map_err(|e| e.to_string())
174175
}
@@ -233,6 +234,7 @@ pub async fn process_to_note(
233234
title: draft.title.clone(),
234235
content: draft.markdown.clone(),
235236
source: "classroom".to_string(),
237+
session_id: None,
236238
})
237239
.map_err(|e| e.to_string())
238240
})
@@ -247,6 +249,8 @@ pub async fn create_note(state: State<'_, AppState>, new: NewNote) -> Result<Not
247249
title: normalize_title(new.title, "未命名笔记"),
248250
content: truncate_chars(new.content, CONTENT_MAX_CHARS),
249251
source: if new.source == "classroom" { "classroom" } else { "manual" }.to_string(),
252+
// 手动新建笔记无来源会话(session_id 仅由会话→笔记链路写入)
253+
session_id: None,
250254
};
251255
state.db.create_note(&new).map_err(|e| e.to_string())
252256
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ pub async fn artifact_to_note(
9494
title: title.chars().take(100).collect(),
9595
content: markdown.chars().take(200_000).collect(),
9696
source: "classroom".to_string(),
97+
// v0.7.1:产物→笔记同样建立会话关联(列表 has_note 标记口径统一)
98+
session_id: Some(session_id),
9799
})
98100
.map_err(|e| e.to_string())
99101
}

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

Lines changed: 113 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,20 @@
77
//! preview_session_note(只读预览)共用 note_filter 单一管线——
88
//! 输出一致性由构造保证(同一过滤链/同一 Markdown 组装)。
99
10+
use std::collections::HashSet;
11+
1012
use tauri::State;
1113

1214
use crate::ai_protocol::TextFilterDecision;
1315
use crate::commands::{normalize_title, AppState, TITLE_MAX_CHARS};
16+
use crate::db::Db;
1417
use crate::db_sessions::SESSION_STATUS_RECORDING;
1518
use crate::note_filter::{apply_ai_decisions, filter_note, NoteFilterResult};
1619
use crate::types::{
17-
NewNote, NewSession, NewSessionOcrBlock, NewSessionSegment, Note, Session, SessionDetail,
18-
SessionOcrBlock, SessionSegment,
20+
BatchNoteResult, ConvertedNote, NewNote, NewSession, NewSessionOcrBlock, NewSessionSegment,
21+
Note, Session, SessionDetail, SessionListItem, SessionOcrBlock, SessionSegment, SkippedNote,
1922
};
23+
use crate::ui_junk::UiJunkList;
2024

2125
/// 会话列表单页上限。
2226
const LIST_LIMIT_MAX: u64 = 200;
@@ -46,14 +50,14 @@ pub async fn finish_session(state: State<'_, AppState>, id: i64) -> Result<bool,
4650
state.db.finish_session(id).map_err(|e| e.to_string())
4751
}
4852

49-
/// 列出会话(关键词可选;默认第 1 页 50 条,新→旧)。
53+
/// 列出会话(关键词可选;默认第 1 页 50 条,新→旧;v0.7.1 起携带转化状态标记)。
5054
#[tauri::command]
5155
pub async fn list_sessions(
5256
state: State<'_, AppState>,
5357
keyword: Option<String>,
5458
limit: Option<u64>,
5559
offset: Option<u64>,
56-
) -> Result<Vec<Session>, String> {
60+
) -> Result<Vec<SessionListItem>, String> {
5761
let limit = limit.unwrap_or(50).min(LIST_LIMIT_MAX);
5862
state
5963
.db
@@ -145,53 +149,131 @@ pub async fn add_session_ocr_block(
145149

146150
/// 加载会话笔记原料(会话 + 转写段 + OCR 块;单一管线双出口共用)。
147151
///
148-
/// @ai-context: REQ-081:预览与转笔记从同一原料装载开始,保证口径一致。
152+
/// @ai-context: REQ-081:预览与转笔记从同一原料装载开始,保证口径一致;
153+
/// v0.7.1 起注入 &Db(不再依赖 AppState)——批量编排可单测。
149154
fn load_note_material(
150-
state: &AppState,
155+
db: &Db,
151156
id: i64,
152157
) -> Result<(Session, Vec<SessionSegment>, Vec<SessionOcrBlock>), String> {
153158
if id <= 0 {
154159
return Err("无效的会话 id".to_string());
155160
}
156-
let session = state
157-
.db
161+
let session = db
158162
.get_session(id)
159163
.map_err(|e| e.to_string())?
160164
.ok_or_else(|| format!("会话不存在: {}", id))?;
161165
if session.status == SESSION_STATUS_RECORDING {
162166
return Err("进行中的会话不能生成笔记,请先结束会话".to_string());
163167
}
164-
let segments = state.db.list_segments(id).map_err(|e| e.to_string())?;
165-
let ocr_blocks = state.db.list_ocr_blocks(id).map_err(|e| e.to_string())?;
168+
let segments = db.list_segments(id).map_err(|e| e.to_string())?;
169+
let ocr_blocks = db.list_ocr_blocks(id).map_err(|e| e.to_string())?;
166170
Ok((session, segments, ocr_blocks))
167171
}
168172

169-
/// 会话 → 笔记:转写段 + OCR 块经 note_filter 净化管线,一键落库(REQ-010 闭环)。
173+
/// 会话 → 笔记核心(v0.7.1 提取:单条与批量共用同一管线)。
170174
///
171175
/// @ai-context: REQ-082:过滤链(UI 垃圾/重复合并/碎片/低置信)与预览共用;
172176
/// ai_decisions(REQ-085)可选叠加——前端把预览中已确认的 AI
173177
/// 判定结果回传,落库与预览输出保持一致(默认 None=纯规则)。
174-
/// @ai-context: 只允许 finished/failed 会话转换;source 沿用 classroom。
175-
#[tauri::command]
176-
pub async fn session_to_note(
177-
state: State<'_, AppState>,
178+
/// @ai-context: 只允许 finished/failed 会话转换;source 沿用 classroom;
179+
/// v0.7.1 起落库携带 session_id(列表 has_note/查看笔记跳转的数据源)。
180+
fn convert_to_note(
181+
db: &Db,
182+
ui_junk: &UiJunkList,
178183
id: i64,
179184
title: Option<String>,
180185
ai_decisions: Option<Vec<TextFilterDecision>>,
181186
) -> Result<Note, String> {
182-
let (session, segments, ocr_blocks) = load_note_material(&state, id)?;
187+
let (session, segments, ocr_blocks) = load_note_material(db, id)?;
183188
let fallback = format!("{}(会话)", session.title);
184189
let title = normalize_title(title.unwrap_or_default(), &fallback);
185-
let mut result = filter_note(&title, &segments, &ocr_blocks, &state.ui_junk);
190+
let mut result = filter_note(&title, &segments, &ocr_blocks, ui_junk);
186191
if let Some(decisions) = ai_decisions {
187192
result = apply_ai_decisions(result, &decisions);
188193
}
189194
let new = NewNote {
190195
title: result.title.clone(),
191196
content: result.markdown.clone(),
192197
source: "classroom".to_string(),
198+
session_id: Some(id),
193199
};
194-
state.db.create_note(&new).map_err(|e| e.to_string())
200+
db.create_note(&new).map_err(|e| e.to_string())
201+
}
202+
203+
/// 会话 → 笔记:转写段 + OCR 块经 note_filter 净化管线,一键落库(REQ-010 闭环)。
204+
#[tauri::command]
205+
pub async fn session_to_note(
206+
state: State<'_, AppState>,
207+
id: i64,
208+
title: Option<String>,
209+
ai_decisions: Option<Vec<TextFilterDecision>>,
210+
) -> Result<Note, String> {
211+
convert_to_note(&state.db, &state.ui_junk, id, title, ai_decisions)
212+
}
213+
214+
/// 批量转笔记核心编排(v0.7.1:部分成功语义——单条失败不阻塞其他)。
215+
///
216+
/// @ai-context: 纯编排纯函数(注入 Db + UiJunkList,无 Tauri 依赖,可单测);
217+
/// 跳过规则显式回传原因(不静默):无效 id / 进行中 / 已转 / 会话不存在;
218+
/// DB 读错误视为硬失败中止(库损坏时继续处理无意义)。
219+
pub fn run_batch_conversion(
220+
db: &Db,
221+
ui_junk: &UiJunkList,
222+
ids: Vec<i64>,
223+
) -> Result<BatchNoteResult, String> {
224+
if ids.len() > 50 {
225+
return Err("批量转换上限 50 条".to_string());
226+
}
227+
let mut converted = Vec::new();
228+
let mut skipped = Vec::new();
229+
let mut seen = HashSet::new();
230+
for id in ids {
231+
if id <= 0 {
232+
skipped.push(SkippedNote { session_id: id, reason: "无效的会话 id".to_string() });
233+
continue;
234+
}
235+
if !seen.insert(id) {
236+
continue; // 重复 id 静默去重(同一会话只处理一次)
237+
}
238+
match db.get_session(id) {
239+
Ok(Some(s)) if s.status == SESSION_STATUS_RECORDING => {
240+
skipped.push(SkippedNote {
241+
session_id: id,
242+
reason: "进行中的会话不能生成笔记".to_string(),
243+
});
244+
}
245+
Ok(Some(_)) => {
246+
// 已转跳过(防重复笔记);详情页单条转换保留"有意重新生成"路径
247+
if db.find_note_by_session(id).map_err(|e| e.to_string())?.is_some() {
248+
skipped.push(SkippedNote {
249+
session_id: id,
250+
reason: "已转笔记(批量转跳过,避免重复)".to_string(),
251+
});
252+
continue;
253+
}
254+
match convert_to_note(db, ui_junk, id, None, None) {
255+
Ok(note) => converted.push(ConvertedNote { session_id: id, note_id: note.id }),
256+
Err(reason) => skipped.push(SkippedNote { session_id: id, reason }),
257+
}
258+
}
259+
Ok(None) => {
260+
skipped.push(SkippedNote { session_id: id, reason: "会话不存在".to_string() });
261+
}
262+
Err(e) => {
263+
skipped.push(SkippedNote { session_id: id, reason: e.to_string() });
264+
}
265+
}
266+
}
267+
Ok(BatchNoteResult { converted, skipped })
268+
}
269+
270+
/// 批量转笔记(v0.7.1 会话体验:列表勾选批量转化)。
271+
#[tauri::command]
272+
pub async fn batch_session_to_note(
273+
state: State<'_, AppState>,
274+
ids: Vec<i64>,
275+
) -> Result<BatchNoteResult, String> {
276+
run_batch_conversion(&state.db, &state.ui_junk, ids)
195277
}
196278

197279
/// 会话笔记预览(REQ-081):过滤后只读预览——不落库、不改库。
@@ -204,7 +286,7 @@ pub async fn preview_session_note(
204286
state: State<'_, AppState>,
205287
id: i64,
206288
) -> Result<NoteFilterResult, String> {
207-
let (session, segments, ocr_blocks) = load_note_material(&state, id)?;
289+
let (session, segments, ocr_blocks) = load_note_material(&state.db, id)?;
208290
Ok(filter_note(&session.title, &segments, &ocr_blocks, &state.ui_junk))
209291
}
210292

@@ -278,12 +360,12 @@ fn is_cjk_num(c: char) -> bool {
278360
"零〇一二三四五六七八九十百".contains(c)
279361
}
280362

281-
/// 课程分组(会话列表按课程键分组,组内新→旧)。
363+
/// 课程分组(会话列表按课程键分组,组内新→旧;v0.7.1:组内携带转化状态标记)。
282364
#[derive(Debug, Clone, serde::Serialize)]
283365
#[serde(rename_all = "camelCase")]
284366
pub struct CourseGroup {
285367
pub course: String,
286-
pub sessions: Vec<Session>,
368+
pub sessions: Vec<SessionListItem>,
287369
}
288370

289371
/// 会话列表按课程分组(REQ-078:长列表可折叠)。
@@ -293,9 +375,10 @@ pub async fn list_session_courses(state: State<'_, AppState>) -> Result<Vec<Cour
293375
.db
294376
.list_sessions(None, LIST_LIMIT_MAX, 0)
295377
.map_err(|e| e.to_string())?;
296-
let mut groups: std::collections::BTreeMap<String, Vec<Session>> = std::collections::BTreeMap::new();
378+
let mut groups: std::collections::BTreeMap<String, Vec<SessionListItem>> =
379+
std::collections::BTreeMap::new();
297380
for s in sessions {
298-
groups.entry(course_of(&s.title)).or_default().push(s);
381+
groups.entry(course_of(&s.session.title)).or_default().push(s);
299382
}
300383
Ok(groups
301384
.into_iter()
@@ -329,7 +412,8 @@ pub async fn search_session_segments(
329412
return Ok(Vec::new());
330413
}
331414
let mut hits = Vec::new();
332-
for session in state.db.list_sessions(None, LIST_LIMIT_MAX, 0).map_err(|e| e.to_string())? {
415+
for item in state.db.list_sessions(None, LIST_LIMIT_MAX, 0).map_err(|e| e.to_string())? {
416+
let session = &item.session;
333417
for s in state.db.list_segments(session.id).map_err(|e| e.to_string())? {
334418
if s.text.to_lowercase().contains(&kw) {
335419
hits.push(SegmentHit {
@@ -384,3 +468,8 @@ fn snippet_around(text: &str, kw_lower: &str) -> String {
384468
}
385469
snippet
386470
}
471+
472+
/// 批量转笔记单测独立文件(保持本文件 ≤300 行目标,AGENTS.md §3)。
473+
#[cfg(test)]
474+
#[path = "commands_session_batch_tests.rs"]
475+
mod batch_tests;

0 commit comments

Comments
 (0)