feat: first take at M2

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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