fix: and evren more gaps in M3
This commit is contained in:
@@ -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,10 +355,27 @@ 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 ");
|
||||||
conditions.push(format!("(p.status = 'draft' OR ({combined}))"));
|
if filters.status.is_some() {
|
||||||
|
conditions.push(format!("({combined})"));
|
||||||
|
} else {
|
||||||
|
conditions.push(format!("(p.status = 'draft' OR ({combined}))"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let where_clause = conditions.join(" AND ");
|
let where_clause = conditions.join(" AND ");
|
||||||
|
|||||||
@@ -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, ¶ms,
|
&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, ¶ms,
|
&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,
|
||||||
|
¶ms,
|
||||||
|
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(),
|
||||||
|
¶ms.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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user