feat: first take at M2
This commit is contained in:
162
Cargo.lock
generated
162
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
11
crates/bds-ui/src/i18n/mod.rs
Normal file
11
crates/bds-ui/src/i18n/mod.rs
Normal 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)
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
pub mod app;
|
||||
pub mod i18n;
|
||||
pub mod platform;
|
||||
pub mod state;
|
||||
pub mod views;
|
||||
|
||||
pub use app::BdsApp;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
35
crates/bds-ui/src/platform/dialog.rs
Normal file
35
crates/bds-ui/src/platform/dialog.rs
Normal 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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod menu;
|
||||
pub mod dialog;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos;
|
||||
|
||||
5
crates/bds-ui/src/state/mod.rs
Normal file
5
crates/bds-ui/src/state/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod navigation;
|
||||
pub mod tabs;
|
||||
|
||||
pub use navigation::{SidebarView, PanelTab, TaskSnapshot, OutputEntry};
|
||||
pub use tabs::{Tab, TabType};
|
||||
126
crates/bds-ui/src/state/navigation.rs
Normal file
126
crates/bds-ui/src/state/navigation.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
161
crates/bds-ui/src/state/tabs.rs
Normal file
161
crates/bds-ui/src/state/tabs.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
89
crates/bds-ui/src/views/activity_bar.rs
Normal file
89
crates/bds-ui/src/views/activity_bar.rs
Normal 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()
|
||||
}
|
||||
7
crates/bds-ui/src/views/mod.rs
Normal file
7
crates/bds-ui/src/views/mod.rs
Normal 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;
|
||||
111
crates/bds-ui/src/views/panel.rs
Normal file
111
crates/bds-ui/src/views/panel.rs
Normal 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()
|
||||
}
|
||||
37
crates/bds-ui/src/views/sidebar.rs
Normal file
37
crates/bds-ui/src/views/sidebar.rs
Normal 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()
|
||||
}
|
||||
97
crates/bds-ui/src/views/status_bar.rs
Normal file
97
crates/bds-ui/src/views/status_bar.rs
Normal 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()
|
||||
}
|
||||
58
crates/bds-ui/src/views/tab_bar.rs
Normal file
58
crates/bds-ui/src/views/tab_bar.rs
Normal 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()
|
||||
}
|
||||
26
crates/bds-ui/src/views/welcome.rs
Normal file
26
crates/bds-ui/src/views/welcome.rs
Normal 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()
|
||||
}
|
||||
84
crates/bds-ui/src/views/workspace.rs
Normal file
84
crates/bds-ui/src/views/workspace.rs
Normal 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()
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
44
crates/bds-ui/tests/menu_routing.rs
Normal file
44
crates/bds-ui/tests/menu_routing.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
124
crates/bds-ui/tests/project_flow.rs
Normal file
124
crates/bds-ui/tests/project_flow.rs
Normal 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
98
locales/ui/de.json
Normal 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
98
locales/ui/en.json
Normal 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
98
locales/ui/es.json
Normal 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
98
locales/ui/fr.json
Normal 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
98
locales/ui/it.json
Normal 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."
|
||||
}
|
||||
Reference in New Issue
Block a user