feat: first take at M2

This commit is contained in:
2026-04-04 18:04:39 +02:00
parent b532104032
commit eedd0a9118
33 changed files with 2899 additions and 78 deletions

162
Cargo.lock generated
View File

@@ -465,6 +465,7 @@ dependencies = [
"serde_json",
"serde_yaml",
"sha2",
"sys-locale",
"tempfile",
"thiserror 2.0.18",
"uuid",
@@ -488,12 +489,17 @@ dependencies = [
"anyhow",
"bds-core",
"bds-editor",
"chrono",
"dirs 5.0.1",
"iced",
"muda",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"rfd",
"serde_json",
"tempfile",
"tokio",
]
@@ -1059,7 +1065,7 @@ checksum = "2a76fa97167fa740dcdbfe18e8895601e1bc36525f09b044e00916e717c03a3c"
dependencies = [
"dconf_rs",
"detect-desktop-environment",
"dirs",
"dirs 4.0.0",
"objc",
"rust-ini",
"web-sys",
@@ -1110,7 +1116,16 @@ version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
dependencies = [
"dirs-sys",
"dirs-sys 0.3.7",
]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys 0.4.1",
]
[[package]]
@@ -1124,6 +1139,18 @@ dependencies = [
"winapi",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.48.0",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -2154,6 +2181,7 @@ dependencies = [
"iced_core",
"log",
"rustc-hash 2.1.2",
"tokio",
"wasm-bindgen-futures",
"wasm-timer",
]
@@ -2489,6 +2517,25 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -3479,6 +3526,23 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "open"
version = "5.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
dependencies = [
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "orbclient"
version = "0.3.51"
@@ -3633,6 +3697,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -5835,7 +5905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
dependencies = [
"windows-core 0.52.0",
"windows-targets",
"windows-targets 0.52.6",
]
[[package]]
@@ -5844,7 +5914,7 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
dependencies = [
"windows-targets",
"windows-targets 0.52.6",
]
[[package]]
@@ -5906,13 +5976,22 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
"windows-targets 0.52.6",
]
[[package]]
@@ -5921,7 +6000,7 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
"windows-targets 0.52.6",
]
[[package]]
@@ -5933,34 +6012,67 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -5973,24 +6085,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"

View File

@@ -29,9 +29,12 @@ tokio = { version = "1", features = ["full"] }
walkdir = "2"
image = "0.25"
rust-stemmers = "1"
sys-locale = "0.3"
dirs = "5"
open = "5"
# UI framework
iced = { version = "0.13", features = ["wgpu", "advanced", "image"] }
iced = { version = "0.13", features = ["wgpu", "advanced", "image", "tokio"] }
# Editor widget
cosmic-text = "0.12"

View File

@@ -18,6 +18,7 @@ thiserror = { workspace = true }
walkdir = { workspace = true }
image = { workspace = true }
rust-stemmers = { workspace = true }
sys-locale = { workspace = true }
[dev-dependencies]
tempfile = "3"

View File

@@ -201,6 +201,15 @@ impl TaskManager {
tasks.iter().find(|t| t.id == task_id).and_then(|t| t.message.clone())
}
/// Return a snapshot of all tasks for UI display.
pub fn snapshots(&self) -> Vec<(TaskId, String, TaskStatus, Option<f32>, Option<String>)> {
let tasks = self.tasks.lock().unwrap();
tasks
.iter()
.map(|t| (t.id, t.label.clone(), t.status.clone(), t.progress, t.message.clone()))
.collect()
}
/// Promote the next queued task to running if capacity allows.
fn promote_next(tasks: &mut Vec<TaskEntry>, max_concurrent: usize) {
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();

View File

@@ -1,3 +1,224 @@
// Internationalization — split localization support.
// ui_locale: follows OS locale (menus, dialogs, toasts).
// content_language: follows project settings (rendering, preview, feeds).
use std::collections::HashMap;
use std::sync::LazyLock;
/// Supported UI locales, matching the i18n.allium SupportedLanguage spec.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UiLocale {
En,
De,
Fr,
It,
Es,
}
impl UiLocale {
/// BCP-47 base code for this locale.
pub fn code(self) -> &'static str {
match self {
Self::En => "en",
Self::De => "de",
Self::Fr => "fr",
Self::It => "it",
Self::Es => "es",
}
}
/// All supported locales.
pub fn all() -> &'static [UiLocale] {
&[Self::En, Self::De, Self::Fr, Self::It, Self::Es]
}
}
impl std::fmt::Display for UiLocale {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.code())
}
}
/// Normalize a language code to a supported UiLocale.
///
/// Strips region suffix ("en-US" → "en"), lowercases, and falls back to English
/// for unrecognized codes. This implements the LanguageNormalization invariant
/// from i18n.allium.
pub fn normalize_language(code: &str) -> UiLocale {
let base = code.split(['-', '_']).next().unwrap_or("en").to_lowercase();
match base.as_str() {
"en" => UiLocale::En,
"de" => UiLocale::De,
"fr" => UiLocale::Fr,
"it" => UiLocale::It,
"es" => UiLocale::Es,
_ => UiLocale::En,
}
}
/// Detect the OS locale and return the closest supported UiLocale.
///
/// Uses sys-locale to query the system. Falls back to English if detection
/// fails or the detected locale is not supported.
pub fn detect_os_locale() -> UiLocale {
match sys_locale::get_locale() {
Some(tag) => normalize_language(&tag),
None => UiLocale::En,
}
}
type Catalog = HashMap<String, String>;
fn parse_catalog(json: &str) -> Catalog {
serde_json::from_str(json).unwrap_or_default()
}
static CATALOG_EN: LazyLock<Catalog> =
LazyLock::new(|| parse_catalog(include_str!("../../../../locales/ui/en.json")));
static CATALOG_DE: LazyLock<Catalog> =
LazyLock::new(|| parse_catalog(include_str!("../../../../locales/ui/de.json")));
static CATALOG_FR: LazyLock<Catalog> =
LazyLock::new(|| parse_catalog(include_str!("../../../../locales/ui/fr.json")));
static CATALOG_IT: LazyLock<Catalog> =
LazyLock::new(|| parse_catalog(include_str!("../../../../locales/ui/it.json")));
static CATALOG_ES: LazyLock<Catalog> =
LazyLock::new(|| parse_catalog(include_str!("../../../../locales/ui/es.json")));
fn catalog_for(locale: UiLocale) -> &'static Catalog {
match locale {
UiLocale::En => &CATALOG_EN,
UiLocale::De => &CATALOG_DE,
UiLocale::Fr => &CATALOG_FR,
UiLocale::It => &CATALOG_IT,
UiLocale::Es => &CATALOG_ES,
}
}
/// Look up a translation key for the given locale.
///
/// Fallback chain: requested locale → English → key itself.
/// This implements the MenuTranslations invariant from i18n.allium.
pub fn translate(locale: UiLocale, key: &str) -> String {
if let Some(val) = catalog_for(locale).get(key) {
return val.clone();
}
if locale != UiLocale::En {
if let Some(val) = CATALOG_EN.get(key) {
return val.clone();
}
}
key.to_string()
}
/// Look up a translation key and substitute `{param}` placeholders.
///
/// Parameters are provided as `&[("param_name", "value")]`.
pub fn translate_with(locale: UiLocale, key: &str, params: &[(&str, &str)]) -> String {
let mut result = translate(locale, key);
for (name, value) in params {
let placeholder = format!("{{{name}}}");
result = result.replace(&placeholder, value);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
// LanguageNormalization invariant: base-extract and fallback
#[test]
fn normalize_strips_region() {
assert_eq!(normalize_language("en-US"), UiLocale::En);
assert_eq!(normalize_language("de-AT"), UiLocale::De);
assert_eq!(normalize_language("fr-CA"), UiLocale::Fr);
assert_eq!(normalize_language("it-CH"), UiLocale::It);
assert_eq!(normalize_language("es-MX"), UiLocale::Es);
}
#[test]
fn normalize_underscore_separator() {
assert_eq!(normalize_language("de_DE"), UiLocale::De);
}
#[test]
fn normalize_unknown_falls_back_to_en() {
assert_eq!(normalize_language("xx"), UiLocale::En);
assert_eq!(normalize_language("ja"), UiLocale::En);
assert_eq!(normalize_language(""), UiLocale::En);
}
#[test]
fn normalize_case_insensitive() {
assert_eq!(normalize_language("DE"), UiLocale::De);
assert_eq!(normalize_language("FR-fr"), UiLocale::Fr);
}
// SplitLocalization invariant: UiLocale is independent of content language
#[test]
fn ui_locale_is_independent_type() {
let ui = UiLocale::De;
let content_lang = "fr";
assert_ne!(ui.code(), content_lang);
}
// MenuTranslations invariant: menu labels come from locale catalog
#[test]
fn translate_menu_labels() {
let label = translate(UiLocale::De, "menu.group.file");
assert_eq!(label, "Datei");
let label = translate(UiLocale::Fr, "menu.item.save");
assert_eq!(label, "Enregistrer");
}
#[test]
fn translate_falls_back_to_english() {
// Non-English locale falls back for missing keys
let result = translate(UiLocale::De, "menu.group.file");
assert_eq!(result, "Datei");
}
#[test]
fn translate_missing_key_returns_key() {
let result = translate(UiLocale::En, "nonexistent.key.xyz");
assert_eq!(result, "nonexistent.key.xyz");
}
#[test]
fn translate_with_interpolation() {
let result = translate_with(
UiLocale::En,
"tasks.triggerTitle",
&[("running", "2"), ("pending", "3")],
);
assert_eq!(result, "2 running, 3 pending");
}
#[test]
fn translate_with_interpolation_german() {
let result = translate_with(
UiLocale::De,
"tasks.triggerTitle",
&[("running", "1"), ("pending", "5")],
);
assert_eq!(result, "1 laufend, 5 ausstehend");
}
#[test]
fn all_locales_have_menu_keys() {
let key = "menu.group.file";
for locale in UiLocale::all() {
let val = translate(*locale, key);
assert_ne!(val, key, "missing {key} for locale {locale}");
}
}
#[test]
fn locale_code_roundtrip() {
for locale in UiLocale::all() {
assert_eq!(normalize_language(locale.code()), *locale);
}
}
#[test]
fn detect_os_locale_does_not_panic() {
let _ = detect_os_locale();
}
}

View File

@@ -10,6 +10,10 @@ bds-editor = { workspace = true }
iced = { workspace = true }
muda = { workspace = true }
rfd = { workspace = true }
serde_json = { workspace = true }
dirs = { workspace = true }
chrono = { workspace = true }
open = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }
@@ -17,3 +21,6 @@ tokio = { workspace = true }
objc2 = "0.6"
objc2-foundation = "0.3"
objc2-app-kit = "0.3"
[dev-dependencies]
tempfile = "3"

View File

