Skip to main content

markon_core/
settings.rs

1use crate::server::{ServerConfig, WorkspaceInit};
2use crate::workspace::{PersistHook, WorkspaceFlags, WorkspaceRegistry};
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5use std::sync::{Arc, Mutex};
6
7fn default_true() -> bool {
8    true
9}
10fn default_stable() -> String {
11    "stable".to_string()
12}
13fn default_auto() -> String {
14    "auto".to_string()
15}
16
17#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum PortMode {
20    Auto,
21    #[default]
22    Spec,
23}
24
25#[derive(Debug, Clone, Default, Serialize, Deserialize)]
26pub struct WorkspaceSettings {
27    pub path: String,
28    #[serde(flatten, default)]
29    pub flags: WorkspaceFlags,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(default)]
34pub struct AppSettings {
35    pub port_mode: PortMode,
36    pub port: u16,
37    pub host: String,
38    pub theme: String,
39    #[serde(default = "default_auto")]
40    pub language: String,
41    #[serde(default = "default_auto")]
42    pub web_theme: String,
43    #[serde(default = "default_auto")]
44    pub web_language: String,
45    pub db_path: Option<String>,
46    pub workspaces: Vec<WorkspaceSettings>,
47    #[serde(default = "default_true")]
48    pub tray_resident: bool,
49    #[serde(default = "default_true")]
50    pub default_search: bool,
51    #[serde(default = "default_true")]
52    pub default_viewed: bool,
53    #[serde(default)]
54    pub default_live: bool,
55    #[serde(default)]
56    pub default_edit: bool,
57    #[serde(default)]
58    pub default_shared_annotation: bool,
59    #[serde(default)]
60    pub web_styles: std::collections::HashMap<String, String>,
61    #[serde(default)]
62    pub shortcuts: std::collections::HashMap<String, serde_json::Value>,
63    #[serde(default = "default_true")]
64    pub auto_update: bool,
65    #[serde(default = "default_stable")]
66    pub update_channel: String,
67    #[serde(default)]
68    pub window_width: Option<u32>,
69    #[serde(default)]
70    pub window_height: Option<u32>,
71}
72
73impl Default for AppSettings {
74    fn default() -> Self {
75        Self {
76            port_mode: PortMode::Spec,
77            port: 6419,
78            host: "127.0.0.1".to_string(),
79            theme: "auto".to_string(),
80            language: "auto".to_string(),
81            web_theme: "auto".to_string(),
82            web_language: "auto".to_string(),
83            db_path: None,
84            workspaces: vec![],
85            tray_resident: true,
86            default_search: true,
87            default_viewed: true,
88            default_live: false,
89            default_edit: false,
90            default_shared_annotation: false,
91            web_styles: std::collections::HashMap::new(),
92            shortcuts: std::collections::HashMap::new(),
93            auto_update: true,
94            update_channel: "stable".to_string(),
95            window_width: None,
96            window_height: None,
97        }
98    }
99}
100
101impl AppSettings {
102    pub fn settings_path() -> PathBuf {
103        dirs::home_dir()
104            .unwrap()
105            .join(".markon")
106            .join("settings.json")
107    }
108    pub fn load() -> Self {
109        let p = Self::settings_path();
110        if let Ok(c) = std::fs::read_to_string(p) {
111            if let Ok(mut s) = serde_json::from_str::<Self>(&c) {
112                s.normalize();
113                return s;
114            }
115        }
116        Self::default()
117    }
118
119    /// Clean up settings loaded from disk:
120    /// - drop duplicate workspaces (keep first occurrence, preserving its flags)
121    /// - coerce empty language/theme strings to "auto" so existing files written
122    ///   before the auto-default fix don't show blank dropdowns
123    fn normalize(&mut self) {
124        use std::collections::HashSet;
125        let mut seen = HashSet::new();
126        self.workspaces.retain(|w| seen.insert(w.path.clone()));
127        for field in [
128            &mut self.language,
129            &mut self.web_theme,
130            &mut self.web_language,
131        ] {
132            if field.is_empty() {
133                *field = "auto".to_string();
134            }
135        }
136    }
137    pub fn save(&self) -> Result<(), String> {
138        let p = Self::settings_path();
139        if let Some(parent) = p.parent() {
140            let _ = std::fs::create_dir_all(parent);
141        }
142        let c = serde_json::to_string_pretty(self).unwrap();
143        std::fs::write(p, c).map_err(|e| e.to_string())
144    }
145    pub fn to_server_config(&self, port: u16) -> ServerConfig {
146        let initial_workspaces: Vec<WorkspaceInit> = self
147            .workspaces
148            .iter()
149            .filter(|w| !w.path.is_empty())
150            .map(|w| WorkspaceInit {
151                path: PathBuf::from(&w.path),
152                flags: w.flags,
153                initial_path: None,
154            })
155            .collect();
156        ServerConfig {
157            host: self.host.clone(),
158            port,
159            theme: if self.web_theme == "auto" {
160                self.theme.clone()
161            } else {
162                self.web_theme.clone()
163            },
164            qr: None,
165            open_browser: None,
166            shared_annotation: initial_workspaces.iter().any(|w| w.flags.shared_annotation),
167            salt: None,
168            initial_workspaces,
169            bound_listener: None,
170            registry: None,
171            management_token: None,
172            language: if self.web_language == "auto" {
173                Some(self.language.clone())
174            } else {
175                Some(self.web_language.clone())
176            },
177            styles_css: self.render_styles_css(),
178            shortcuts_json: self.render_shortcuts_json(),
179        }
180    }
181    pub fn effective_web_language(&self) -> Option<String> {
182        let l = if self.web_language == "auto" {
183            &self.language
184        } else {
185            &self.web_language
186        };
187        if l == "auto" || l.is_empty() {
188            None
189        } else {
190            Some(l.clone())
191        }
192    }
193    /// Build a registry persist-hook that mirrors every registry mutation
194    /// back into the shared `AppSettings` and writes to disk. CLI daemon and
195    /// GUI both wire this so workspace changes initiated from either side
196    /// end up with the same persistent state.
197    pub fn persist_hook(settings: Arc<Mutex<AppSettings>>) -> PersistHook {
198        Arc::new(move |reg| {
199            let mut s = settings.lock().unwrap();
200            s.sync_from_registry(reg);
201            let _ = s.save();
202        })
203    }
204
205    pub fn render_shortcuts_json(&self) -> Option<String> {
206        if self.shortcuts.is_empty() {
207            None
208        } else {
209            serde_json::to_string(&self.shortcuts).ok()
210        }
211    }
212    /// Overwrite `workspaces` with the current registry contents.
213    pub fn sync_from_registry(&mut self, registry: &WorkspaceRegistry) {
214        self.workspaces = registry
215            .info_list()
216            .into_iter()
217            .map(|info| WorkspaceSettings {
218                path: info.path,
219                flags: info.flags,
220            })
221            .collect();
222    }
223
224    pub fn render_styles_css(&self) -> Option<String> {
225        if self.web_styles.is_empty() {
226            return None;
227        }
228        Some(
229            self.web_styles
230                .iter()
231                .map(|(k, v)| format!("{k}: {v};"))
232                .collect::<Vec<_>>()
233                .join(" "),
234        )
235    }
236}