Skip to content

Commit 394223f

Browse files
committed
feat(notes): 笔记图片显示与编辑机制修复(0.10.1)
1 parent 63cd7bf commit 394223f

10 files changed

Lines changed: 1437 additions & 141 deletions

File tree

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

Lines changed: 82 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ pub(crate) const TITLE_MAX_CHARS: usize = 100;
2525
const CONTENT_MAX_CHARS: usize = 200_000;
2626
/// 搜索关键词最大长度。
2727
const KEYWORD_MAX_CHARS: usize = 100;
28+
/// 标签最大长度(防超长标签字符串)。
29+
const TAG_MAX_CHARS: usize = 50;
2830
/// 一键流水线最大图片数。
2931
const MAX_IMAGES: usize = 20;
3032
/// 拼接输入段/块数量上限(防恶意超大列表拖垮拼接)。
@@ -194,6 +196,8 @@ pub async fn save_draft_as_note(state: State<'_, AppState>, draft: NoteDraft) ->
194196
session_id: None,
195197
rule_version: None,
196198
purify_stats: None,
199+
tags: None,
200+
properties: None,
197201
};
198202
state.db.create_note(&new).map_err(|e| e.to_string())
199203
}
@@ -261,6 +265,8 @@ pub async fn process_to_note(
261265
session_id: None,
262266
rule_version: None,
263267
purify_stats: None,
268+
tags: None,
269+
properties: None,
264270
})
265271
.map_err(|e| e.to_string())
266272
})
@@ -280,14 +286,22 @@ pub async fn create_note(state: State<'_, AppState>, new: NewNote) -> Result<Not
280286
// 手动笔记无净化规则版本/统计(None 诚实降级——REQ-171 口径)
281287
rule_version: None,
282288
purify_stats: None,
289+
tags: None,
290+
properties: None,
283291
};
284292
state.db.create_note(&new).map_err(|e| e.to_string())
285293
}
286294