@@ -1,48 +1,610 @@
use iced::widget::{column, container, text};
use iced::{Element, Length, Subscription, Task};
use std::path::PathBuf;
use std::sync::Arc;
use crate::platform::menu;
use iced::{Element, Subscription, Task};
use bds_core::db::Database;
use bds_core::engine::task::{TaskManager, TaskStatus};
use bds_core::engine;
use bds_core::i18n::{detect_os_locale, UiLocale};
use bds_core::model::Project;
use crate::i18n::{t, tw};
use crate::platform::menu::{self, MenuAction, MenuRegistry};
use crate::state::navigation::{
handle_activity_click, OutputEntry, PanelTab, SidebarView, TaskSnapshot,
};
use crate::state::tabs::{self, Tab, TabType};
use crate::views::workspace;
// ───────────────────────────────────────────────────────────
// Message
// ───────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub enum Message {
// Menu
MenuEvent(muda::MenuId),
// Navigation
SetActiveView(SidebarView),
ToggleSidebar,
TogglePanel,
// Tabs
OpenTab(Tab),
CloseTab(String),
SelectTab(String),
PinTab(String),
// Project
ProjectsLoaded(Vec<Project>),
SwitchProject(String),
ProjectSwitched(Result<String, String>),
RequestCreateProject,
CreateProject { name: String, data_path: Option<PathBuf> },
ProjectCreated(Result<String, String>),
DeleteProject(String),
ProjectDeleted(Result<String, String>),
// Dialogs
FolderPicked(Option<PathBuf>),
MediaFilesPicked(Option<Vec<PathBuf>>),
// Tasks
TaskTick,
// macOS lifecycle
FileOpenRequested(PathBuf),
UrlOpenRequested(String),
// Panel
SetPanelTab(PanelTab),
// Settings
SetOfflineMode(bool),
SetUiLocale(UiLocale),
// Blog actions (dispatched to engine)
RebuildDatabase,
ReindexText,
RunMetadataDiff,
BlogTaskFinished { label: String, result: Result<(), String> },
Noop,
}
// ───────────────────────────────────────────────────────────
// App State
// ───────────────────────────────────────────────────────────
pub struct BdsApp {
// Database
db: Option<Database>,
// Project
active_project: Option<Project>,
projects: Vec<Project>,
data_dir: Option<PathBuf>,
// Navigation
sidebar_view: SidebarView,
sidebar_visible: bool,
// Tabs
tabs: Vec<Tab>,
active_tab: Option<String>,
// Panel
panel_visible: bool,
panel_tab: PanelTab,
// Tasks
task_manager: Arc<TaskManager>,
task_snapshots: Vec<TaskSnapshot>,
output_entries: Vec<OutputEntry>,
// Platform
_menu_bar: muda::Menu,
menu_registry: MenuRegistry,
// i18n
ui_locale: UiLocale,
// Flags
offline_mode: bool,
// macOS lifecycle receiver
#[cfg(target_os = "macos")]
_lifecycle_rx: std::sync::mpsc::Receiver<crate::platform::macos::LifecycleEvent>,
}
// ───────────────────────────────────────────────────────────
// App Implementation
// ───────────────────────────────────────────────────────────
impl BdsApp {
pub fn new() -> (Self, Task<Message>) {
let menu_bar = menu::build_menu_bar();
(Self { _menu_bar: menu_bar }, Task::none())
let locale = detect_os_locale();
let (menu_bar, registry) = menu::build_menu_bar(locale);
// Open or create the database
let db_path = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("bds")
.join("bds.db");
// Ensure parent directory exists
if let Some(parent) = db_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let db = Database::open(&db_path).ok();
if let Some(ref db) = db {
let _ = db.migrate();
}
// Load projects
let projects = db
.as_ref()
.and_then(|d| engine::project::list_projects(d.conn()).ok())
.unwrap_or_default();
let active_project = db
.as_ref()
.and_then(|d| engine::project::get_active_project(d.conn()).ok())
.flatten();
let data_dir = active_project
.as_ref()
.and_then(|p| p.data_path.as_ref())
.map(PathBuf::from);
// If no projects exist, create a default one
let init_task = if projects.is_empty() {
if let Some(ref db) = db {
let default_data = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("bds")
.join("projects")
.join("my-blog");
match engine::project::create_project(
db.conn(),
"My Blog",
Some(default_data.to_str().unwrap_or("my-blog")),
) {
Ok(project) => {
let _ = engine::project::set_active_project(db.conn(), &project.id);
Task::done(Message::ProjectsLoaded(vec![project]))
}
Err(_) => Task::none(),
}
} else {
Task::none()
}
} else {
Task::done(Message::ProjectsLoaded(projects.clone()))
};
// Disable items that need selection
registry.set_enabled(MenuAction::Save, false);
registry.set_enabled(MenuAction::PublishSelected, false);
registry.set_enabled(MenuAction::PreviewPost, false);
#[cfg(target_os = "macos")]
let (_lifecycle_tx, _lifecycle_rx) = crate::platform::macos::lifecycle_channel();
(
Self {
db,
active_project: active_project.clone(),
projects,
data_dir,
sidebar_view: SidebarView::Posts,
sidebar_visible: true,
tabs: Vec::new(),
active_tab: None,
panel_visible: false,
panel_tab: PanelTab::Tasks,
task_manager: Arc::new(TaskManager::default()),
task_snapshots: Vec::new(),
output_entries: Vec::new(),
_menu_bar: menu_bar,
menu_registry: registry,
ui_locale: locale,
offline_mode: false,
#[cfg(target_os = "macos")]
_lifecycle_rx,
},
init_task,
)
}
pub fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::MenuEvent(_id) => {
// Menu routing will be expanded in M2
// ── Menu event dispatch ──
Message::MenuEvent(id) => {
if let Some(action) = self.menu_registry.lookup(&id) {
return self.dispatch_menu_action(action);
}
Task::none()
}
// ── Navigation ──
Message::SetActiveView(view) => {
let (new_view, new_visible) =
handle_activity_click(self.sidebar_view, self.sidebar_visible, view);
self.sidebar_view = new_view;
self.sidebar_visible = new_visible;
Task::none()
}
Message::ToggleSidebar => {
self.sidebar_visible = !self.sidebar_visible;
Task::none()
}
Message::TogglePanel => {
self.panel_visible = !self.panel_visible;
Task::none()
}
// ── Tabs ──
Message::OpenTab(tab) => {
let idx = tabs::open_tab(&mut self.tabs, tab);
if let Some(t) = self.tabs.get(idx) {
self.active_tab = Some(t.id.clone());
}
Task::none()
}
Message::CloseTab(id) => {
if let Some(next_idx) = tabs::close_tab(&mut self.tabs, &id) {
self.active_tab = self.tabs.get(next_idx).map(|t| t.id.clone());
} else {
self.active_tab = None;
}
Task::none()
}
Message::SelectTab(id) => {
if self.tabs.iter().any(|t| t.id == id) {
self.active_tab = Some(id);
}
Task::none()
}
Message::PinTab(id) => {
tabs::pin_tab(&mut self.tabs, &id);
Task::none()
}
// ── Project management ──
Message::ProjectsLoaded(projects) => {
self.projects = projects;
// Re-resolve active
if let Some(ref db) = self.db {
self.active_project = engine::project::get_active_project(db.conn())
.ok()
.flatten();
self.data_dir = self
.active_project
.as_ref()
.and_then(|p| p.data_path.as_ref())
.map(PathBuf::from);
}
Task::none()
}
Message::SwitchProject(project_id) => {
if let Some(ref db) = self.db {
match engine::project::set_active_project(db.conn(), &project_id) {
Ok(()) => {
self.active_project = self.projects.iter().find(|p| p.id == project_id).cloned();
self.data_dir = self
.active_project
.as_ref()
.and_then(|p| p.data_path.as_ref())
.map(PathBuf::from);
let name = self.active_project.as_ref().map(|p| p.name.clone()).unwrap_or_default();
self.add_output(&tw(self.ui_locale, "projectSelector.toast.switched", &[("name", &name)]));
}
Err(_) => {
self.add_output(&t(self.ui_locale, "projectSelector.toast.switchFailed"));
}
}
}
Task::none()
}
Message::ProjectSwitched(result) => {
match result {
Ok(name) => self.add_output(&tw(self.ui_locale, "projectSelector.toast.switched", &[("name", &name)])),
Err(msg) => self.add_output(&msg),
}
Task::none()
}
Message::RequestCreateProject => {
crate::platform::dialog::pick_folder()
}
Message::CreateProject { name, data_path } => {
if let Some(ref db) = self.db {
let path_str = data_path.as_ref().map(|p| p.to_string_lossy().to_string());
match engine::project::create_project(
db.conn(),
&name,
path_str.as_deref(),
) {
Ok(project) => {
let _ = engine::project::set_active_project(db.conn(), &project.id);
self.projects = engine::project::list_projects(db.conn()).unwrap_or_default();
self.active_project = Some(project.clone());
self.data_dir = project.data_path.as_ref().map(PathBuf::from);
let msg = tw(self.ui_locale, "projectSelector.toast.created", &[("name", &project.name)]);
self.add_output(&msg);
}
Err(_) => {
self.add_output(&t(self.ui_locale, "projectSelector.toast.createFailed"));
}
}
}
Task::none()
}
Message::ProjectCreated(result) => {
match result {
Ok(name) => self.add_output(&tw(self.ui_locale, "projectSelector.toast.created", &[("name", &name)])),
Err(msg) => self.add_output(&msg),
}
Task::none()
}
Message::DeleteProject(project_id) => {
if let Some(ref db) = self.db {
let data_path = self.projects.iter()
.find(|p| p.id == project_id)
.and_then(|p| p.data_path.as_ref())
.map(PathBuf::from);
match engine::project::delete_project(db.conn(), &project_id, data_path.as_deref()) {
Ok(()) => {
self.projects.retain(|p| p.id != project_id);
}
Err(_) => {
self.add_output(&t(self.ui_locale, "projectSelector.toast.deleteFailed"));
}
}
}
Task::none()
}
Message::ProjectDeleted(result) => {
if let Err(msg) = result {
self.add_output(&msg);
}
Task::none()
}
// ── Dialogs ──
Message::FolderPicked(path) => {
if let Some(path) = path {
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "New Project".to_string());
return Task::done(Message::CreateProject {
name,
data_path: Some(path),
});
}
Task::none()
}
Message::MediaFilesPicked(_paths) => {
// Media import will be expanded in later milestones
Task::none()
}
// ── Tasks ──
Message::TaskTick => {
self.refresh_task_snapshots();
Task::none()
}
// ── macOS lifecycle ──
Message::FileOpenRequested(_path) => {
// File open handling deferred to later milestones
Task::none()
}
Message::UrlOpenRequested(_url) => {
// URL open handling deferred to later milestones
Task::none()
}
// ── Panel ──
Message::SetPanelTab(tab) => {
self.panel_tab = tab;
Task::none()
}
// ── Settings ──
Message::SetOfflineMode(mode) => {
self.offline_mode = mode;
Task::none()
}
Message::SetUiLocale(locale) => {
self.ui_locale = locale;
menu::update_menu_labels(&self.menu_registry, locale);
Task::none()
}
// ── Blog engine actions ──
Message::RebuildDatabase => {
self.add_output("Rebuilding database...");
// Actual rebuild dispatch deferred to later milestones
Task::none()
}
Message::ReindexText => {
self.add_output("Reindexing search text...");
Task::none()
}
Message::RunMetadataDiff => {
self.open_singleton_tab(TabType::MetadataDiff, "Metadata Diff");
Task::none()
}
Message::BlogTaskFinished { label, result } => {
match result {
Ok(()) => {
let msg = tw(self.ui_locale, "app.taskCompleted", &[("message", &label)]);
self.add_output(&msg);
}
Err(err) => {
let msg = tw(self.ui_locale, "app.taskFailed", &[("message", &err)]);
self.add_output(&msg);
}
}
Task::none()
}
Message::Noop => Task::none(),
}
}
pub fn view(&self) -> Element<'_, Message> {
let content = column![text("bDS — Blogging Desktop Server").size(24),]
.padding(20)
.spacing(10);
let active_name = self.active_project.as_ref().map(|p| p.name.as_str());
container(content)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into()
workspace::view(
self.sidebar_view,
self.sidebar_visible,
&self.tabs,
self.active_tab.as_deref(),
self.panel_visible,
self.panel_tab,
&self.task_snapshots,
&self.output_entries,
active_name,
0, // post_count — populated in later milestones
0, // media_count — populated in later milestones
self.offline_mode,
self.ui_locale,
)
}
pub fn subscription(&self) -> Subscription<Message> {
menu::menu_subscription()
let menu_sub = menu::menu_subscription();
let task_tick = iced::time::every(std::time::Duration::from_millis(500))
.map(|_| Message::TaskTick);
Subscription::batch([menu_sub, task_tick])
}
// ── Private helpers ──
fn dispatch_menu_action(&mut self, action: MenuAction) -> Task<Message> {
match action {
// File
MenuAction::NewPost => {
// Will create post + open tab in later milestones
Task::none()
}
MenuAction::ImportMedia => crate::platform::dialog::pick_media_files(),
MenuAction::Save => Task::none(), // Disabled in M2
MenuAction::OpenInBrowser => Task::none(),
MenuAction::OpenDataFolder => {
if let Some(ref dir) = self.data_dir {
let _ = open::that(dir);
}
Task::none()
}
// Edit
MenuAction::Find => Task::none(),
MenuAction::Replace => Task::none(),
MenuAction::EditPreferences => {
self.open_singleton_tab(TabType::Settings, "Settings");
Task::none()
}
// View
MenuAction::ViewPosts => {
Task::done(Message::SetActiveView(SidebarView::Posts))
}
MenuAction::ViewMedia => {
Task::done(Message::SetActiveView(SidebarView::Media))
}
MenuAction::ToggleSidebar => {
Task::done(Message::ToggleSidebar)
}
MenuAction::TogglePanel => {
Task::done(Message::TogglePanel)
}
// Blog
MenuAction::PublishSelected => Task::none(), // Disabled in M2
MenuAction::PreviewPost => Task::none(), // Disabled in M2
MenuAction::EditMenu => {
self.open_singleton_tab(TabType::MenuEditor, "Menu Editor");
Task::none()
}
MenuAction::RebuildDatabase => Task::done(Message::RebuildDatabase),
MenuAction::ReindexText => Task::done(Message::ReindexText),
MenuAction::MetadataDiff => Task::done(Message::RunMetadataDiff),
MenuAction::RegenerateCalendar => Task::none(),
MenuAction::ValidateTranslations => {
self.open_singleton_tab(TabType::TranslationValidation, "Translation Validation");
Task::none()
}
MenuAction::FillMissingTranslations => Task::none(),
MenuAction::GenerateSitemap => Task::none(),
MenuAction::ValidateSite => {
self.open_singleton_tab(TabType::SiteValidation, "Site Validation");
Task::none()
}
MenuAction::UploadSite => Task::none(),
// Help
MenuAction::About => Task::none(),
MenuAction::OpenDocumentation => {
self.open_singleton_tab(TabType::Documentation, "Documentation");
Task::none()
}
MenuAction::ViewOnGitHub => {
let _ = open::that("https://github.com/nickarumern/bds");
Task::none()
}
MenuAction::ReportIssue => {
let _ = open::that("https://github.com/nickarumern/bds/issues");
Task::none()
}
}
}
fn open_singleton_tab(&mut self, tab_type: TabType, title: &str) {
let tab = Tab {
id: format!("singleton-{title}"),
tab_type,
title: title.to_string(),
is_transient: false,
};
let idx = tabs::open_tab(&mut self.tabs, tab);
if let Some(t) = self.tabs.get(idx) {
self.active_tab = Some(t.id.clone());
}
}
fn refresh_task_snapshots(&mut self) {
self.task_snapshots = self
.task_manager
.snapshots()
.into_iter()
.map(|(id, label, status, progress, message)| {
let status_str = match &status {
TaskStatus::Queued => "queued".to_string(),
TaskStatus::Running => "running".to_string(),
TaskStatus::Completed => "completed".to_string(),
TaskStatus::Failed(e) => format!("failed: {e}"),
TaskStatus::Cancelled => "cancelled".to_string(),
};
TaskSnapshot {
id,
label,
status: status_str,
progress,
message,
}
})
.collect();
}
fn add_output(&mut self, text: &str) {
self.output_entries.push(OutputEntry {
timestamp: chrono::Utc::now().timestamp(),
text: text.to_string(),
});
}
}

