use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::page_aggregate::{PageAggregate, PageOptions}; use crate::routes::gateway::current_actor_id; use crate::routes::local_folder_source::{ local_root_has_workspace_manifest, local_workspace_id_from_root_uri, }; use axum::extract::{Extension, Query, State}; use axum::http::StatusCode; use axum::Json; use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord}; use serde::{Deserialize, Serialize}; use serde_json::Value; use serde_json::{json, Map}; use std::collections::BTreeMap; pub(crate) const SOURCE_FAMILY_MY_SPACE: &str = "my_space"; pub(crate) const SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER: &str = "external_local_folder"; #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct UiPreferencesQuery { #[serde(default, alias = "workspace_id")] workspace_id: String, #[serde(default, alias = "source_kind")] source_kind: String, #[serde(default, alias = "root_uri")] root_uri: String, #[serde(default, alias = "document_id")] document_id: String, } #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct UiPreferencesUpdateRequest { #[serde(default, alias = "workspace_id")] workspace_id: String, #[serde(default, alias = "source_kind")] source_kind: String, #[serde(default, alias = "root_uri")] root_uri: String, #[serde(default, alias = "document_id")] document_id: String, #[serde(default)] updates: BTreeMap, } #[derive(Debug, Clone)] struct PagePreferenceScope { workspace_id: String, source_kind: String, source_family: String, document_id: String, } #[derive(Debug, Clone)] struct EffectivePagePreferences { scope: PagePreferenceScope, page_options: PageOptions, page_width_preferences: BTreeMap, ai_preferences: BTreeMap, local_ocr_preferences: BTreeMap, sources: BTreeMap, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct EffectivePageWidthPreference { mode: String, custom: Option, resolved_mode: String, resolved_custom: Option, css_max_width: String, source: String, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct PageWidthPreferenceValue { mode: String, custom: Option, } const PAGE_WIDTH_CONTENT_TYPES: [&str; 7] = [ "default", "markdown", "word", "pdf", "excel", "ppt", "mindmap", ]; pub(crate) async fn effective_preferences( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result, WebError> { let actor_id = require_actor_id(&state, &context)?; let effective = resolve_effective_page_preferences( &state, &actor_id, &query.workspace_id, &query.source_kind, &query.root_uri, &query.document_id, PageOptions::default(), )?; Ok(Json(effective_preferences_payload(effective))) } pub(crate) async fn update_preferences( State(state): State, Extension(context): Extension, Json(request): Json, ) -> Result, WebError> { let actor_id = require_actor_id(&state, &context)?; let result = update_page_preferences_from_value( &state, &context, &actor_id, &request.workspace_id, &request.source_kind, &request.root_uri, &request.document_id, &Value::Object(request.updates.into_iter().collect()), )?; Ok(Json(json!({ "ok": true, "owner": "mnote-web", "result": result, }))) } pub(crate) fn update_page_preferences_from_value( state: &AppState, context: &RequestContext, actor_id: &str, workspace_id: &str, source_kind: &str, root_uri: &str, document_id: &str, updates: &Value, ) -> Result { ensure_actor_user(state, actor_id)?; let scope = page_preference_scope(workspace_id, source_kind, root_uri, document_id)?; let update_map = updates.as_object().ok_or_else(|| { WebError::bad_request_code("ui_preference_updates_invalid", "updates 必须是对象") .with_context(context) })?; for (key, value) in update_map { let Some((scope_kind, scope_id)) = preference_scope_for_key(key, &scope) else { continue; }; state .control_plane() .upsert_user_ui_preference(UpsertUserUiPreferenceInput { id: None, user_id: actor_id.to_string(), workspace_id: preference_workspace_for_scope(&scope.workspace_id, &scope_kind), source_kind: preference_source_kind_for_scope(&scope.source_kind, &scope_kind), scope_kind, scope_id, key: key.trim().to_string(), value_json: value.to_string(), }) .map_err(|error| { WebError::bad_request_code( "ui_preference_write_failed", format!("写入 UI 偏好失败: {error}"), ) .with_context(context) })?; } let effective = resolve_effective_page_preferences( state, actor_id, workspace_id, source_kind, root_uri, document_id, PageOptions::default(), )?; Ok(effective_preferences_payload(effective)["result"].clone()) } pub(crate) fn source_family_for_page( source_kind: Option<&str>, root_uri: Option<&str>, ) -> Result { if source_kind.map(str::trim) != Some("local_folder") { return Ok("workspace".to_string()); } let Some(root_uri) = root_uri.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER.to_string()); }; if local_root_has_workspace_manifest(root_uri)? { Ok(SOURCE_FAMILY_MY_SPACE.to_string()) } else { Ok(SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER.to_string()) } } pub(crate) fn apply_effective_page_preferences( state: &AppState, context: &RequestContext, aggregate: &mut PageAggregate, source_kind: Option<&str>, root_uri: Option<&str>, ) -> Result<(), WebError> { let actor_id = context.auth.actor_id.trim(); if actor_id.is_empty() || actor_id == "anonymous" { return Ok(()); } let effective = resolve_effective_page_preferences( state, actor_id, &aggregate.identity.workspace_id, source_kind.unwrap_or_default(), root_uri.unwrap_or_default(), &aggregate.identity.document_id, aggregate.layout.page_options.clone(), )?; aggregate.layout.page_options = effective.page_options; aggregate.layout_options = serde_json::to_value(&aggregate.layout.page_options) .unwrap_or_else(|_| serde_json::json!({})); Ok(()) } fn resolve_effective_page_preferences( state: &AppState, actor_id: &str, workspace_id: &str, source_kind: &str, root_uri: &str, document_id: &str, base_options: PageOptions, ) -> Result { let scope = page_preference_scope(workspace_id, source_kind, root_uri, document_id)?; let preferences = state .control_plane() .list_user_ui_preferences( actor_id, Some(&scope.workspace_id), Some(&scope.source_kind), ) .map_err(|error| WebError::internal(format!("SQLite UI 偏好读取失败: {error}")))?; let mut page_options = base_options; if scope.source_family == SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER { page_options.hide_title_header = true; } 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, )?; resolve_page_width_preferences(&mut page_width_preferences); Ok(EffectivePagePreferences { scope, page_options, page_width_preferences, ai_preferences, local_ocr_preferences, sources, }) } fn apply_preference_records( page_options: &mut PageOptions, page_width_preferences: &mut BTreeMap, ai_preferences: &mut BTreeMap, local_ocr_preferences: &mut BTreeMap, sources: &mut BTreeMap, scope: &PagePreferenceScope, preferences: &[UserUiPreferenceRecord], ) -> Result<(), WebError> { let mut scope_kinds = vec![ "global".to_string(), "source_family".to_string(), "workspace".to_string(), "document".to_string(), ]; for preference in preferences { if preference.scope_kind.starts_with("ai.") && !scope_kinds.contains(&preference.scope_kind) { scope_kinds.push(preference.scope_kind.clone()); } } for scope_kind in scope_kinds { for preference in preferences .iter() .filter(|preference| preference.scope_kind.trim() == scope_kind.as_str()) { let scope_matches = match scope_kind.as_str() { "global" => preference.scope_id.trim() == "default", "source_family" => preference.scope_id.trim() == scope.source_family, "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, _ => false, }; if !scope_matches { continue; } let value = serde_json::from_str::(&preference.value_json).map_err(|error| { WebError::internal(format!( "SQLite UI 偏好 JSON 无效 {}: {error}", preference.key )) })?; if preference.key.starts_with("ai.common.") || preference.key.starts_with("ai.agent.") { ai_preferences.insert(preference.key.clone(), value); sources.insert(preference.key.clone(), scope_kind.to_string()); continue; } if preference.key.starts_with("localOcr.") { local_ocr_preferences.insert(preference.key.clone(), Value::Bool(false)); sources.insert(preference.key.clone(), "retired".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) { preference_value.mode = normalized.mode; preference_value.custom = normalized.custom; preference_value.source = scope_kind.to_string(); sources.insert(preference.key.clone(), scope_kind.to_string()); } } continue; } if apply_page_option_value(page_options, &preference.key, &value) { sources.insert(preference.key.clone(), scope_kind.to_string()); } } } Ok(()) } fn page_preference_scope( workspace_id: &str, source_kind: &str, root_uri: &str, document_id: &str, ) -> Result { let source_kind = source_kind.trim(); let source_kind = if source_kind.is_empty() { "convex_workspace" } else { source_kind }; let workspace_id = workspace_id.trim().to_string().if_empty_else(|| { if source_kind == "local_folder" { local_workspace_id_from_root_uri(root_uri).unwrap_or_else(|_| "local-folder".into()) } else { "default".into() } }); Ok(PagePreferenceScope { workspace_id, source_kind: source_kind.to_string(), source_family: source_family_for_page(Some(source_kind), Some(root_uri))?, document_id: document_id.trim().to_string(), }) } fn preference_scope_for_key(key: &str, scope: &PagePreferenceScope) -> Option<(String, String)> { let trimmed = key.trim(); if trimmed.starts_with("ai.common.") { return Some(("ai.common".to_string(), scope.workspace_id.clone())); } if let Some(rest) = trimmed.strip_prefix("ai.agent.") { let agent = rest.split('.').next().unwrap_or_default().trim(); if !agent.is_empty() { return Some((format!("ai.agent.{agent}"), scope.workspace_id.clone())); } } if trimmed.starts_with("localOcr.") { return None; } if page_width_content_type_for_key(key).is_some() { return Some(("global".to_string(), "default".to_string())); } match key.trim() { "hideTitleHeader" | "hide_title_header" => { Some(("source_family".to_string(), scope.source_family.clone())) } "showHeadingNumbers" | "show_heading_numbers" | "showWordCount" | "show_word_count" => { Some(("global".to_string(), "default".to_string())) } "wideLayout" | "wide_layout" | "smallText" | "small_text" | "layoutDensity" | "layout_density" | "pageFont" | "page_font" | "showToc" | "show_toc" | "showStructure" | "show_structure" | "collapseBacklinks" | "collapse_backlinks" | "hideChildPages" | "hide_child_pages" | "showBlockRefCount" | "show_block_ref_count" => Some(("workspace".to_string(), scope.workspace_id.clone())), _ => None, } } fn preference_workspace_for_scope(workspace_id: &str, scope_kind: &str) -> Option { if scope_kind == "workspace" || scope_kind == "document" || scope_kind.starts_with("ai.") { Some(workspace_id.to_string()) } else { None } } fn preference_source_kind_for_scope(source_kind: &str, scope_kind: &str) -> Option { if scope_kind == "workspace" || scope_kind == "document" || scope_kind.starts_with("ai.") { Some(source_kind.to_string()) } else { None } } fn effective_preferences_payload(effective: EffectivePagePreferences) -> Value { let page_options = serde_json::to_value(&effective.page_options).unwrap_or_else(|_| json!({})); let page_width_preferences = 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() .map(|(key, value)| (key, Value::String(value))) .collect::>(); json!({ "ok": true, "owner": "mnote-web", "result": { "scope": { "sourceFamily": effective.scope.source_family, "workspaceId": effective.scope.workspace_id, "sourceKind": effective.scope.source_kind, "documentId": effective.scope.document_id, }, "pageOptions": page_options, "pageWidthPreferences": page_width_preferences, "aiPreferences": ai_preferences, "localOcrPreferences": local_ocr_preferences, "sources": Value::Object(sources), } }) } fn require_actor_id(state: &AppState, context: &RequestContext) -> Result { current_actor_id(state, context) .filter(|actor_id| !actor_id.trim().is_empty() && actor_id.trim() != "anonymous") .ok_or_else(|| { WebError::new( StatusCode::UNAUTHORIZED, "ui_preference_auth_required", "UI 偏好需要登录用户", ) .with_context(context) }) } fn ensure_actor_user(state: &AppState, actor_id: &str) -> Result<(), WebError> { state .control_plane() .upsert_user(UpsertUserInput { id: Some(actor_id.to_string()), email: None, username: actor_id.to_string(), display_name: actor_id.to_string(), role: None, password_hash: None, }) .map(|_| ()) .map_err(|error| WebError::internal(format!("SQLite 用户初始化失败: {error}"))) } fn apply_page_option_value(options: &mut PageOptions, key: &str, value: &Value) -> bool { match key { "hideTitleHeader" | "hide_title_header" => { if let Some(value) = value.as_bool() { options.hide_title_header = value; return true; } } "showHeadingNumbers" | "show_heading_numbers" => { if let Some(value) = value.as_bool() { options.show_heading_numbers = value; return true; } } "wideLayout" | "wide_layout" => { if let Some(value) = value.as_bool() { options.wide_layout = value; return true; } } "smallText" | "small_text" => { if let Some(value) = value.as_bool() { options.small_text = value; return true; } } "layoutDensity" | "layout_density" => { if let Some(value) = value .as_str() .map(str::trim) .filter(|value| !value.is_empty()) { options.layout_density = value.to_string(); return true; } } "pageFont" | "page_font" => { if let Some(value) = value .as_str() .map(str::trim) .filter(|value| !value.is_empty()) { options.page_font = value.to_string(); return true; } } "showToc" | "show_toc" => { if let Some(value) = value.as_bool() { options.show_toc = value; return true; } } "showStructure" | "show_structure" => { if let Some(value) = value.as_bool() { options.show_structure = value; return true; } } "showWordCount" | "show_word_count" => { if let Some(value) = value.as_bool() { options.show_word_count = value; return true; } } "collapseBacklinks" | "collapse_backlinks" => { if let Some(value) = value.as_bool() { options.collapse_backlinks = value; return true; } } "hideChildPages" | "hide_child_pages" => { if let Some(value) = value.as_bool() { options.hide_child_pages = value; return true; } } "showBlockRefCount" | "show_block_ref_count" => { if let Some(value) = value.as_bool() { options.show_block_ref_count = value; return true; } } _ => {} } false } fn page_width_content_type_for_key(key: &str) -> Option<&'static str> { let content_type = key.trim().strip_prefix("pageWidth.")?; PAGE_WIDTH_CONTENT_TYPES .iter() .copied() .find(|candidate| *candidate == content_type) } fn default_page_width_preferences() -> BTreeMap { PAGE_WIDTH_CONTENT_TYPES .into_iter() .map(|content_type| { let mode = match content_type { "default" => "comfortable", "markdown" => "readable", "word" | "pdf" | "ppt" => "wide", "excel" | "mindmap" => "full", _ => "comfortable", }; ( content_type.to_string(), EffectivePageWidthPreference { mode: mode.to_string(), custom: None, resolved_mode: mode.to_string(), resolved_custom: None, css_max_width: page_width_css_max_width(mode).to_string(), source: "system".to_string(), }, ) }) .collect() } fn default_local_ocr_preferences() -> BTreeMap { BTreeMap::from([("localOcr.autoEnabled".to_string(), Value::Bool(false))]) } fn normalize_page_width_preference( content_type: &str, value: &Value, ) -> Option { let mode = value .as_object() .and_then(|object| object.get("mode")) .and_then(Value::as_str) .or_else(|| value.as_str()) .map(str::trim) .filter(|mode| !mode.is_empty())?; let mode = if page_width_mode_is_supported(mode) { mode } else { return None; }; let mode = if content_type == "default" && mode == "inherit" { "comfortable" } else { mode }; let custom = value .as_object() .and_then(|object| object.get("custom")) .and_then(Value::as_str) .map(str::trim) .filter(|custom| !custom.is_empty()) .map(ToOwned::to_owned); Some(PageWidthPreferenceValue { mode: mode.to_string(), custom, }) } fn page_width_mode_is_supported(mode: &str) -> bool { matches!( mode, "inherit" | "readable" | "comfortable" | "wide" | "full" | "custom" ) } fn resolve_page_width_preferences( preferences: &mut BTreeMap, ) { let default_mode = preferences .get("default") .map(|preference| preference.mode.as_str()) .filter(|mode| *mode != "inherit") .unwrap_or("comfortable") .to_string(); let default_custom = preferences .get("default") .and_then(|preference| preference.custom.clone()); for content_type in PAGE_WIDTH_CONTENT_TYPES { let Some(preference) = preferences.get_mut(content_type) else { continue; }; let resolved_mode = if content_type != "default" && preference.mode == "inherit" { default_mode.clone() } else if preference.mode == "inherit" { "comfortable".to_string() } else { preference.mode.clone() }; let resolved_custom = if content_type != "default" && preference.mode == "inherit" { default_custom.clone() } else { preference.custom.clone() }; preference.resolved_mode = resolved_mode.clone(); preference.resolved_custom = resolved_custom; preference.css_max_width = page_width_css_max_width(&resolved_mode).to_string(); } } pub(crate) fn page_width_css_max_width(mode: &str) -> &'static str { match mode { "readable" => "760px", "comfortable" => "980px", "wide" => "1180px", "full" => "none", "custom" => "980px", _ => "980px", } } trait EmptyStringExt { fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String; } impl EmptyStringExt for String { fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String { if self.trim().is_empty() { fallback() } else { self } } }