287-
/// 列出全部笔记(REQ-004)。
295+
/// 列出全部笔记(REQ-004;v0.10.0 支持排序模式)。
288296
#[tauri::command]
289-
pub async fn list_notes(state: State<'_, AppState>) -> Result<Vec<Note>, String> {
290-
state.db.list_notes().map_err(|e| e.to_string())
297+
pub async fn list_notes(
298+
state: State<'_, AppState>,
299+
sort_mode: Option<crate::types::NoteSortMode>,
300+
) -> Result<Vec<Note>, String> {
301+
match sort_mode {
302+
Some(mode) => state.db.list_notes_sorted(&mode).map_err(|e| e.to_string()),
303+
None => state.db.list_notes().map_err(|e| e.to_string()),
304+
}
291305
}
292306

293307
/// 读取单条笔记(REQ-004)。
@@ -308,21 +322,32 @@ pub async fn update_note(
308322
id: i64,
309323
title: String,
310324
content: String,
325+
create_version: Option<bool>,
311326
) -> Result<bool, String> {
312327
if id <= 0 {
313328
return Err("无效的笔记 id".to_string());
314329
}
315330
let title = truncate_chars(title, TITLE_MAX_CHARS);
316331
let content = truncate_chars(content, CONTENT_MAX_CHARS);
317-
state
318-
.db
319-
.versioned_save(
320-
id,
321-
&content,
322-
crate::note_version::NoteVersionSource::UserEdit,
323-
&crate::note_version::VersionMeta::default(),
324-
)
325-
.map_err(|e| e.to_string())?;
332+
// v0.10.1 F2:轻量保存(自动保存/失焦/任务勾选回写)——只刷新内容不建版本
333+
// (v0.10.0 状态一致性规则:版本快照只在显式保存/AI 采纳时建立)
334+
if create_version == Some(false) {
335+
return state.db.update_note(id, &title, &content).map_err(|e| e.to_string());
336+
}
337+
// v0.10.1 F3:内容去重——与最新版本相同则跳过 versioned_save
338+
// (防 Ctrl+S 空保存/重复保存污染版本时间线;无版本时照常建链)
339+
let latest = state.db.latest_version_content(id).map_err(|e| e.to_string())?;
340+
if latest.as_deref() != Some(content.as_str()) {
341+
state
342+
.db
343+
.versioned_save(
344+
id,
345+
&content,
346+
crate::note_version::NoteVersionSource::UserEdit,
347+
&crate::note_version::VersionMeta::default(),
348+
)
349+
.map_err(|e| e.to_string())?;
350+
}
326351
state
327352
.db
328353
.update_note(id, &title, &content)
@@ -339,11 +364,54 @@ pub async fn delete_note(state: State<'_, AppState>, id: i64) -> Result<bool, St
339364
state.db.delete_note(id).map_err(|e| e.to_string())
340365
}
341366

342-
/// 搜索笔记(REQ-004;关键词截断——TD-005)。
367+
/// 搜索笔记(REQ-004;关键词截断——TD-005;v0.10.0 支持按标签过滤)。
343368
#[tauri::command]
344-
pub async fn search_notes(state: State<'_, AppState>, keyword: String) -> Result<Vec<Note>, String> {
369+
pub async fn search_notes(
370+
state: State<'_, AppState>,
371+
keyword: String,
372+
tag: Option<String>,
373+
) -> Result<Vec<Note>, String> {
374+
// 按标签过滤优先
375+
if let Some(t) = tag {
376+
return state
377+
.db
378+
.search_notes_by_tag(&truncate_chars(t, TAG_MAX_CHARS))
379+
.map_err(|e| e.to_string());
380+
}
345381
state
346382
.db
347383
.search_notes(&truncate_chars(keyword, KEYWORD_MAX_CHARS))
348384
.map_err(|e| e.to_string())
349385
}
386+
387+
/// 更新笔记标签(v0.10.0)。
388+
#[tauri::command]
389+
pub async fn update_note_tags(
390+
state: State<'_, AppState>,
391+
id: i64,
392+
tags: String,
393+
) -> Result<bool, String> {
394+
if id <= 0 {
395+
return Err("无效的笔记 id".to_string());
396+
}
397+
state
398+
.db
399+
.update_note_tags(id, &tags)
400+
.map_err(|e| e.to_string())
401+
}
402+
403+
/// 更新笔记固定状态(v0.10.0)。
404+
#[tauri::command]
405+
pub async fn update_note_pin(
406+
state: State<'_, AppState>,
407+
id: i64,
408+
pin: i64,
409+
) -> Result<bool, String> {
410+
if id <= 0 {
411+
return Err("无效的笔记 id".to_string());
412+
}
413+
state
414+
.db
415+
.update_note_pin(id, pin)
416+
.map_err(|e| e.to_string())
417+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
//! 笔记图片命令(v0.10.1)——显示解析 + 本地导入。
2+
//!
3+
//! @ai-context: 笔记 Markdown 内嵌"data_dir 相对引用"(session-images/{sid}/...、
4+
//! notes-images/{nid}/...、产物裸 full/thumbs/...)。WebView 无法
5+
//! 直接读本地文件,resolve 在服务端校验后返回可 convert 的绝对路径;
6+
//! 本地图片导入必须复制进 data_dir(assetProtocol scope=$APPDATA/**)。
7+
8+
use std::path::{Path, PathBuf};
9+
10+
use tauri::State;
11+
12+
use crate::commands::AppState;
13+
use crate::types::Note;
14+
15+
/// 导入扩展名白名单(小写)。
16+
const IMAGE_EXT_WHITELIST: &[&str] = &["png", "jpg", "jpeg", "webp", "gif"];
17+
/// 导入大小上限(10MB——关键帧/截图场景足够)。
18+
const IMAGE_MAX_BYTES: u64 = 10 * 1024 * 1024;
19+
20+
/// 解析笔记图片引用 → 可 convert 的本地绝对路径(纯函数,可单测)。
21+
///
22+
/// @ai-context: 三规则(v0.10.1 设计):
23+
/// 1. `session-images/{sid}/...` → data_dir/session-images/{sid}/...(sid 必须==笔记来源会话)
24+
/// 2. `notes-images/{nid}/...` → data_dir/notes-images/{nid}/...(nid 必须==笔记 id)
25+
/// 3. 裸 `full/...`/`thumbs/...` → 按笔记来源会话目录解析(产物转笔记格式)
26+
///
27+
/// 拒绝 `..` 穿越;http(s)/data:/其他 → None(前端直出,不经本地文件系统)。
28+
fn resolve_note_image_path(data_dir: &Path, note: &Note, note_id: i64, src: &str) -> Option<PathBuf> {
29+
// 穿越防护:任何路径段为 `..` 一律拒绝(含反斜杠形式的 Windows 写法)
30+
if src.split(['/', '\\']).any(|seg| seg == "..") {
31+
return None;
32+
}
33+
if let Some(rest) = src.strip_prefix("session-images/") {
34+
let sid = rest.split('/').next()?.parse::<i64>().ok()?;
35+
if note.session_id != Some(sid) {
36+
return None; // 越权:笔记只能引用自己来源会话的图
37+
}
38+
return Some(data_dir.join(src));
39+
}
40+
if let Some(rest) = src.strip_prefix("notes-images/") {
41+
let nid = rest.split('/').next()?.parse::<i64>().ok()?;
42+
if nid != note_id {
43+
return None; // 越权:笔记只能引用自己的图
44+
}
45+
return Some(data_dir.join(src));
46+
}
47+
if src.starts_with("full/") || src.starts_with("thumbs/") {
48+
let sid = note.session_id?;
49+
return Some(data_dir.join("session-images").join(sid.to_string()).join(src));
50+
}
51+
None
52+
}
53+
54+
/// 笔记图片引用 → 本地绝对路径(前端 convertFileSrc 消费)。
55+
#[tauri::command]
56+
pub async fn resolve_note_image(
57+
state: State<'_, AppState>,
58+
note_id: i64,
59+
src: String,
60+
) -> Result<Option<String>, String> {
61+
if note_id <= 0 {
62+
return Err("无效的笔记 id".to_string());
63+
}
64+
let note = state
65+
.db
66+
.get_note(note_id)
67+
.map_err(|e| e.to_string())?
68+
.ok_or_else(|| "笔记不存在".to_string())?;
69+
Ok(resolve_note_image_path(&state.data_dir, &note, note_id, &src)
70+
.map(|p| p.to_string_lossy().into_owned()))
71+
}
72+
73+
/// 本地图片导入笔记(v0.10.1:复制进 notes-images/{nid}/ 并返回相对引用)。
74+
///
75+
/// @ai-context: 硬约束——assetProtocol scope 仅放行 $APPDATA/**,用户自选路径
76+
/// (桌面/下载)不在 scope 内,必须复制进 data_dir 才能被 WebView
77+
/// 读取;复制同时保证备份一致性(REQ-107 备份走 data_dir)。
78+
#[tauri::command]
79+
pub async fn import_note_image(
80+
state: State<'_, AppState>,
81+
note_id: i64,
82+
source_path: String,
83+
) -> Result<String, String> {
84+
if note_id <= 0 {
85+
return Err("无效的笔记 id".to_string());
86+
}
87+
if state.db.get_note(note_id).map_err(|e| e.to_string())?.is_none() {
88+
return Err("笔记不存在".to_string());
89+
}
90+
import_image_file(&source_path, &state.data_dir, note_id, IMAGE_MAX_BYTES)
91+
}
92+
93+
/// 复制图片进笔记图片目录并返回相对引用(纯逻辑,命令层复用——可单测)。
94+
fn import_image_file(
95+
source_path: &str,
96+
data_dir: &Path,
97+
note_id: i64,
98+
max_bytes: u64,
99+
) -> Result<String, String> {
100+
let src = Path::new(source_path);
101+
if !src.is_file() {
102+
return Err("源文件不存在".to_string());
103+
}
104+
let ext = src
105+
.extension()
106+
.and_then(|e| e.to_str())
107+
.map(|e| e.to_ascii_lowercase())
108+
.unwrap_or_default();
109+
if !IMAGE_EXT_WHITELIST.contains(&ext.as_str()) {
110+
return Err(format!("不支持的图片格式: {}(支持 png/jpg/jpeg/webp/gif)", ext));
111+
}
112+
let len = std::fs::metadata(src).map_err(|e| e.to_string())?.len();
113+
if len > max_bytes {
114+
return Err(format!("图片超过大小上限({} 字节)", len));
115+
}
116+
let dir = data_dir.join("notes-images").join(note_id.to_string());
117+
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
118+
let ts = crate::db_sessions_rows::unix_seconds();
119+
let mut name = format!("{}.{}", ts, ext);
120+
let mut seq = 0u64;
121+
while dir.join(&name).exists() {
122+
seq += 1;
123+
name = format!("{}_{}.{}", ts, seq, ext);
124+
}
125+
std::fs::copy(src, dir.join(&name)).map_err(|e| e.to_string())?;
126+
Ok(format!("notes-images/{}/{}", note_id, name))
127+
}
128+
129+
/// 应用数据根目录(前端拼接 data_dir 相对引用用——NotePreviewView 配图行)。
130+
#[tauri::command]
131+
pub fn app_data_dir(state: State<'_, AppState>) -> String {
132+
state.data_dir.to_string_lossy().into_owned()
133+
}
134+
135+
#[cfg(test)]
136+
#[path = "commands_note_images_tests.rs"]
137+
mod tests;

0 commit comments

Comments
 (0)