View File

@@ -0,0 +1,11 @@
use bds_core::i18n::{translate, translate_with, UiLocale};
/// Shorthand for translate in view code.
pub fn t(locale: UiLocale, key: &str) -> String {
translate(locale, key)
}
/// Shorthand for translate_with in view code.
pub fn tw(locale: UiLocale, key: &str, params: &[(&str, &str)]) -> String {
translate_with(locale, key, params)
}

View File

@@ -1,4 +1,7 @@
pub mod app;
pub mod i18n;
pub mod platform;
pub mod state;
pub mod views;
pub use app::BdsApp;

View File

@@ -3,5 +3,6 @@ use bds_ui::BdsApp;
fn main() -> iced::Result {
iced::application("bDS", BdsApp::update, BdsApp::view)
.subscription(BdsApp::subscription)
.window_size((1200.0, 800.0))
.run_with(BdsApp::new)
}

View File

@@ -0,0 +1,35 @@
use iced::Task;
use crate::app::Message;
/// Pick a folder using the native file dialog.
pub fn pick_folder() -> Task<Message> {
Task::perform(
async {
rfd::AsyncFileDialog::new()
.set_title("Select Folder")
.pick_folder()
.await
.map(|h| h.path().to_path_buf())
},
Message::FolderPicked,
)
}
/// Pick one or more image/media files using the native file dialog.
pub fn pick_media_files() -> Task<Message> {
Task::perform(
async {
rfd::AsyncFileDialog::new()
.set_title("Import Media")
.add_filter(
"Images",
&["jpg", "jpeg", "png", "gif", "webp", "svg", "tiff", "bmp"],
)
.pick_files()
.await
.map(|hs| hs.into_iter().map(|h| h.path().to_path_buf()).collect())
},
Message::MediaFilesPicked,
)
}

View File

@@ -1,10 +1,27 @@
// macOS lifecycle shim — objc2 hooks for NSApplicationDelegate.
//
// Handles:
// - application:openFile: (Finder open)
// - application:openURLs: (URL scheme handling)
//
// These will be forwarded as Message variants into the Iced event loop
// via a channel-based subscription.
//
// Implementation deferred to M2 when the full message routing is in place.
use std::path::PathBuf;
use std::sync::mpsc;
use crate::app::Message;
/// Events that arrive from macOS lifecycle hooks (openFile, openURLs).
#[derive(Debug, Clone)]
pub enum LifecycleEvent {
FileOpen(PathBuf),
UrlOpen(String),
}
/// Create the macOS lifecycle channel (sender goes to objc hooks, receiver polled by subscription).
pub fn lifecycle_channel() -> (mpsc::Sender<LifecycleEvent>, mpsc::Receiver<LifecycleEvent>) {
mpsc::channel()
}
/// Poll the macOS lifecycle receiver for the next event and map to a Message.
pub fn poll_lifecycle(
receiver: &mpsc::Receiver<LifecycleEvent>,
) -> Option<Message> {
match receiver.try_recv() {
Ok(LifecycleEvent::FileOpen(path)) => Some(Message::FileOpenRequested(path)),
Ok(LifecycleEvent::UrlOpen(url)) => Some(Message::UrlOpenRequested(url)),
Err(_) => None,
}
}

View File

