diff --git a/README.md b/README.md index dabdcdf..dca2fbb 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The project is under active development. Core blogging workflows are broadly ava - `bds-cli server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection. - bDS2-compatible Markdown/Liquid rendering with native macros, descriptive category archive titles, canonical multilingual and flat page routes, recursive menus, calendar archives, feeds, a root hreflang sitemap, Pagefind, and incremental site generation through cancellable section task groups. - Navigable generated-route preview in the app or system browser, with draft database overlays and published filesystem content. -- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations run in background tasks with editor-level waiting indicators, using provider-portable JSON-only requests through independent online and local OpenAI-compatible profiles. Each profile has secure credentials, persistently discovered chat/title/image model selections, explicit tool/vision overrides, chat testing, and status-bar airplane-mode routing. +- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations run in background tasks with editor-level waiting indicators, using provider-portable JSON-only requests through independent online and local OpenAI-compatible profiles. Each profile has secure credentials, persistently discovered chat/title/image model selections, explicit tool/vision overrides, chat testing, and restart-persistent status-bar airplane-mode routing. - Persistent conversational AI with safe Markdown, streamed and cancellable responses, model/session/token tracking, bounded project-aware blog tools, and localized conversation management in the Chat workspace. Allowlisted render tools add persistent native cards, charts, forms, lists, metrics, mind maps, tables, and tabs without executing assistant-provided HTML or JavaScript. - SSH-agent-based SCP or rsync publishing. - Integrated Git workflow with repository initialization, Git LFS image tracking, status and diffs, branch/file history, commits, remotes, cancellable fetch/pull/push, and post-pull filesystem reconciliation; network actions respect airplane mode. diff --git a/crates/bds-core/src/engine/settings.rs b/crates/bds-core/src/engine/settings.rs index 42dbbd2..9b388da 100644 --- a/crates/bds-core/src/engine/settings.rs +++ b/crates/bds-core/src/engine/settings.rs @@ -6,6 +6,7 @@ use crate::engine::{EngineError, EngineResult, domain_events}; use crate::util::now_unix_ms; pub const UI_LANGUAGE_KEY: &str = "ui.language"; +pub const AIRPLANE_MODE_KEY: &str = "ai.airplane_mode_enabled"; pub const ONLINE_API_KEY: &str = "ai.endpoint.online.api_key"; pub const AIRPLANE_API_KEY: &str = "ai.endpoint.airplane.api_key"; const ONLINE_API_KEY_CONFIGURED: &str = "ai.endpoint.online.api_key_configured"; @@ -112,6 +113,18 @@ pub fn ui_language(conn: &Connection) -> EngineResult> { get(conn, UI_LANGUAGE_KEY) } +pub fn airplane_mode(conn: &Connection) -> EngineResult { + Ok(get(conn, AIRPLANE_MODE_KEY)?.as_deref() != Some("false")) +} + +pub fn set_airplane_mode(conn: &Connection, enabled: bool) -> EngineResult<()> { + set( + conn, + AIRPLANE_MODE_KEY, + if enabled { "true" } else { "false" }, + ) +} + #[cfg(test)] mod tests { use super::*; @@ -127,4 +140,26 @@ mod tests { let removed_prefix = ["style", "."].concat(); assert!(!values.keys().any(|key| key.starts_with(&removed_prefix))); } + + #[test] + fn airplane_mode_defaults_to_safe_mode_and_persists_changes() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("bds.db"); + let db = Database::open(&path).unwrap(); + db.migrate().unwrap(); + + assert!(airplane_mode(db.conn()).unwrap()); + + set_airplane_mode(db.conn(), false).unwrap(); + drop(db); + + let db = Database::open(&path).unwrap(); + assert!(!airplane_mode(db.conn()).unwrap()); + + set_airplane_mode(db.conn(), true).unwrap(); + drop(db); + + let db = Database::open(&path).unwrap(); + assert!(airplane_mode(db.conn()).unwrap()); + } } diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index e62bb42..b593d7c 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -1061,6 +1061,9 @@ impl BdsApp { .and_then(|db| engine::settings::ui_language(db.conn()).ok().flatten()) .map(|language| normalize_language(&language)) .unwrap_or(os_locale); + let offline_mode = db + .as_ref() + .is_none_or(|db| engine::settings::airplane_mode(db.conn()).unwrap_or(true)); let (menu_bar, registry) = menu::build_menu_bar(locale); let search_index_rebuild_required = db .as_ref() @@ -1186,7 +1189,7 @@ impl BdsApp { ui_locale: locale, content_language: "en".to_string(), blog_languages: Vec::new(), - offline_mode: false, + offline_mode, locale_dropdown_open: false, project_dropdown_open: false, theme_badge: String::from("pico"), @@ -1234,6 +1237,7 @@ impl BdsApp { #[cfg(test)] fn new_for_tests(db: Database, project: Project, data_dir: PathBuf) -> Self { let chat_conversations = engine::chat::list_conversations(db.conn()).unwrap_or_default(); + let offline_mode = engine::settings::airplane_mode(db.conn()).unwrap_or(true); Self { db: Some(db), db_path: data_dir.join("bds.db"), @@ -1287,7 +1291,7 @@ impl BdsApp { ui_locale: UiLocale::En, content_language: "en".to_string(), blog_languages: Vec::new(), - offline_mode: false, + offline_mode, locale_dropdown_open: false, project_dropdown_open: false, theme_badge: String::from("pico"), @@ -2645,6 +2649,21 @@ impl BdsApp { // ── Settings ── Message::SetOfflineMode(mode) => { + if let Some(db) = &self.db + && let Err(error) = engine::settings::set_airplane_mode(db.conn(), mode) + { + let operation = t(self.ui_locale, "settings.offlineMode"); + let error = error.to_string(); + self.notify( + ToastLevel::Error, + &tw( + self.ui_locale, + "common.operationFailed", + &[("operation", &operation), ("error", &error)], + ), + ); + return Task::none(); + } self.offline_mode = mode; let models = self.chat_model_options(); let selected = self.db.as_ref().and_then(|db| { @@ -10111,6 +10130,33 @@ mod tests { BdsApp::new_for_tests(db, project, tmp.path().to_path_buf()) } + #[test] + fn airplane_mode_is_restored_and_status_bar_changes_are_persisted() { + let (db, project, tmp) = setup(); + bds_core::engine::settings::set( + db.conn(), + bds_core::engine::settings::AIRPLANE_MODE_KEY, + "true", + ) + .unwrap(); + + let mut app = make_app(db, project, &tmp); + assert!(app.offline_mode); + + let _ = app.update(Message::SetOfflineMode(false)); + + assert!(!app.offline_mode); + assert_eq!( + bds_core::engine::settings::get( + app.db.as_ref().unwrap().conn(), + bds_core::engine::settings::AIRPLANE_MODE_KEY + ) + .unwrap() + .as_deref(), + Some("false") + ); + } + #[test] fn native_paste_menu_action_is_queued_for_the_focused_widget() { let (db, project, tmp) = setup();