fix: stabilize local folder AI document workflow
- scope local-folder PageTree revision and document sidebar rendering to fileTreeScope - preserve projected table/image attrs for local Markdown aggregate fallback - avoid FileTree restore forced layouts on cold design open - add API ChatOnly provider runtime and local OCR task handling regressions
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -2838,7 +2838,11 @@ fn load_local_folder_page_tree_snapshot_for_scope(
|
||||
"PageTree scope parentRelativePath 必须指向目录",
|
||||
));
|
||||
}
|
||||
let watch_revision = local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?;
|
||||
let watch_revision = if parent_relative_path.is_empty() {
|
||||
local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?
|
||||
} else {
|
||||
local_folder_watch_revision_for_directory(&canonical_root, &scan_root, &root_source_uri)?
|
||||
};
|
||||
let cache_key = format!("{root_source_uri}\n{parent_relative_path}");
|
||||
if let Ok(cache) = local_page_tree_snapshot_cache().lock() {
|
||||
if let Some(entry) = cache.get(&cache_key) {
|
||||
@@ -7046,7 +7050,45 @@ fn should_ignore_entry(relative_path: &str, file_name: &str) -> bool {
|
||||
if matches!(file_name, ".git" | "node_modules" | ".mnote") {
|
||||
return true;
|
||||
}
|
||||
relative_path == ".mnote/trash" || relative_path.starts_with(".mnote/trash/")
|
||||
relative_path == ".mnote/trash"
|
||||
|| relative_path.starts_with(".mnote/trash/")
|
||||
|| is_local_ocr_intermediate_entry(relative_path, file_name)
|
||||
}
|
||||
|
||||
fn is_local_ocr_intermediate_entry(relative_path: &str, file_name: &str) -> bool {
|
||||
let normalized = relative_path.trim().trim_matches('/').replace('\\', "/");
|
||||
if normalized.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let segments = normalized
|
||||
.split('/')
|
||||
.filter(|segment| !segment.trim().is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if segments.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let in_ocr_dir = segments
|
||||
.len()
|
||||
.checked_sub(2)
|
||||
.and_then(|index| segments.get(index))
|
||||
.map(|segment| segment.ends_with(".ocr"))
|
||||
.unwrap_or(false);
|
||||
let in_ocr_images_dir = segments
|
||||
.windows(2)
|
||||
.any(|window| window[0].ends_with(".ocr") && window[1] == "images");
|
||||
if in_ocr_images_dir {
|
||||
return true;
|
||||
}
|
||||
if !in_ocr_dir {
|
||||
return false;
|
||||
}
|
||||
let lower = file_name.to_ascii_lowercase();
|
||||
lower == "layout.json"
|
||||
|| lower == "images"
|
||||
|| lower.ends_with("_content_list.json")
|
||||
|| lower.ends_with("_content_list_v2.json")
|
||||
|| lower.ends_with("_model.json")
|
||||
|| lower.ends_with("_origin.pdf")
|
||||
}
|
||||
|
||||
fn compare_local_entries(a: &LocalFolderEntry, b: &LocalFolderEntry) -> Ordering {
|
||||
@@ -7723,6 +7765,14 @@ pub(crate) fn local_workspace_id(root: &Path) -> String {
|
||||
fn local_folder_watch_revision_for_root(
|
||||
root: &Path,
|
||||
root_source_uri: &str,
|
||||
) -> Result<LocalFolderWatchRevision, WebError> {
|
||||
local_folder_watch_revision_for_directory(root, root, root_source_uri)
|
||||
}
|
||||
|
||||
fn local_folder_watch_revision_for_directory(
|
||||
root: &Path,
|
||||
directory: &Path,
|
||||
root_source_uri: &str,
|
||||
) -> Result<LocalFolderWatchRevision, WebError> {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut entry_count = 0usize;
|
||||
@@ -7763,7 +7813,7 @@ fn local_folder_watch_revision_for_root(
|
||||
|
||||
visit(
|
||||
root,
|
||||
root,
|
||||
directory,
|
||||
&mut hasher,
|
||||
&mut entry_count,
|
||||
&mut latest_modified_ms,
|
||||
@@ -9282,9 +9332,10 @@ mod tests {
|
||||
get_share_links, get_user_access_policy, initialize_local_page_id,
|
||||
initialize_local_workspace_for_actor, load_local_folder_file_tree_children_snapshot,
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_file_tree_snapshot_with_reveal,
|
||||
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
|
||||
local_markdown_path_page_id, local_resource_write_editor_blocks, local_workspace_id,
|
||||
open_local_file, read_local_resource, record_shared_cache, record_sync_pending_change,
|
||||
load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot,
|
||||
local_folder_watch_revision, local_markdown_path_page_id,
|
||||
local_resource_write_editor_blocks, local_workspace_id, open_local_file,
|
||||
read_local_resource, record_shared_cache, record_sync_pending_change,
|
||||
resolve_local_markdown_page_aggregate, save_local_markdown_page,
|
||||
update_local_markdown_title, validate_local_access_root, write_local_markdown_asset,
|
||||
write_local_markdown_page_body, write_local_mindmap_data, write_local_resource,
|
||||
@@ -9424,6 +9475,30 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_folder_page_tree_scope_cache_ignores_outside_changes() {
|
||||
let root = temp_root("mnote-page-tree-scope-cache");
|
||||
init_workspace(&root);
|
||||
std::fs::create_dir_all(root.join("design")).expect("create design");
|
||||
std::fs::create_dir_all(root.join("notes")).expect("create notes");
|
||||
std::fs::write(root.join("design").join("page.md"), "# Design\n").expect("write design");
|
||||
std::fs::write(root.join("notes").join("outside.md"), "# Outside\n")
|
||||
.expect("write outside");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
super::reset_local_page_tree_snapshot_test_loads();
|
||||
|
||||
let first =
|
||||
load_local_folder_page_tree_scope_snapshot(&root_uri, "design").expect("first scope");
|
||||
std::fs::write(root.join("notes").join("next.md"), "# Next\n").expect("write next");
|
||||
let second =
|
||||
load_local_folder_page_tree_scope_snapshot(&root_uri, "design").expect("second scope");
|
||||
|
||||
assert_eq!(first.projection, second.projection);
|
||||
assert_eq!(super::local_page_tree_snapshot_scan_test_loads(), 1);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_identity_uses_path_even_when_frontmatter_has_mnote_id() {
|
||||
let root = temp_root("mnote-local-frontmatter-path-id");
|
||||
@@ -13320,12 +13395,41 @@ fn main() {}
|
||||
init_workspace(&root);
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
std::fs::create_dir_all(root.join("docs").join("Page.ocr")).expect("ocr dir");
|
||||
std::fs::create_dir_all(root.join("docs").join("Page.ocr").join("images"))
|
||||
.expect("ocr images dir");
|
||||
std::fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
|
||||
std::fs::write(
|
||||
root.join("docs").join("Page.ocr").join("photo.png.ocr.md"),
|
||||
"---\nmnote_ocr_version: 1\nprovider: mock\nmodel_version: vlm\nowner_document: ../Page.md\nsource_path: ./Page.assets/photo.png\nsource_root_relative_path: docs/Page.assets/photo.png\nsource_size: 3\nsource_mtime_ms: 1\nstatus: done\ncreated_at: 1\nupdated_at: 1\n---\n\nOCR-only-token\n",
|
||||
)
|
||||
.expect("ocr markdown");
|
||||
std::fs::write(root.join("docs").join("Page.ocr").join("layout.json"), "{}")
|
||||
.expect("ocr layout");
|
||||
std::fs::write(
|
||||
root.join("docs")
|
||||
.join("Page.ocr")
|
||||
.join("abc_content_list.json"),
|
||||
"[]",
|
||||
)
|
||||
.expect("ocr content list");
|
||||
std::fs::write(
|
||||
root.join("docs").join("Page.ocr").join("abc_model.json"),
|
||||
"{}",
|
||||
)
|
||||
.expect("ocr model");
|
||||
std::fs::write(
|
||||
root.join("docs").join("Page.ocr").join("abc_origin.pdf"),
|
||||
b"%PDF-1.4\n",
|
||||
)
|
||||
.expect("ocr origin pdf");
|
||||
std::fs::write(
|
||||
root.join("docs")
|
||||
.join("Page.ocr")
|
||||
.join("images")
|
||||
.join("page_1.jpg"),
|
||||
b"jpg",
|
||||
)
|
||||
.expect("ocr image asset");
|
||||
|
||||
let file_tree = load_local_folder_file_tree_children_snapshot(&root_uri, "docs/Page.ocr")
|
||||
.expect("file tree");
|
||||
@@ -13335,6 +13439,20 @@ fn main() {}
|
||||
assert!(file_items
|
||||
.iter()
|
||||
.any(|item| item["title"].as_str() == Some("photo.png.ocr.md")));
|
||||
for hidden_title in [
|
||||
"layout.json",
|
||||
"abc_content_list.json",
|
||||
"abc_model.json",
|
||||
"abc_origin.pdf",
|
||||
"images",
|
||||
] {
|
||||
assert!(
|
||||
!file_items
|
||||
.iter()
|
||||
.any(|item| item["title"].as_str() == Some(hidden_title)),
|
||||
"OCR 中间产物不应出现在 FileTree: {hidden_title}"
|
||||
);
|
||||
}
|
||||
|
||||
let page_tree = load_local_folder_page_tree_snapshot(&root_uri).expect("page tree");
|
||||
let page_items = page_tree.projection["items"]
|
||||
|
||||
@@ -64,6 +64,13 @@ pub(crate) struct OcrInsertRequest {
|
||||
mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OcrDeleteRequest {
|
||||
root_uri: String,
|
||||
source_root_relative_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OcrIndex {
|
||||
@@ -108,6 +115,18 @@ struct MineruClientConfig {
|
||||
max_polls: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct MineruZipAsset {
|
||||
relative_path: PathBuf,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct MineruZipExtraction {
|
||||
markdown: String,
|
||||
assets: Vec<MineruZipAsset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct OcrSidecarPlan {
|
||||
owner_document_path: String,
|
||||
@@ -284,6 +303,47 @@ pub(crate) async fn status(
|
||||
Ok(ok_json(&context, json!({ "ok": true, "job": job })))
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_job(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(request): Json<OcrDeleteRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let root =
|
||||
ensure_local_workspace_write_access_with_state(&state, &context, request.root_uri.trim())
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let source = normalize_relative_path(&request.source_root_relative_path)?;
|
||||
let mut index = read_ocr_index(&root)?;
|
||||
let removed = index.entries.remove(&source);
|
||||
if let Some(entry) = &removed {
|
||||
let sidecar = root.join(&entry.ocr_root_relative_path);
|
||||
ensure_target_under_root(&root, &sidecar, "local_ocr_delete_root_escape")?;
|
||||
if sidecar.exists() {
|
||||
fs::remove_file(&sidecar).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_ocr_delete_failed",
|
||||
format!("无法删除 OCR Markdown {}: {error}", sidecar.display()),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
}
|
||||
cleanup_empty_ocr_sidecar_dir(&root, &sidecar)?;
|
||||
}
|
||||
write_ocr_index(&root, &index)?;
|
||||
let key = format!("{}:{source}", request.root_uri.trim());
|
||||
if let Ok(mut jobs) = state.local_ocr_active_jobs.write() {
|
||||
jobs.remove(&key);
|
||||
}
|
||||
broadcast_ocr_job_deleted(&state, request.root_uri.trim(), &source, removed.as_ref());
|
||||
Ok(ok_json(
|
||||
&context,
|
||||
json!({
|
||||
"ok": true,
|
||||
"deleted": removed.is_some(),
|
||||
"sourceRootRelativePath": source,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn read(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -519,7 +579,9 @@ async fn run_mineru_ocr(
|
||||
entry = advance_ocr_entry(&entry, "downloading", now_ms(), "", None);
|
||||
upsert_and_broadcast_ocr_index_entry(state, root, root_uri, entry)?;
|
||||
let zip_bytes = download_mineru_result_zip(&client, &zip_url).await?;
|
||||
extract_mineru_markdown_from_zip(&zip_bytes)
|
||||
let extraction = extract_mineru_markdown_and_assets_from_zip(&zip_bytes)?;
|
||||
write_mineru_zip_assets(plan, &extraction.assets)?;
|
||||
Ok(extraction.markdown)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -704,7 +766,9 @@ async fn download_mineru_result_zip(
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_mineru_markdown_from_zip(bytes: &[u8]) -> Result<String, WebError> {
|
||||
fn extract_mineru_markdown_and_assets_from_zip(
|
||||
bytes: &[u8],
|
||||
) -> Result<MineruZipExtraction, WebError> {
|
||||
let cursor = Cursor::new(bytes);
|
||||
let mut archive = zip::ZipArchive::new(cursor).map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
@@ -713,6 +777,7 @@ fn extract_mineru_markdown_from_zip(bytes: &[u8]) -> Result<String, WebError> {
|
||||
)
|
||||
})?;
|
||||
let mut candidates = Vec::<(String, String)>::new();
|
||||
let mut assets = Vec::<MineruZipAsset>::new();
|
||||
for index in 0..archive.len() {
|
||||
let mut file = archive.by_index(index).map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
@@ -721,19 +786,36 @@ fn extract_mineru_markdown_from_zip(bytes: &[u8]) -> Result<String, WebError> {
|
||||
)
|
||||
})?;
|
||||
let name = file.name().replace('\\', "/");
|
||||
if !name.to_ascii_lowercase().ends_with(".md") || name.contains("/.") {
|
||||
if file.is_dir() || name.contains("/.") {
|
||||
continue;
|
||||
}
|
||||
let mut markdown = String::new();
|
||||
file.read_to_string(&mut markdown).map_err(|error| {
|
||||
if name.to_ascii_lowercase().ends_with(".md") {
|
||||
let mut markdown = String::new();
|
||||
file.read_to_string(&mut markdown).map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"mineru_result_markdown_read_failed",
|
||||
format!("MinerU Markdown 读取失败: {error}"),
|
||||
)
|
||||
})?;
|
||||
candidates.push((name, markdown));
|
||||
continue;
|
||||
}
|
||||
let Some(relative_path) = safe_mineru_asset_relative_path(&name) else {
|
||||
continue;
|
||||
};
|
||||
let mut asset_bytes = Vec::new();
|
||||
file.read_to_end(&mut asset_bytes).map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"mineru_result_markdown_read_failed",
|
||||
format!("MinerU Markdown 读取失败: {error}"),
|
||||
"mineru_result_asset_read_failed",
|
||||
format!("MinerU 资源读取失败: {error}"),
|
||||
)
|
||||
})?;
|
||||
candidates.push((name, markdown));
|
||||
assets.push(MineruZipAsset {
|
||||
relative_path,
|
||||
bytes: asset_bytes,
|
||||
});
|
||||
}
|
||||
candidates
|
||||
let markdown = candidates
|
||||
.into_iter()
|
||||
.max_by_key(|(name, markdown)| {
|
||||
let preferred =
|
||||
@@ -747,7 +829,55 @@ fn extract_mineru_markdown_from_zip(bytes: &[u8]) -> Result<String, WebError> {
|
||||
"mineru_result_markdown_missing",
|
||||
"MinerU 结果包中缺少 Markdown 文件",
|
||||
)
|
||||
})
|
||||
})?;
|
||||
Ok(MineruZipExtraction { markdown, assets })
|
||||
}
|
||||
|
||||
fn safe_mineru_asset_relative_path(name: &str) -> Option<PathBuf> {
|
||||
let normalized = name.trim().trim_start_matches('/').replace('\\', "/");
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut path = PathBuf::new();
|
||||
for component in Path::new(&normalized).components() {
|
||||
match component {
|
||||
Component::Normal(value) => {
|
||||
let text = value.to_str()?.trim();
|
||||
if text.is_empty() || text == "." || text == ".." || text.starts_with('.') {
|
||||
return None;
|
||||
}
|
||||
path.push(text);
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
(!path.as_os_str().is_empty()).then_some(path)
|
||||
}
|
||||
|
||||
fn write_mineru_zip_assets(
|
||||
plan: &OcrSidecarPlan,
|
||||
assets: &[MineruZipAsset],
|
||||
) -> Result<(), WebError> {
|
||||
let sidecar_dir = plan.ocr_path.parent().unwrap_or_else(|| Path::new(""));
|
||||
for asset in assets {
|
||||
let target = sidecar_dir.join(&asset.relative_path);
|
||||
ensure_target_under_root(sidecar_dir, &target, "local_ocr_asset_root_escape")?;
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_ocr_asset_create_failed",
|
||||
format!("无法创建 OCR 资源目录 {}: {error}", parent.display()),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
fs::write(&target, &asset.bytes).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_ocr_asset_write_failed",
|
||||
format!("无法写入 OCR 资源 {}: {error}", target.display()),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_mineru_json(body: &str, code: &'static str) -> Result<Value, WebError> {
|
||||
@@ -760,7 +890,52 @@ fn find_upload_url(value: &Value) -> Option<String> {
|
||||
if let Some(url) = find_json_string_by_keys(value, &["upload_url", "uploadUrl"]) {
|
||||
return Some(url);
|
||||
}
|
||||
None
|
||||
find_json_url_array_item_by_keys(
|
||||
value,
|
||||
&[
|
||||
"file_urls",
|
||||
"fileUrls",
|
||||
"file_url",
|
||||
"fileUrl",
|
||||
"urls",
|
||||
"upload_urls",
|
||||
"uploadUrls",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn find_json_url_array_item_by_keys(value: &Value, keys: &[&str]) -> Option<String> {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
for key in keys {
|
||||
if let Some(found) = map.get(*key).and_then(find_first_non_empty_json_string) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
for nested in map.values() {
|
||||
if let Some(found) = find_json_url_array_item_by_keys(nested, keys) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Value::Array(items) => items
|
||||
.iter()
|
||||
.find_map(|item| find_json_url_array_item_by_keys(item, keys)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_first_non_empty_json_string(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(text) => {
|
||||
let trimmed = text.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}
|
||||
Value::Array(items) => items.iter().find_map(find_first_non_empty_json_string),
|
||||
Value::Object(map) => map.values().find_map(find_first_non_empty_json_string),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_json_string_by_keys(value: &Value, keys: &[&str]) -> Option<String> {
|
||||
@@ -1065,6 +1240,70 @@ fn upsert_and_broadcast_ocr_index_entry(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cleanup_empty_ocr_sidecar_dir(root: &Path, sidecar: &Path) -> Result<(), WebError> {
|
||||
let Some(parent) = sidecar.parent() else {
|
||||
return Ok(());
|
||||
};
|
||||
ensure_target_under_root(root, parent, "local_ocr_delete_root_escape")?;
|
||||
let Ok(entries) = fs::read_dir(parent) else {
|
||||
return Ok(());
|
||||
};
|
||||
let has_other_sidecars = entries.filter_map(Result::ok).any(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.to_ascii_lowercase()
|
||||
.ends_with(".ocr.md")
|
||||
});
|
||||
if !has_other_sidecars {
|
||||
fs::remove_dir_all(parent).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_ocr_delete_failed",
|
||||
format!("无法删除 OCR sidecar 目录 {}: {error}", parent.display()),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn broadcast_ocr_job_deleted(
|
||||
state: &AppState,
|
||||
root_uri: &str,
|
||||
source_root_relative_path: &str,
|
||||
removed: Option<&OcrIndexEntry>,
|
||||
) {
|
||||
let now = now_ms();
|
||||
let job = json!({
|
||||
"jobId": removed.map(|entry| entry.job_id.as_str()).unwrap_or(""),
|
||||
"ownerDocumentId": removed.map(|entry| entry.owner_document_id.as_str()).unwrap_or(""),
|
||||
"ownerDocumentPath": removed.map(|entry| entry.owner_document_path.as_str()).unwrap_or(""),
|
||||
"sourceRootRelativePath": source_root_relative_path,
|
||||
"ocrRootRelativePath": removed.map(|entry| entry.ocr_root_relative_path.as_str()).unwrap_or(""),
|
||||
"provider": removed.map(|entry| entry.provider.as_str()).unwrap_or(""),
|
||||
"modelVersion": removed.map(|entry| entry.model_version.as_str()).unwrap_or(""),
|
||||
"status": "deleted",
|
||||
"stageLabel": "已删除",
|
||||
"stale": false,
|
||||
"updatedAtMs": now,
|
||||
"finishedAtMs": now,
|
||||
"plainTextPreview": "",
|
||||
"error": null,
|
||||
});
|
||||
let payload = json!({
|
||||
"schema": "mnote.local_ocr.job.updated.v1",
|
||||
"kind": "local_ocr_job_updated",
|
||||
"eventType": "local_ocr.job.updated",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"relativePath": source_root_relative_path,
|
||||
"documentId": removed.map(|entry| entry.owner_document_id.as_str()).unwrap_or(""),
|
||||
"revision": now.to_string(),
|
||||
"job": job,
|
||||
});
|
||||
let _ = state.local_ocr_job_tx.send(payload.clone());
|
||||
let _ = state.stream_delta_tx.send(payload);
|
||||
}
|
||||
|
||||
fn broadcast_ocr_job_update(state: &AppState, root: &Path, root_uri: &str, entry: &OcrIndexEntry) {
|
||||
let job = ocr_job_payload(root, entry);
|
||||
let key = format!("{root_uri}:{}", entry.source_root_relative_path);
|
||||
@@ -1420,7 +1659,7 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_test_mineru_zip(markdown: &str) -> Vec<u8> {
|
||||
fn build_test_mineru_zip_with_files(markdown: &str, files: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut bytes = Cursor::new(Vec::<u8>::new());
|
||||
{
|
||||
let mut writer = zip::ZipWriter::new(&mut bytes);
|
||||
@@ -1428,6 +1667,12 @@ mod tests {
|
||||
.start_file("full.md", zip::write::SimpleFileOptions::default())
|
||||
.expect("zip start file");
|
||||
writer.write_all(markdown.as_bytes()).expect("zip markdown");
|
||||
for (name, content) in files {
|
||||
writer
|
||||
.start_file(*name, zip::write::SimpleFileOptions::default())
|
||||
.expect("zip asset start file");
|
||||
writer.write_all(content).expect("zip asset");
|
||||
}
|
||||
writer.finish().expect("zip finish");
|
||||
}
|
||||
bytes.into_inner()
|
||||
@@ -1585,6 +1830,35 @@ mod tests {
|
||||
assert!(read_payload["markdown"]
|
||||
.as_str()
|
||||
.is_some_and(|markdown| markdown.contains("Route OCR Token")));
|
||||
let delete_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("DELETE")
|
||||
.uri("/api/local-folder/ocr/jobs")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"rootUri": root_uri,
|
||||
"sourceRootRelativePath": "docs/Page.assets/photo.png"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("delete request"),
|
||||
)
|
||||
.await
|
||||
.expect("delete response");
|
||||
assert_eq!(delete_response.status(), StatusCode::OK);
|
||||
assert!(!root
|
||||
.join("docs")
|
||||
.join("Page.ocr")
|
||||
.join("photo.png.ocr.md")
|
||||
.exists());
|
||||
assert!(read_ocr_index(&root)
|
||||
.expect("index after delete")
|
||||
.entries
|
||||
.is_empty());
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
@@ -1690,7 +1964,10 @@ mod tests {
|
||||
let base_url = format!("http://{}", listener.local_addr().expect("mock addr"));
|
||||
let upload_count = Arc::new(AtomicUsize::new(0));
|
||||
let poll_count = Arc::new(AtomicUsize::new(0));
|
||||
let zip_bytes = Arc::new(build_test_mineru_zip("# MinerU Result\n\n识别文本"));
|
||||
let zip_bytes = Arc::new(build_test_mineru_zip_with_files(
|
||||
"# MinerU Result\n\n\n\n识别文本",
|
||||
&[("images/ocr.png", b"png-bytes")],
|
||||
));
|
||||
|
||||
let mock_mineru = axum::Router::new()
|
||||
.route(
|
||||
@@ -1699,8 +1976,12 @@ mod tests {
|
||||
let base_url = base_url.clone();
|
||||
|| async move {
|
||||
Json(json!({
|
||||
"batch_id": "batch_1",
|
||||
"file_urls": [{ "upload_url": format!("{base_url}/upload/source") }]
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"batch_id": "batch_1",
|
||||
"file_urls": [format!("{base_url}/upload/source")]
|
||||
}
|
||||
}))
|
||||
}
|
||||
}),
|
||||
@@ -1831,7 +2112,18 @@ mod tests {
|
||||
fs::read_to_string(root.join("docs").join("Page.ocr").join("photo.png.ocr.md"))
|
||||
.expect("sidecar");
|
||||
assert!(sidecar.contains("provider: mineru"));
|
||||
assert!(sidecar.contains(""));
|
||||
assert!(sidecar.contains("识别文本"));
|
||||
assert_eq!(
|
||||
fs::read(
|
||||
root.join("docs")
|
||||
.join("Page.ocr")
|
||||
.join("images")
|
||||
.join("ocr.png")
|
||||
)
|
||||
.expect("sidecar image"),
|
||||
b"png-bytes"
|
||||
);
|
||||
|
||||
mock_handle.abort();
|
||||
let _ = fs::remove_dir_all(root);
|
||||
|
||||
@@ -536,11 +536,14 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/ocr/jobs",
|
||||
get(local_ocr::list_jobs).post(local_ocr::create_job),
|
||||
get(local_ocr::list_jobs)
|
||||
.post(local_ocr::create_job)
|
||||
.delete(local_ocr::delete_job),
|
||||
)
|
||||
.route("/api/local-folder/ocr/status", get(local_ocr::status))
|
||||
.route("/api/local-folder/ocr/read", get(local_ocr::read))
|
||||
.route("/api/local-folder/ocr/insert", post(local_ocr::insert))
|
||||
.route("/api/local-folder/ocr/delete", post(local_ocr::delete_job))
|
||||
.route(
|
||||
"/api/local-folder/workspaces/default",
|
||||
post(local_folder_source::create_default_local_workspace),
|
||||
@@ -1015,7 +1018,8 @@ mod tests {
|
||||
"current_page": true,
|
||||
"folder": true
|
||||
},
|
||||
"ai.agent.hermes.profile_id": "mnoteai"
|
||||
"ai.agent.hermes.profile_id": "mnoteai",
|
||||
"localOcr.autoEnabled": true
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
@@ -1074,6 +1078,10 @@ mod tests {
|
||||
alice_payload["result"]["aiPreferences"]["ai.agent.hermes.profile_id"],
|
||||
"mnoteai"
|
||||
);
|
||||
assert_eq!(
|
||||
alice_payload["result"]["localOcrPreferences"]["localOcr.autoEnabled"],
|
||||
true
|
||||
);
|
||||
|
||||
let mut bob_get = Request::builder()
|
||||
.uri(format!("/api/ui/preferences/effective?workspaceId=workspace-ai-a&sourceKind=local_folder&rootUri={root_uri}&documentId=local-md:ai.md"))
|
||||
@@ -1094,6 +1102,10 @@ mod tests {
|
||||
.as_object()
|
||||
.map(|value| value.is_empty())
|
||||
.unwrap_or(false));
|
||||
assert_eq!(
|
||||
bob_payload["result"]["localOcrPreferences"]["localOcr.autoEnabled"],
|
||||
false
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ struct EffectivePagePreferences {
|
||||
page_options: PageOptions,
|
||||
page_width_preferences: BTreeMap<String, EffectivePageWidthPreference>,
|
||||
ai_preferences: BTreeMap<String, Value>,
|
||||
local_ocr_preferences: BTreeMap<String, Value>,
|
||||
sources: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
@@ -246,10 +247,12 @@ fn resolve_effective_page_preferences(
|
||||
let mut sources = BTreeMap::new();
|
||||
let mut page_width_preferences = default_page_width_preferences();
|
||||
let mut ai_preferences = BTreeMap::new();
|
||||
let mut local_ocr_preferences = default_local_ocr_preferences();
|
||||
apply_preference_records(
|
||||
&mut page_options,
|
||||
&mut page_width_preferences,
|
||||
&mut ai_preferences,
|
||||
&mut local_ocr_preferences,
|
||||
&mut sources,
|
||||
&scope,
|
||||
&preferences,
|
||||
@@ -260,6 +263,7 @@ fn resolve_effective_page_preferences(
|
||||
page_options,
|
||||
page_width_preferences,
|
||||
ai_preferences,
|
||||
local_ocr_preferences,
|
||||
sources,
|
||||
})
|
||||
}
|
||||
@@ -268,6 +272,7 @@ fn apply_preference_records(
|
||||
page_options: &mut PageOptions,
|
||||
page_width_preferences: &mut BTreeMap<String, EffectivePageWidthPreference>,
|
||||
ai_preferences: &mut BTreeMap<String, Value>,
|
||||
local_ocr_preferences: &mut BTreeMap<String, Value>,
|
||||
sources: &mut BTreeMap<String, String>,
|
||||
scope: &PagePreferenceScope,
|
||||
preferences: &[UserUiPreferenceRecord],
|
||||
@@ -277,6 +282,7 @@ fn apply_preference_records(
|
||||
"source_family".to_string(),
|
||||
"workspace".to_string(),
|
||||
"document".to_string(),
|
||||
"localOcr".to_string(),
|
||||
];
|
||||
for preference in preferences {
|
||||
if preference.scope_kind.starts_with("ai.") && !scope_kinds.contains(&preference.scope_kind)
|
||||
@@ -295,6 +301,7 @@ fn apply_preference_records(
|
||||
"workspace" => preference.scope_id.trim() == scope.workspace_id,
|
||||
"document" => preference.scope_id.trim() == scope.document_id,
|
||||
kind if kind.starts_with("ai.") => preference.scope_id.trim() == scope.workspace_id,
|
||||
"localOcr" => preference.scope_id.trim() == scope.workspace_id,
|
||||
_ => false,
|
||||
};
|
||||
if !scope_matches {
|
||||
@@ -311,6 +318,11 @@ fn apply_preference_records(
|
||||
sources.insert(preference.key.clone(), scope_kind.to_string());
|
||||
continue;
|
||||
}
|
||||
if preference.key.starts_with("localOcr.") {
|
||||
local_ocr_preferences.insert(preference.key.clone(), value);
|
||||
sources.insert(preference.key.clone(), scope_kind.to_string());
|
||||
continue;
|
||||
}
|
||||
if let Some(content_type) = page_width_content_type_for_key(&preference.key) {
|
||||
if let Some(normalized) = normalize_page_width_preference(content_type, &value) {
|
||||
if let Some(preference_value) = page_width_preferences.get_mut(content_type) {
|
||||
@@ -368,6 +380,9 @@ fn preference_scope_for_key(key: &str, scope: &PagePreferenceScope) -> Option<(S
|
||||
return Some((format!("ai.agent.{agent}"), scope.workspace_id.clone()));
|
||||
}
|
||||
}
|
||||
if trimmed.starts_with("localOcr.") {
|
||||
return Some(("localOcr".to_string(), scope.workspace_id.clone()));
|
||||
}
|
||||
if page_width_content_type_for_key(key).is_some() {
|
||||
return Some(("global".to_string(), "default".to_string()));
|
||||
}
|
||||
@@ -401,7 +416,11 @@ fn preference_scope_for_key(key: &str, scope: &PagePreferenceScope) -> Option<(S
|
||||
}
|
||||
|
||||
fn preference_workspace_for_scope(workspace_id: &str, scope_kind: &str) -> Option<String> {
|
||||
if scope_kind == "workspace" || scope_kind == "document" || scope_kind.starts_with("ai.") {
|
||||
if scope_kind == "workspace"
|
||||
|| scope_kind == "document"
|
||||
|| scope_kind.starts_with("ai.")
|
||||
|| scope_kind == "localOcr"
|
||||
{
|
||||
Some(workspace_id.to_string())
|
||||
} else {
|
||||
None
|
||||
@@ -409,7 +428,11 @@ fn preference_workspace_for_scope(workspace_id: &str, scope_kind: &str) -> Optio
|
||||
}
|
||||
|
||||
fn preference_source_kind_for_scope(source_kind: &str, scope_kind: &str) -> Option<String> {
|
||||
if scope_kind == "workspace" || scope_kind == "document" || scope_kind.starts_with("ai.") {
|
||||
if scope_kind == "workspace"
|
||||
|| scope_kind == "document"
|
||||
|| scope_kind.starts_with("ai.")
|
||||
|| scope_kind == "localOcr"
|
||||
{
|
||||
Some(source_kind.to_string())
|
||||
} else {
|
||||
None
|
||||
@@ -422,6 +445,8 @@ fn effective_preferences_payload(effective: EffectivePagePreferences) -> Value {
|
||||
serde_json::to_value(&effective.page_width_preferences).unwrap_or_else(|_| json!({}));
|
||||
let ai_preferences =
|
||||
serde_json::to_value(&effective.ai_preferences).unwrap_or_else(|_| json!({}));
|
||||
let local_ocr_preferences =
|
||||
serde_json::to_value(&effective.local_ocr_preferences).unwrap_or_else(|_| json!({}));
|
||||
let sources = effective
|
||||
.sources
|
||||
.into_iter()
|
||||
@@ -440,6 +465,7 @@ fn effective_preferences_payload(effective: EffectivePagePreferences) -> Value {
|
||||
"pageOptions": page_options,
|
||||
"pageWidthPreferences": page_width_preferences,
|
||||
"aiPreferences": ai_preferences,
|
||||
"localOcrPreferences": local_ocr_preferences,
|
||||
"sources": Value::Object(sources),
|
||||
}
|
||||
})
|
||||
@@ -594,6 +620,10 @@ fn default_page_width_preferences() -> BTreeMap<String, EffectivePageWidthPrefer
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn default_local_ocr_preferences() -> BTreeMap<String, Value> {
|
||||
BTreeMap::from([("localOcr.autoEnabled".to_string(), Value::Bool(false))])
|
||||
}
|
||||
|
||||
fn normalize_page_width_preference(
|
||||
content_type: &str,
|
||||
value: &Value,
|
||||
|
||||
@@ -11,8 +11,8 @@ use crate::routes::gateway::default_workspace_name_for_context;
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
|
||||
load_local_folder_file_tree_children_snapshot,
|
||||
load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_snapshot,
|
||||
resolve_local_markdown_page_aggregate,
|
||||
load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_scope_snapshot,
|
||||
load_local_folder_page_tree_snapshot, resolve_local_markdown_page_aggregate,
|
||||
};
|
||||
use crate::routes::query_support::execute_runtime_query_against_data;
|
||||
use crate::routes::snapshot_support::{
|
||||
@@ -215,7 +215,8 @@ pub async fn document_page_shell(
|
||||
let (sidebar_tree_html, file_tree_html) = if is_local_folder {
|
||||
let root_uri = query.root_uri.as_deref().unwrap_or_default();
|
||||
(
|
||||
render_local_sidebar_tree_html(root_uri, Some(&document_id)).unwrap_or_default(),
|
||||
render_local_sidebar_tree_html_scoped(root_uri, Some(&document_id), file_tree_scope)
|
||||
.unwrap_or_default(),
|
||||
render_local_file_tree_html_scoped(root_uri, Some(&document_id), None, file_tree_scope)
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
@@ -2659,7 +2660,22 @@ pub(crate) fn render_local_sidebar_tree_html(
|
||||
root_uri: &str,
|
||||
active_document_id: Option<&str>,
|
||||
) -> Result<String, WebError> {
|
||||
let snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
|
||||
render_local_sidebar_tree_html_scoped(root_uri, active_document_id, None)
|
||||
}
|
||||
|
||||
pub(crate) fn render_local_sidebar_tree_html_scoped(
|
||||
root_uri: &str,
|
||||
active_document_id: Option<&str>,
|
||||
file_tree_scope: Option<&str>,
|
||||
) -> Result<String, WebError> {
|
||||
let snapshot = if let Some(scope) = file_tree_scope
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
load_local_folder_page_tree_scope_snapshot(root_uri, scope)?
|
||||
} else {
|
||||
load_local_folder_page_tree_snapshot(root_uri)?
|
||||
};
|
||||
Ok(render_local_sidebar_tree_html_from_snapshot(
|
||||
&snapshot,
|
||||
active_document_id,
|
||||
@@ -4277,6 +4293,51 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn document_shell_local_folder_filetree_scope_renders_scoped_page_tree() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-document-shell-scoped-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("design").join("done")).expect("create design done");
|
||||
std::fs::write(root.join("Home.md"), "# Home\n").expect("write home");
|
||||
std::fs::write(
|
||||
root.join("design").join("done").join("Target.md"),
|
||||
"# Target\n",
|
||||
)
|
||||
.expect("write target");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/documents/local-md:design~2Fdone~2FTarget.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("html");
|
||||
assert!(html.contains(r#"data-node-id="local-md:design~2Fdone~2FTarget.md""#));
|
||||
assert!(
|
||||
!html.contains(r#"data-node-id="local-md:Home.md""#),
|
||||
"文档页带 fileTreeScope 时 PageTree 不应回退到 workspace root 全量扫描"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_conflict_panel_runtime_contains_dom_helpers() {
|
||||
const DOCUMENT_CONFLICT_PANEL_RUNTIME_JS: &str =
|
||||
|
||||
Reference in New Issue
Block a user