@@ -1,17 +1,197 @@
use iced::Subscription;
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use std::collections::HashMap;
use iced::Subscription;
use muda::accelerator::{Accelerator, Code, Modifiers, CMD_OR_CTRL};
use muda::{Menu, MenuEvent, MenuItem, MenuId, PredefinedMenuItem, Submenu};
use bds_core::i18n::{translate, UiLocale};
use crate::app::Message;
/// Build the native menu bar with standard application menus.
/// Every custom menu item that the application handles.
///
/// On macOS, also calls `init_for_nsapp()` to register the menu with AppKit.
/// This requires an active NSApplication, so it will silently fail in
/// test contexts without one (muda returns an error that we discard).
pub fn build_menu_bar() -> Menu {
let menu = Menu::new();
/// Predefined OS items (Undo, Redo, Cut, Copy, Paste, SelectAll, etc.)
/// are handled by the platform and do not appear here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MenuAction {
// File
NewPost,
ImportMedia,
Save,
OpenInBrowser,
OpenDataFolder,
// Edit (custom items only)
Find,
Replace,
EditPreferences,
// View
ViewPosts,
ViewMedia,
ToggleSidebar,
TogglePanel,
// Blog
PublishSelected,
PreviewPost,
EditMenu,
RebuildDatabase,
ReindexText,
MetadataDiff,
RegenerateCalendar,
ValidateTranslations,
FillMissingTranslations,
GenerateSitemap,
ValidateSite,
UploadSite,
// Help
About,
OpenDocumentation,
ViewOnGitHub,
ReportIssue,
}
// App menu (macOS only shows this as the app name)
impl MenuAction {
/// All variants in declaration order.
pub const ALL: &'static [MenuAction] = &[
MenuAction::NewPost,
MenuAction::ImportMedia,
MenuAction::Save,
MenuAction::OpenInBrowser,
MenuAction::OpenDataFolder,
MenuAction::Find,
MenuAction::Replace,
MenuAction::EditPreferences,
MenuAction::ViewPosts,
MenuAction::ViewMedia,
MenuAction::ToggleSidebar,
MenuAction::TogglePanel,
MenuAction::PublishSelected,
MenuAction::PreviewPost,
MenuAction::EditMenu,
MenuAction::RebuildDatabase,
MenuAction::ReindexText,
MenuAction::MetadataDiff,
MenuAction::RegenerateCalendar,
MenuAction::ValidateTranslations,
MenuAction::FillMissingTranslations,
MenuAction::GenerateSitemap,
MenuAction::ValidateSite,
MenuAction::UploadSite,
MenuAction::About,
MenuAction::OpenDocumentation,
MenuAction::ViewOnGitHub,
MenuAction::ReportIssue,
];
/// Return the i18n key for this action's menu label.
pub fn i18n_key(self) -> &'static str {
match self {
Self::NewPost => "menu.item.newPost",
Self::ImportMedia => "menu.item.importMedia",
Self::Save => "menu.item.save",
Self::OpenInBrowser => "menu.item.openInBrowser",
Self::OpenDataFolder => "menu.item.openDataFolder",
Self::Find => "menu.item.find",
Self::Replace => "menu.item.replace",
Self::EditPreferences => "menu.item.editPreferences",
Self::ViewPosts => "menu.item.viewPosts",
Self::ViewMedia => "menu.item.viewMedia",
Self::ToggleSidebar => "menu.item.toggleSidebar",
Self::TogglePanel => "menu.item.togglePanel",
Self::PublishSelected => "menu.item.publishSelected",
Self::PreviewPost => "menu.item.previewPost",
Self::EditMenu => "menu.item.editMenu",
Self::RebuildDatabase => "menu.item.rebuildDatabase",
Self::ReindexText => "menu.item.reindexText",
Self::MetadataDiff => "menu.item.metadataDiff",
Self::RegenerateCalendar => "menu.item.regenerateCalendar",
Self::ValidateTranslations => "menu.item.validateTranslations",
Self::FillMissingTranslations => "menu.item.fillMissingTranslations",
Self::GenerateSitemap => "menu.item.generateSitemap",
Self::ValidateSite => "menu.item.validateSite",
Self::UploadSite => "menu.item.uploadSite",
Self::About => "menu.item.about",
Self::OpenDocumentation => "menu.item.openDocumentation",
Self::ViewOnGitHub => "menu.item.viewOnGitHub",
Self::ReportIssue => "menu.item.reportIssue",
}
}
}
/// Maps between muda `MenuId`s and application `MenuAction`s.
///
/// Also holds clones of the `MenuItem` handles so that labels and
/// enabled state can be changed at runtime (e.g. on locale switch).
pub struct MenuRegistry {
action_map: HashMap<MenuId, MenuAction>,
id_map: HashMap<MenuAction, MenuId>,
items: HashMap<MenuAction, MenuItem>,
}
impl MenuRegistry {
fn new() -> Self {
Self {
action_map: HashMap::new(),
id_map: HashMap::new(),
items: HashMap::new(),
}
}
fn register(&mut self, action: MenuAction, item: &MenuItem) {
self.action_map.insert(item.id().clone(), action);
self.id_map.insert(action, item.id().clone());
self.items.insert(action, item.clone());
}
/// Look up the `MenuAction` for a raw muda event id.
pub fn lookup(&self, id: &MenuId) -> Option<MenuAction> {
self.action_map.get(id).copied()
}
/// Enable or disable the menu item for a given action.
pub fn set_enabled(&self, action: MenuAction, enabled: bool) {
if let Some(item) = self.items.get(&action) {
item.set_enabled(enabled);
}
}
/// Change the displayed text for a given action.
pub fn set_text(&self, action: MenuAction, text: &str) {
if let Some(item) = self.items.get(&action) {
item.set_text(text);
}
}
/// Number of registered (action, MenuId) pairs.
pub fn action_count(&self) -> usize {
self.action_map.len()
}
}
// ---------------------------------------------------------------------------
// Menu construction helpers
// ---------------------------------------------------------------------------
/// Helper: create a `MenuItem`, register it, and return a reference for
/// appending to a `Submenu`.
fn item(
registry: &mut MenuRegistry,
action: MenuAction,
locale: UiLocale,
accel: Option<Accelerator>,
) -> MenuItem {
let label = translate(locale, action.i18n_key());
let mi = MenuItem::new(label, true, accel);
registry.register(action, &mi);
mi
}
/// Build the full native menu bar and a registry that maps ids to actions.
///
/// On macOS this also calls `init_for_nsapp()` to attach the menu.
pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
let menu = Menu::new();
let mut reg = MenuRegistry::new();
// -- macOS app menu --
let app_menu = Submenu::new("bDS", true);
let _ = app_menu.append(&PredefinedMenuItem::about(None, None));
let _ = app_menu.append(&PredefinedMenuItem::separator());
@@ -23,15 +203,22 @@ pub fn build_menu_bar() -> Menu {
let _ = app_menu.append(&PredefinedMenuItem::separator());
let _ = app_menu.append(&PredefinedMenuItem::quit(None));
// File menu
let file_menu = Submenu::new("File", true);
let _ = file_menu.append(&MenuItem::new("Open Project...", true, None));
// -- File --
let file_menu = Submenu::new(translate(locale, "menu.group.file"), true);
let _ = file_menu.append(&item(&mut reg, MenuAction::NewPost, locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyN))));
let _ = file_menu.append(&item(&mut reg, MenuAction::ImportMedia, locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyI))));
let _ = file_menu.append(&item(&mut reg, MenuAction::Save, locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyS))));
let _ = file_menu.append(&PredefinedMenuItem::separator());
let _ = file_menu.append(&item(&mut reg, MenuAction::OpenInBrowser, locale, None));
let _ = file_menu.append(&item(&mut reg, MenuAction::OpenDataFolder, locale, None));
let _ = file_menu.append(&PredefinedMenuItem::separator());
let _ = file_menu.append(&MenuItem::new("Save", true, None));
let _ = file_menu.append(&PredefinedMenuItem::close_window(None));
// Edit menu
let edit_menu = Submenu::new("Edit", true);
// -- Edit --
let edit_menu = Submenu::new(translate(locale, "menu.group.edit"), true);
let _ = edit_menu.append(&PredefinedMenuItem::undo(None));
let _ = edit_menu.append(&PredefinedMenuItem::redo(None));
let _ = edit_menu.append(&PredefinedMenuItem::separator());
@@ -39,41 +226,91 @@ pub fn build_menu_bar() -> Menu {
let _ = edit_menu.append(&PredefinedMenuItem::copy(None));
let _ = edit_menu.append(&PredefinedMenuItem::paste(None));
let _ = edit_menu.append(&PredefinedMenuItem::select_all(None));
let _ = edit_menu.append(&PredefinedMenuItem::separator());
let _ = edit_menu.append(&item(&mut reg, MenuAction::Find, locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyF))));
let _ = edit_menu.append(&item(&mut reg, MenuAction::Replace, locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyH))));
let _ = edit_menu.append(&PredefinedMenuItem::separator());
let _ = edit_menu.append(&item(&mut reg, MenuAction::EditPreferences, locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Comma))));
// View menu
let view_menu = Submenu::new("View", true);
let _ = view_menu.append(&MenuItem::new("Toggle Sidebar", true, None));
// -- View --
let view_menu = Submenu::new(translate(locale, "menu.group.view"), true);
let _ = view_menu.append(&item(&mut reg, MenuAction::ViewPosts, locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Digit1))));
let _ = view_menu.append(&item(&mut reg, MenuAction::ViewMedia, locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Digit2))));
let _ = view_menu.append(&PredefinedMenuItem::separator());
let _ = view_menu.append(&item(&mut reg, MenuAction::ToggleSidebar, locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyB))));
let _ = view_menu.append(&item(&mut reg, MenuAction::TogglePanel, locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyJ))));
let _ = view_menu.append(&PredefinedMenuItem::separator());
let _ = view_menu.append(&PredefinedMenuItem::fullscreen(None));
// Window menu
let window_menu = Submenu::new("Window", true);
let _ = window_menu.append(&PredefinedMenuItem::minimize(None));
let _ = window_menu.append(&PredefinedMenuItem::maximize(None));
// -- Blog --
let blog_menu = Submenu::new(translate(locale, "menu.group.blog"), true);
let _ = blog_menu.append(&item(&mut reg, MenuAction::PublishSelected, locale,
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyP))));
let _ = blog_menu.append(&item(&mut reg, MenuAction::PreviewPost, locale,
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyV))));
let _ = blog_menu.append(&PredefinedMenuItem::separator());
let _ = blog_menu.append(&item(&mut reg, MenuAction::EditMenu, locale, None));
let _ = blog_menu.append(&PredefinedMenuItem::separator());
let _ = blog_menu.append(&item(&mut reg, MenuAction::RebuildDatabase, locale, None));
let _ = blog_menu.append(&item(&mut reg, MenuAction::ReindexText, locale, None));
let _ = blog_menu.append(&item(&mut reg, MenuAction::MetadataDiff, locale, None));
let _ = blog_menu.append(&PredefinedMenuItem::separator());
let _ = blog_menu.append(&item(&mut reg, MenuAction::RegenerateCalendar, locale, None));
let _ = blog_menu.append(&item(&mut reg, MenuAction::ValidateTranslations, locale, None));
let _ = blog_menu.append(&item(&mut reg, MenuAction::FillMissingTranslations, locale, None));
let _ = blog_menu.append(&PredefinedMenuItem::separator());
let _ = blog_menu.append(&item(&mut reg, MenuAction::GenerateSitemap, locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyR))));
let _ = blog_menu.append(&item(&mut reg, MenuAction::ValidateSite, locale,
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyL))));
let _ = blog_menu.append(&item(&mut reg, MenuAction::UploadSite, locale,
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyU))));
// Help menu
let help_menu = Submenu::new("Help", true);
let _ = help_menu.append(&MenuItem::new("bDS Help", true, None));
// -- Help --
let help_menu = Submenu::new(translate(locale, "menu.group.help"), true);
let _ = help_menu.append(&item(&mut reg, MenuAction::About, locale, None));
let _ = help_menu.append(&PredefinedMenuItem::separator());
let _ = help_menu.append(&item(&mut reg, MenuAction::OpenDocumentation, locale, None));
let _ = help_menu.append(&item(&mut reg, MenuAction::ViewOnGitHub, locale, None));
let _ = help_menu.append(&item(&mut reg, MenuAction::ReportIssue, locale, None));
// Assemble the menu bar
let _ = menu.append(&app_menu);
let _ = menu.append(&file_menu);
let _ = menu.append(&edit_menu);
let _ = menu.append(&view_menu);
let _ = menu.append(&window_menu);
let _ = menu.append(&blog_menu);
let _ = menu.append(&help_menu);
// Initialize the menu on macOS
// Attach on macOS
#[cfg(target_os = "macos")]
{
let _ = menu.init_for_nsapp();
}
menu
(menu, reg)
}
/// Iced subscription that polls muda menu events.
/// Re-translate every registered menu item for a new locale.
pub fn update_menu_labels(registry: &MenuRegistry, locale: UiLocale) {
for &action in MenuAction::ALL {
registry.set_text(action, &translate(locale, action.i18n_key()));
}
}
/// Iced subscription that polls muda `MenuEvent`s each frame.
///
/// Produces `Message::MenuEvent(MenuId)` so the app can look up the
/// `MenuAction` via its `MenuRegistry`.
pub fn menu_subscription() -> Subscription<Message> {
iced::event::listen_with(|_event, _status, _id| {
// Check for pending menu events
if let Ok(event) = MenuEvent::receiver().try_recv() {
Some(Message::MenuEvent(event.id))
} else {
@@ -81,3 +318,68 @@ pub fn menu_subscription() -> Subscription<Message> {
}
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use bds_core::i18n::UiLocale;
#[test]
fn all_variants_are_listed() {
// MenuAction::ALL must contain every variant.
// If someone adds a variant but forgets ALL, the i18n_key match
// will produce a compile error, but this also catches ALL length.
assert_eq!(MenuAction::ALL.len(), 28);
}
#[test]
fn i18n_keys_resolve_for_english() {
for &action in MenuAction::ALL {
let key = action.i18n_key();
let label = translate(UiLocale::En, key);
assert_ne!(label, key, "missing English translation for {key}");
}
}
#[test]
fn registry_register_and_lookup_roundtrip() {
let mut reg = MenuRegistry::new();
let mi = MenuItem::new("Test", true, None);
reg.register(MenuAction::Save, &mi);
assert_eq!(reg.lookup(mi.id()), Some(MenuAction::Save));
assert_eq!(reg.action_count(), 1);
}
#[test]
fn registry_lookup_missing_returns_none() {
let reg = MenuRegistry::new();
let bogus = MenuId::new("nonexistent");
assert_eq!(reg.lookup(&bogus), None);
}
#[test]
fn registry_set_enabled_and_text() {
let mut reg = MenuRegistry::new();
let mi = MenuItem::new("Original", true, None);
reg.register(MenuAction::NewPost, &mi);
reg.set_enabled(MenuAction::NewPost, false);
assert!(!mi.is_enabled());
reg.set_text(MenuAction::NewPost, "Changed");
assert_eq!(mi.text(), "Changed");
}
#[test]
fn all_actions_have_unique_i18n_keys() {
let mut seen = std::collections::HashSet::new();
for &action in MenuAction::ALL {
let key = action.i18n_key();
assert!(seen.insert(key), "duplicate i18n key: {key}");
}
}
}

View File

@@ -1,4 +1,5 @@
pub mod menu;
pub mod dialog;
#[cfg(target_os = "macos")]
pub mod macos;

View File

@@ -0,0 +1,5 @@
pub mod navigation;
pub mod tabs;
pub use navigation::{SidebarView, PanelTab, TaskSnapshot, OutputEntry};
pub use tabs::{Tab, TabType};

View File

@@ -0,0 +1,126 @@
use std::fmt;
/// Which list the sidebar is currently showing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SidebarView {
Posts,
Pages,
Media,
Scripts,
Templates,
Tags,
Chat,
Import,
Git,
Settings,
}
impl fmt::Display for SidebarView {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let key = self.i18n_key();
f.write_str(key)
}
}
impl SidebarView {
/// Returns the `activity.*` i18n key for this sidebar view.
pub fn i18n_key(&self) -> &'static str {
match self {
Self::Posts => "activity.posts",
Self::Pages => "activity.pages",
Self::Media => "activity.media",
Self::Scripts => "activity.scripts",
Self::Templates => "activity.templates",
Self::Tags => "activity.tags",
Self::Chat => "activity.chat",
Self::Import => "activity.import",
Self::Git => "activity.git",
Self::Settings => "activity.settings",
}
}
}
/// Which tab is selected in the bottom panel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PanelTab {
Tasks,
Output,
}
/// A snapshot of a running or completed task shown in the panel.
#[derive(Debug, Clone)]
pub struct TaskSnapshot {
pub id: u64,
pub label: String,
pub status: String,
pub progress: Option<f32>,
pub message: Option<String>,
}
/// A single line of output shown in the panel.
#[derive(Debug, Clone)]
pub struct OutputEntry {
pub timestamp: i64,
pub text: String,
}
/// Determine the next `(SidebarView, sidebar_visible)` after a user clicks
/// an activity-bar icon.
///
/// * Same icon + visible -> toggle sidebar off
/// * Same icon + hidden -> toggle sidebar on
/// * Different icon -> switch view and ensure visible
pub fn handle_activity_click(
current_view: SidebarView,
sidebar_visible: bool,
clicked: SidebarView,
) -> (SidebarView, bool) {
if clicked == current_view {
(current_view, !sidebar_visible)
} else {
(clicked, true)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn toggle_off_when_same_and_visible() {
let (view, visible) =
handle_activity_click(SidebarView::Posts, true, SidebarView::Posts);
assert_eq!(view, SidebarView::Posts);
assert!(!visible);
}
#[test]
fn toggle_on_when_same_and_hidden() {
let (view, visible) =
handle_activity_click(SidebarView::Posts, false, SidebarView::Posts);
assert_eq!(view, SidebarView::Posts);
assert!(visible);
}
#[test]
fn switch_view_when_different() {
let (view, visible) =
handle_activity_click(SidebarView::Posts, true, SidebarView::Media);
assert_eq!(view, SidebarView::Media);
assert!(visible);
}
#[test]
fn switch_view_when_different_and_hidden() {
let (view, visible) =
handle_activity_click(SidebarView::Posts, false, SidebarView::Settings);
assert_eq!(view, SidebarView::Settings);
assert!(visible);
}
#[test]
fn display_returns_i18n_key() {
assert_eq!(SidebarView::Posts.to_string(), "activity.posts");
assert_eq!(SidebarView::Settings.to_string(), "activity.settings");
}
}

View File

@@ -0,0 +1,161 @@
/// The kind of content a tab holds.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TabType {
Post,
Media,
Settings,
Style,
Tags,
Chat,
Import,
MenuEditor,
MetadataDiff,
Scripts,
Templates,
Documentation,
SiteValidation,
TranslationValidation,
}
impl TabType {
/// Singleton tabs may only appear once in the tab bar.
/// Every type except `Post` and `Media` is a singleton.
pub fn is_singleton(&self) -> bool {
!matches!(self, Self::Post | Self::Media)
}
}
/// A single tab in the editor area.
#[derive(Debug, Clone)]
pub struct Tab {
pub id: String,
pub tab_type: TabType,
pub title: String,
pub is_transient: bool,
}
/// Open (or focus) a tab in the tab list.
///
/// * **Singleton** — if a tab with the same `TabType` already exists, return
/// its index instead of inserting a duplicate.
/// * **Transient** — replace an existing transient tab of the same type, or
/// append if none exists.
/// * Otherwise — append unconditionally.
///
/// Returns the index of the resulting tab.
pub fn open_tab(tabs: &mut Vec<Tab>, new_tab: Tab) -> usize {
if new_tab.tab_type.is_singleton() {
if let Some(idx) = tabs.iter().position(|t| t.tab_type == new_tab.tab_type) {
return idx;
}
}
if new_tab.is_transient {
if let Some(idx) = tabs
.iter()
.position(|t| t.tab_type == new_tab.tab_type && t.is_transient)
{
tabs[idx] = new_tab;
return idx;
}
}
tabs.push(new_tab);
tabs.len() - 1
}
/// Remove the tab whose `id` matches and return the index that should be
/// selected next.
///
/// Prefers the same index (now occupied by the next tab), clamped to
/// `len - 1`. Returns `None` when the list is empty after removal.
pub fn close_tab(tabs: &mut Vec<Tab>, id: &str) -> Option<usize> {
let pos = tabs.iter().position(|t| t.id == id)?;
tabs.remove(pos);
if tabs.is_empty() {
None
} else {
Some(pos.min(tabs.len() - 1))
}
}
/// Mark a transient tab as permanent (non-transient).
pub fn pin_tab(tabs: &mut Vec<Tab>, id: &str) {
if let Some(tab) = tabs.iter_mut().find(|t| t.id == id) {
tab.is_transient = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tab(id: &str, tab_type: TabType, transient: bool) -> Tab {
Tab {
id: id.to_string(),
tab_type,
title: id.to_string(),
is_transient: transient,
}
}
#[test]
fn singleton_dedup() {
let mut tabs = Vec::new();
let idx1 = open_tab(&mut tabs, make_tab("s1", TabType::Settings, false));
let idx2 = open_tab(&mut tabs, make_tab("s2", TabType::Settings, false));
assert_eq!(idx1, idx2);
assert_eq!(tabs.len(), 1);
assert_eq!(tabs[0].id, "s1");
}
#[test]
fn transient_replacement() {
let mut tabs = Vec::new();
open_tab(&mut tabs, make_tab("p1", TabType::Post, true));
assert_eq!(tabs.len(), 1);
let idx = open_tab(&mut tabs, make_tab("p2", TabType::Post, true));
assert_eq!(tabs.len(), 1);
assert_eq!(tabs[idx].id, "p2");
}
#[test]
fn pin_makes_permanent() {
let mut tabs = Vec::new();
open_tab(&mut tabs, make_tab("p1", TabType::Post, true));
assert!(tabs[0].is_transient);
pin_tab(&mut tabs, "p1");
assert!(!tabs[0].is_transient);
}
#[test]
fn close_selects_neighbor() {
let mut tabs = Vec::new();
open_tab(&mut tabs, make_tab("a", TabType::Post, false));
open_tab(&mut tabs, make_tab("b", TabType::Media, false));
open_tab(&mut tabs, make_tab("c", TabType::Post, false));
// Close the middle tab.
let next = close_tab(&mut tabs, "b");
assert_eq!(next, Some(1)); // index 1 now holds "c"
assert_eq!(tabs[1].id, "c");
// Close the last tab.
let next = close_tab(&mut tabs, "c");
assert_eq!(next, Some(0)); // clamped to len-1
// Close the only remaining tab.
let next = close_tab(&mut tabs, "a");
assert_eq!(next, None);
}
#[test]
fn open_appends_non_singleton_non_transient() {
let mut tabs = Vec::new();
let i0 = open_tab(&mut tabs, make_tab("p1", TabType::Post, false));
let i1 = open_tab(&mut tabs, make_tab("p2", TabType::Post, false));
assert_eq!(i0, 0);
assert_eq!(i1, 1);
assert_eq!(tabs.len(), 2);
}
}

View File

@@ -0,0 +1,89 @@
use iced::widget::{button, column, container, text, Column, Space};
use iced::{Alignment, Element, Length};
use bds_core::i18n::UiLocale;
use crate::app::Message;
use crate::state::navigation::SidebarView;
/// Activity-bar short labels used in M2 (no SVG icons yet).
fn short_label(view: SidebarView) -> &'static str {
match view {
SidebarView::Posts => "Po",
SidebarView::Pages => "Pa",
SidebarView::Media => "Me",
SidebarView::Scripts => "Sc",
SidebarView::Templates => "Tp",
SidebarView::Tags => "Ta",
SidebarView::Chat => "AI",
SidebarView::Import => "Im",
SidebarView::Git => "Gi",
SidebarView::Settings => "Se",
}
}
/// Top group of activity items.
const TOP_ACTIVITIES: &[SidebarView] = &[
SidebarView::Posts,
SidebarView::Pages,
SidebarView::Media,
SidebarView::Scripts,
SidebarView::Templates,
SidebarView::Tags,
SidebarView::Chat,
SidebarView::Import,
];
/// Bottom group of activity items.
const BOTTOM_ACTIVITIES: &[SidebarView] = &[
SidebarView::Git,
SidebarView::Settings,
];
pub fn view(
active_view: SidebarView,
_locale: UiLocale,
) -> Element<'static, Message> {
let make_btn = |view: SidebarView| -> Element<'static, Message> {
let label = short_label(view);
let mut btn = button(
text(label).size(12).center(),
)
.width(Length::Fixed(48.0))
.height(Length::Fixed(48.0))
.on_press(Message::SetActiveView(view));
if view == active_view {
btn = btn.style(button::primary);
} else {
btn = btn.style(button::secondary);
}
container(btn)
.center_x(Length::Fixed(48.0))
.into()
};
let top_items: Vec<Element<'static, Message>> = TOP_ACTIVITIES
.iter()
.map(|v| make_btn(*v))
.collect();
let bottom_items: Vec<Element<'static, Message>> = BOTTOM_ACTIVITIES
.iter()
.map(|v| make_btn(*v))
.collect();
let top = Column::with_children(top_items).spacing(2);
let bottom = Column::with_children(bottom_items).spacing(2);
column![
top,
Space::with_height(Length::Fill),
bottom,
]
.width(Length::Fixed(48.0))
.height(Length::Fill)
.align_x(Alignment::Center)
.into()
}

