@@ -8,7 +8,7 @@ use rusqlite::params;
88
99use crate :: db:: { unix_seconds, Db } ;
1010use crate :: error:: Result ;
11- use crate :: types:: Fragment ;
11+ use crate :: types:: { Fragment , Note } ;
1212
1313/// fragments 表统一查询列(列顺序与 row_to_fragment 严格对应)。
1414const 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。
0 commit comments