Files
mnote/rust/crates/mnote-web/src/routes/local_ocr.rs
T

2063 lines
73 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source::{
decode_local_id_segment, encode_local_id_segment,
ensure_local_workspace_read_access_with_state, ensure_local_workspace_write_access_with_state,
};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::hash::{Hash, Hasher};
use std::io::{Cursor, Read};
use std::path::{Component, Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const OCR_INDEX_VERSION: u32 = 1;
const DEFAULT_PROVIDER: &str = "mineru";
const DEFAULT_MODEL_VERSION: &str = "vlm";
const DEFAULT_MINERU_API_BASE_URL: &str = "https://mineru.net";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OcrJobRequest {
root_uri: String,
document_id: String,
#[serde(default)]
source_path: Option<String>,
source_root_relative_path: String,
#[serde(default)]
provider: Option<String>,
#[serde(default)]
force: bool,
#[serde(default)]
mock_markdown: Option<String>,
#[serde(default)]
mock_error: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OcrStatusQuery {
root_uri: String,
source_root_relative_path: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OcrReadQuery {
root_uri: String,
ocr_root_relative_path: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OcrInsertRequest {
root_uri: String,
document_id: String,
ocr_root_relative_path: String,
#[serde(default)]
mode: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct OcrIndex {
version: u32,
#[serde(default)]
entries: BTreeMap<String, OcrIndexEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OcrIndexEntry {
pub(crate) job_id: String,
pub(crate) owner_document_id: String,
pub(crate) owner_document_path: String,
pub(crate) source_root_relative_path: String,
pub(crate) ocr_root_relative_path: String,
pub(crate) provider: String,
pub(crate) model_version: String,
pub(crate) status: String,
pub(crate) source_size: u64,
pub(crate) source_mtime_ms: u128,
pub(crate) created_at_ms: u128,
pub(crate) updated_at_ms: u128,
#[serde(default)]
pub(crate) plain_text_preview: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) error: Option<String>,
}
#[derive(Debug, Clone)]
struct SourceMetadata {
size: u64,
mtime_ms: u128,
}
#[derive(Debug, Clone)]
struct MineruClientConfig {
api_base_url: String,
token: String,
model_version: String,
poll_interval: Duration,
max_polls: usize,
}
#[derive(Debug, Clone)]
struct OcrSidecarPlan {
owner_document_path: String,
source_root_relative_path: String,
ocr_root_relative_path: String,
ocr_path: PathBuf,
provider: String,
model_version: String,
source_size: u64,
source_mtime_ms: u128,
}
#[derive(Debug, Clone)]
pub(crate) struct OcrFrontmatter {
pub provider: String,
pub owner_document: String,
pub source_path: String,
pub source_root_relative_path: String,
pub source_size: u64,
pub source_mtime_ms: u128,
pub status: String,
}
pub(crate) async fn create_job(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<OcrJobRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let root_uri = body.root_uri.trim();
let root = ensure_local_workspace_write_access_with_state(&state, &context, root_uri)
.map_err(|error| error.with_context(&context))?;
let provider = body
.provider
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_PROVIDER);
let token = if provider != "mock" {
Some(mineru_token().ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"mineru_token_missing",
"缺少 MinerU API token",
)
.with_context(&context)
})?)
} else {
None
};
if provider != "mock" && token.is_none() {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"mineru_token_missing",
"缺少 MinerU API token",
)
.with_context(&context));
}
let source_relative = body.source_root_relative_path.trim().replace('\\', "/");
let _source_path_hint = body
.source_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(&source_relative);
let plan = plan_ocr_sidecar_path(
&root,
&body.document_id,
&source_relative,
provider,
DEFAULT_MODEL_VERSION,
body.force,
)?;
let now = now_ms();
let mut entry = build_index_entry(&plan, "queued", now, "", None);
upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
if let Some(error) = body.mock_error.as_deref() {
entry = advance_ocr_entry(&entry, "failed", now_ms(), "", Some(redact_error(error)));
upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
return Ok(ok_json(
&context,
json!({
"ok": true,
"job": ocr_job_payload(&root, &entry),
}),
));
}
let markdown = if provider == "mock" {
entry = advance_ocr_entry(&entry, "writing_sidecar", now_ms(), "", None);
upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
body.mock_markdown
.unwrap_or_else(|| format!("OCR mock result for {}", plan.source_root_relative_path))
} else {
entry = advance_ocr_entry(&entry, "uploading", now_ms(), "", None);
upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
match run_mineru_ocr(
&state,
&root,
root_uri,
&plan,
token.unwrap_or_default(),
entry.clone(),
)
.await
{
Ok(markdown) => markdown,
Err(error) => {
let failed_entry = advance_ocr_entry(
&entry,
"failed",
now_ms(),
"",
Some(redact_error(error.message())),
);
upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, failed_entry)?;
return Err(error.with_context(&context));
}
}
};
if entry.status != "writing_sidecar" {
entry = advance_ocr_entry(&entry, "writing_sidecar", now_ms(), "", None);
upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
}
write_ocr_sidecar(&plan, &markdown, now)?;
entry = advance_ocr_entry(&entry, "done", now_ms(), &markdown, None);
upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
Ok(ok_json(
&context,
json!({
"ok": true,
"job": ocr_job_payload(&root, &entry),
}),
))
}
pub(crate) async fn list_jobs(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<OcrStatusQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let root =
ensure_local_workspace_read_access_with_state(&state, &context, query.root_uri.trim())
.map_err(|error| error.with_context(&context))?;
let index = read_ocr_index(&root)?;
let jobs = index
.entries
.values()
.map(|entry| ocr_job_payload(&root, entry))
.collect::<Vec<_>>();
Ok(ok_json(&context, json!({ "ok": true, "jobs": jobs })))
}
pub(crate) async fn status(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<OcrStatusQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let root =
ensure_local_workspace_read_access_with_state(&state, &context, query.root_uri.trim())
.map_err(|error| error.with_context(&context))?;
let source = query
.source_root_relative_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_ocr_source_required", "OCR 状态查询缺少来源路径")
.with_context(&context)
})?;
let index = read_ocr_index(&root)?;
let job = index
.entries
.get(source)
.map(|entry| ocr_job_payload(&root, entry));
Ok(ok_json(&context, json!({ "ok": true, "job": job })))
}
pub(crate) async fn read(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<OcrReadQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let root =
ensure_local_workspace_read_access_with_state(&state, &context, query.root_uri.trim())
.map_err(|error| error.with_context(&context))?;
let relative = normalize_relative_path(&query.ocr_root_relative_path)?;
if !is_local_ocr_sidecar_relative_path(&relative) {
return Err(WebError::bad_request_code(
"local_ocr_sidecar_invalid",
"OCR 读取目标不是 OCR sidecar",
)
.with_context(&context));
}
let path = root.join(&relative);
ensure_target_under_root(&root, &path, "local_ocr_read_root_escape")?;
let markdown = fs::read_to_string(&path).map_err(|error| {
WebError::bad_request_code(
"local_ocr_read_failed",
format!("无法读取 OCR Markdown {}: {error}", path.display()),
)
.with_context(&context)
})?;
let frontmatter = parse_ocr_frontmatter(&markdown);
Ok(ok_json(
&context,
json!({
"ok": true,
"markdown": markdown,
"frontmatter": frontmatter.map(ocr_frontmatter_payload),
"ocrRootRelativePath": relative,
}),
))
}
pub(crate) async fn insert(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<OcrInsertRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let root =
ensure_local_workspace_write_access_with_state(&state, &context, body.root_uri.trim())
.map_err(|error| error.with_context(&context))?;
let mode = body
.mode
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("link");
if mode != "link" {
return Err(WebError::bad_request_code(
"local_ocr_insert_mode_unsupported",
"当前仅支持插入 OCR 链接",
)
.with_context(&context));
}
let owner_document_path = owner_document_path_from_id(&body.document_id)?;
let owner_path = root.join(&owner_document_path);
ensure_target_under_root(&root, &owner_path, "local_ocr_owner_root_escape")
.map_err(|error| error.with_context(&context))?;
if !owner_path.is_file() {
return Err(WebError::bad_request_code(
"local_ocr_owner_missing",
"OCR owner Markdown 不存在",
)
.with_context(&context));
}
let ocr_root_relative_path = normalize_relative_path(&body.ocr_root_relative_path)?;
if !is_local_ocr_sidecar_relative_path(&ocr_root_relative_path) {
return Err(WebError::bad_request_code(
"local_ocr_sidecar_invalid",
"OCR 插入目标不是 OCR sidecar",
)
.with_context(&context));
}
let ocr_path = root.join(&ocr_root_relative_path);
ensure_target_under_root(&root, &ocr_path, "local_ocr_read_root_escape")
.map_err(|error| error.with_context(&context))?;
if !ocr_path.is_file() {
return Err(
WebError::bad_request_code("local_ocr_read_failed", "OCR Markdown 不存在")
.with_context(&context),
);
}
let current = fs::read_to_string(&owner_path).map_err(|error| {
WebError::bad_request_code(
"local_ocr_owner_read_failed",
format!(
"无法读取 OCR owner Markdown {}: {error}",
owner_path.display()
),
)
.with_context(&context)
})?;
let link_target = relative_from_owner_dir(&owner_document_path, &ocr_root_relative_path);
let label = ocr_link_label(&ocr_root_relative_path);
let inserted_markdown = format!("[OCR{}]({})", label, link_target);
let mut next = current.trim_end_matches('\n').to_string();
if next.is_empty() {
next.push_str(&inserted_markdown);
next.push('\n');
} else {
next.push_str("\n\n");
next.push_str(&inserted_markdown);
next.push('\n');
}
fs::write(&owner_path, next).map_err(|error| {
WebError::bad_request_code(
"local_ocr_owner_write_failed",
format!(
"无法写入 OCR owner Markdown {}: {error}",
owner_path.display()
),
)
.with_context(&context)
})?;
Ok(ok_json(
&context,
json!({
"ok": true,
"documentId": body.document_id,
"ownerDocumentPath": owner_document_path,
"ocrRootRelativePath": ocr_root_relative_path,
"insertedMarkdown": inserted_markdown,
}),
))
}
pub(crate) fn is_local_ocr_sidecar_relative_path(relative_path: &str) -> bool {
let normalized = relative_path.trim().replace('\\', "/");
let path = Path::new(&normalized);
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
return false;
};
let Some(parent_name) = path
.parent()
.and_then(|value| value.file_name())
.and_then(|value| value.to_str())
else {
return false;
};
parent_name.ends_with(".ocr") && file_name.ends_with(".ocr.md")
}
pub(crate) fn parse_ocr_frontmatter(markdown: &str) -> Option<OcrFrontmatter> {
let trimmed = markdown.strip_prefix("---\n")?;
let end = trimmed.find("\n---")?;
let frontmatter = &trimmed[..end];
let mut fields = HashMap::<String, String>::new();
for line in frontmatter.lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
};
fields.insert(
key.trim().to_string(),
value.trim().trim_matches('"').to_string(),
);
}
if fields.get("mnote_ocr_version").map(String::as_str) != Some("1") {
return None;
}
Some(OcrFrontmatter {
provider: fields.get("provider")?.to_string(),
owner_document: fields.get("owner_document")?.to_string(),
source_path: fields.get("source_path")?.to_string(),
source_root_relative_path: fields.get("source_root_relative_path")?.to_string(),
source_size: fields.get("source_size")?.parse().ok()?,
source_mtime_ms: fields.get("source_mtime_ms")?.parse().ok()?,
status: fields.get("status")?.to_string(),
})
}
#[cfg(test)]
fn read_ocr_index_entry(
root: &Path,
source_root_relative_path: &str,
) -> Result<Option<OcrIndexEntry>, WebError> {
let source = normalize_relative_path(source_root_relative_path)?;
Ok(read_ocr_index(root)?.entries.remove(&source))
}
pub(crate) fn ocr_index_entries(root: &Path) -> Result<Vec<OcrIndexEntry>, WebError> {
Ok(read_ocr_index(root)?.entries.into_values().collect())
}
pub(crate) fn strip_ocr_frontmatter(markdown: &str) -> &str {
let Some(trimmed) = markdown.strip_prefix("---\n") else {
return markdown;
};
let Some(end) = trimmed.find("\n---") else {
return markdown;
};
trimmed[end + 4..].trim_start_matches('\n')
}
async fn run_mineru_ocr(
state: &AppState,
root: &Path,
root_uri: &str,
plan: &OcrSidecarPlan,
token: String,
mut entry: OcrIndexEntry,
) -> Result<String, WebError> {
let config = MineruClientConfig {
api_base_url: mineru_api_base_url(),
token,
model_version: plan.model_version.clone(),
poll_interval: mineru_poll_interval(),
max_polls: mineru_max_polls(),
};
let source_path = root.join(&plan.source_root_relative_path);
let bytes = fs::read(&source_path).map_err(|error| {
WebError::bad_request_code(
"mineru_source_read_failed",
format!(
"无法读取 MinerU OCR 来源文件 {}: {error}",
source_path.display()
),
)
})?;
let file_name = Path::new(&plan.source_root_relative_path)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("source.pdf");
let client = reqwest::Client::new();
let upload = create_mineru_upload_task(&client, &config, file_name).await?;
upload_mineru_source(&client, &upload.upload_url, bytes).await?;
entry = advance_ocr_entry(&entry, "mineru_processing", now_ms(), "", None);
upsert_and_broadcast_ocr_index_entry(state, root, root_uri, entry.clone())?;
let zip_url = poll_mineru_result_zip_url(&client, &config, &upload.batch_id).await?;
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)
}
#[derive(Debug)]
struct MineruUploadTask {
batch_id: String,
upload_url: String,
}
async fn create_mineru_upload_task(
client: &reqwest::Client,
config: &MineruClientConfig,
file_name: &str,
) -> Result<MineruUploadTask, WebError> {
let url = format!(
"{}/api/v4/file-urls/batch",
config.api_base_url.trim_end_matches('/')
);
let payload = json!({
"files": [{ "name": file_name, "data_id": short_hash(file_name) }],
"model_version": config.model_version,
});
let response = client
.post(&url)
.bearer_auth(&config.token)
.json(&payload)
.send()
.await
.map_err(|error| {
WebError::bad_gateway_code(
"mineru_upload_task_failed",
format!(
"MinerU 上传任务创建失败: {}",
redact_error(&error.to_string())
),
)
})?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(WebError::bad_gateway_code(
"mineru_upload_task_http_failed",
format!("MinerU 上传任务创建失败: HTTP {}", status.as_u16()),
));
}
let value = parse_mineru_json(&body, "mineru_upload_task_json_invalid")?;
let batch_id =
find_json_string_by_keys(&value, &["batch_id", "batchId", "id"]).ok_or_else(|| {
WebError::bad_gateway_code(
"mineru_upload_task_batch_missing",
"MinerU 上传任务响应缺少 batch_id",
)
})?;
let upload_url = find_upload_url(&value).ok_or_else(|| {
WebError::bad_gateway_code(
"mineru_upload_url_missing",
"MinerU 上传任务响应缺少 upload_url",
)
})?;
Ok(MineruUploadTask {
batch_id,
upload_url,
})
}
async fn upload_mineru_source(
client: &reqwest::Client,
upload_url: &str,
bytes: Vec<u8>,
) -> Result<(), WebError> {
let response = client
.put(upload_url)
.body(bytes)
.send()
.await
.map_err(|error| {
WebError::bad_gateway_code(
"mineru_source_upload_failed",
format!(
"MinerU 源文件上传失败: {}",
redact_error(&error.to_string())
),
)
})?;
if !response.status().is_success() {
return Err(WebError::bad_gateway_code(
"mineru_source_upload_http_failed",
format!("MinerU 源文件上传失败: HTTP {}", response.status().as_u16()),
));
}
Ok(())
}
async fn poll_mineru_result_zip_url(
client: &reqwest::Client,
config: &MineruClientConfig,
batch_id: &str,
) -> Result<String, WebError> {
let url = format!(
"{}/api/v4/extract-results/batch/{}",
config.api_base_url.trim_end_matches('/'),
batch_id
);
for _ in 0..config.max_polls.max(1) {
let response = client
.get(&url)
.bearer_auth(&config.token)
.send()
.await
.map_err(|error| {
WebError::bad_gateway_code(
"mineru_result_poll_failed",
format!("MinerU 结果轮询失败: {}", redact_error(&error.to_string())),
)
})?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(WebError::bad_gateway_code(
"mineru_result_poll_http_failed",
format!("MinerU 结果轮询失败: HTTP {}", status.as_u16()),
));
}
let value = parse_mineru_json(&body, "mineru_result_poll_json_invalid")?;
if let Some(error_message) = mineru_result_error(&value) {
return Err(WebError::bad_gateway_code(
"mineru_result_failed",
format!("MinerU 识别失败: {}", redact_error(&error_message)),
));
}
if let Some(zip_url) = find_json_string_by_keys(
&value,
&[
"full_zip_url",
"fullZipUrl",
"zip_url",
"zipUrl",
"result_url",
"resultUrl",
],
) {
return Ok(zip_url);
}
tokio::time::sleep(config.poll_interval).await;
}
Err(WebError::gateway_timeout_code(
"mineru_result_poll_timeout",
"MinerU 识别结果轮询超时",
))
}
async fn download_mineru_result_zip(
client: &reqwest::Client,
zip_url: &str,
) -> Result<Vec<u8>, WebError> {
let response = client.get(zip_url).send().await.map_err(|error| {
WebError::bad_gateway_code(
"mineru_result_download_failed",
format!(
"MinerU 结果包下载失败: {}",
redact_error(&error.to_string())
),
)
})?;
if !response.status().is_success() {
return Err(WebError::bad_gateway_code(
"mineru_result_download_http_failed",
format!("MinerU 结果包下载失败: HTTP {}", response.status().as_u16()),
));
}
response
.bytes()
.await
.map(|bytes| bytes.to_vec())
.map_err(|error| {
WebError::bad_gateway_code(
"mineru_result_download_failed",
format!(
"MinerU 结果包读取失败: {}",
redact_error(&error.to_string())
),
)
})
}
fn extract_mineru_markdown_from_zip(bytes: &[u8]) -> Result<String, WebError> {
let cursor = Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor).map_err(|error| {
WebError::bad_gateway_code(
"mineru_result_zip_invalid",
format!("MinerU 结果包不是合法 zip: {error}"),
)
})?;
let mut candidates = Vec::<(String, String)>::new();
for index in 0..archive.len() {
let mut file = archive.by_index(index).map_err(|error| {
WebError::bad_gateway_code(
"mineru_result_zip_read_failed",
format!("MinerU 结果包读取失败: {error}"),
)
})?;
let name = file.name().replace('\\', "/");
if !name.to_ascii_lowercase().ends_with(".md") || name.contains("/.") {
continue;
}
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));
}
candidates
.into_iter()
.max_by_key(|(name, markdown)| {
let preferred =
name.ends_with("/full.md") || name == "full.md" || name.ends_with("/result.md");
(preferred, markdown.len())
})
.map(|(_, markdown)| markdown)
.filter(|markdown| !markdown.trim().is_empty())
.ok_or_else(|| {
WebError::bad_gateway_code(
"mineru_result_markdown_missing",
"MinerU 结果包中缺少 Markdown 文件",
)
})
}
fn parse_mineru_json(body: &str, code: &'static str) -> Result<Value, WebError> {
serde_json::from_str::<Value>(body).map_err(|error| {
WebError::bad_gateway_code(code, format!("MinerU 响应 JSON 解析失败: {error}"))
})
}
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
}
fn find_json_string_by_keys(value: &Value, keys: &[&str]) -> Option<String> {
match value {
Value::Object(map) => {
for key in keys {
if let Some(text) = map.get(*key).and_then(Value::as_str) {
let trimmed = text.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
for nested in map.values() {
if let Some(found) = find_json_string_by_keys(nested, keys) {
return Some(found);
}
}
None
}
Value::Array(items) => items
.iter()
.find_map(|item| find_json_string_by_keys(item, keys)),
_ => None,
}
}
fn mineru_result_error(value: &Value) -> Option<String> {
let status = find_json_string_by_keys(value, &["state", "status"])
.unwrap_or_default()
.to_ascii_lowercase();
if matches!(
status.as_str(),
"failed" | "fail" | "error" | "interrupted" | "canceled" | "cancelled"
) {
return find_json_string_by_keys(value, &["error", "message", "msg", "err_msg", "errMsg"])
.or_else(|| Some(status));
}
None
}
fn ok_json(context: &RequestContext, result: Value) -> (StatusCode, HeaderMap, Json<Value>) {
let mut headers = HeaderMap::new();
if let Ok(value) = axum::http::HeaderValue::from_str(&context.trace.request_id) {
headers.insert("x-request-id", value);
}
(StatusCode::OK, headers, Json(result))
}
fn plan_ocr_sidecar_path(
root: &Path,
document_id: &str,
source_root_relative_path: &str,
provider: &str,
model_version: &str,
force: bool,
) -> Result<OcrSidecarPlan, WebError> {
let owner_document_path = owner_document_path_from_id(document_id)?;
let source_root_relative_path = normalize_relative_path(source_root_relative_path)?;
let source_path = root.join(&source_root_relative_path);
ensure_target_under_root(root, &source_path, "local_ocr_source_root_escape")?;
if !source_path.is_file() {
return Err(WebError::bad_request_code(
"local_ocr_source_missing",
"OCR 来源文件不存在",
));
}
if !is_supported_ocr_source(&source_path) {
return Err(WebError::bad_request_code(
"local_ocr_source_type_unsupported",
"OCR 仅支持图片和 PDF",
));
}
let owner_path = root.join(&owner_document_path);
ensure_target_under_root(root, &owner_path, "local_ocr_owner_root_escape")?;
if !owner_path.is_file() {
return Err(WebError::bad_request_code(
"local_ocr_owner_missing",
"OCR owner Markdown 不存在",
));
}
let metadata = fs::metadata(&source_path).map_err(|error| {
WebError::bad_request_code(
"local_ocr_source_stat_failed",
format!(
"无法读取 OCR 来源文件状态 {}: {error}",
source_path.display()
),
)
})?;
let source_metadata = SourceMetadata {
size: metadata.len(),
mtime_ms: metadata
.modified()
.ok()
.and_then(|value| value.duration_since(UNIX_EPOCH).ok())
.map(|value| value.as_millis())
.unwrap_or_default(),
};
let owner_parent = Path::new(&owner_document_path)
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
let owner_stem = Path::new(&owner_document_path)
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("Page");
let ocr_dir = owner_parent.join(format!("{owner_stem}.ocr"));
let source_leaf = Path::new(&source_root_relative_path)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("source");
let base_file_name = format!("{source_leaf}.ocr.md");
let mut ocr_relative = ocr_dir.join(&base_file_name);
let default_path = root.join(&ocr_relative);
if !force && default_path.exists() {
let suffix = short_hash(&format!(
"{}:{}:{}",
source_root_relative_path, source_metadata.size, source_metadata.mtime_ms
));
ocr_relative = ocr_dir.join(format!("{source_leaf}-{suffix}.ocr.md"));
}
let ocr_root_relative_path = ocr_relative.to_string_lossy().replace('\\', "/");
let ocr_path = root.join(&ocr_root_relative_path);
ensure_target_under_root(root, &ocr_path, "local_ocr_sidecar_root_escape")?;
Ok(OcrSidecarPlan {
owner_document_path,
source_root_relative_path,
ocr_root_relative_path,
ocr_path,
provider: provider.to_string(),
model_version: model_version.to_string(),
source_size: source_metadata.size,
source_mtime_ms: source_metadata.mtime_ms,
})
}
fn write_ocr_sidecar(
plan: &OcrSidecarPlan,
markdown_body: &str,
now: u128,
) -> Result<(), WebError> {
if let Some(parent) = plan.ocr_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
WebError::bad_request_code(
"local_ocr_sidecar_create_failed",
format!("无法创建 OCR sidecar 目录 {}: {error}", parent.display()),
)
})?;
}
let content = build_ocr_markdown(plan, markdown_body, "done", now);
fs::write(&plan.ocr_path, content).map_err(|error| {
WebError::bad_request_code(
"local_ocr_sidecar_write_failed",
format!("无法写入 OCR Markdown {}: {error}", plan.ocr_path.display()),
)
})
}
fn build_ocr_markdown(
plan: &OcrSidecarPlan,
markdown_body: &str,
status: &str,
timestamp_ms: u128,
) -> String {
let owner_document =
relative_from_sidecar_dir(&plan.ocr_root_relative_path, &plan.owner_document_path);
let source_path =
relative_from_owner_dir(&plan.owner_document_path, &plan.source_root_relative_path);
let timestamp = timestamp_ms.to_string();
format!(
"---\nmnote_ocr_version: 1\nprovider: {}\nmodel_version: {}\nowner_document: {}\nsource_path: {}\nsource_root_relative_path: {}\nsource_size: {}\nsource_mtime_ms: {}\nstatus: {}\ncreated_at: {}\nupdated_at: {}\n---\n\n{}\n",
plan.provider,
plan.model_version,
owner_document,
source_path,
plan.source_root_relative_path,
plan.source_size,
plan.source_mtime_ms,
status,
timestamp,
timestamp,
markdown_body.trim_end()
)
}
fn build_index_entry(
plan: &OcrSidecarPlan,
status: &str,
now: u128,
markdown: &str,
error: Option<String>,
) -> OcrIndexEntry {
OcrIndexEntry {
job_id: format!(
"ocr_{}_{}",
now,
short_hash(&plan.source_root_relative_path)
),
owner_document_id: format!(
"local-md:{}",
encode_local_id_segment(&plan.owner_document_path)
),
owner_document_path: plan.owner_document_path.clone(),
source_root_relative_path: plan.source_root_relative_path.clone(),
ocr_root_relative_path: plan.ocr_root_relative_path.clone(),
provider: plan.provider.clone(),
model_version: plan.model_version.clone(),
status: status.to_string(),
source_size: plan.source_size,
source_mtime_ms: plan.source_mtime_ms,
created_at_ms: now,
updated_at_ms: now,
plain_text_preview: markdown.chars().take(240).collect(),
error,
}
}
fn advance_ocr_entry(
previous: &OcrIndexEntry,
status: &str,
now: u128,
markdown: &str,
error: Option<String>,
) -> OcrIndexEntry {
let mut next = previous.clone();
next.status = status.to_string();
next.updated_at_ms = now;
if !markdown.is_empty() {
next.plain_text_preview = markdown.chars().take(240).collect();
}
next.error = error;
next
}
fn read_ocr_index(root: &Path) -> Result<OcrIndex, WebError> {
let path = ocr_index_path(root);
if !path.exists() {
return Ok(OcrIndex {
version: OCR_INDEX_VERSION,
entries: BTreeMap::new(),
});
}
let content = fs::read_to_string(&path).map_err(|error| {
WebError::bad_request_code(
"local_ocr_index_read_failed",
format!("无法读取 OCR 索引 {}: {error}", path.display()),
)
})?;
serde_json::from_str::<OcrIndex>(&content).map_err(|error| {
WebError::bad_request_code(
"local_ocr_index_invalid",
format!("OCR 索引格式非法 {}: {error}", path.display()),
)
})
}
fn write_ocr_index(root: &Path, index: &OcrIndex) -> Result<(), WebError> {
let path = ocr_index_path(root);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
WebError::bad_request_code(
"local_ocr_index_create_failed",
format!("无法创建 OCR 索引目录 {}: {error}", parent.display()),
)
})?;
}
let content = serde_json::to_vec_pretty(index)
.map_err(|error| WebError::internal(format!("OCR 索引序列化失败: {error}")))?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, [&content[..], b"\n"].concat()).map_err(|error| {
WebError::bad_request_code(
"local_ocr_index_write_failed",
format!("无法写入 OCR 临时索引 {}: {error}", tmp.display()),
)
})?;
fs::rename(&tmp, &path).map_err(|error| {
WebError::bad_request_code(
"local_ocr_index_write_failed",
format!("无法替换 OCR 索引 {}: {error}", path.display()),
)
})
}
fn upsert_ocr_index_entry(root: &Path, entry: OcrIndexEntry) -> Result<(), WebError> {
let mut index = read_ocr_index(root)?;
index.version = OCR_INDEX_VERSION;
index
.entries
.insert(entry.source_root_relative_path.clone(), entry);
write_ocr_index(root, &index)
}
fn upsert_and_broadcast_ocr_index_entry(
state: &AppState,
root: &Path,
root_uri: &str,
entry: OcrIndexEntry,
) -> Result<(), WebError> {
upsert_ocr_index_entry(root, entry.clone())?;
broadcast_ocr_job_update(state, root, root_uri, &entry);
Ok(())
}
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);
if matches!(entry.status.as_str(), "done" | "failed" | "interrupted") {
if let Ok(mut jobs) = state.local_ocr_active_jobs.write() {
jobs.remove(&key);
}
} else if let Ok(mut jobs) = state.local_ocr_active_jobs.write() {
jobs.insert(key, job.clone());
}
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": entry.source_root_relative_path,
"documentId": entry.owner_document_id,
"revision": entry.updated_at_ms.to_string(),
"job": job,
});
let _ = state.local_ocr_job_tx.send(payload.clone());
let _ = state.stream_delta_tx.send(payload);
}
fn ocr_job_payload(root: &Path, entry: &OcrIndexEntry) -> Value {
let stale = source_is_stale(root, entry);
json!({
"jobId": entry.job_id,
"ownerDocumentId": entry.owner_document_id,
"ownerDocumentPath": entry.owner_document_path,
"sourceRootRelativePath": entry.source_root_relative_path,
"ocrRootRelativePath": entry.ocr_root_relative_path,
"provider": entry.provider,
"modelVersion": entry.model_version,
"status": if stale && entry.status == "done" { "stale" } else { entry.status.as_str() },
"stageLabel": stage_label(if stale && entry.status == "done" { "stale" } else { entry.status.as_str() }),
"stale": stale,
"updatedAtMs": entry.updated_at_ms,
"finishedAtMs": if matches!(entry.status.as_str(), "done" | "failed") { Some(entry.updated_at_ms) } else { None },
"plainTextPreview": entry.plain_text_preview,
"error": entry.error,
})
}
fn source_is_stale(root: &Path, entry: &OcrIndexEntry) -> bool {
let source = root.join(&entry.source_root_relative_path);
let Ok(metadata) = fs::metadata(source) else {
return true;
};
let mtime_ms = metadata
.modified()
.ok()
.and_then(|value| value.duration_since(UNIX_EPOCH).ok())
.map(|value| value.as_millis())
.unwrap_or_default();
metadata.len() != entry.source_size || mtime_ms != entry.source_mtime_ms
}
fn stage_label(status: &str) -> &'static str {
match status {
"queued" => "排队中",
"uploading" => "上传中",
"mineru_processing" => "识别中",
"downloading" => "下载中",
"writing_sidecar" => "写入中",
"done" => "已识别",
"failed" => "识别失败",
"interrupted" => "已中断",
"stale" => "来源已变化",
_ => "未知状态",
}
}
fn ocr_frontmatter_payload(frontmatter: OcrFrontmatter) -> Value {
json!({
"provider": frontmatter.provider,
"ownerDocument": frontmatter.owner_document,
"sourcePath": frontmatter.source_path,
"sourceRootRelativePath": frontmatter.source_root_relative_path,
"sourceSize": frontmatter.source_size,
"sourceMtimeMs": frontmatter.source_mtime_ms,
"status": frontmatter.status,
})
}
fn ocr_index_path(root: &Path) -> PathBuf {
root.join(".mnote").join("ocr-index.json")
}
fn owner_document_path_from_id(document_id: &str) -> Result<String, WebError> {
let encoded = document_id
.trim()
.strip_prefix("local-md:")
.ok_or_else(|| {
WebError::bad_request_code(
"local_ocr_owner_document_invalid",
"OCR owner documentId 必须是 local-md 路径型 ID",
)
})?;
let decoded = decode_local_id_segment(encoded)?;
normalize_relative_path(&decoded)
}
fn normalize_relative_path(value: &str) -> Result<String, WebError> {
let normalized = value.trim().trim_start_matches('/').replace('\\', "/");
if normalized.is_empty() {
return Err(WebError::bad_request_code(
"local_ocr_path_required",
"OCR 路径不能为空",
));
}
let path = Path::new(&normalized);
if path.is_absolute()
|| path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
{
return Err(WebError::bad_request_code(
"local_ocr_path_escape",
"OCR 相对路径不能越过 root",
));
}
Ok(normalized)
}
fn ensure_target_under_root(
root: &Path,
target: &Path,
code: &'static str,
) -> Result<(), WebError> {
let parent = target.parent().unwrap_or(root);
let canonical_parent = parent
.canonicalize()
.unwrap_or_else(|_| parent.to_path_buf());
let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
if !canonical_parent.starts_with(&canonical_root) {
return Err(WebError::bad_request_code(code, "OCR 路径不能越过 root"));
}
Ok(())
}
fn is_supported_ocr_source(path: &Path) -> bool {
let ext = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
matches!(
ext.as_str(),
"pdf" | "png" | "jpg" | "jpeg" | "webp" | "bmp" | "tif" | "tiff"
)
}
fn relative_from_owner_dir(owner_document_path: &str, source_root_relative_path: &str) -> String {
let owner_parent = Path::new(owner_document_path)
.parent()
.unwrap_or(Path::new(""));
Path::new(source_root_relative_path)
.strip_prefix(owner_parent)
.map(|value| format!("./{}", value.to_string_lossy().replace('\\', "/")))
.unwrap_or_else(|_| source_root_relative_path.to_string())
}
fn relative_from_sidecar_dir(ocr_root_relative_path: &str, owner_document_path: &str) -> String {
let sidecar_parent = Path::new(ocr_root_relative_path)
.parent()
.unwrap_or_else(|| Path::new(""));
let owner = Path::new(owner_document_path);
let sidecar_components = sidecar_parent
.components()
.map(|component| component.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>();
let owner_components = owner
.components()
.map(|component| component.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>();
let common = sidecar_components
.iter()
.zip(owner_components.iter())
.take_while(|(left, right)| left == right)
.count();
let mut parts = Vec::<String>::new();
for _ in common..sidecar_components.len() {
parts.push("..".to_string());
}
parts.extend(owner_components.into_iter().skip(common));
if parts.is_empty() {
".".to_string()
} else {
parts.join("/")
}
}
fn short_hash(value: &str) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
value.hash(&mut hasher);
format!("{:x}", hasher.finish()).chars().take(6).collect()
}
fn now_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|value| value.as_millis())
.unwrap_or_default()
}
fn redact_error(error: &str) -> String {
let text = error.trim().replace('\n', " ");
let lower = text.to_ascii_lowercase();
if lower.contains("token") || lower.contains("signature") || lower.contains("x-oss-") {
return "provider_error_redacted".to_string();
}
text.chars().take(160).collect()
}
fn ocr_link_label(ocr_root_relative_path: &str) -> String {
Path::new(ocr_root_relative_path)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("OCR")
.trim_end_matches(".ocr.md")
.replace(['[', ']'], "")
}
fn mineru_token() -> Option<String> {
std::env::var("MNOTE_MINERU_API_TOKEN")
.ok()
.or_else(|| std::env::var("MINERU_API_TOKEN").ok())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn mineru_api_base_url() -> String {
std::env::var("MNOTE_MINERU_API_BASE_URL")
.ok()
.or_else(|| std::env::var("MINERU_API_BASE_URL").ok())
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| DEFAULT_MINERU_API_BASE_URL.to_string())
}
fn mineru_poll_interval() -> Duration {
std::env::var("MNOTE_MINERU_POLL_INTERVAL_MS")
.ok()
.and_then(|value| value.trim().parse::<u64>().ok())
.filter(|value| *value > 0)
.map(Duration::from_millis)
.unwrap_or_else(|| Duration::from_secs(2))
}
fn mineru_max_polls() -> usize {
std::env::var("MNOTE_MINERU_MAX_POLLS")
.ok()
.and_then(|value| value.trim().parse::<usize>().ok())
.filter(|value| *value > 0)
.unwrap_or(90)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::Request;
use std::io::Write as _;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use tower::util::ServiceExt;
fn temp_root(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("docs").join("Page.assets")).expect("create assets");
fs::create_dir_all(root.join(".mnote")).expect("create metadata");
root
}
fn test_app_state() -> AppState {
AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
})
}
fn app() -> axum::Router {
build_app(test_app_state())
}
fn app_with_state(state: AppState) -> axum::Router {
build_app(state)
}
fn write_workspace_manifest(root: &Path) {
fs::write(
root.join(".mnote").join("workspace.json"),
r#"{"workspaceId":"local-ws-ocr","ownerId":"user_test","createdAt":"2026-06-01T00:00:00Z","capabilities":["local_files"]}"#,
)
.expect("manifest");
}
async fn post_ocr_job(root: &Path, body: Value) -> (StatusCode, Value) {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.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(body.to_string()))
.expect("request"),
)
.await
.expect("response");
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("response body");
let payload = serde_json::from_slice(&body).expect("response json");
let _ = fs::remove_dir_all(root);
(status, payload)
}
fn query_escape(value: &str) -> String {
value
.bytes()
.map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(byte as char).to_string()
}
_ => format!("%{byte:02X}"),
})
.collect()
}
fn build_test_mineru_zip(markdown: &str) -> Vec<u8> {
let mut bytes = Cursor::new(Vec::<u8>::new());
{
let mut writer = zip::ZipWriter::new(&mut bytes);
writer
.start_file("full.md", zip::write::SimpleFileOptions::default())
.expect("zip start file");
writer.write_all(markdown.as_bytes()).expect("zip markdown");
writer.finish().expect("zip finish");
}
bytes.into_inner()
}
#[test]
fn local_ocr_sidecar_path_and_frontmatter_contract() {
let root = temp_root("mnote-local-ocr-sidecar");
fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
fs::write(
root.join("docs").join("Page.assets").join("photo.png"),
b"png",
)
.expect("photo");
let plan = plan_ocr_sidecar_path(
&root,
"local-md:docs~2FPage.md",
"docs/Page.assets/photo.png",
"mineru",
"vlm",
false,
)
.expect("plan");
assert_eq!(
plan.ocr_root_relative_path,
"docs/Page.ocr/photo.png.ocr.md"
);
let markdown = build_ocr_markdown(&plan, "识别文本", "done", 1780000000000);
assert!(markdown.contains("mnote_ocr_version: 1"));
assert!(markdown.contains("provider: mineru"));
assert!(markdown.contains("model_version: vlm"));
assert!(markdown.contains("owner_document: ../Page.md"));
assert!(markdown.contains("source_path: ./Page.assets/photo.png"));
assert!(markdown.contains("source_root_relative_path: docs/Page.assets/photo.png"));
assert!(markdown.contains("status: done"));
let parsed = parse_ocr_frontmatter(&markdown).expect("frontmatter");
assert_eq!(
parsed.source_root_relative_path,
"docs/Page.assets/photo.png"
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn local_ocr_index_marks_stale_and_redacts_error() {
let root = temp_root("mnote-local-ocr-index");
fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
let source = root.join("docs").join("Page.assets").join("scan.pdf");
fs::write(&source, b"pdf").expect("pdf");
let plan = plan_ocr_sidecar_path(
&root,
"local-md:docs~2FPage.md",
"docs/Page.assets/scan.pdf",
"mock",
"vlm",
false,
)
.expect("plan");
let entry = build_index_entry(
&plan,
"failed",
1780000000000,
"",
Some(redact_error("token=secret upload_url=https://example.test")),
);
upsert_ocr_index_entry(&root, entry.clone()).expect("write index");
let stored = read_ocr_index_entry(&root, "docs/Page.assets/scan.pdf")
.expect("read index")
.expect("entry");
assert_eq!(stored.error.as_deref(), Some("provider_error_redacted"));
fs::write(&source, b"pdf changed").expect("change source");
assert!(source_is_stale(&root, &stored));
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn local_ocr_jobs_route_writes_mock_sidecar_and_reads_status() {
let root = temp_root("mnote-local-ocr-route");
write_workspace_manifest(&root);
fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
fs::write(
root.join("docs").join("Page.assets").join("photo.png"),
b"png",
)
.expect("photo");
let root_uri = format!("file://{}", root.display());
let response = app()
.oneshot(
Request::builder()
.method("POST")
.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,
"documentId": "local-md:docs~2FPage.md",
"sourceRootRelativePath": "docs/Page.assets/photo.png",
"provider": "mock",
"mockMarkdown": "Route OCR Token"
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert!(root
.join("docs")
.join("Page.ocr")
.join("photo.png.ocr.md")
.is_file());
let escaped_root = query_escape(&root_uri);
let status_response = app()
.oneshot(
Request::builder()
.uri(format!(
"/api/local-folder/ocr/status?rootUri={escaped_root}&sourceRootRelativePath=docs%2FPage.assets%2Fphoto.png"
))
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("status response");
assert_eq!(status_response.status(), StatusCode::OK);
let status_body = to_bytes(status_response.into_body(), usize::MAX)
.await
.expect("status body");
let status_payload: Value = serde_json::from_slice(&status_body).expect("status json");
assert_eq!(status_payload["job"]["status"].as_str(), Some("done"));
let read_response = app()
.oneshot(
Request::builder()
.uri(format!(
"/api/local-folder/ocr/read?rootUri={escaped_root}&ocrRootRelativePath=docs%2FPage.ocr%2Fphoto.png.ocr.md"
))
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("read response");
assert_eq!(read_response.status(), StatusCode::OK);
let read_body = to_bytes(read_response.into_body(), usize::MAX)
.await
.expect("read body");
let read_payload: Value = serde_json::from_slice(&read_body).expect("read json");
assert!(read_payload["markdown"]
.as_str()
.is_some_and(|markdown| markdown.contains("Route OCR Token")));
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn local_ocr_jobs_route_broadcasts_stage_updates() {
let root = temp_root("mnote-local-ocr-events");
write_workspace_manifest(&root);
fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
fs::write(
root.join("docs").join("Page.assets").join("photo.png"),
b"png",
)
.expect("photo");
let root_uri = format!("file://{}", root.display());
let state = test_app_state();
let mut events = state.local_ocr_job_tx.subscribe();
let response = app_with_state(state.clone())
.oneshot(
Request::builder()
.method("POST")
.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,
"documentId": "local-md:docs~2FPage.md",
"sourceRootRelativePath": "docs/Page.assets/photo.png",
"provider": "mock",
"mockMarkdown": "Event OCR Token"
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let mut statuses = Vec::new();
for _ in 0..3 {
let payload = tokio::time::timeout(Duration::from_secs(1), events.recv())
.await
.expect("ocr event timeout")
.expect("ocr event");
assert_eq!(payload["eventType"].as_str(), Some("local_ocr.job.updated"));
assert_eq!(
payload["job"]["sourceRootRelativePath"].as_str(),
Some("docs/Page.assets/photo.png")
);
statuses.push(
payload["job"]["status"]
.as_str()
.unwrap_or_default()
.to_string(),
);
}
assert_eq!(statuses, vec!["queued", "writing_sidecar", "done"]);
assert!(state
.local_ocr_active_jobs
.read()
.expect("active jobs lock")
.is_empty());
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn local_ocr_jobs_route_rejects_missing_mineru_token() {
let old_mnote_token = std::env::var("MNOTE_MINERU_API_TOKEN").ok();
let old_mineru_token = std::env::var("MINERU_API_TOKEN").ok();
std::env::remove_var("MNOTE_MINERU_API_TOKEN");
std::env::remove_var("MINERU_API_TOKEN");
let root = temp_root("mnote-local-ocr-token");
write_workspace_manifest(&root);
let root_uri = format!("file://{}", root.display());
let (status, payload) = post_ocr_job(
&root,
json!({
"rootUri": root_uri,
"documentId": "local-md:docs~2FPage.md",
"sourceRootRelativePath": "docs/Page.assets/photo.png",
"provider": "mineru"
}),
)
.await;
if let Some(value) = old_mnote_token {
std::env::set_var("MNOTE_MINERU_API_TOKEN", value);
}
if let Some(value) = old_mineru_token {
std::env::set_var("MINERU_API_TOKEN", value);
}
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(payload["code"].as_str(), Some("mineru_token_missing"));
}
#[tokio::test]
async fn local_ocr_jobs_route_runs_mineru_runtime_against_http_mock() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("mock mineru bind");
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 mock_mineru = axum::Router::new()
.route(
"/api/v4/file-urls/batch",
axum::routing::post({
let base_url = base_url.clone();
|| async move {
Json(json!({
"batch_id": "batch_1",
"file_urls": [{ "upload_url": format!("{base_url}/upload/source") }]
}))
}
}),
)
.route(
"/upload/source",
axum::routing::put({
let upload_count = upload_count.clone();
move |body: axum::body::Bytes| {
let upload_count = upload_count.clone();
async move {
assert!(!body.is_empty());
upload_count.fetch_add(1, Ordering::SeqCst);
StatusCode::OK
}
}
}),
)
.route(
"/api/v4/extract-results/batch/batch_1",
axum::routing::get({
let base_url = base_url.clone();
let poll_count = poll_count.clone();
move || {
let poll_count = poll_count.clone();
async move {
poll_count.fetch_add(1, Ordering::SeqCst);
Json(json!({
"status": "done",
"full_zip_url": format!("{base_url}/result.zip")
}))
}
}
}),
)
.route(
"/result.zip",
axum::routing::get({
let zip_bytes = zip_bytes.clone();
move || {
let zip_bytes = zip_bytes.clone();
async move {
(
[(axum::http::header::CONTENT_TYPE, "application/zip")],
(*zip_bytes).clone(),
)
}
}
}),
);
let mock_handle = tokio::spawn(async move {
axum::serve(listener, mock_mineru)
.await
.expect("mock mineru server");
});
let old_token = std::env::var("MNOTE_MINERU_API_TOKEN").ok();
let old_base = std::env::var("MNOTE_MINERU_API_BASE_URL").ok();
let old_interval = std::env::var("MNOTE_MINERU_POLL_INTERVAL_MS").ok();
let old_max_polls = std::env::var("MNOTE_MINERU_MAX_POLLS").ok();
std::env::set_var("MNOTE_MINERU_API_TOKEN", "test-mineru-token");
std::env::set_var("MNOTE_MINERU_API_BASE_URL", &base_url);
std::env::set_var("MNOTE_MINERU_POLL_INTERVAL_MS", "1");
std::env::set_var("MNOTE_MINERU_MAX_POLLS", "2");
let root = temp_root("mnote-local-ocr-mineru-runtime");
write_workspace_manifest(&root);
fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
fs::write(
root.join("docs").join("Page.assets").join("photo.png"),
b"png",
)
.expect("photo");
let root_uri = format!("file://{}", root.display());
let response = app()
.oneshot(
Request::builder()
.method("POST")
.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,
"documentId": "local-md:docs~2FPage.md",
"sourceRootRelativePath": "docs/Page.assets/photo.png",
"provider": "mineru"
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
if let Some(value) = old_token {
std::env::set_var("MNOTE_MINERU_API_TOKEN", value);
} else {
std::env::remove_var("MNOTE_MINERU_API_TOKEN");
}
if let Some(value) = old_base {
std::env::set_var("MNOTE_MINERU_API_BASE_URL", value);
} else {
std::env::remove_var("MNOTE_MINERU_API_BASE_URL");
}
if let Some(value) = old_interval {
std::env::set_var("MNOTE_MINERU_POLL_INTERVAL_MS", value);
} else {
std::env::remove_var("MNOTE_MINERU_POLL_INTERVAL_MS");
}
if let Some(value) = old_max_polls {
std::env::set_var("MNOTE_MINERU_MAX_POLLS", value);
} else {
std::env::remove_var("MNOTE_MINERU_MAX_POLLS");
}
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("response body");
let payload: Value = serde_json::from_slice(&body).expect("response json");
assert_eq!(payload["job"]["status"].as_str(), Some("done"));
assert_eq!(payload["job"]["provider"].as_str(), Some("mineru"));
assert_eq!(upload_count.load(Ordering::SeqCst), 1);
assert_eq!(poll_count.load(Ordering::SeqCst), 1);
let sidecar =
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("识别文本"));
mock_handle.abort();
let _ = fs::remove_dir_all(root);
}
#[test]
fn local_ocr_mineru_runtime_config_uses_safe_defaults_and_env_overrides() {
let old_base = std::env::var("MNOTE_MINERU_API_BASE_URL").ok();
let old_legacy_base = std::env::var("MINERU_API_BASE_URL").ok();
let old_interval = std::env::var("MNOTE_MINERU_POLL_INTERVAL_MS").ok();
let old_max_polls = std::env::var("MNOTE_MINERU_MAX_POLLS").ok();
std::env::remove_var("MNOTE_MINERU_API_BASE_URL");
std::env::remove_var("MINERU_API_BASE_URL");
std::env::remove_var("MNOTE_MINERU_POLL_INTERVAL_MS");
std::env::remove_var("MNOTE_MINERU_MAX_POLLS");
assert_eq!(mineru_api_base_url(), DEFAULT_MINERU_API_BASE_URL);
assert_eq!(mineru_poll_interval(), Duration::from_secs(2));
assert_eq!(mineru_max_polls(), 90);
std::env::set_var("MNOTE_MINERU_API_BASE_URL", "https://mineru.example.test/");
std::env::set_var("MNOTE_MINERU_POLL_INTERVAL_MS", "25");
std::env::set_var("MNOTE_MINERU_MAX_POLLS", "3");
assert_eq!(mineru_api_base_url(), "https://mineru.example.test");
assert_eq!(mineru_poll_interval(), Duration::from_millis(25));
assert_eq!(mineru_max_polls(), 3);
if let Some(value) = old_base {
std::env::set_var("MNOTE_MINERU_API_BASE_URL", value);
} else {
std::env::remove_var("MNOTE_MINERU_API_BASE_URL");
}
if let Some(value) = old_legacy_base {
std::env::set_var("MINERU_API_BASE_URL", value);
} else {
std::env::remove_var("MINERU_API_BASE_URL");
}
if let Some(value) = old_interval {
std::env::set_var("MNOTE_MINERU_POLL_INTERVAL_MS", value);
} else {
std::env::remove_var("MNOTE_MINERU_POLL_INTERVAL_MS");
}
if let Some(value) = old_max_polls {
std::env::set_var("MNOTE_MINERU_MAX_POLLS", value);
} else {
std::env::remove_var("MNOTE_MINERU_MAX_POLLS");
}
}
#[tokio::test]
async fn local_ocr_jobs_route_rejects_root_escape_source() {
let root = temp_root("mnote-local-ocr-escape");
write_workspace_manifest(&root);
fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
let root_uri = format!("file://{}", root.display());
let (status, payload) = post_ocr_job(
&root,
json!({
"rootUri": root_uri,
"documentId": "local-md:docs~2FPage.md",
"sourceRootRelativePath": "../outside.png",
"provider": "mock",
"mockMarkdown": "should not write"
}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(payload["code"].as_str(), Some("local_ocr_path_escape"));
}
#[tokio::test]
async fn local_ocr_jobs_route_rejects_non_image_pdf_source() {
let root = temp_root("mnote-local-ocr-unsupported");
write_workspace_manifest(&root);
fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
fs::write(
root.join("docs").join("Page.assets").join("notes.txt"),
b"text",
)
.expect("text");
let root_uri = format!("file://{}", root.display());
let (status, payload) = post_ocr_job(
&root,
json!({
"rootUri": root_uri,
"documentId": "local-md:docs~2FPage.md",
"sourceRootRelativePath": "docs/Page.assets/notes.txt",
"provider": "mock",
"mockMarkdown": "should not write"
}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(
payload["code"].as_str(),
Some("local_ocr_source_type_unsupported")
);
}
#[tokio::test]
async fn local_ocr_jobs_route_writes_failed_mock_entry_with_redacted_error() {
let root = temp_root("mnote-local-ocr-failed");
write_workspace_manifest(&root);
fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
fs::write(
root.join("docs").join("Page.assets").join("photo.png"),
b"png",
)
.expect("photo");
let root_uri = format!("file://{}", root.display());
let response = app()
.oneshot(
Request::builder()
.method("POST")
.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,
"documentId": "local-md:docs~2FPage.md",
"sourceRootRelativePath": "docs/Page.assets/photo.png",
"provider": "mock",
"mockError": "token=secret https://signed.example.test/full.zip?X-Oss-Signature=abc"
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("response body");
let payload: Value = serde_json::from_slice(&body).expect("response json");
assert_eq!(payload["job"]["status"].as_str(), Some("failed"));
assert_eq!(
payload["job"]["error"].as_str(),
Some("provider_error_redacted")
);
let stored = read_ocr_index_entry(&root, "docs/Page.assets/photo.png")
.expect("read index")
.expect("entry");
assert_eq!(stored.status, "failed");
assert_eq!(stored.error.as_deref(), Some("provider_error_redacted"));
assert!(!root
.join("docs")
.join("Page.ocr")
.join("photo.png.ocr.md")
.exists());
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn local_ocr_insert_route_appends_explicit_ocr_link() {
let root = temp_root("mnote-local-ocr-insert");
write_workspace_manifest(&root);
fs::write(root.join("docs").join("Page.md"), "# Page\n\n正文\n").expect("page");
fs::write(
root.join("docs").join("Page.assets").join("photo.png"),
b"png",
)
.expect("photo");
let root_uri = format!("file://{}", root.display());
let create_response = app()
.oneshot(
Request::builder()
.method("POST")
.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,
"documentId": "local-md:docs~2FPage.md",
"sourceRootRelativePath": "docs/Page.assets/photo.png",
"provider": "mock",
"mockMarkdown": "识别文本"
})
.to_string(),
))
.expect("request"),
)
.await
.expect("create response");
assert_eq!(create_response.status(), StatusCode::OK);
let insert_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/local-folder/ocr/insert")
.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,
"documentId": "local-md:docs~2FPage.md",
"ocrRootRelativePath": "docs/Page.ocr/photo.png.ocr.md",
"mode": "link"
})
.to_string(),
))
.expect("request"),
)
.await
.expect("insert response");
assert_eq!(insert_response.status(), StatusCode::OK);
let body = to_bytes(insert_response.into_body(), usize::MAX)
.await
.expect("insert body");
let payload: Value = serde_json::from_slice(&body).expect("insert json");
assert_eq!(
payload["insertedMarkdown"].as_str(),
Some("[OCRphoto.png](./Page.ocr/photo.png.ocr.md)")
);
let owner = fs::read_to_string(root.join("docs").join("Page.md")).expect("owner");
assert_eq!(
owner,
"# Page\n\n正文\n\n[OCRphoto.png](./Page.ocr/photo.png.ocr.md)\n"
);
let _ = fs::remove_dir_all(root);
}
}