View File

@@ -0,0 +1,7 @@
pub mod workspace;
pub mod activity_bar;
pub mod sidebar;
pub mod tab_bar;
pub mod status_bar;
pub mod panel;
pub mod welcome;

View File

@@ -0,0 +1,111 @@
use iced::widget::{button, column, container, row, scrollable, text, Space};
use iced::{Alignment, Element, Length};
use bds_core::i18n::UiLocale;
use crate::app::Message;
use crate::i18n::t;
use crate::state::navigation::{OutputEntry, PanelTab, TaskSnapshot};
pub fn view(
panel_tab: PanelTab,
task_snapshots: &[TaskSnapshot],
output_entries: &[OutputEntry],
locale: UiLocale,
) -> Element<'static, Message> {
// Tab header
let tasks_btn = {
let mut btn = button(text(t(locale, "common.tasks")).size(12))
.on_press(Message::SetPanelTab(PanelTab::Tasks))
.padding([4, 8]);
if panel_tab == PanelTab::Tasks {
btn = btn.style(button::primary);
} else {
btn = btn.style(button::secondary);
}
btn
};
let output_btn = {
let mut btn = button(text(t(locale, "panel.output")).size(12))
.on_press(Message::SetPanelTab(PanelTab::Output))
.padding([4, 8]);
if panel_tab == PanelTab::Output {
btn = btn.style(button::primary);
} else {
btn = btn.style(button::secondary);
}
btn
};
let close_btn = button(text("×").size(12))
.on_press(Message::TogglePanel)
.padding([4, 6])
.style(button::text);
let tab_header = row![
tasks_btn,
output_btn,
Space::with_width(Length::Fill),
close_btn,
]
.spacing(4)
.align_y(Alignment::Center);
// Tab content
let content: Element<'static, Message> = match panel_tab {
PanelTab::Tasks => {
if task_snapshots.is_empty() {
container(text(t(locale, "tasks.noActive")).size(12))
.padding(8)
.into()
} else {
let items: Vec<Element<'static, Message>> = task_snapshots
.iter()
.map(|snap| {
let status_text = format!(
"{}{}{}",
snap.label,
snap.status,
snap.progress
.map(|p| format!(" ({:.0}%)", p * 100.0))
.unwrap_or_default(),
);
text(status_text).size(11).into()
})
.collect();
scrollable(
iced::widget::Column::with_children(items)
.spacing(4)
.padding(8),
)
.into()
}
}
PanelTab::Output => {
if output_entries.is_empty() {
container(text(t(locale, "panel.noOutput")).size(12))
.padding(8)
.into()
} else {
let items: Vec<Element<'static, Message>> = output_entries
.iter()
.map(|entry| text(entry.text.clone()).size(11).into())
.collect();
scrollable(
iced::widget::Column::with_children(items)
.spacing(2)
.padding(8),
)
.into()
}
}
};
container(
column![tab_header, content].spacing(4),
)
.width(Length::Fill)
.height(Length::Fixed(200.0))
.into()
}

View File

@@ -0,0 +1,37 @@
use iced::widget::{column, container, scrollable, text};
use iced::{Element, Length};
use bds_core::i18n::UiLocale;
use crate::app::Message;
use crate::i18n::t;
use crate::state::navigation::SidebarView;
pub fn view(
sidebar_view: SidebarView,
locale: UiLocale,
) -> Element<'static, Message> {
let header_key = sidebar_view.i18n_key();
let header_text = t(locale, header_key);
let placeholder = match sidebar_view {
SidebarView::Posts => t(locale, "sidebar.noPostsYet"),
SidebarView::Pages => t(locale, "sidebar.noPagesYet"),
SidebarView::Media => t(locale, "sidebar.noMediaYet"),
SidebarView::Settings => t(locale, "sidebar.settingsHeader"),
SidebarView::Tags => t(locale, "sidebar.tagsHeader"),
_ => t(locale, "sidebar.loading"),
};
let content = column![
text(header_text).size(14),
text(placeholder).size(12),
]
.spacing(8)
.padding(12);
container(scrollable(content))
.width(Length::Fixed(280.0))
.height(Length::Fill)
.into()
}

View File

@@ -0,0 +1,97 @@
use iced::widget::{button, container, row, text, Space};
use iced::{Alignment, Element, Length};
use bds_core::i18n::UiLocale;
use crate::app::Message;
use crate::i18n::{t, tw};
use crate::state::navigation::TaskSnapshot;
pub fn view(
active_project_name: Option<&str>,
post_count: usize,
media_count: usize,
locale: UiLocale,
offline_mode: bool,
task_snapshots: &[TaskSnapshot],
) -> Element<'static, Message> {
// Left: project name + task indicator
let project_label = active_project_name.unwrap_or("").to_string();
let project_text = text(project_label).size(12);
let running: Vec<&TaskSnapshot> = task_snapshots
.iter()
.filter(|t| t.status == "running")
.collect();
let task_indicator: Element<'static, Message> = if !running.is_empty() {
let first = running[0].label.clone();
if running.len() > 1 {
text(format!("{first} (+{})", running.len() - 1)).size(11).into()
} else {
text(first).size(11).into()
}
} else {
Space::with_width(0).into()
};
let left = row![project_text, task_indicator].spacing(12);
// Right: post count, media count, airplane mode, locale selector, brand
let posts_label = tw(locale, "statusBar.posts", &[("count", &post_count.to_string())]);
let media_label = tw(locale, "statusBar.media", &[("count", &media_count.to_string())]);
let airplane_label = if offline_mode {
t(locale, "statusBar.offlineModeActive")
} else {
t(locale, "statusBar.offlineMode")
};
let airplane_btn = button(text(airplane_label).size(11))
.on_press(Message::SetOfflineMode(!offline_mode))
.padding([2, 6])
.style(if offline_mode { button::primary } else { button::text });
let ui_label = t(locale, "statusBar.ui");
let locale_buttons: Vec<Element<'static, Message>> = UiLocale::all()
.iter()
.map(|l| {
let code = l.code().to_string();
let mut btn = button(text(code).size(11))
.on_press(Message::SetUiLocale(*l))
.padding([2, 4]);
if *l == locale {
btn = btn.style(button::primary);
} else {
btn = btn.style(button::text);
}
btn.into()
})
.collect();
let locale_row = iced::widget::Row::with_children(locale_buttons).spacing(2);
let right = row![
text(posts_label).size(11),
text(media_label).size(11),
airplane_btn,
text(ui_label).size(11),
locale_row,
text("bDS").size(11),
]
.spacing(12)
.align_y(Alignment::Center);
container(
row![
left,
Space::with_width(Length::Fill),
right,
]
.align_y(Alignment::Center)
.padding([0, 8]),
)
.width(Length::Fill)
.height(Length::Fixed(22.0))
.into()
}

