fix: and evren more gaps in M3

This commit is contained in:
2026-04-09 16:11:24 +02:00
parent 476dc99aac
commit 6955defbcf
11 changed files with 889 additions and 126 deletions

View File

@@ -209,10 +209,18 @@ pub fn count_posts_by_project(conn: &Connection, project_id: &str) -> rusqlite::
pub struct PostFilterParams { pub struct PostFilterParams {
/// FTS search query (empty = no search filter). /// FTS search query (empty = no search filter).
pub search_query: String, pub search_query: String,
/// Exact status filter.
pub status: Option<String>,
/// Exact language filter.
pub language: Option<String>,
/// Year filter from calendar archive. /// Year filter from calendar archive.
pub year: Option<i32>, pub year: Option<i32>,
/// Month filter (1-12) from calendar archive. /// Month filter (1-12) from calendar archive.
pub month: Option<u32>, pub month: Option<u32>,
/// Inclusive start timestamp filter.
pub from: Option<i64>,
/// Inclusive end timestamp filter.
pub to: Option<i64>,
/// Tag filter (post must have ALL of these tags). /// Tag filter (post must have ALL of these tags).
pub tags: Vec<String>, pub tags: Vec<String>,
/// Category filter (post must have at least one of these categories). /// Category filter (post must have at least one of these categories).
@@ -228,7 +236,11 @@ pub struct PostFilterParams {
impl PostFilterParams { impl PostFilterParams {
pub fn has_active_filters(&self) -> bool { pub fn has_active_filters(&self) -> bool {
!self.search_query.is_empty() !self.search_query.is_empty()
|| self.status.is_some()
|| self.language.is_some()
|| self.year.is_some() || self.year.is_some()
|| self.from.is_some()
|| self.to.is_some()
|| !self.tags.is_empty() || !self.tags.is_empty()
|| !self.categories.is_empty() || !self.categories.is_empty()
} }
@@ -270,6 +282,18 @@ pub fn list_posts_filtered(
param_values.push(Box::new(pattern)); param_values.push(Box::new(pattern));
} }
if let Some(status) = &filters.status {
let idx = param_values.len() + 1;
filter_conditions.push(format!("(p.status = ?{idx})"));
param_values.push(Box::new(status.clone()));
}
if let Some(language) = &filters.language {
let idx = param_values.len() + 1;
filter_conditions.push(format!("(p.language = ?{idx} OR p.language IS NULL)"));
param_values.push(Box::new(language.clone()));
}
if let Some(year) = filters.year { if let Some(year) = filters.year {
// created_at is unix ms; compute year range // created_at is unix ms; compute year range
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1) let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1)
@@ -331,11 +355,28 @@ pub fn list_posts_filtered(
param_values.push(Box::new(pattern)); param_values.push(Box::new(pattern));
} }
// If there are active filter conditions, apply them only to non-draft posts if let Some(from) = filters.from {
let idx = param_values.len() + 1;
filter_conditions.push(format!("(p.created_at >= ?{idx})"));
param_values.push(Box::new(from));
}
if let Some(to) = filters.to {
let idx = param_values.len() + 1;
filter_conditions.push(format!("(p.created_at <= ?{idx})"));
param_values.push(Box::new(to));
}
// Without an explicit status filter, drafts remain visible regardless of the
// rest of the active filters.
if !filter_conditions.is_empty() { if !filter_conditions.is_empty() {
let combined = filter_conditions.join(" AND "); let combined = filter_conditions.join(" AND ");
if filters.status.is_some() {
conditions.push(format!("({combined})"));
} else {
conditions.push(format!("(p.status = 'draft' OR ({combined}))")); conditions.push(format!("(p.status = 'draft' OR ({combined}))"));
} }
}
let where_clause = conditions.join(" AND "); let where_clause = conditions.join(" AND ");
let idx_limit = param_values.len() + 1; let idx_limit = param_values.len() + 1;

View File

@@ -97,8 +97,12 @@ pub enum Message {
// Sidebar filters // Sidebar filters
PostSearchChanged(String), PostSearchChanged(String),
TogglePostFilterPanel, TogglePostFilterPanel,
SetPostStatusFilter(Option<String>),
SetPostLanguageFilter(Option<String>),
SetPostCalendarYear(Option<i32>), SetPostCalendarYear(Option<i32>),
SetPostCalendarMonth(Option<u32>), SetPostCalendarMonth(Option<u32>),
SetPostFromDate(String),
SetPostToDate(String),
TogglePostTagFilter(String), TogglePostTagFilter(String),
TogglePostCategoryFilter(String), TogglePostCategoryFilter(String),
ClearPostFilters, ClearPostFilters,
@@ -152,6 +156,12 @@ pub enum Message {
LoadMorePosts, LoadMorePosts,
LoadMoreMedia, LoadMoreMedia,
CreatePost,
CreatePage,
CreateMedia,
CreateScript,
CreateTemplate,
Noop, Noop,
InitMenuBar, InitMenuBar,
} }
@@ -347,6 +357,7 @@ fn format_bytes(size: i64) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{month_abbreviation, persist_media_editor_state_impl, persist_post_editor_state_impl, save_editor_settings_state_impl, save_script_editor_state_impl, save_template_editor_state_impl, BdsApp, Message, PersistedMediaState, PersistedPostState, PostStatus, SettingsMsg, POST_AUTO_SAVE_DELAY_MS}; use super::{month_abbreviation, persist_media_editor_state_impl, persist_post_editor_state_impl, save_editor_settings_state_impl, save_script_editor_state_impl, save_template_editor_state_impl, BdsApp, Message, PersistedMediaState, PersistedPostState, PostStatus, SettingsMsg, POST_AUTO_SAVE_DELAY_MS};
use crate::state::sidebar_filter::PostFilter;
use crate::views::media_editor::{MediaEditorMsg, MediaEditorState}; use crate::views::media_editor::{MediaEditorMsg, MediaEditorState};
use crate::views::post_editor::PostEditorState; use crate::views::post_editor::PostEditorState;
use crate::views::script_editor::ScriptEditorState; use crate::views::script_editor::ScriptEditorState;
@@ -914,6 +925,156 @@ mod tests {
"picture".to_string(), "picture".to_string(),
]); ]);
} }
#[test]
fn create_script_opens_editor_and_refreshes_sidebar() {
let (db, project, tmp) = setup();
let mut app = make_app(db, project, &tmp);
let _ = app.update(Message::CreateScript);
assert_eq!(app.sidebar_scripts.len(), 1);
assert_eq!(app.tabs.len(), 1);
assert_eq!(app.tabs[0].tab_type, crate::state::tabs::TabType::Scripts);
assert_eq!(app.active_tab.as_deref(), Some(app.sidebar_scripts[0].id.as_str()));
let editor = app
.script_editors
.get(&app.sidebar_scripts[0].id)
.expect("script editor should be hydrated");
assert_eq!(editor.entrypoint, "render");
assert!(editor.content.contains("new script"));
}
#[test]
fn create_template_opens_editor_and_refreshes_sidebar() {
let (db, project, tmp) = setup();
let mut app = make_app(db, project, &tmp);
let _ = app.update(Message::CreateTemplate);
assert_eq!(app.sidebar_templates.len(), 1);
assert_eq!(app.tabs.len(), 1);
assert_eq!(app.tabs[0].tab_type, crate::state::tabs::TabType::Templates);
assert_eq!(app.active_tab.as_deref(), Some(app.sidebar_templates[0].id.as_str()));
let editor = app
.template_editors
.get(&app.sidebar_templates[0].id)
.expect("template editor should be hydrated");
assert!(editor.content.is_empty());
}
#[test]
fn query_sidebar_posts_filters_status_language_and_date_range() {
let (_db, project, tmp) = setup();
let db_path = tmp.path().join("sidebar.db");
let mut db = Database::open(&db_path).unwrap();
db.migrate().unwrap();
ensure_fts_tables(db.conn()).unwrap();
insert_project(db.conn(), &project).unwrap();
let draft = post::create_post(
db.conn(),
tmp.path(),
&project.id,
"Draft German",
Some("Body"),
vec![],
vec!["article".to_string()],
None,
Some("de"),
None,
)
.unwrap();
let published = post::create_post(
db.conn(),
tmp.path(),
&project.id,
"Published English",
Some("Body"),
vec![],
vec!["article".to_string()],
None,
Some("en"),
None,
)
.unwrap();
let mut published_saved = bds_core::db::queries::post::get_post_by_id(db.conn(), &published.id).unwrap();
published_saved.status = PostStatus::Published;
published_saved.created_at = chrono::Utc
.with_ymd_and_hms(2026, 4, 12, 8, 0, 0)
.single()
.unwrap()
.timestamp_millis();
bds_core::db::queries::post::update_post(db.conn(), &published_saved).unwrap();
let mut draft_saved = bds_core::db::queries::post::get_post_by_id(db.conn(), &draft.id).unwrap();
draft_saved.created_at = chrono::Utc
.with_ymd_and_hms(2025, 1, 5, 8, 0, 0)
.single()
.unwrap()
.timestamp_millis();
bds_core::db::queries::post::update_post(db.conn(), &draft_saved).unwrap();
let filter = PostFilter {
status_filter: Some("published".to_string()),
language_filter: Some("en".to_string()),
from_date: "2026-04-01".to_string(),
to_date: "2026-04-30".to_string(),
..PostFilter::default()
};
let posts = BdsApp::query_sidebar_posts_blocking(
db_path.as_path(),
&project.id,
"en",
&filter,
false,
50,
0,
);
assert_eq!(posts.len(), 1);
assert_eq!(posts[0].id, published.id);
}
#[test]
fn regenerate_project_thumbnails_recreates_missing_files() {
let (db, project, tmp) = setup();
let source = tmp.path().join("tiny.png");
std::fs::write(&source, tiny_png_bytes()).unwrap();
let media = media::import_media(
db.conn(),
tmp.path(),
&project.id,
&source,
"tiny.png",
Some("Tiny"),
Some("Alt"),
None,
None,
Some("en"),
vec![],
)
.unwrap();
let small_thumb = tmp
.path()
.join(bds_core::util::paths::thumbnail_path(&media.id, "small", "webp"));
std::fs::remove_file(&small_thumb).unwrap();
assert!(!small_thumb.exists());
let regenerated = BdsApp::regenerate_project_thumbnails(
&db,
tmp.path(),
&project.id,
|_index, _total, _name| {},
)
.unwrap();
assert_eq!(regenerated, 1);
assert!(small_thumb.exists());
}
} }
// ─────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────
@@ -1218,6 +1379,14 @@ impl BdsApp {
self.panel_visible = !self.panel_visible; self.panel_visible = !self.panel_visible;
Task::none() Task::none()
} }
Message::CreatePost => self.create_sidebar_post(false),
Message::CreatePage => self.create_sidebar_post(true),
Message::CreateMedia => crate::platform::dialog::pick_media_files(
t(self.ui_locale, "dialog.importMedia"),
t(self.ui_locale, "dialog.imageFilter"),
),
Message::CreateScript => self.create_sidebar_script(),
Message::CreateTemplate => self.create_sidebar_template(),
Message::OpenSettingsSection(section) => { Message::OpenSettingsSection(section) => {
self.sidebar_view = SidebarView::Settings; self.sidebar_view = SidebarView::Settings;
self.sidebar_visible = true; self.sidebar_visible = true;
@@ -1656,6 +1825,22 @@ impl BdsApp {
filter.filter_panel_visible = !filter.filter_panel_visible; filter.filter_panel_visible = !filter.filter_panel_visible;
Task::none() Task::none()
} }
Message::SetPostStatusFilter(status) => {
let filter = match self.sidebar_view {
SidebarView::Pages => &mut self.page_filter,
_ => &mut self.post_filter,
};
filter.status_filter = status;
self.refresh_sidebar_posts()
}
Message::SetPostLanguageFilter(language) => {
let filter = match self.sidebar_view {
SidebarView::Pages => &mut self.page_filter,
_ => &mut self.post_filter,
};
filter.language_filter = language;
self.refresh_sidebar_posts()
}
Message::SetPostCalendarYear(year) => { Message::SetPostCalendarYear(year) => {
let filter = match self.sidebar_view { let filter = match self.sidebar_view {
SidebarView::Pages => &mut self.page_filter, SidebarView::Pages => &mut self.page_filter,
@@ -1673,6 +1858,22 @@ impl BdsApp {
filter.calendar.selected_month = month; filter.calendar.selected_month = month;
self.refresh_sidebar_posts() self.refresh_sidebar_posts()
} }
Message::SetPostFromDate(value) => {
let filter = match self.sidebar_view {
SidebarView::Pages => &mut self.page_filter,
_ => &mut self.post_filter,
};
filter.from_date = value;
self.refresh_sidebar_posts()
}
Message::SetPostToDate(value) => {
let filter = match self.sidebar_view {
SidebarView::Pages => &mut self.page_filter,
_ => &mut self.post_filter,
};
filter.to_date = value;
self.refresh_sidebar_posts()
}
Message::TogglePostTagFilter(tag) => { Message::TogglePostTagFilter(tag) => {
let filter = match self.sidebar_view { let filter = match self.sidebar_view {
SidebarView::Pages => &mut self.page_filter, SidebarView::Pages => &mut self.page_filter,
@@ -2451,43 +2652,7 @@ impl BdsApp {
fn dispatch_menu_action(&mut self, action: MenuAction) -> Task<Message> { fn dispatch_menu_action(&mut self, action: MenuAction) -> Task<Message> {
match action { match action {
// File // File
MenuAction::NewPost => { MenuAction::NewPost => self.create_sidebar_post(false),
if let (Some(db), Some(project), Some(data_dir)) =
(&self.db, &self.active_project, &self.data_dir)
{
let display_title = t(self.ui_locale, "post.untitled");
match engine::post::create_post(
db.conn(),
data_dir,
&project.id,
"",
Some(""),
Vec::new(),
Vec::new(),
None,
None,
None,
) {
Ok(post) => {
let tab = Tab {
id: post.id.clone(),
tab_type: TabType::Post,
title: display_title.to_string(),
is_transient: true,
is_dirty: 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());
}
}
Err(e) => {
self.add_output(&format!("Failed to create post: {e}"));
}
}
}
Task::none()
}
MenuAction::ImportMedia => crate::platform::dialog::pick_media_files( MenuAction::ImportMedia => crate::platform::dialog::pick_media_files(
t(self.ui_locale, "dialog.importMedia"), t(self.ui_locale, "dialog.importMedia"),
t(self.ui_locale, "dialog.imageFilter"), t(self.ui_locale, "dialog.imageFilter"),
@@ -2601,6 +2766,133 @@ impl BdsApp {
self.enforce_panel_tab_fallback(); self.enforce_panel_tab_fallback();
} }
fn create_sidebar_post(&mut self, is_page: bool) -> Task<Message> {
let (Some(db), Some(project), Some(data_dir)) =
(&self.db, &self.active_project, &self.data_dir)
else {
return Task::none();
};
let categories = if is_page {
vec!["page".to_string()]
} else {
Vec::new()
};
match engine::post::create_post(
db.conn(),
data_dir,
&project.id,
"",
Some(""),
Vec::new(),
categories,
None,
None,
None,
) {
Ok(post) => {
let tab = Tab {
id: post.id.clone(),
tab_type: TabType::Post,
title: t(self.ui_locale, "post.untitled"),
is_transient: true,
is_dirty: false,
};
let idx = tabs::open_tab(&mut self.tabs, tab);
if let Some(tab) = self.tabs.get(idx).cloned() {
self.active_tab = Some(tab.id.clone());
self.load_editor_for_tab(&tab);
}
self.sync_menu_state();
self.refresh_counts()
}
Err(error) => {
self.notify(ToastLevel::Error, &format!("Failed to create post: {error}"));
Task::none()
}
}
}
fn create_sidebar_script(&mut self) -> Task<Message> {
let (Some(db), Some(project)) = (&self.db, &self.active_project) else {
return Task::none();
};
match engine::script::create_script(
db.conn(),
&project.id,
&t(self.ui_locale, "editor.untitled"),
bds_core::model::ScriptKind::Utility,
"print(\"new script\")",
Some("render"),
) {
Ok(script) => {
let tab = Tab {
id: script.id.clone(),
tab_type: TabType::Scripts,
title: if script.title.is_empty() {
t(self.ui_locale, "editor.untitled")
} else {
script.title.clone()
},
is_transient: true,
is_dirty: false,
};
let idx = tabs::open_tab(&mut self.tabs, tab);
if let Some(tab) = self.tabs.get(idx).cloned() {
self.active_tab = Some(tab.id.clone());
self.load_editor_for_tab(&tab);
}
self.sync_menu_state();
self.refresh_counts()
}
Err(error) => {
self.notify(ToastLevel::Error, &format!("Failed to create script: {error}"));
Task::none()
}
}
}
fn create_sidebar_template(&mut self) -> Task<Message> {
let (Some(db), Some(project)) = (&self.db, &self.active_project) else {
return Task::none();
};
match engine::template::create_template(
db.conn(),
&project.id,
&t(self.ui_locale, "editor.untitled"),
bds_core::model::TemplateKind::Post,
"",
) {
Ok(template) => {
let tab = Tab {
id: template.id.clone(),
tab_type: TabType::Templates,
title: if template.title.is_empty() {
t(self.ui_locale, "editor.untitled")
} else {
template.title.clone()
},
is_transient: true,
is_dirty: false,
};
let idx = tabs::open_tab(&mut self.tabs, tab);
if let Some(tab) = self.tabs.get(idx).cloned() {
self.active_tab = Some(tab.id.clone());
self.load_editor_for_tab(&tab);
}
self.sync_menu_state();
self.refresh_counts()
}
Err(error) => {
self.notify(ToastLevel::Error, &format!("Failed to create template: {error}"));
Task::none()
}
}
}
fn refresh_task_snapshots(&mut self) { fn refresh_task_snapshots(&mut self) {
self.task_snapshots = self self.task_snapshots = self
.task_manager .task_manager
@@ -2901,36 +3193,27 @@ impl BdsApp {
return Task::none(); return Task::none();
}; };
use bds_core::db::queries::post::PostFilterParams;
let db_path = self.db_path.clone(); let db_path = self.db_path.clone();
let project_id = project.id.clone(); let project_id = project.id.clone();
let content_language = self.content_language.clone();
let filter = match self.sidebar_view { let filter = match self.sidebar_view {
SidebarView::Pages => &self.page_filter, SidebarView::Pages => self.page_filter.clone(),
_ => &self.post_filter, _ => self.post_filter.clone(),
}; };
let is_pages = self.sidebar_view == SidebarView::Pages; let is_pages = self.sidebar_view == SidebarView::Pages;
let params = PostFilterParams {
search_query: filter.search_query.clone(),
year: filter.calendar.selected_year,
month: filter.calendar.selected_month,
tags: filter.tag_filter.clone(),
categories: filter.category_filter.clone(),
exclude_pages: !is_pages,
pages_only: is_pages,
};
Task::perform( Task::perform(
async move { async move {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let db = Database::open(&db_path).ok(); Self::query_sidebar_posts_blocking(
db.and_then(|db| { &db_path,
bds_core::db::queries::post::list_posts_filtered( &project_id,
db.conn(), &project_id, &params, &content_language,
Self::SIDEBAR_PAGE_SIZE + 1, 0, &filter,
).ok() is_pages,
}).unwrap_or_default() Self::SIDEBAR_PAGE_SIZE + 1,
0,
)
}).await.unwrap_or_default() }).await.unwrap_or_default()
}, },
Message::SidebarPostsLoaded, Message::SidebarPostsLoaded,
@@ -2991,43 +3274,161 @@ impl BdsApp {
return Task::none(); return Task::none();
}; };
use bds_core::db::queries::post::PostFilterParams;
let db_path = self.db_path.clone(); let db_path = self.db_path.clone();
let project_id = project.id.clone(); let project_id = project.id.clone();
let content_language = self.content_language.clone();
let offset = self.sidebar_posts.len() as i64; let offset = self.sidebar_posts.len() as i64;
let filter = match self.sidebar_view { let filter = match self.sidebar_view {
SidebarView::Pages => &self.page_filter, SidebarView::Pages => self.page_filter.clone(),
_ => &self.post_filter, _ => self.post_filter.clone(),
}; };
let is_pages = self.sidebar_view == SidebarView::Pages; let is_pages = self.sidebar_view == SidebarView::Pages;
let params = PostFilterParams {
search_query: filter.search_query.clone(),
year: filter.calendar.selected_year,
month: filter.calendar.selected_month,
tags: filter.tag_filter.clone(),
categories: filter.category_filter.clone(),
exclude_pages: !is_pages,
pages_only: is_pages,
};
Task::perform( Task::perform(
async move { async move {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let db = Database::open(&db_path).ok(); Self::query_sidebar_posts_blocking(
db.and_then(|db| { &db_path,
bds_core::db::queries::post::list_posts_filtered( &project_id,
db.conn(), &project_id, &params, &content_language,
Self::SIDEBAR_PAGE_SIZE + 1, offset, &filter,
).ok() is_pages,
}).unwrap_or_default() Self::SIDEBAR_PAGE_SIZE + 1,
offset,
)
}).await.unwrap_or_default() }).await.unwrap_or_default()
}, },
Message::SidebarPostsAppended, Message::SidebarPostsAppended,
) )
} }
fn parse_filter_date(input: &str, end_of_day: bool) -> Option<i64> {
let trimmed = input.trim();
if trimmed.is_empty() {
return None;
}
let date = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d").ok()?;
let time = if end_of_day {
date.and_hms_opt(23, 59, 59)?
} else {
date.and_hms_opt(0, 0, 0)?
};
Some(time.and_utc().timestamp_millis())
}
fn build_post_filter_params(
filter: &PostFilter,
is_pages: bool,
) -> bds_core::db::queries::post::PostFilterParams {
bds_core::db::queries::post::PostFilterParams {
search_query: filter.search_query.clone(),
status: filter.status_filter.clone(),
language: filter.language_filter.clone(),
year: filter.calendar.selected_year,
month: filter.calendar.selected_month,
from: Self::parse_filter_date(&filter.from_date, false),
to: Self::parse_filter_date(&filter.to_date, true),
tags: filter.tag_filter.clone(),
categories: filter.category_filter.clone(),
exclude_pages: !is_pages,
pages_only: is_pages,
}
}
fn query_sidebar_posts_blocking(
db_path: &Path,
project_id: &str,
content_language: &str,
filter: &PostFilter,
is_pages: bool,
limit: i64,
offset: i64,
) -> Vec<Post> {
let Ok(db) = Database::open(db_path) else {
return Vec::new();
};
let params = Self::build_post_filter_params(filter, is_pages);
if filter.search_query.trim().is_empty() {
return bds_core::db::queries::post::list_posts_filtered(
db.conn(),
project_id,
&params,
limit,
offset,
)
.unwrap_or_default();
}
let fts_filters = bds_core::db::fts::PostSearchFilters {
status: params.status.as_deref(),
tags: (!params.tags.is_empty()).then_some(params.tags.as_slice()),
categories: (!params.categories.is_empty()).then_some(params.categories.as_slice()),
language: params.language.as_deref(),
year: params.year,
month: params.month,
from: params.from,
to: params.to,
limit: Some(limit as usize),
offset: Some(offset as usize),
..Default::default()
};
let ids = bds_core::db::fts::search_posts_filtered(
db.conn(),
&params.search_query,
content_language,
&fts_filters,
)
.map(|results| results.post_ids)
.unwrap_or_default();
ids.into_iter()
.filter_map(|post_id| bds_core::db::queries::post::get_post_by_id(db.conn(), &post_id).ok())
.filter(|post| post.project_id == project_id)
.filter(|post| {
let is_page_post = post
.categories
.iter()
.any(|category| category.eq_ignore_ascii_case("page"));
if is_pages {
is_page_post
} else {
!is_page_post
}
})
.collect()
}
fn regenerate_project_thumbnails(
db: &Database,
data_dir: &Path,
project_id: &str,
mut progress: impl FnMut(usize, usize, &str),
) -> Result<usize, String> {
let media = bds_core::db::queries::media::list_media_by_project(db.conn(), project_id)
.map_err(|e| e.to_string())?;
let total = media.len();
let thumbnails_dir = data_dir.join("thumbnails");
let mut regenerated = 0usize;
for (index, item) in media.iter().enumerate() {
progress(index, total, &item.original_name);
if !item.mime_type.starts_with("image/") {
continue;
}
let source = data_dir.join(&item.file_path);
if !source.exists() {
continue;
}
bds_core::util::thumbnail::generate_all_thumbnails(&source, &thumbnails_dir, &item.id)
.map_err(|e| e.to_string())?;
regenerated += 1;
}
Ok(regenerated)
}
/// Load more media (append to existing sidebar data). /// Load more media (append to existing sidebar data).
fn load_more_sidebar_media(&mut self) -> Task<Message> { fn load_more_sidebar_media(&mut self) -> Task<Message> {
let (Some(_), Some(project)) = (&self.db, &self.active_project) else { let (Some(_), Some(project)) = (&self.db, &self.active_project) else {
@@ -3094,6 +3495,7 @@ impl BdsApp {
).unwrap_or_default(); ).unwrap_or_default();
self.post_filter.available_tags = all_tags.clone(); self.post_filter.available_tags = all_tags.clone();
self.post_filter.available_categories = all_cats.clone(); self.post_filter.available_categories = all_cats.clone();
self.post_filter.available_languages = self.blog_languages.clone();
self.post_filter.calendar_years = Self::build_calendar_tree(&post_cal); self.post_filter.calendar_years = Self::build_calendar_tree(&post_cal);
// Calendar counts for pages only // Calendar counts for pages only
@@ -3102,6 +3504,7 @@ impl BdsApp {
).unwrap_or_default(); ).unwrap_or_default();
self.page_filter.available_tags = all_tags; self.page_filter.available_tags = all_tags;
self.page_filter.available_categories = all_cats; self.page_filter.available_categories = all_cats;
self.page_filter.available_languages = self.blog_languages.clone();
self.page_filter.calendar_years = Self::build_calendar_tree(&page_cal); self.page_filter.calendar_years = Self::build_calendar_tree(&page_cal);
// Media filter metadata // Media filter metadata
@@ -4591,34 +4994,126 @@ impl BdsApp {
SettingsMsg::SemanticSimilarityChanged(value) => { SettingsMsg::SemanticSimilarityChanged(value) => {
state.semantic_similarity_enabled = value; state.semantic_similarity_enabled = value;
} }
SettingsMsg::RebuildPosts => { return Task::done(Message::RebuildDatabase); } SettingsMsg::RebuildPosts => {
SettingsMsg::RebuildMedia => { return Task::done(Message::RebuildDatabase); } return self.spawn_engine_task(
SettingsMsg::RebuildScripts => { return Task::done(Message::RebuildDatabase); } "settings.rebuildPosts",
SettingsMsg::RebuildTemplates => { return Task::done(Message::RebuildDatabase); } |db_path, project_id, data_dir, _tm, _tid| {
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
let report = engine::post::rebuild_posts_from_filesystem(
db.conn(),
&data_dir,
&project_id,
)
.map_err(|e| e.to_string())?;
Ok(format!(
"created={}, updated={}, translations={}",
report.posts_created,
report.posts_updated,
report.translations_created + report.translations_updated,
))
},
);
}
SettingsMsg::RebuildMedia => {
return self.spawn_engine_task(
"settings.rebuildMedia",
|db_path, project_id, data_dir, _tm, _tid| {
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
let report = engine::media::rebuild_media_from_filesystem(
db.conn(),
&data_dir,
&project_id,
)
.map_err(|e| e.to_string())?;
Ok(format!(
"created={}, updated={}, translations={}",
report.media_created,
report.media_updated,
report.translations_created + report.translations_updated,
))
},
);
}
SettingsMsg::RebuildScripts => {
return self.spawn_engine_task(
"settings.rebuildScripts",
|db_path, project_id, data_dir, _tm, _tid| {
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
let report = engine::script_rebuild::rebuild_scripts_from_filesystem(
db.conn(),
&data_dir,
&project_id,
)
.map_err(|e| e.to_string())?;
Ok(format!(
"created={}, updated={}, errors={}",
report.created,
report.updated,
report.errors.len(),
))
},
);
}
SettingsMsg::RebuildTemplates => {
return self.spawn_engine_task(
"settings.rebuildTemplates",
|db_path, project_id, data_dir, _tm, _tid| {
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
let report = engine::template_rebuild::rebuild_templates_from_filesystem(
db.conn(),
&data_dir,
&project_id,
)
.map_err(|e| e.to_string())?;
Ok(format!(
"created={}, updated={}, errors={}",
report.created,
report.updated,
report.errors.len(),
))
},
);
}
SettingsMsg::RebuildLinks => { SettingsMsg::RebuildLinks => {
if let (Some(db), Some(data_dir), Some(project)) = return self.spawn_engine_task(
(&self.db, &self.data_dir, &self.active_project) "settings.rebuildLinks",
{ |db_path, project_id, data_dir, _tm, _tid| {
match bds_core::engine::post::rebuild_all_links( let db = Database::open(&db_path).map_err(|e| e.to_string())?;
db.conn(), data_dir, &project.id, let rebuilt = bds_core::engine::post::rebuild_all_links(
) { db.conn(),
Ok(count) => { &data_dir,
self.notify( &project_id,
ToastLevel::Info, )
&format!("Rebuilt {} links", count), .map_err(|e| e.to_string())?;
Ok(format!("rebuilt={rebuilt}"))
},
); );
} }
Err(e) => {
self.notify(
ToastLevel::Error,
&format!("Failed to rebuild links: {e}"),
);
}
}
}
}
SettingsMsg::RegenerateThumbnails => { SettingsMsg::RegenerateThumbnails => {
self.notify(ToastLevel::Info, &t(self.ui_locale, "settings.regeneratingThumbnails")); return self.spawn_engine_task(
"settings.regenerateThumbnails",
|db_path, project_id, data_dir, tm, tid| {
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
let regenerated = Self::regenerate_project_thumbnails(
&db,
&data_dir,
&project_id,
|index, total, name| {
let progress = if total > 0 {
index as f32 / total as f32
} else {
1.0
};
tm.report_progress(
tid,
Some(progress),
Some(format!("Regenerating: {name}")),
);
},
)?;
Ok(format!("regenerated={regenerated}"))
},
);
} }
SettingsMsg::OpenDataFolder => { SettingsMsg::OpenDataFolder => {
if let Some(ref dir) = self.data_dir { if let Some(ref dir) = self.data_dir {

View File

@@ -35,8 +35,16 @@ pub struct PostFilter {
pub search_query: String, pub search_query: String,
/// Whether the collapsible filter panel is visible. /// Whether the collapsible filter panel is visible.
pub filter_panel_visible: bool, pub filter_panel_visible: bool,
/// Exact status filter.
pub status_filter: Option<String>,
/// Exact language filter.
pub language_filter: Option<String>,
/// Year/month archive filter. /// Year/month archive filter.
pub calendar: CalendarFilter, pub calendar: CalendarFilter,
/// Inclusive start date input (`YYYY-MM-DD`).
pub from_date: String,
/// Inclusive end date input (`YYYY-MM-DD`).
pub to_date: String,
/// Selected tag names (multi-select). /// Selected tag names (multi-select).
pub tag_filter: Vec<String>, pub tag_filter: Vec<String>,
/// Selected category names (multi-select). /// Selected category names (multi-select).
@@ -47,6 +55,8 @@ pub struct PostFilter {
pub available_tags: Vec<String>, pub available_tags: Vec<String>,
/// All available categories for the chip selector. /// All available categories for the chip selector.
pub available_categories: Vec<String>, pub available_categories: Vec<String>,
/// Available languages for the chip selector.
pub available_languages: Vec<String>,
} }
/// Filter state for the Media sidebar. /// Filter state for the Media sidebar.
@@ -70,7 +80,11 @@ impl PostFilter {
/// Returns true if any filter is active (for "Clear All" visibility). /// Returns true if any filter is active (for "Clear All" visibility).
pub fn has_active_filters(&self) -> bool { pub fn has_active_filters(&self) -> bool {
!self.search_query.is_empty() !self.search_query.is_empty()
|| self.status_filter.is_some()
|| self.language_filter.is_some()
|| self.calendar.selected_year.is_some() || self.calendar.selected_year.is_some()
|| !self.from_date.trim().is_empty()
|| !self.to_date.trim().is_empty()
|| !self.tag_filter.is_empty() || !self.tag_filter.is_empty()
|| !self.category_filter.is_empty() || !self.category_filter.is_empty()
} }
@@ -78,7 +92,11 @@ impl PostFilter {
/// Reset all filters to defaults. /// Reset all filters to defaults.
pub fn clear(&mut self) { pub fn clear(&mut self) {
self.search_query.clear(); self.search_query.clear();
self.status_filter = None;
self.language_filter = None;
self.calendar = CalendarFilter::default(); self.calendar = CalendarFilter::default();
self.from_date.clear();
self.to_date.clear();
self.tag_filter.clear(); self.tag_filter.clear();
self.category_filter.clear(); self.category_filter.clear();
} }

View File

@@ -156,9 +156,6 @@ impl MediaEditorState {
/// Build translation flags for the view. /// Build translation flags for the view.
pub fn translation_flags(&self) -> Vec<TranslationFlag> { pub fn translation_flags(&self) -> Vec<TranslationFlag> {
if self.translation_drafts.is_empty() {
return Vec::new();
}
let mut flags = Vec::new(); let mut flags = Vec::new();
let canon = &self.canonical_language; let canon = &self.canonical_language;
let canon_locale = i18n::normalize_language(canon); let canon_locale = i18n::normalize_language(canon);
@@ -168,15 +165,29 @@ impl MediaEditorState {
status: "canonical".to_string(), status: "canonical".to_string(),
is_active: self.active_language == *canon, is_active: self.active_language == *canon,
}); });
let mut langs: Vec<&String> = self.translation_drafts.keys().collect(); let mut langs: Vec<String> = self
.blog_languages
.iter()
.filter(|lang| **lang != *canon)
.cloned()
.collect();
for lang in self.translation_drafts.keys() {
if lang != canon && !langs.contains(lang) {
langs.push(lang.clone());
}
}
langs.sort(); langs.sort();
for lang in langs { for lang in langs {
let locale = i18n::normalize_language(lang); let locale = i18n::normalize_language(&lang);
flags.push(TranslationFlag { flags.push(TranslationFlag {
language: lang.clone(), language: lang.clone(),
flag_emoji: locale.flag_emoji().to_string(), flag_emoji: locale.flag_emoji().to_string(),
status: "translation".to_string(), status: if self.translation_drafts.contains_key(&lang) {
is_active: self.active_language == **lang, "translation".to_string()
} else {
"missing".to_string()
},
is_active: self.active_language == lang,
}); });
} }
flags flags
@@ -448,6 +459,50 @@ pub fn view<'a>(
.into() .into()
} }
#[cfg(test)]
mod tests {
use super::MediaEditorState;
use bds_core::model::Media;
fn make_media() -> Media {
Media {
id: "m1".to_string(),
project_id: "proj".to_string(),
filename: "image.png".to_string(),
original_name: "image.png".to_string(),
mime_type: "image/png".to_string(),
size: 10,
width: Some(1),
height: Some(1),
title: Some("Image".to_string()),
alt: Some("Alt".to_string()),
caption: None,
author: None,
language: Some("en".to_string()),
file_path: "media/test/image.png".to_string(),
sidecar_path: "media/test/image.png.meta".to_string(),
checksum: None,
tags: Vec::new(),
created_at: 0,
updated_at: 0,
}
}
#[test]
fn translation_flags_include_configured_languages_without_translations() {
let state = MediaEditorState::from_media(
&make_media(),
&["en".to_string(), "de".to_string()],
&[],
Vec::new(),
);
let flags = state.translation_flags();
let languages = flags.into_iter().map(|flag| flag.language).collect::<Vec<_>>();
assert_eq!(languages, vec!["en".to_string(), "de".to_string()]);
}
}
fn no_preview<'a>() -> Element<'a, Message> { fn no_preview<'a>() -> Element<'a, Message> {
container( container(
text("No preview available") text("No preview available")

View File

@@ -276,14 +276,9 @@ impl PostEditorState {
} }
/// Build the translation flags list for the view. /// Build the translation flags list for the view.
/// Flags are driven by the post's actual translations, not blog-level languages.
pub fn translation_flags(&self) -> Vec<TranslationFlag> { pub fn translation_flags(&self) -> Vec<TranslationFlag> {
if self.translation_drafts.is_empty() {
return Vec::new();
}
let mut flags = Vec::new(); let mut flags = Vec::new();
// Canonical language first (always shown when translations exist)
let canon = &self.canonical_language; let canon = &self.canonical_language;
let canon_locale = i18n::normalize_language(canon); let canon_locale = i18n::normalize_language(canon);
flags.push(TranslationFlag { flags.push(TranslationFlag {
@@ -293,20 +288,30 @@ impl PostEditorState {
is_active: self.active_language == *canon, is_active: self.active_language == *canon,
}); });
// Each existing translation for this post let mut langs: Vec<String> = self
let mut langs: Vec<&String> = self.translation_drafts.keys().collect(); .blog_languages
.iter()
.filter(|lang| **lang != *canon)
.cloned()
.collect();
for lang in self.translation_drafts.keys() {
if lang != canon && !langs.contains(lang) {
langs.push(lang.clone());
}
}
langs.sort(); langs.sort();
for lang in langs { for lang in langs {
let locale = i18n::normalize_language(lang); let locale = i18n::normalize_language(&lang);
let status = match self.translation_drafts[lang].status { let status = match self.translation_drafts.get(&lang).map(|draft| &draft.status) {
PostStatus::Published => "published", Some(PostStatus::Published) => "published",
_ => "draft", Some(_) => "draft",
None => "missing",
}; };
flags.push(TranslationFlag { flags.push(TranslationFlag {
language: lang.clone(), language: lang.clone(),
flag_emoji: locale.flag_emoji().to_string(), flag_emoji: locale.flag_emoji().to_string(),
status: status.to_string(), status: status.to_string(),
is_active: self.active_language == **lang, is_active: self.active_language == lang,
}); });
} }

View File

@@ -386,6 +386,50 @@ fn chip_selector(
iced::widget::Column::with_children(items).spacing(2).into() iced::widget::Column::with_children(items).spacing(2).into()
} }
fn single_select_chip_selector(
label: &str,
available: &[(String, String)],
selected: Option<&str>,
on_toggle: impl Fn(Option<String>) -> Message + 'static + Clone,
locale: UiLocale,
) -> Element<'static, Message> {
let muted = Color::from_rgb(0.50, 0.50, 0.55);
let mut items: Vec<Element<'static, Message>> = Vec::new();
items.push(
text(t(locale, label))
.size(10)
.shaping(Shaping::Advanced)
.color(muted)
.into(),
);
let chips = available
.iter()
.map(|(value, display)| {
let is_selected = selected == Some(value.as_str());
let style_fn = if is_selected { chip_selected_style } else { chip_style };
let value = value.clone();
let on_toggle = on_toggle.clone();
button(text(display.clone()).size(10).shaping(Shaping::Advanced))
.on_press(if is_selected {
on_toggle(None)
} else {
on_toggle(Some(value))
})
.padding([2, 6])
.style(style_fn)
.into()
})
.collect::<Vec<Element<'static, Message>>>();
if !chips.is_empty() {
items.push(row(chips).spacing(4).into());
}
iced::widget::Column::with_children(items).spacing(2).into()
}
/// Build the filter panel for Posts/Pages view. /// Build the filter panel for Posts/Pages view.
fn post_filter_panel( fn post_filter_panel(
filter: &PostFilter, filter: &PostFilter,
@@ -394,6 +438,35 @@ fn post_filter_panel(
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let mut sections: Vec<Element<'static, Message>> = Vec::new(); let mut sections: Vec<Element<'static, Message>> = Vec::new();
sections.push(single_select_chip_selector(
"sidebar.filter.status",
&[
("draft".to_string(), t(locale, "sidebar.drafts")),
("published".to_string(), t(locale, "sidebar.published")),
("archived".to_string(), t(locale, "sidebar.archived")),
],
filter.status_filter.as_deref(),
Message::SetPostStatusFilter,
locale,
));
sections.push(Space::with_height(4.0).into());
if !filter.available_languages.is_empty() {
let languages = filter
.available_languages
.iter()
.map(|lang| (lang.clone(), lang.to_uppercase()))
.collect::<Vec<_>>();
sections.push(single_select_chip_selector(
"sidebar.filter.language",
&languages,
filter.language_filter.as_deref(),
Message::SetPostLanguageFilter,
locale,
));
sections.push(Space::with_height(4.0).into());
}
// Calendar archive // Calendar archive
if !filter.calendar_years.is_empty() { if !filter.calendar_years.is_empty() {
sections.push(calendar_widget( sections.push(calendar_widget(
@@ -431,6 +504,33 @@ fn post_filter_panel(
sections.push(Space::with_height(4.0).into()); sections.push(Space::with_height(4.0).into());
} }
sections.push(
iced::widget::Column::with_children(vec![
text(t(locale, "sidebar.filter.dateRange"))
.size(10)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.50, 0.50, 0.55))
.into(),
row![
text_input(&t(locale, "sidebar.filter.from"), &filter.from_date)
.on_input(Message::SetPostFromDate)
.size(10)
.padding([3, 5])
.style(search_input_style),
text_input(&t(locale, "sidebar.filter.to"), &filter.to_date)
.on_input(Message::SetPostToDate)
.size(10)
.padding([3, 5])
.style(search_input_style),
]
.spacing(4)
.into(),
])
.spacing(2)
.into(),
);
sections.push(Space::with_height(4.0).into());
// Clear all filters button // Clear all filters button
if filter.has_active_filters() { if filter.has_active_filters() {
sections.push( sections.push(
@@ -520,6 +620,30 @@ pub fn view(
.shaping(Shaping::Advanced) .shaping(Shaping::Advanced)
.color(Color::from_rgb(0.85, 0.85, 0.90)); .color(Color::from_rgb(0.85, 0.85, 0.90));
let create_action = match sidebar_view {
SidebarView::Posts => Some(Message::CreatePost),
SidebarView::Pages => Some(Message::CreatePage),
SidebarView::Media => Some(Message::CreateMedia),
SidebarView::Scripts => Some(Message::CreateScript),
SidebarView::Templates => Some(Message::CreateTemplate),
_ => None,
};
let header_row: Element<'static, Message> = if let Some(action) = create_action {
row![
header,
Space::with_width(Length::Fill),
button(text(t(locale, "common.add")).size(11).shaping(Shaping::Advanced))
.on_press(action)
.padding([3, 8])
.style(filter_button_style),
]
.align_y(iced::Alignment::Center)
.into()
} else {
header.into()
};
let body: Element<'static, Message> = match sidebar_view { let body: Element<'static, Message> = match sidebar_view {
SidebarView::Posts | SidebarView::Pages => { SidebarView::Posts | SidebarView::Pages => {
let is_pages = sidebar_view == SidebarView::Pages; let is_pages = sidebar_view == SidebarView::Pages;
@@ -1020,7 +1144,7 @@ pub fn view(
}; };
let content = column![ let content = column![
header, header_row,
Space::with_height(8.0), Space::with_height(8.0),
body, body,
] ]

View File

@@ -196,6 +196,11 @@
"sidebar.filter.search": "Suchen...", "sidebar.filter.search": "Suchen...",
"sidebar.filter.toggle": "Filter", "sidebar.filter.toggle": "Filter",
"sidebar.filter.clearAll": "Alle Filter zurücksetzen", "sidebar.filter.clearAll": "Alle Filter zurücksetzen",
"sidebar.filter.status": "Status",
"sidebar.filter.language": "Sprache",
"sidebar.filter.dateRange": "Zeitraum",
"sidebar.filter.from": "Von (JJJJ-MM-TT)",
"sidebar.filter.to": "Bis (JJJJ-MM-TT)",
"editor.insertLink": "Link einfügen", "editor.insertLink": "Link einfügen",
"editor.insertMedia": "Medium einfügen", "editor.insertMedia": "Medium einfügen",
"editor.gallery": "Galerie", "editor.gallery": "Galerie",

View File

@@ -196,6 +196,11 @@
"sidebar.filter.search": "Search...", "sidebar.filter.search": "Search...",
"sidebar.filter.toggle": "Filters", "sidebar.filter.toggle": "Filters",
"sidebar.filter.clearAll": "Clear All Filters", "sidebar.filter.clearAll": "Clear All Filters",
"sidebar.filter.status": "Status",
"sidebar.filter.language": "Language",
"sidebar.filter.dateRange": "Date Range",
"sidebar.filter.from": "From (YYYY-MM-DD)",
"sidebar.filter.to": "To (YYYY-MM-DD)",
"sidebar.filter.tags": "Tags", "sidebar.filter.tags": "Tags",
"sidebar.filter.categories": "Categories", "sidebar.filter.categories": "Categories",
"sidebar.filter.calendar": "Archive", "sidebar.filter.calendar": "Archive",

View File

@@ -196,6 +196,11 @@
"sidebar.filter.search": "Buscar...", "sidebar.filter.search": "Buscar...",
"sidebar.filter.toggle": "Filtros", "sidebar.filter.toggle": "Filtros",
"sidebar.filter.clearAll": "Borrar todos los filtros", "sidebar.filter.clearAll": "Borrar todos los filtros",
"sidebar.filter.status": "Estado",
"sidebar.filter.language": "Idioma",
"sidebar.filter.dateRange": "Rango de fechas",
"sidebar.filter.from": "Desde (AAAA-MM-DD)",
"sidebar.filter.to": "Hasta (AAAA-MM-DD)",
"editor.insertLink": "Insertar enlace", "editor.insertLink": "Insertar enlace",
"editor.insertMedia": "Insertar medio", "editor.insertMedia": "Insertar medio",
"editor.gallery": "Galería", "editor.gallery": "Galería",

View File

@@ -196,6 +196,11 @@
"sidebar.filter.search": "Rechercher...", "sidebar.filter.search": "Rechercher...",
"sidebar.filter.toggle": "Filtres", "sidebar.filter.toggle": "Filtres",
"sidebar.filter.clearAll": "Effacer tous les filtres", "sidebar.filter.clearAll": "Effacer tous les filtres",
"sidebar.filter.status": "Statut",
"sidebar.filter.language": "Langue",
"sidebar.filter.dateRange": "Plage de dates",
"sidebar.filter.from": "Depuis (AAAA-MM-JJ)",
"sidebar.filter.to": "Jusqu'au (AAAA-MM-JJ)",
"editor.insertLink": "Insérer un lien", "editor.insertLink": "Insérer un lien",
"editor.insertMedia": "Insérer un média", "editor.insertMedia": "Insérer un média",
"editor.gallery": "Galerie", "editor.gallery": "Galerie",

View File

@@ -196,6 +196,11 @@
"sidebar.filter.search": "Cerca...", "sidebar.filter.search": "Cerca...",
"sidebar.filter.toggle": "Filtri", "sidebar.filter.toggle": "Filtri",
"sidebar.filter.clearAll": "Cancella tutti i filtri", "sidebar.filter.clearAll": "Cancella tutti i filtri",
"sidebar.filter.status": "Stato",
"sidebar.filter.language": "Lingua",
"sidebar.filter.dateRange": "Intervallo date",
"sidebar.filter.from": "Da (AAAA-MM-GG)",
"sidebar.filter.to": "A (AAAA-MM-GG)",
"editor.insertLink": "Inserisci link", "editor.insertLink": "Inserisci link",
"editor.insertMedia": "Inserisci media", "editor.insertMedia": "Inserisci media",
"editor.gallery": "Galleria", "editor.gallery": "Galleria",