feat(sync): 持久化类型化主题与编辑器偏好并支持草稿恢复
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
pub mod core;
|
||||
pub mod credentials;
|
||||
mod payloads;
|
||||
mod preference_records;
|
||||
pub mod recent;
|
||||
pub mod records;
|
||||
#[cfg(feature = "desktop")]
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
//! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。
|
||||
|
||||
mod record_commands;
|
||||
use record_commands::*;
|
||||
mod sync_commands;
|
||||
use sync_commands::*;
|
||||
|
||||
@@ -831,6 +833,8 @@ fn main() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
host_capabilities,
|
||||
record_get,
|
||||
record_write,
|
||||
sync_login,
|
||||
sync_vaults,
|
||||
sync_create_vault,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Preference schemas contain portable values only; no paths, permissions, providers or secrets.
|
||||
use crate::workspace::{HostError, Result};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct Level {
|
||||
size: f64,
|
||||
weight: u16,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct Headings {
|
||||
custom: bool,
|
||||
family: String,
|
||||
levels: Vec<Level>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct Theme {
|
||||
theme_id: String,
|
||||
font_editor_size: f64,
|
||||
font_editor_family: String,
|
||||
line_height: f64,
|
||||
code_block_theme: String,
|
||||
headings: Headings,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct Markdown {
|
||||
heading: String,
|
||||
bullet: String,
|
||||
increment_list: bool,
|
||||
fence: String,
|
||||
math: bool,
|
||||
callouts: bool,
|
||||
diagrams: bool,
|
||||
auto_links: bool,
|
||||
line_numbers: bool,
|
||||
wrap_code: bool,
|
||||
indent: u8,
|
||||
default_language: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct Preset {
|
||||
name: String,
|
||||
preferences: Markdown,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct Preferences {
|
||||
restore_last_vault: bool,
|
||||
auto_save_interval: u32,
|
||||
language: String,
|
||||
default_editor_mode: String,
|
||||
editor_line_width: u16,
|
||||
spell_check: bool,
|
||||
markdown: Markdown,
|
||||
presets: Vec<Preset>,
|
||||
}
|
||||
fn decode<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T> {
|
||||
serde_json::from_value(value.clone()).map_err(|_| HostError::new("RECORD_SCHEMA_INVALID"))
|
||||
}
|
||||
fn markdown(value: &Markdown) -> bool {
|
||||
let _ = (
|
||||
value.increment_list,
|
||||
value.math,
|
||||
value.callouts,
|
||||
value.diagrams,
|
||||
value.auto_links,
|
||||
value.line_numbers,
|
||||
value.wrap_code,
|
||||
);
|
||||
matches!(value.heading.as_str(), "atx" | "setext")
|
||||
&& matches!(value.bullet.as_str(), "-" | "*" | "+")
|
||||
&& matches!(value.fence.as_str(), "`" | "~")
|
||||
&& [2, 4, 8].contains(&value.indent)
|
||||
&& value.default_language.len() <= 40
|
||||
&& value
|
||||
.default_language
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"_+-".contains(&b))
|
||||
}
|
||||
pub fn validate(kind: &str, value: &Value) -> Result<()> {
|
||||
let valid = match kind {
|
||||
"theme_settings" => {
|
||||
let value: Theme = decode(value)?;
|
||||
let _ = value.headings.custom;
|
||||
!value.theme_id.is_empty()
|
||||
&& value.theme_id.len() <= 128
|
||||
&& value
|
||||
.theme_id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
|
||||
&& (6.0..=72.0).contains(&value.font_editor_size)
|
||||
&& (1.0..=3.0).contains(&value.line_height)
|
||||
&& !value.font_editor_family.is_empty()
|
||||
&& value.font_editor_family.len() <= 256
|
||||
&& !value
|
||||
.font_editor_family
|
||||
.chars()
|
||||
.any(|v| v.is_control() || ";{}\\".contains(v))
|
||||
&& matches!(
|
||||
value.code_block_theme.as_str(),
|
||||
"auto" | "github-light" | "github-dark"
|
||||
)
|
||||
&& matches!(
|
||||
value.headings.family.as_str(),
|
||||
"inherit" | "serif" | "sans-serif" | "monospace"
|
||||
)
|
||||
&& value.headings.levels.len() == 6
|
||||
&& value.headings.levels.iter().all(|level| {
|
||||
(12.0..=72.0).contains(&level.size)
|
||||
&& [400, 500, 600, 700, 800].contains(&level.weight)
|
||||
})
|
||||
}
|
||||
"preferences" => {
|
||||
let value: Preferences = decode(value)?;
|
||||
let _ = (value.restore_last_vault, value.spell_check);
|
||||
(50..=60000).contains(&value.auto_save_interval)
|
||||
&& matches!(value.language.as_str(), "zh-CN" | "en")
|
||||
&& matches!(value.default_editor_mode.as_str(), "source" | "wysiwyg")
|
||||
&& (40..=200).contains(&value.editor_line_width)
|
||||
&& markdown(&value.markdown)
|
||||
&& value.presets.len() <= 20
|
||||
&& value.presets.iter().all(|p| {
|
||||
!p.name.trim().is_empty()
|
||||
&& p.name.chars().count() <= 40
|
||||
&& markdown(&p.preferences)
|
||||
})
|
||||
}
|
||||
_ => return Err(HostError::new("RECORD_SCHEMA_UNSUPPORTED")),
|
||||
};
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(HostError::new("RECORD_DATA_INVALID"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn portable_preferences_reject_unowned_nested_fields_and_invalid_values() {
|
||||
let theme = json!({"themeId":"dark","fontEditorSize":18,"fontEditorFamily":"system-ui","lineHeight":1.7,"codeBlockTheme":"auto","headings":{"custom":false,"family":"inherit","levels":([32,28,24,21,18,16].map(|size|json!({"size":size,"weight":700})))}});
|
||||
validate("theme_settings", &theme).unwrap();
|
||||
let markdown = json!({"heading":"atx","bullet":"-","incrementList":true,"fence":"`","math":true,"callouts":true,"diagrams":true,"autoLinks":true,"lineNumbers":true,"wrapCode":false,"indent":4,"defaultLanguage":""});
|
||||
let preferences = json!({"restoreLastVault":true,"autoSaveInterval":1500,"language":"zh-CN","defaultEditorMode":"wysiwyg","editorLineWidth":80,"spellCheck":false,"markdown":markdown,"presets":[]});
|
||||
validate("preferences", &preferences).unwrap();
|
||||
for field in [
|
||||
"apiKey",
|
||||
"permissions",
|
||||
"environment",
|
||||
"vaultPath",
|
||||
"provider",
|
||||
] {
|
||||
let mut bad = preferences.clone();
|
||||
bad[field] = json!("planted-secret");
|
||||
assert_eq!(
|
||||
validate("preferences", &bad).unwrap_err().code,
|
||||
"RECORD_SCHEMA_INVALID"
|
||||
);
|
||||
}
|
||||
let mut bad = preferences.clone();
|
||||
bad["markdown"]["apiKey"] = json!("planted-secret");
|
||||
assert_eq!(
|
||||
validate("preferences", &bad).unwrap_err().code,
|
||||
"RECORD_SCHEMA_INVALID"
|
||||
);
|
||||
let mut bad = theme;
|
||||
bad["fontEditorFamily"] = json!("x;url(secret)");
|
||||
assert_eq!(
|
||||
validate("theme_settings", &bad).unwrap_err().code,
|
||||
"RECORD_DATA_INVALID"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Main-window preference records; no generic credential or application-state accessor.
|
||||
use super::{with_workspace, Host};
|
||||
use notesagent_host::{records, workspace::HostError};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tauri::State;
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Get {
|
||||
vault_id: String,
|
||||
kind: String,
|
||||
id: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Write {
|
||||
vault_id: String,
|
||||
record: Value,
|
||||
expected: String,
|
||||
operation_id: String,
|
||||
}
|
||||
fn preference(kind: &str) -> Result<(), String> {
|
||||
if matches!(kind, "theme_settings" | "preferences") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("RECORD_SCOPE_DENIED".into())
|
||||
}
|
||||
}
|
||||
#[tauri::command]
|
||||
pub fn record_get(host: State<'_, Host>, request: Get) -> Result<Option<Value>, String> {
|
||||
preference(&request.kind)?;
|
||||
with_workspace(&host, |ws| {
|
||||
if ws.vault_id != request.vault_id {
|
||||
return Err(HostError::new("VAULT_CHANGED"));
|
||||
}
|
||||
ws.record_get_kind(&request.kind, &request.id)
|
||||
})
|
||||
}
|
||||
#[tauri::command]
|
||||
pub fn record_write(host: State<'_, Host>, request: Write) -> Result<Value, String> {
|
||||
let kind = request.record["kind"]
|
||||
.as_str()
|
||||
.ok_or("RECORD_SCHEMA_INVALID")?;
|
||||
preference(kind)?;
|
||||
let path =
|
||||
records::path_for(kind, request.record["id"].as_str().unwrap_or("")).map_err(|e| e.code)?;
|
||||
let bytes = serde_json::to_vec(&request.record).map_err(|_| "RECORD_SCHEMA_INVALID")?;
|
||||
with_workspace(&host, |ws| {
|
||||
if ws.vault_id != request.vault_id {
|
||||
return Err(HostError::new("VAULT_CHANGED"));
|
||||
}
|
||||
let entry = ws.write_operation(
|
||||
&path,
|
||||
&request.expected,
|
||||
&bytes,
|
||||
"local",
|
||||
&request.operation_id,
|
||||
)?;
|
||||
Ok(json!({"record":request.record,"hash":entry.hash,"file_id":entry.file_id}))
|
||||
})
|
||||
}
|
||||
@@ -19,7 +19,7 @@ pub struct Record {
|
||||
pub schema: u32,
|
||||
pub kind: String,
|
||||
pub id: String,
|
||||
pub data: TaskData,
|
||||
pub data: Value,
|
||||
}
|
||||
pub fn path(id: &str) -> Result<String> {
|
||||
if !id.starts_with("task_")
|
||||
@@ -32,10 +32,27 @@ pub fn path(id: &str) -> Result<String> {
|
||||
}
|
||||
Ok(format!("opennexus-records/v1/tasks/{id}.json"))
|
||||
}
|
||||
pub fn path_for(kind: &str, id: &str) -> Result<String> {
|
||||
match (kind, id) {
|
||||
("task", id) => path(id),
|
||||
("theme_settings", "appearance") => {
|
||||
Ok("opennexus-records/v1/theme-settings/appearance.json".into())
|
||||
}
|
||||
("preferences", "editor") => Ok("opennexus-records/v1/preferences/editor.json".into()),
|
||||
_ => Err(HostError::new("RECORD_ID_INVALID")),
|
||||
}
|
||||
}
|
||||
pub fn is_record(path: &str) -> bool {
|
||||
path.starts_with("opennexus-records/")
|
||||
}
|
||||
pub fn allowed(path_value: &str) -> bool {
|
||||
if matches!(
|
||||
path_value,
|
||||
"opennexus-records/v1/theme-settings/appearance.json"
|
||||
| "opennexus-records/v1/preferences/editor.json"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
path_value
|
||||
.strip_prefix("opennexus-records/v1/tasks/")
|
||||
.and_then(|v| v.strip_suffix(".json"))
|
||||
@@ -48,10 +65,15 @@ pub fn validate(path_value: &str, content: &[u8]) -> Result<Record> {
|
||||
let record: Record =
|
||||
serde_json::from_slice(content).map_err(|_| HostError::new("RECORD_SCHEMA_INVALID"))?;
|
||||
let time = |value: i64| (0..=253402300799999).contains(&value);
|
||||
if record.schema != 1 || record.kind != "task" || path(&record.id)? != path_value {
|
||||
if record.schema != 1 || path_for(&record.kind, &record.id)? != path_value {
|
||||
return Err(HostError::new("RECORD_SCHEMA_UNSUPPORTED"));
|
||||
}
|
||||
let data = &record.data;
|
||||
if record.kind != "task" {
|
||||
crate::preference_records::validate(&record.kind, &record.data)?;
|
||||
return Ok(record);
|
||||
}
|
||||
let data: TaskData = serde_json::from_value(record.data.clone())
|
||||
.map_err(|_| HostError::new("RECORD_SCHEMA_INVALID"))?;
|
||||
if data.title.trim().is_empty()
|
||||
|| data.title.len() > 4096
|
||||
|| data.description.len() > 262144
|
||||
@@ -76,15 +98,19 @@ pub fn validate(path_value: &str, content: &[u8]) -> Result<Record> {
|
||||
}
|
||||
impl Workspace {
|
||||
pub(crate) fn normalize_record_links(&mut self) -> Result<()> {
|
||||
for path in self.sync_paths()?.into_iter().filter(|v| allowed(v)) {
|
||||
for path in self
|
||||
.sync_paths()?
|
||||
.into_iter()
|
||||
.filter(|v| v.starts_with("opennexus-records/v1/tasks/") && allowed(v))
|
||||
{
|
||||
let bytes = std::fs::read(self.resolve(&path)?)?;
|
||||
let original = validate(&path, &bytes)?;
|
||||
if let Some(note_id) = original.data.note_id.as_ref() {
|
||||
if let Some(note_id) = original.data["note_id"].as_str() {
|
||||
if let Ok(note_path) = self.path_for_id(note_id) {
|
||||
if let Some(entry) = self.entry(¬e_path)? {
|
||||
if &entry.file_id != note_id {
|
||||
if entry.file_id != note_id {
|
||||
let mut record = original;
|
||||
record.data.note_id = Some(entry.file_id);
|
||||
record.data["note_id"] = json!(entry.file_id);
|
||||
self.write(
|
||||
&path,
|
||||
&crate::workspace::hash(&bytes),
|
||||
@@ -100,7 +126,10 @@ impl Workspace {
|
||||
Ok(())
|
||||
}
|
||||
pub fn record_get(&mut self, id: &str) -> Result<Option<Value>> {
|
||||
let path = path(id)?;
|
||||
self.record_get_kind("task", id)
|
||||
}
|
||||
pub fn record_get_kind(&mut self, kind: &str, id: &str) -> Result<Option<Value>> {
|
||||
let path = path_for(kind, id)?;
|
||||
if !self.resolve(&path)?.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -109,10 +138,10 @@ impl Workspace {
|
||||
}
|
||||
let document = self.read(&path)?;
|
||||
let mut record = validate(&path, document.content.as_bytes())?;
|
||||
if let Some(note_id) = record.data.note_id.as_ref() {
|
||||
if let Some(note_id) = record.data["note_id"].as_str() {
|
||||
if let Ok(path) = self.path_for_id(note_id) {
|
||||
if let Some(entry) = self.entry(&path)? {
|
||||
record.data.note_id = Some(entry.file_id);
|
||||
record.data["note_id"] = json!(entry.file_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,7 +156,7 @@ impl Workspace {
|
||||
let paths = self
|
||||
.sync_paths()?
|
||||
.into_iter()
|
||||
.filter(|path| allowed(path))
|
||||
.filter(|path| path.starts_with("opennexus-records/v1/tasks/") && allowed(path))
|
||||
.collect::<Vec<_>>();
|
||||
let total = paths.len();
|
||||
let mut items = Vec::new();
|
||||
|
||||
@@ -40,6 +40,9 @@ impl Workspace {
|
||||
return Err(HostError::new("FILE_TOO_LARGE"));
|
||||
}
|
||||
let bytes = fs::read(source)?;
|
||||
if crate::records::is_record(&path) {
|
||||
crate::records::validate(&path, &bytes)?;
|
||||
}
|
||||
Ok(Local {
|
||||
path,
|
||||
hash: hash(&bytes),
|
||||
|
||||
Reference in New Issue
Block a user