View File

@@ -0,0 +1,58 @@
use iced::widget::{button, container, row, text, Space};
use iced::{Element, Length};
use crate::app::Message;
use crate::state::tabs::Tab;
pub fn view(
tabs: &[Tab],
active_tab: Option<&str>,
) -> Element<'static, Message> {
if tabs.is_empty() {
return Space::with_height(0).into();
}
let tab_buttons: Vec<Element<'static, Message>> = tabs
.iter()
.map(|tab| {
let is_active = active_tab == Some(tab.id.as_str());
let title_text = if tab.is_transient {
format!("{} (preview)", tab.title)
} else {
tab.title.clone()
};
let tab_id = tab.id.clone();
let close_id = tab.id.clone();
let label = row![
text(title_text).size(12),
button(text("×").size(12))
.on_press(Message::CloseTab(close_id))
.padding(2)
.style(button::text),
]
.spacing(4);
let mut btn = button(label)
.on_press(Message::SelectTab(tab_id))
.padding([4, 8]);
if is_active {
btn = btn.style(button::primary);
} else {
btn = btn.style(button::secondary);
}
btn.into()
})
.collect();
container(
iced::widget::Row::with_children(tab_buttons)
.spacing(1)
.height(Length::Fixed(35.0)),
)
.width(Length::Fill)
.height(Length::Fixed(35.0))
.into()
}

View File

@@ -0,0 +1,26 @@
use iced::widget::{column, container, text};
use iced::{Element, Length};
use bds_core::i18n::UiLocale;
use crate::app::Message;
use crate::i18n::t;
pub fn view(locale: UiLocale) -> Element<'static, Message> {
let title = t(locale, "welcome.title");
let subtitle = t(locale, "welcome.subtitle");
container(
column![
text(title).size(28),
text(subtitle).size(14),
]
.spacing(12)
.align_x(iced::Alignment::Center),
)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into()
}

View File

@@ -0,0 +1,84 @@
use iced::widget::{column, row};
use iced::{Element, Length};
use bds_core::i18n::UiLocale;
use crate::app::Message;
use crate::state::navigation::{OutputEntry, PanelTab, SidebarView, TaskSnapshot};
use crate::state::tabs::Tab;
use crate::views::{activity_bar, panel, sidebar, status_bar, tab_bar, welcome};
/// Compose the full workspace layout.
pub fn view(
// Navigation
sidebar_view: SidebarView,
sidebar_visible: bool,
// Tabs
tabs: &[Tab],
active_tab: Option<&str>,
// Panel
panel_visible: bool,
panel_tab: PanelTab,
task_snapshots: &[TaskSnapshot],
output_entries: &[OutputEntry],
// Status bar
active_project_name: Option<&str>,
post_count: usize,
media_count: usize,
offline_mode: bool,
// i18n
locale: UiLocale,
) -> Element<'static, Message> {
// Activity bar (leftmost column)
let activity = activity_bar::view(sidebar_view, locale);
// Sidebar (conditionally visible)
let sidebar_el: Option<Element<'static, Message>> = if sidebar_visible {
Some(sidebar::view(sidebar_view, locale))
} else {
None
};
// Tab bar
let tabs_el = tab_bar::view(tabs, active_tab);
// Content area
let content_area = welcome::view(locale);
// Panel (conditionally visible)
let panel_el: Option<Element<'static, Message>> = if panel_visible {
Some(panel::view(panel_tab, task_snapshots, output_entries, locale))
} else {
None
};
// Right column: tab bar + content + panel
let mut right_col = column![tabs_el, content_area];
if let Some(p) = panel_el {
right_col = right_col.push(p);
}
let right = right_col.width(Length::Fill).height(Length::Fill);
// Main row: activity bar + sidebar? + right column
let mut main_row = row![activity];
if let Some(sb) = sidebar_el {
main_row = main_row.push(sb);
}
main_row = main_row.push(right);
let main_row = main_row.height(Length::Fill);
// Status bar at bottom
let status = status_bar::view(
active_project_name,
post_count,
media_count,
locale,
offline_mode,
task_snapshots,
);
column![main_row, status]
.width(Length::Fill)
.height(Length::Fill)
.into()
}

View File

@@ -1,6 +1,6 @@
//! App launch smoke tests.
//!
//! M0 validation: verifies that the core UI types can be constructed
//! Validates that the core UI types can be constructed
//! and that the message routing works at the type level.
//!
//! NOTE: muda::Menu on macOS requires the actual main thread (not just
@@ -8,7 +8,11 @@
//! be tested via `cargo test`. The full smoke test is launching the binary:
//! cargo run -p bds-ui
use std::path::PathBuf;
use bds_core::i18n::UiLocale;
use bds_ui::app::Message;
use bds_ui::state::navigation::{PanelTab, SidebarView};
use bds_ui::state::tabs::{Tab, TabType};
// ── Smoke: Message enum is well-formed ──
@@ -16,7 +20,6 @@ use bds_ui::app::Message;
fn message_variants_constructable() {
let _noop = Message::Noop;
let _menu = Message::MenuEvent(muda::MenuId::new("test"));
// Verify Debug trait works
assert!(format!("{:?}", Message::Noop).contains("Noop"));
}
@@ -27,12 +30,62 @@ fn message_clone_works() {
assert!(format!("{cloned:?}").contains("MenuEvent"));
}
#[test]
fn new_message_variants_constructable() {
// Navigation
let _view = Message::SetActiveView(SidebarView::Posts);
let _toggle_sb = Message::ToggleSidebar;
let _toggle_p = Message::TogglePanel;
// Tabs
let tab = Tab {
id: "test".to_string(),
tab_type: TabType::Post,
title: "Test".to_string(),
is_transient: false,
};
let _open = Message::OpenTab(tab);
let _close = Message::CloseTab("test".into());
let _select = Message::SelectTab("test".into());
let _pin = Message::PinTab("test".into());
// Project
let _switch = Message::SwitchProject("id".into());
let _create = Message::CreateProject { name: "X".into(), data_path: None };
let _delete = Message::DeleteProject("id".into());
// Dialogs
let _folder = Message::FolderPicked(Some(PathBuf::from("/tmp")));
let _media = Message::MediaFilesPicked(None);
// Tasks
let _tick = Message::TaskTick;
// macOS lifecycle
let _file = Message::FileOpenRequested(PathBuf::from("/test"));
let _url = Message::UrlOpenRequested("bds://open".into());
// Panel
let _panel = Message::SetPanelTab(PanelTab::Output);
// Settings
let _offline = Message::SetOfflineMode(true);
let _locale = Message::SetUiLocale(UiLocale::De);
// Blog actions
let _rebuild = Message::RebuildDatabase;
let _reindex = Message::ReindexText;
let _diff = Message::RunMetadataDiff;
let _finished = Message::BlogTaskFinished {
label: "test".into(),
result: Ok(()),
};
}
// ── Smoke: BdsApp type is accessible from integration tests ──
#[test]
fn bds_app_type_is_public() {
// This test verifies the public API surface exists.
// BdsApp, Message, platform::menu are all reachable.
fn _assert_types() {
let _: fn() -> (bds_ui::BdsApp, iced::Task<Message>) = bds_ui::BdsApp::new;
}

View File

@@ -0,0 +1,44 @@
//! Menu routing integration tests.
//!
//! Validates: all MenuAction variants registered, no collisions,
//! bidirectional lookup, and i18n keys resolve in every locale.
use bds_core::i18n::{translate, UiLocale};
use bds_ui::platform::menu::MenuAction;
#[test]
fn all_menu_actions_have_i18n_keys() {
for &action in MenuAction::ALL {
let key = action.i18n_key();
assert!(key.starts_with("menu.item."), "bad key prefix: {key}");
}
}
#[test]
fn all_menu_actions_translate_in_all_locales() {
for locale in UiLocale::all() {
for &action in MenuAction::ALL {
let key = action.i18n_key();
let label = translate(*locale, key);
assert_ne!(
label, key,
"missing translation for {key} in locale {locale}"
);
}
}
}
#[test]
fn menu_action_count_matches_spec() {
// M2 spec: 28 custom menu actions
assert_eq!(MenuAction::ALL.len(), 28);
}
#[test]
fn no_duplicate_i18n_keys() {
let mut keys = std::collections::HashSet::new();
for &action in MenuAction::ALL {
let key = action.i18n_key();
assert!(keys.insert(key), "duplicate i18n key: {key}");
}
}

View File

@@ -0,0 +1,124 @@
//! Project flow integration tests.
//!
//! Tests the project lifecycle: create, list, set active, switch, delete.
//! Uses in-memory DB — no Iced window required.
use bds_core::db::Database;
use bds_core::engine::project;
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
let dir = TempDir::new().unwrap();
(db, dir)
}
#[test]
fn create_and_activate_default_project() {
let (db, dir) = setup();
let data_path = dir.path().join("my-blog");
let p = project::create_project(
db.conn(),
"My Blog",
Some(data_path.to_str().unwrap()),
)
.unwrap();
assert_eq!(p.name, "My Blog");
assert_eq!(p.slug, "my-blog");
assert!(!p.is_active);
// Activate it
project::set_active_project(db.conn(), &p.id).unwrap();
let active = project::get_active_project(db.conn()).unwrap().unwrap();
assert_eq!(active.id, p.id);
}
#[test]
fn switch_between_projects() {
let (db, dir) = setup();
let p1_path = dir.path().join("blog-a");
let p2_path = dir.path().join("blog-b");
let p1 = project::create_project(db.conn(), "Blog A", Some(p1_path.to_str().unwrap())).unwrap();
let p2 = project::create_project(db.conn(), "Blog B", Some(p2_path.to_str().unwrap())).unwrap();
project::set_active_project(db.conn(), &p1.id).unwrap();
let active = project::get_active_project(db.conn()).unwrap().unwrap();
assert_eq!(active.id, p1.id);
// Switch
project::set_active_project(db.conn(), &p2.id).unwrap();
let active = project::get_active_project(db.conn()).unwrap().unwrap();
assert_eq!(active.id, p2.id);
}
#[test]
fn delete_inactive_project() {
let (db, dir) = setup();
let p1_path = dir.path().join("keep");
let p2_path = dir.path().join("remove");
let p1 = project::create_project(db.conn(), "Keep", Some(p1_path.to_str().unwrap())).unwrap();
let p2 = project::create_project(db.conn(), "Remove", Some(p2_path.to_str().unwrap())).unwrap();
project::set_active_project(db.conn(), &p1.id).unwrap();
project::delete_project(db.conn(), &p2.id, Some(&p2_path)).unwrap();
let list = project::list_projects(db.conn()).unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].name, "Keep");
}
#[test]
fn cannot_delete_active_project() {
let (db, dir) = setup();
let p_path = dir.path().join("active");
let p = project::create_project(db.conn(), "Active", Some(p_path.to_str().unwrap())).unwrap();
project::set_active_project(db.conn(), &p.id).unwrap();
let result = project::delete_project(db.conn(), &p.id, None);
assert!(result.is_err());
}
#[test]
fn project_directory_structure_created() {
let (db, dir) = setup();
let p_path = dir.path().join("structured");
project::create_project(db.conn(), "Structured", Some(p_path.to_str().unwrap())).unwrap();
assert!(p_path.join("posts").is_dir());
assert!(p_path.join("media").is_dir());
assert!(p_path.join("meta").is_dir());
assert!(p_path.join("thumbnails").is_dir());
assert!(p_path.join("templates").is_dir());
assert!(p_path.join("scripts").is_dir());
}
#[test]
fn default_meta_files_written() {
let (db, dir) = setup();
let p_path = dir.path().join("meta-test");
project::create_project(db.conn(), "Meta Test", Some(p_path.to_str().unwrap())).unwrap();
let project_json: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(p_path.join("meta/project.json")).unwrap(),
)
.unwrap();
assert_eq!(project_json["name"], "Meta Test");
assert_eq!(project_json["maxPostsPerPage"], 50);
let categories: Vec<String> = serde_json::from_str(
&std::fs::read_to_string(p_path.join("meta/categories.json")).unwrap(),
)
.unwrap();
assert_eq!(categories, vec!["article", "aside", "page", "picture"]);
let tags: Vec<String> = serde_json::from_str(
&std::fs::read_to_string(p_path.join("meta/tags.json")).unwrap(),
)
.unwrap();
assert!(tags.is_empty());
}

