Retire legacy OCR/media/evidence fallbacks, add local-folder event bus and Page Aggregate guards, and archive completed design checklists. Validation: cargo test -p mnote-web -- --test-threads=1; cargo test --workspace -- --test-threads=1; git diff --check; codegraph sync .; codegraph_status.
717 lines
24 KiB
Rust
717 lines
24 KiB
Rust
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<String, Value>,
|
|
}
|
|
|
|
#[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<String, EffectivePageWidthPreference>,
|
|
ai_preferences: BTreeMap<String, Value>,
|
|
local_ocr_preferences: BTreeMap<String, Value>,
|
|
sources: BTreeMap<String, String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct EffectivePageWidthPreference {
|
|
mode: String,
|
|
custom: Option<String>,
|
|
resolved_mode: String,
|
|
resolved_custom: Option<String>,
|
|
css_max_width: String,
|
|
source: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct PageWidthPreferenceValue {
|
|
mode: String,
|
|
custom: Option<String>,
|
|
}
|
|
|
|
const PAGE_WIDTH_CONTENT_TYPES: [&str; 7] = [
|
|
"default", "markdown", "word", "pdf", "excel", "ppt", "mindmap",
|
|
];
|
|
|
|
pub(crate) async fn effective_preferences(
|
|
State(state): State<AppState>,
|
|
Extension(context): Extension<RequestContext>,
|
|
Query(query): Query<UiPreferencesQuery>,
|
|
) -> Result<Json<Value>, 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<AppState>,
|
|
Extension(context): Extension<RequestContext>,
|
|
Json(request): Json<UiPreferencesUpdateRequest>,
|
|
) -> Result<Json<Value>, 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<Value, WebError> {
|
|
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<String, WebError> {
|
|
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<EffectivePagePreferences, WebError> {
|
|
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<String, EffectivePageWidthPreference>,
|
|
ai_preferences: &mut BTreeMap<String, Value>,
|
|
local_ocr_preferences: &mut BTreeMap<String, Value>,
|
|
sources: &mut BTreeMap<String, String>,
|
|
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::<Value>(&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<PagePreferenceScope, WebError> {
|
|
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<String> {
|
|
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<String> {
|
|
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::<Map<String, Value>>();
|
|
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<String, WebError> {
|
|
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<String, EffectivePageWidthPreference> {
|
|
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<String, Value> {
|
|
BTreeMap::from([("localOcr.autoEnabled".to_string(), Value::Bool(false))])
|
|
}
|
|
|
|
fn normalize_page_width_preference(
|
|
content_type: &str,
|
|
value: &Value,
|
|
) -> Option<PageWidthPreferenceValue> {
|
|
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<String, EffectivePageWidthPreference>,
|
|
) {
|
|
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
|
|
}
|
|
}
|
|
}
|