98
locales/ui/de.json Normal file
View File

@@ -0,0 +1,98 @@
{
"menu.group.file": "Datei",
"menu.group.edit": "Bearbeiten",
"menu.group.view": "Ansicht",
"menu.group.blog": "Blogbereich",
"menu.group.help": "Hilfe",
"menu.item.newPost": "Neuer Beitrag",
"menu.item.importMedia": "Medien importieren...",
"menu.item.save": "Speichern",
"menu.item.openInBrowser": "Im Browser öffnen",
"menu.item.openDataFolder": "Datenordner öffnen",
"menu.item.find": "Suchen",
"menu.item.replace": "Ersetzen",
"menu.item.editPreferences": "Einstellungen bearbeiten",
"menu.item.viewPosts": "Beiträge",
"menu.item.viewMedia": "Medien",
"menu.item.toggleSidebar": "Seitenleiste umschalten",
"menu.item.togglePanel": "Panel umschalten",
"menu.item.publishSelected": "Ausgewählte veröffentlichen",
"menu.item.previewPost": "Beitragsvorschau",
"menu.item.rebuildDatabase": "Datenbank aus Dateien neu aufbauen",
"menu.item.reindexText": "Suchtext neu indizieren",
"menu.item.metadataDiff": "Metadaten-Diff-Werkzeug",
"menu.item.editMenu": "Blog-Menü bearbeiten",
"menu.item.generateSitemap": "Site rendern",
"menu.item.regenerateCalendar": "Kalender neu erzeugen",
"menu.item.validateSite": "Website validieren",
"menu.item.validateTranslations": "Übersetzungen validieren",
"menu.item.fillMissingTranslations": "Fehlende Übersetzungen ausfüllen",
"menu.item.uploadSite": "Website hochladen",
"menu.item.about": "Über Blogging Desktop Server",
"menu.item.openDocumentation": "Dokumentation öffnen",
"menu.item.viewOnGitHub": "Auf GitHub ansehen",
"menu.item.reportIssue": "Problem melden",
"activity.posts": "Beiträge",
"activity.pages": "Seiten",
"activity.media": "Medien",
"activity.scripts": "Skripte",
"activity.tags": "Schlagwörter",
"activity.templates": "Vorlagen",
"activity.aiAssistant": "KI-Assistent",
"activity.import": "Importieren",
"activity.sourceControl": "Versionskontrolle",
"common.save": "Speichern",
"common.cancel": "Abbrechen",
"common.settings": "Einstellungen",
"common.tasks": "Aufgaben",
"common.running": "laufend",
"common.pending": "ausstehend",
"tasks.backgroundTasks": "Hintergrundaufgaben",
"tasks.clearCompleted": "Abgeschlossene löschen",
"tasks.noActive": "Keine aktiven Aufgaben",
"tasks.cancelTask": "Aufgabe abbrechen",
"tasks.triggerTitle": "{running} laufend, {pending} ausstehend",
"app.taskCompleted": "Aufgabe abgeschlossen: {message}",
"app.taskFailed": "Aufgabe fehlgeschlagen: {message}",
"app.databaseRebuildFailed": "Datenbank-Neuaufbau fehlgeschlagen",
"app.textReindexFailed": "Text-Neuindizierung fehlgeschlagen",
"panel.output": "Ausgabe",
"panel.closeTitle": "Panel schließen",
"panel.noRecentTasks": "Keine aktuellen Aufgaben",
"panel.noOutput": "Keine Ausgabe",
"statusBar.posts": "{count} Beiträge",
"statusBar.media": "{count} Medien",
"statusBar.ui": "UI",
"statusBar.uiLanguage": "UI-Sprache",
"statusBar.offlineMode": "Flugmodus",
"statusBar.offlineModeActive": "Flugmodus (aktiv)",
"projectSelector.switchProject": "Projekt wechseln",
"projectSelector.projectsHeader": "Projekte",
"projectSelector.newProject": "Neues Projekt",
"projectSelector.createProject": "Projekt erstellen",
"projectSelector.projectName": "Projektname",
"projectSelector.projectNamePlaceholder": "Mein Blog",
"projectSelector.chooseFolder": "Ordner wählen...",
"projectSelector.useDefaultLocation": "Standardort verwenden",
"projectSelector.deleteProject": "Projekt löschen",
"projectSelector.toast.switched": "Zu {name} gewechselt",
"projectSelector.toast.switchFailed": "Projektwechsel fehlgeschlagen",
"projectSelector.toast.created": "Projekt \"{name}\" erstellt",
"projectSelector.toast.createFailed": "Projekt konnte nicht erstellt werden",
"projectSelector.toast.deleteFailed": "Projekt konnte nicht gelöscht werden",
"tabBar.closeHint": "Schließen",
"tabBar.loading": "Laden...",
"sidebar.noPostsYet": "Noch keine Beiträge",
"sidebar.noPagesYet": "Noch keine Seiten",
"sidebar.noMediaYet": "Noch keine Medien",
"sidebar.loading": "Lädt...",
"sidebar.settingsHeader": "Einstellungen",
"sidebar.tagsHeader": "Schlagwörter",
"language.en": "Englisch",
"language.de": "Deutsch",
"language.fr": "Französisch",
"language.it": "Italienisch",
"language.es": "Spanisch",
"welcome.title": "Willkommen bei bDS",
"welcome.subtitle": "Öffne ein Projekt oder erstelle einen neuen Beitrag."
}

98
locales/ui/en.json Normal file
View File

@@ -0,0 +1,98 @@
{
"menu.group.file": "File",
"menu.group.edit": "Edit",
"menu.group.view": "View",
"menu.group.blog": "Blog",
"menu.group.help": "Help",
"menu.item.newPost": "New Post",
"menu.item.importMedia": "Import Media...",
"menu.item.save": "Save",
"menu.item.openInBrowser": "Open in Browser",
"menu.item.openDataFolder": "Open Data Folder",
"menu.item.find": "Find",
"menu.item.replace": "Replace",
"menu.item.editPreferences": "Edit Preferences",
"menu.item.viewPosts": "Posts",
"menu.item.viewMedia": "Media",
"menu.item.toggleSidebar": "Toggle Sidebar",
"menu.item.togglePanel": "Toggle Panel",
"menu.item.publishSelected": "Publish Selected",
"menu.item.previewPost": "Preview Post",
"menu.item.rebuildDatabase": "Rebuild Database from Files",
"menu.item.reindexText": "Reindex Search Text",
"menu.item.metadataDiff": "Metadata Diff Tool",
"menu.item.editMenu": "Edit Blog Menu",
"menu.item.generateSitemap": "Render Site",
"menu.item.regenerateCalendar": "Regenerate Calendar",
"menu.item.validateSite": "Validate Site",
"menu.item.validateTranslations": "Validate Translations",
"menu.item.fillMissingTranslations": "Fill Missing Translations",
"menu.item.uploadSite": "Upload Site",
"menu.item.about": "About Blogging Desktop Server",
"menu.item.openDocumentation": "Open Documentation",
"menu.item.viewOnGitHub": "View on GitHub",
"menu.item.reportIssue": "Report Issue",
"activity.posts": "Posts",
"activity.pages": "Pages",
"activity.media": "Media",
"activity.scripts": "Scripts",
"activity.tags": "Tags",
"activity.templates": "Templates",
"activity.aiAssistant": "AI Assistant",
"activity.import": "Import",
"activity.sourceControl": "Source Control",
"common.save": "Save",
"common.cancel": "Cancel",
"common.settings": "Settings",
"common.tasks": "Tasks",
"common.running": "running",
"common.pending": "pending",
"tasks.backgroundTasks": "Background Tasks",
"tasks.clearCompleted": "Clear completed",
"tasks.noActive": "No active tasks",
"tasks.cancelTask": "Cancel task",
"tasks.triggerTitle": "{running} running, {pending} pending",
"app.taskCompleted": "Task completed: {message}",
"app.taskFailed": "Task failed: {message}",
"app.databaseRebuildFailed": "Database rebuild failed",
"app.textReindexFailed": "Text reindex failed",
"panel.output": "Output",
"panel.closeTitle": "Close panel",
"panel.noRecentTasks": "No recent tasks",
"panel.noOutput": "No output",
"statusBar.posts": "{count} posts",
"statusBar.media": "{count} media",
"statusBar.ui": "UI",
"statusBar.uiLanguage": "UI Language",
"statusBar.offlineMode": "Airplane Mode",
"statusBar.offlineModeActive": "Airplane Mode (active)",
"projectSelector.switchProject": "Switch project",
"projectSelector.projectsHeader": "Projects",
"projectSelector.newProject": "New Project",
"projectSelector.createProject": "Create Project",
"projectSelector.projectName": "Project name",
"projectSelector.projectNamePlaceholder": "My Blog",
"projectSelector.chooseFolder": "Choose folder...",
"projectSelector.useDefaultLocation": "Use default location",
"projectSelector.deleteProject": "Delete Project",
"projectSelector.toast.switched": "Switched to {name}",
"projectSelector.toast.switchFailed": "Project switch failed",
"projectSelector.toast.created": "Project \"{name}\" created",
"projectSelector.toast.createFailed": "Failed to create project",
"projectSelector.toast.deleteFailed": "Failed to delete project",
"tabBar.closeHint": "Close",
"tabBar.loading": "Loading...",
"sidebar.noPostsYet": "No posts yet",
"sidebar.noPagesYet": "No pages yet",
"sidebar.noMediaYet": "No media yet",
"sidebar.loading": "Loading...",
"sidebar.settingsHeader": "Settings",
"sidebar.tagsHeader": "Tags",
"language.en": "English",
"language.de": "German",
"language.fr": "French",
"language.it": "Italian",
"language.es": "Spanish",
"welcome.title": "Welcome to bDS",
"welcome.subtitle": "Open a project or create a new post to get started."
}

98
locales/ui/es.json Normal file
View File

@@ -0,0 +1,98 @@
{
"menu.group.file": "Archivo",
"menu.group.edit": "Editar",
"menu.group.view": "Ver",
"menu.group.blog": "Bitácora",
"menu.group.help": "Ayuda",
"menu.item.newPost": "Nueva entrada",
"menu.item.importMedia": "Importar medios...",
"menu.item.save": "Guardar",
"menu.item.openInBrowser": "Abrir en el navegador",
"menu.item.openDataFolder": "Abrir carpeta de datos",
"menu.item.find": "Buscar",
"menu.item.replace": "Reemplazar",
"menu.item.editPreferences": "Editar preferencias",
"menu.item.viewPosts": "Entradas",
"menu.item.viewMedia": "Medios",
"menu.item.toggleSidebar": "Alternar barra lateral",
"menu.item.togglePanel": "Alternar panel",
"menu.item.publishSelected": "Publicar selección",
"menu.item.previewPost": "Vista previa de entrada",
"menu.item.rebuildDatabase": "Reconstruir base de datos desde archivos",
"menu.item.reindexText": "Reindexar texto de búsqueda",
"menu.item.metadataDiff": "Herramienta diff de metadatos",
"menu.item.editMenu": "Editar menú del blog",
"menu.item.generateSitemap": "Renderizar sitio",
"menu.item.regenerateCalendar": "Regenerar calendario",
"menu.item.validateSite": "Validar sitio",
"menu.item.validateTranslations": "Validar traducciones",
"menu.item.fillMissingTranslations": "Completar traducciones faltantes",
"menu.item.uploadSite": "Subir sitio",
"menu.item.about": "Acerca de Blogging Desktop Server",
"menu.item.openDocumentation": "Abrir documentación",
"menu.item.viewOnGitHub": "Ver en GitHub",
"menu.item.reportIssue": "Reportar problema",
"activity.posts": "Entradas",
"activity.pages": "Páginas",
"activity.media": "Medios",
"activity.scripts": "Scripts",
"activity.tags": "Etiquetas",
"activity.templates": "Plantillas",
"activity.aiAssistant": "Asistente IA",
"activity.import": "Importar",
"activity.sourceControl": "Control de código fuente",
"common.save": "Guardar",
"common.cancel": "Cancelar",
"common.settings": "Configuración",
"common.tasks": "Tareas",
"common.running": "en ejecución",
"common.pending": "pendiente",
"tasks.backgroundTasks": "Tareas en segundo plano",
"tasks.clearCompleted": "Limpiar completadas",
"tasks.noActive": "No hay tareas activas",
"tasks.cancelTask": "Cancelar tarea",
"tasks.triggerTitle": "{running} en ejecución, {pending} pendiente",
"app.taskCompleted": "Tarea completada: {message}",
"app.taskFailed": "Tarea fallida: {message}",
"app.databaseRebuildFailed": "La reconstrucción de la base de datos falló",
"app.textReindexFailed": "La reindexación de texto falló",
"panel.output": "Salida",
"panel.closeTitle": "Cerrar panel",
"panel.noRecentTasks": "No hay tareas recientes",
"panel.noOutput": "Sin salida",
"statusBar.posts": "{count} publicaciones",
"statusBar.media": "{count} medios",
"statusBar.ui": "UI",
"statusBar.uiLanguage": "Idioma de la interfaz",
"statusBar.offlineMode": "Modo avión",
"statusBar.offlineModeActive": "Modo avión (activo)",
"projectSelector.switchProject": "Cambiar proyecto",
"projectSelector.projectsHeader": "Proyectos",
"projectSelector.newProject": "Nuevo proyecto",
"projectSelector.createProject": "Crear proyecto",
"projectSelector.projectName": "Nombre del proyecto",
"projectSelector.projectNamePlaceholder": "Mi blog",
"projectSelector.chooseFolder": "Elegir carpeta...",
"projectSelector.useDefaultLocation": "Usar ubicación predeterminada",
"projectSelector.deleteProject": "Eliminar proyecto",
"projectSelector.toast.switched": "Cambiado a {name}",
"projectSelector.toast.switchFailed": "No se pudo cambiar de proyecto",
"projectSelector.toast.created": "Proyecto \"{name}\" creado",
"projectSelector.toast.createFailed": "No se pudo crear el proyecto",
"projectSelector.toast.deleteFailed": "No se pudo eliminar el proyecto",
"tabBar.closeHint": "Cerrar",
"tabBar.loading": "Cargando...",
"sidebar.noPostsYet": "Aún no hay entradas",
"sidebar.noPagesYet": "Aún no hay páginas",
"sidebar.noMediaYet": "Aún no hay medios",
"sidebar.loading": "Cargando...",
"sidebar.settingsHeader": "Configuración",
"sidebar.tagsHeader": "Etiquetas",
"language.en": "Inglés",
"language.de": "Alemán",
"language.fr": "Francés",
"language.it": "Italiano",
"language.es": "Español",
"welcome.title": "Bienvenido a bDS",
"welcome.subtitle": "Abre un proyecto o crea una nueva entrada para comenzar."
}

98
locales/ui/fr.json Normal file
View File

@@ -0,0 +1,98 @@
{
"menu.group.file": "Fichier",
"menu.group.edit": "Édition",
"menu.group.view": "Affichage",
"menu.group.blog": "Espace blog",
"menu.group.help": "Aide",
"menu.item.newPost": "Nouvel article",
"menu.item.importMedia": "Importer des médias...",
"menu.item.save": "Enregistrer",
"menu.item.openInBrowser": "Ouvrir dans le navigateur",
"menu.item.openDataFolder": "Ouvrir le dossier de données",
"menu.item.find": "Rechercher",
"menu.item.replace": "Remplacer",
"menu.item.editPreferences": "Modifier les préférences",
"menu.item.viewPosts": "Articles",
"menu.item.viewMedia": "Médias",
"menu.item.toggleSidebar": "Basculer la barre latérale",
"menu.item.togglePanel": "Basculer le panneau",
"menu.item.publishSelected": "Publier la sélection",
"menu.item.previewPost": "Aperçu de l'article",
"menu.item.rebuildDatabase": "Reconstruire la base de données",
"menu.item.reindexText": "Réindexer le texte de recherche",
"menu.item.metadataDiff": "Outil de diff des métadonnées",
"menu.item.editMenu": "Modifier le menu du blog",
"menu.item.generateSitemap": "Rendre le site",
"menu.item.regenerateCalendar": "Régénérer le calendrier",
"menu.item.validateSite": "Valider le site",
"menu.item.validateTranslations": "Valider les traductions",
"menu.item.fillMissingTranslations": "Compléter les traductions manquantes",
"menu.item.uploadSite": "Publier le site",
"menu.item.about": "À propos de Blogging Desktop Server",
"menu.item.openDocumentation": "Ouvrir la documentation",
"menu.item.viewOnGitHub": "Voir sur GitHub",
"menu.item.reportIssue": "Signaler un problème",
"activity.posts": "Articles",
"activity.pages": "Pages du site",
"activity.media": "Médias",
"activity.scripts": "Scripts",
"activity.tags": "Étiquettes",
"activity.templates": "Modèles",
"activity.aiAssistant": "Assistant IA",
"activity.import": "Importation",
"activity.sourceControl": "Contrôle de source",
"common.save": "Enregistrer",
"common.cancel": "Annuler",
"common.settings": "Paramètres",
"common.tasks": "Tâches",
"common.running": "en cours",
"common.pending": "en attente",
"tasks.backgroundTasks": "Tâches en arrière-plan",
"tasks.clearCompleted": "Effacer terminé",
"tasks.noActive": "Aucune tâche active",
"tasks.cancelTask": "Annuler la tâche",
"tasks.triggerTitle": "{running} en cours, {pending} en attente",
"app.taskCompleted": "Tâche terminée : {message}",
"app.taskFailed": "Échec de la tâche : {message}",
"app.databaseRebuildFailed": "Échec de la reconstruction de la base de données",
"app.textReindexFailed": "Échec de la réindexation du texte",
"panel.output": "Sortie",
"panel.closeTitle": "Fermer le panneau",
"panel.noRecentTasks": "Aucune tâche récente",
"panel.noOutput": "Aucune sortie",
"statusBar.posts": "{count} articles",
"statusBar.media": "{count} médias",
"statusBar.ui": "UI",
"statusBar.uiLanguage": "Langue de l'interface",
"statusBar.offlineMode": "Mode avion",
"statusBar.offlineModeActive": "Mode avion (actif)",
"projectSelector.switchProject": "Changer de projet",
"projectSelector.projectsHeader": "Projets",
"projectSelector.newProject": "Nouveau projet",
"projectSelector.createProject": "Créer le projet",
"projectSelector.projectName": "Nom du projet",
"projectSelector.projectNamePlaceholder": "Mon blog",
"projectSelector.chooseFolder": "Choisir un dossier...",
"projectSelector.useDefaultLocation": "Utiliser l'emplacement par défaut",
"projectSelector.deleteProject": "Supprimer le projet",
"projectSelector.toast.switched": "Projet actif : {name}",
"projectSelector.toast.switchFailed": "Échec du changement de projet",
"projectSelector.toast.created": "Projet \"{name}\" créé",
"projectSelector.toast.createFailed": "Impossible de créer le projet",
"projectSelector.toast.deleteFailed": "Impossible de supprimer le projet",
"tabBar.closeHint": "Fermer",
"tabBar.loading": "Chargement...",
"sidebar.noPostsYet": "Aucun article pour le moment",
"sidebar.noPagesYet": "Aucune page pour le moment",
"sidebar.noMediaYet": "Aucun média pour le moment",
"sidebar.loading": "Chargement...",
"sidebar.settingsHeader": "Paramètres",
"sidebar.tagsHeader": "Étiquettes",
"language.en": "Anglais",
"language.de": "Allemand",
"language.fr": "Français",
"language.it": "Italien",
"language.es": "Espagnol",
"welcome.title": "Bienvenue dans bDS",
"welcome.subtitle": "Ouvrez un projet ou créez un nouvel article pour commencer."
}

98
locales/ui/it.json Normal file
View File

@@ -0,0 +1,98 @@
{
"menu.group.file": "Archivio",
"menu.group.edit": "Modifica",
"menu.group.view": "Vista",
"menu.group.blog": "Sezione blog",
"menu.group.help": "Aiuto",
"menu.item.newPost": "Nuovo post",
"menu.item.importMedia": "Importa media...",
"menu.item.save": "Salva",
"menu.item.openInBrowser": "Apri nel browser",
"menu.item.openDataFolder": "Apri cartella dati",
"menu.item.find": "Trova",
"menu.item.replace": "Sostituisci",
"menu.item.editPreferences": "Modifica preferenze",
"menu.item.viewPosts": "Post",
"menu.item.viewMedia": "Contenuti media",
"menu.item.toggleSidebar": "Attiva/disattiva barra laterale",
"menu.item.togglePanel": "Attiva/disattiva pannello",
"menu.item.publishSelected": "Pubblica selezionato",
"menu.item.previewPost": "Anteprima post",
"menu.item.rebuildDatabase": "Ricostruisci database dai file",
"menu.item.reindexText": "Reindicizza testo di ricerca",
"menu.item.metadataDiff": "Strumento diff metadati",
"menu.item.editMenu": "Modifica menu blog",
"menu.item.generateSitemap": "Renderizza sito",
"menu.item.regenerateCalendar": "Rigenera calendario",
"menu.item.validateSite": "Valida sito",
"menu.item.validateTranslations": "Valida traduzioni",
"menu.item.fillMissingTranslations": "Completa le traduzioni mancanti",
"menu.item.uploadSite": "Carica sito",
"menu.item.about": "Informazioni su Blogging Desktop Server",
"menu.item.openDocumentation": "Apri documentazione",
"menu.item.viewOnGitHub": "Visualizza su GitHub",
"menu.item.reportIssue": "Segnala problema",
"activity.posts": "Post",
"activity.pages": "Pagine",
"activity.media": "Contenuti media",
"activity.scripts": "Script",
"activity.tags": "Tag",
"activity.templates": "Modelli",
"activity.aiAssistant": "Assistente IA",
"activity.import": "Importa",
"activity.sourceControl": "Controllo sorgente",
"common.save": "Salva",
"common.cancel": "Annulla",
"common.settings": "Impostazioni",
"common.tasks": "Attività",
"common.running": "in esecuzione",
"common.pending": "in attesa",
"tasks.backgroundTasks": "Attività in background",
"tasks.clearCompleted": "Cancella completate",
"tasks.noActive": "Nessuna attività attiva",
"tasks.cancelTask": "Annulla attività",
"tasks.triggerTitle": "{running} in esecuzione, {pending} in attesa",
"app.taskCompleted": "Attività completata: {message}",
"app.taskFailed": "Attività non riuscita: {message}",
"app.databaseRebuildFailed": "Ricostruzione database non riuscita",
"app.textReindexFailed": "Reindicizzazione testo non riuscita",
"panel.output": "Uscita",
"panel.closeTitle": "Chiudi pannello",
"panel.noRecentTasks": "Nessuna attività recente",
"panel.noOutput": "Nessun output",
"statusBar.posts": "{count} articoli",
"statusBar.media": "{count} media",
"statusBar.ui": "UI",
"statusBar.uiLanguage": "Lingua interfaccia",
"statusBar.offlineMode": "Modalità aereo",
"statusBar.offlineModeActive": "Modalità aereo (attiva)",
"projectSelector.switchProject": "Cambia progetto",
"projectSelector.projectsHeader": "Progetti",
"projectSelector.newProject": "Nuovo progetto",
"projectSelector.createProject": "Crea progetto",
"projectSelector.projectName": "Nome progetto",
"projectSelector.projectNamePlaceholder": "Il mio blog",
"projectSelector.chooseFolder": "Scegli cartella...",
"projectSelector.useDefaultLocation": "Usa percorso predefinito",
"projectSelector.deleteProject": "Elimina progetto",
"projectSelector.toast.switched": "Passato a {name}",
"projectSelector.toast.switchFailed": "Cambio progetto non riuscito",
"projectSelector.toast.created": "Progetto \"{name}\" creato",
"projectSelector.toast.createFailed": "Creazione progetto non riuscita",
"projectSelector.toast.deleteFailed": "Eliminazione progetto non riuscita",
"tabBar.closeHint": "Chiudi",
"tabBar.loading": "Caricamento...",
"sidebar.noPostsYet": "Nessun post",
"sidebar.noPagesYet": "Nessuna pagina",
"sidebar.noMediaYet": "Nessun media",
"sidebar.loading": "Caricamento...",
"sidebar.settingsHeader": "Impostazioni",
"sidebar.tagsHeader": "Tag",
"language.en": "Inglese",
"language.de": "Tedesco",
"language.fr": "Francese",
"language.it": "Italiano",
"language.es": "Spagnolo",
"welcome.title": "Benvenuto in bDS",
"welcome.subtitle": "Apri un progetto o crea un nuovo post per iniziare."
}