fix: small findings with regard to deletes from sidebar

This commit is contained in:
Bauer, Georg
2026-07-22 15:35:22 +02:00
parent cc520d517a
commit a3f2c451e3
13 changed files with 499 additions and 93 deletions

View File

@@ -22,7 +22,7 @@ uuid = { version = "1", features = ["v4", "serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
chrono = { version = "0.4", features = ["serde"] }
chrono = { version = "0.4", features = ["serde", "unstable-locales"] }
sha2 = "0.10"
md5 = "0.8.1"
unicode-normalization = "0.1"

View File

@@ -382,6 +382,10 @@ pub enum Message {
CreateImport,
/// Per sidebar_views.allium ScriptListItemEntry: row-level delete affordance.
ScriptDeleteRequested(String),
/// Per sidebar_views.allium TemplateListItemEntry: row-level delete affordance.
TemplateDeleteRequested(String),
/// Per sidebar_views.allium ImportListItemEntry: row-level delete affordance.
ImportDeleteRequested(String),
// Conversational AI
ChatCreate,
@@ -1390,6 +1394,12 @@ impl BdsApp {
Message::ScriptDeleteRequested(script_id) => {
self.show_script_delete_confirmation(&script_id)
}
Message::TemplateDeleteRequested(template_id) => {
self.show_template_delete_confirmation(&template_id)
}
Message::ImportDeleteRequested(definition_id) => {
self.show_import_delete_confirmation(&definition_id)
}
Message::ChatCreate => self.create_chat_conversation(),
Message::ChatRenameInputChanged(value) => {
if let Some(state) = self.active_chat_state_mut() {
@@ -4677,17 +4687,7 @@ impl BdsApp {
ImportEditorMsg::Execute => self.start_import_execution(&definition_id),
ImportEditorMsg::AutoMapTaxonomy => self.start_import_auto_mapping(&definition_id),
ImportEditorMsg::DeleteRequested => {
let name = self
.import_editors
.get(&definition_id)
.map(|state| state.definition.name.clone())
.unwrap_or_default();
self.active_modal = Some(modal::ModalState::Confirm {
title: t(self.ui_locale, "import.deleteTitle"),
message: tw(self.ui_locale, "import.deleteMessage", &[("name", &name)]),
on_confirm: modal::ConfirmAction::DeleteImport(definition_id),
});
Task::none()
self.show_import_delete_confirmation(&definition_id)
}
ImportEditorMsg::SetResolution {
kind,
@@ -7046,18 +7046,20 @@ impl BdsApp {
("tags", &referencing_tags.to_string()),
],
);
return Task::done(Message::ShowModal(modal::ModalState::Confirm {
self.active_modal = Some(modal::ModalState::Confirm {
title,
message,
on_confirm: modal::ConfirmAction::ForceDeleteTemplate(template_id.to_string()),
}));
});
return Task::none();
}
Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete {
self.active_modal = Some(modal::ModalState::ConfirmDelete {
entity_name: template.title,
references: Vec::new(),
on_confirm: modal::ConfirmAction::DeleteTemplate(template_id.to_string()),
}))
});
Task::none()
}
/// Per editor_script.allium ScriptDeleteAction / action_patterns.allium
@@ -7079,6 +7081,29 @@ impl BdsApp {
Task::none()
}
/// Per sidebar_views.allium ImportListItemEntry: confirming an import
/// deletion must not depend on an open editor tab, so the definition
/// name comes from the database.
fn show_import_delete_confirmation(&mut self, definition_id: &str) -> Task<Message> {
let Some(db) = &self.db else {
return Task::none();
};
let Ok(definition) = engine::wordpress_import::get_definition(db.conn(), definition_id)
else {
return Task::none();
};
self.active_modal = Some(modal::ModalState::Confirm {
title: t(self.ui_locale, "import.deleteTitle"),
message: tw(
self.ui_locale,
"import.deleteMessage",
&[("name", &definition.name)],
),
on_confirm: modal::ConfirmAction::DeleteImport(definition_id.to_string()),
});
Task::none()
}
fn delete_script_editor(&mut self, script_id: &str) -> Task<Message> {
let Some(db) = &self.db else {
return Task::none();
@@ -12619,6 +12644,166 @@ mod tests {
}
}
/// sidebar_views.allium TemplateListItemEntry provides
/// TemplateDeleteRequested(item.template_id): deleting from the sidebar
/// row routes through the same confirm modal as the editor delete button
/// and works without an open editor tab.
#[test]
fn sidebar_template_delete_requires_confirmation_and_works_without_open_tab() {
let (db, project, tmp) = setup();
let mut app = make_app(db, project, &tmp);
let _ = app.update(Message::CreateTemplate);
let template_id = app.sidebar_templates[0].id.clone();
// Close the editor tab: the sidebar row must not depend on one.
let _ = app.update(Message::CloseTab(template_id.clone()));
assert!(!app.tabs.iter().any(|tab| tab.id == template_id));
// Requesting deletion only opens the confirm modal; nothing is deleted.
let _ = app.update(Message::TemplateDeleteRequested(template_id.clone()));
assert!(matches!(
app.active_modal,
Some(modal::ModalState::ConfirmDelete {
on_confirm: modal::ConfirmAction::DeleteTemplate(ref id),
..
}) if id == &template_id
));
assert!(
bds_core::db::queries::template::get_template_by_id(
app.db.as_ref().unwrap().conn(),
&template_id
)
.is_ok()
);
let _ = app.update(Message::ConfirmModal(modal::ConfirmAction::DeleteTemplate(
template_id.clone(),
)));
assert!(
bds_core::db::queries::template::get_template_by_id(
app.db.as_ref().unwrap().conn(),
&template_id
)
.is_err()
);
assert!(!app.template_editors.contains_key(&template_id));
}
/// editor_template.allium TemplateDelete / TemplateListItemEntry
/// DeleteConfirmation: a referenced template prompts the force-delete
/// confirmation with reference counts instead of the plain delete modal.
#[test]
fn sidebar_template_delete_with_references_prompts_force_delete() {
let (db, project, tmp) = setup();
let mut app = make_app(db, project, &tmp);
let _ = app.update(Message::CreateTemplate);
let template = app.sidebar_templates[0].clone();
let referencing_post = post::create_post(
app.db.as_ref().unwrap().conn(),
tmp.path(),
&template.project_id,
"Uses Template",
None,
vec![],
vec![],
None,
Some("en"),
Some(&template.slug),
)
.unwrap();
let _ = app.update(Message::TemplateDeleteRequested(template.id.clone()));
assert!(matches!(
app.active_modal,
Some(modal::ModalState::Confirm {
on_confirm: modal::ConfirmAction::ForceDeleteTemplate(ref id),
..
}) if id == &template.id
));
// Force delete removes the template and clears the post reference.
let _ = app.update(Message::ConfirmModal(
modal::ConfirmAction::ForceDeleteTemplate(template.id.clone()),
));
assert!(
bds_core::db::queries::template::get_template_by_id(
app.db.as_ref().unwrap().conn(),
&template.id
)
.is_err()
);
assert_eq!(
bds_core::db::queries::post::get_post_by_id(
app.db.as_ref().unwrap().conn(),
&referencing_post.id
)
.unwrap()
.template_slug,
None
);
}
/// sidebar_views.allium ChatListItemEntry provides
/// ChatDeleteRequested(item.conversation_id); per DeleteBehaviour and
/// action_patterns.allium the delete is immediate (no confirmation) and
/// must not depend on an open tab.
#[test]
fn sidebar_chat_delete_works_without_open_tab() {
let (db, project, tmp) = setup();
let mut app = make_app(db, project, &tmp);
let _ = app.update(Message::ChatCreate);
let conversation_id = app.chat_conversations[0].id.clone();
let _ = app.update(Message::CloseTab(conversation_id.clone()));
assert!(!app.tabs.iter().any(|tab| tab.id == conversation_id));
let _ = app.update(Message::ChatDelete(conversation_id.clone()));
assert!(app.active_modal.is_none());
assert!(app.chat_conversations.is_empty());
assert!(
chat::list_conversations(app.db.as_ref().unwrap().conn())
.unwrap()
.is_empty()
);
}
/// sidebar_views.allium ImportListItemEntry provides
/// ImportDeleteRequested(item.definition_id): deleting from the sidebar
/// row confirms first and works without an open editor tab.
#[test]
fn sidebar_import_delete_requires_confirmation_and_works_without_open_tab() {
let (db, project, tmp) = setup();
let mut app = make_app(db, project, &tmp);
let _ = app.update(Message::CreateImport);
let definition_id = app.sidebar_imports[0].id.clone();
let _ = app.update(Message::CloseTab(definition_id.clone()));
assert!(!app.tabs.iter().any(|tab| tab.id == definition_id));
let _ = app.update(Message::ImportDeleteRequested(definition_id.clone()));
assert!(matches!(
app.active_modal,
Some(modal::ModalState::Confirm {
on_confirm: modal::ConfirmAction::DeleteImport(ref id),
..
}) if id == &definition_id
));
assert!(
wordpress_import::get_definition(app.db.as_ref().unwrap().conn(), &definition_id)
.is_ok()
);
let _ = app.update(Message::ConfirmModal(modal::ConfirmAction::DeleteImport(
definition_id.clone(),
)));
assert!(
wordpress_import::get_definition(app.db.as_ref().unwrap().conn(), &definition_id)
.is_err()
);
assert!(app.sidebar_imports.is_empty());
}
#[test]
fn rebuild_completion_refreshes_project_metadata_in_settings() {
let (db, project, tmp) = setup();

View File

@@ -1,3 +1,5 @@
pub mod relative_date;
use bds_core::i18n::{UiLocale, translate, translate_with};
/// Shorthand for translate in view code.

View File

@@ -0,0 +1,163 @@
use bds_core::i18n::{UiLocale, translate, translate_with};
use chrono::{DateTime, Datelike, Local, NaiveDate};
/// chrono format locale per sidebar_views.allium LocaleMapping
/// (ui_locale → format_locale, e.g. "de" → "de-DE").
fn format_locale(locale: UiLocale) -> chrono::Locale {
match locale {
UiLocale::En => chrono::Locale::en_US,
UiLocale::De => chrono::Locale::de_DE,
UiLocale::Fr => chrono::Locale::fr_FR,
UiLocale::It => chrono::Locale::it_IT,
UiLocale::Es => chrono::Locale::es_ES,
}
}
/// Per sidebar_views.allium RelativeDateFormat, with
/// diff_days = (today - timestamp.date).days on local calendar dates:
/// diff_days = 0 → locale time string
/// diff_days = 1 → localized "Yesterday"
/// diff_days < 7 → short weekday name
/// otherwise → short month name + numeric day
pub fn format_relative_date(locale: UiLocale, unix_ms: i64, today: NaiveDate) -> String {
let timestamp = DateTime::from_timestamp_millis(unix_ms)
.unwrap_or_default()
.with_timezone(&Local);
let chrono_locale = format_locale(locale);
let diff_days = (today - timestamp.date_naive()).num_days();
if diff_days == 0 {
timestamp.format_localized("%X", chrono_locale).to_string()
} else if diff_days == 1 {
translate(locale, "sidebar.chatYesterday")
} else if diff_days < 7 {
timestamp.format_localized("%a", chrono_locale).to_string()
} else {
let month = timestamp.format_localized("%b", chrono_locale).to_string();
let day = timestamp.day().to_string();
translate_with(
locale,
"sidebar.relativeDateMonthDay",
&[("month", &month), ("day", &day)],
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn local_ms(y: i32, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> i64 {
Local
.with_ymd_and_hms(y, mo, d, h, mi, s)
.single()
.expect("unambiguous local time")
.timestamp_millis()
}
fn day(y: i32, mo: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, mo, d).expect("valid date")
}
// Reference "today" throughout: Tuesday, 2026-02-17.
const TODAY: (i32, u32, u32) = (2026, 2, 17);
fn today() -> NaiveDate {
day(TODAY.0, TODAY.1, TODAY.2)
}
#[test]
fn same_day_shows_locale_time() {
let ts = local_ms(2026, 2, 17, 14, 5, 7);
let en = format_relative_date(UiLocale::En, ts, today());
assert!(en.contains("05:07"), "expected time string, got {en}");
assert!(en.contains("PM"), "en-US uses 12-hour clock, got {en}");
let de = format_relative_date(UiLocale::De, ts, today());
assert!(de.contains("14:05"), "de-DE uses 24-hour clock, got {de}");
}
#[test]
fn one_day_ago_is_yesterday_even_under_24_hours() {
// 23:59 yesterday vs. any time today: calendar-day diff is 1.
let ts = local_ms(2026, 2, 16, 23, 59, 0);
assert_eq!(format_relative_date(UiLocale::En, ts, today()), "Yesterday");
assert_eq!(format_relative_date(UiLocale::De, ts, today()), "Gestern");
}
#[test]
fn two_days_ago_shows_short_weekday() {
// 2026-02-15 is a Sunday.
let ts = local_ms(2026, 2, 15, 12, 0, 0);
assert_eq!(format_relative_date(UiLocale::En, ts, today()), "Sun");
assert_eq!(format_relative_date(UiLocale::De, ts, today()), "So");
}
#[test]
fn six_days_ago_still_shows_weekday() {
// 2026-02-11 is a Wednesday: diff_days = 6 is the last weekday case.
let ts = local_ms(2026, 2, 11, 12, 0, 0);
assert_eq!(format_relative_date(UiLocale::En, ts, today()), "Wed");
}
#[test]
fn seven_days_ago_shows_month_and_day() {
// diff_days = 7 is the first month+day case.
let ts = local_ms(2026, 2, 10, 12, 0, 0);
assert_eq!(format_relative_date(UiLocale::En, ts, today()), "Feb 10");
assert_eq!(format_relative_date(UiLocale::De, ts, today()), "10. Feb");
}
#[test]
fn month_day_order_is_day_first_in_romance_locales() {
let ts = local_ms(2026, 2, 10, 12, 0, 0);
for locale in [UiLocale::Fr, UiLocale::It, UiLocale::Es] {
let result = format_relative_date(locale, ts, today());
assert!(
result.starts_with("10 "),
"expected day-first order for {locale}, got {result}"
);
}
}
#[test]
fn distant_past_shows_month_and_day() {
let ts = local_ms(2025, 6, 3, 12, 0, 0);
assert_eq!(format_relative_date(UiLocale::En, ts, today()), "Jun 3");
}
#[test]
fn future_timestamp_uses_weekday_branch() {
// Spec formula: diff_days < 7 → weekday; a future date has a
// negative diff and therefore falls into the weekday case.
// 2026-02-20 is a Friday.
let ts = local_ms(2026, 2, 20, 12, 0, 0);
assert_eq!(format_relative_date(UiLocale::En, ts, today()), "Fri");
}
#[test]
fn yesterday_is_translated_in_all_locales() {
let ts = local_ms(2026, 2, 16, 12, 0, 0);
for locale in UiLocale::all() {
let result = format_relative_date(*locale, ts, today());
assert_ne!(
result, "sidebar.chatYesterday",
"missing yesterday translation for {locale}"
);
assert!(!result.is_empty());
}
}
#[test]
fn month_day_pattern_exists_in_all_locales() {
let ts = local_ms(2025, 6, 3, 12, 0, 0);
for locale in UiLocale::all() {
let result = format_relative_date(*locale, ts, today());
assert_ne!(
result, "sidebar.relativeDateMonthDay",
"missing month-day pattern for {locale}"
);
assert!(result.contains('3'), "day missing for {locale}: {result}");
}
}
}

View File

@@ -9,6 +9,7 @@ use bds_core::model::{ChatConversation, ImportDefinition, Media, Post, Script, T
use crate::app::Message;
use crate::components::inputs;
use crate::i18n::relative_date::format_relative_date;
use crate::i18n::t;
use crate::state::navigation::SidebarView;
use crate::state::sidebar_filter::{CalendarYear, MediaFilter, PostFilter};
@@ -287,6 +288,32 @@ fn row_delete_style(_theme: &Theme, status: button::Status) -> button::Style {
}
}
/// Wrap a sidebar row in a hover overlay carrying its delete button (×).
/// Per sidebar_views.allium *ListItemEntry RowLayout: right-aligned, visible
/// only on row hover, routed to the row's delete message.
fn with_row_delete(
open_button: iced::widget::Button<'static, Message>,
on_delete: Message,
) -> Element<'static, Message> {
let delete_button = button(
text("\u{2715}") // ✕
.size(11)
.shaping(Shaping::Advanced),
)
.on_press(on_delete)
.padding([2, 6])
.style(row_delete_style);
iced::widget::hover(
open_button,
container(delete_button)
.width(Length::Fill)
.height(Length::Fill)
.align_x(iced::alignment::Horizontal::Right)
.align_y(iced::alignment::Vertical::Center)
.padding([0, 4]),
)
}
/// Month name abbreviation for calendar display.
fn month_abbr(month: u32) -> &'static str {
match month {
@@ -661,6 +688,8 @@ pub fn view(
) -> Element<'static, Message> {
let header_text = t(locale, sidebar_view.i18n_key());
let muted = Color::from_rgb(0.50, 0.50, 0.55);
// Reference date for RelativeDateFormat's diff_days (sidebar_views.allium).
let today = chrono::Local::now().date_naive();
let header = text(header_text)
.size(13)
@@ -1006,42 +1035,33 @@ pub fn view(
.size(12)
.shaping(Shaping::Advanced)
.wrapping(iced::widget::text::Wrapping::None);
// sidebar_views.allium ScriptListItemEntry RowLayout:
// line 2 is the date in relative format, smaller and muted.
let date_text = text(format_relative_date(locale, s.updated_at, today))
.size(10)
.shaping(Shaping::Advanced)
.color(muted);
let style_fn = if is_active {
item_active_style
} else {
item_style
};
let open_button =
button(container(label_text).width(Length::Fill).clip(true))
.on_press(Message::OpenTab(Tab {
id: s.id.clone(),
tab_type: TabType::Scripts,
title: display_title,
is_transient: true,
is_dirty: false,
}))
.padding([5, 8])
let open_button = button(
container(column![label_text, date_text].spacing(1))
.width(Length::Fill)
.style(style_fn);
// sidebar_views.allium ScriptListItemEntry: delete button (x),
// visible only on row hover, routed through the confirm modal.
let delete_button = button(
text("\u{2715}") // ✕
.size(11)
.shaping(Shaping::Advanced),
)
.on_press(Message::ScriptDeleteRequested(s.id.clone()))
.padding([2, 6])
.style(row_delete_style);
iced::widget::hover(
open_button,
container(delete_button)
.width(Length::Fill)
.height(Length::Fill)
.align_x(iced::alignment::Horizontal::Right)
.align_y(iced::alignment::Vertical::Center)
.padding([0, 4]),
.clip(true),
)
.on_press(Message::OpenTab(Tab {
id: s.id.clone(),
tab_type: TabType::Scripts,
title: display_title,
is_transient: true,
is_dirty: false,
}))
.padding([5, 8])
.width(Length::Fill)
.style(style_fn);
with_row_delete(open_button, Message::ScriptDeleteRequested(s.id.clone()))
})
.collect();
iced::widget::Column::with_children(items).spacing(1).into()
@@ -1059,29 +1079,43 @@ pub fn view(
.iter()
.map(|tmpl| {
let is_active = active_tab == Some(tmpl.id.as_str());
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX;
// Reserve room on the right for the hover-revealed delete button.
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX - 20.0;
let display_title = truncate_to_fit(&tmpl.title, text_px);
let label_text = text(display_title.clone())
.size(12)
.shaping(Shaping::Advanced)
.wrapping(iced::widget::text::Wrapping::None);
// sidebar_views.allium TemplateListItemEntry RowLayout:
// line 2 is the date in relative format, smaller and muted.
let date_text = text(format_relative_date(locale, tmpl.updated_at, today))
.size(10)
.shaping(Shaping::Advanced)
.color(muted);
let style_fn = if is_active {
item_active_style
} else {
item_style
};
button(container(label_text).width(Length::Fill).clip(true))
.on_press(Message::OpenTab(Tab {
id: tmpl.id.clone(),
tab_type: TabType::Templates,
title: display_title,
is_transient: true,
is_dirty: false,
}))
.padding([5, 8])
.width(Length::Fill)
.style(style_fn)
.into()
let open_button = button(
container(column![label_text, date_text].spacing(1))
.width(Length::Fill)
.clip(true),
)
.on_press(Message::OpenTab(Tab {
id: tmpl.id.clone(),
tab_type: TabType::Templates,
title: display_title,
is_transient: true,
is_dirty: false,
}))
.padding([5, 8])
.width(Length::Fill)
.style(style_fn);
with_row_delete(
open_button,
Message::TemplateDeleteRequested(tmpl.id.clone()),
)
})
.collect();
iced::widget::Column::with_children(items).spacing(1).into()
@@ -1104,19 +1138,18 @@ pub fn view(
} else {
item_style
};
let analyzed = if definition.last_analysis_result.is_some() {
t(locale, "import.sidebar.analyzed")
} else {
t(locale, "import.sidebar.pending")
};
button(
// sidebar_views.allium ImportListItemEntry RowLayout:
// line 2 is the date in relative format, smaller and muted.
let date_text =
text(format_relative_date(locale, definition.updated_at, today))
.size(10)
.shaping(Shaping::Advanced)
.color(muted);
let open_button = button(
container(
column![
text(definition_name).size(12).shaping(Shaping::Advanced),
text(analyzed)
.size(10)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.50, 0.50, 0.55)),
date_text,
]
.spacing(1),
)
@@ -1131,8 +1164,11 @@ pub fn view(
}))
.padding([5, 8])
.width(Length::Fill)
.style(style)
.into()
.style(style);
with_row_delete(
open_button,
Message::ImportDeleteRequested(definition.id.clone()),
)
})
.collect::<Vec<Element<'static, Message>>>();
iced::widget::Column::with_children(items).spacing(1).into()
@@ -1154,17 +1190,20 @@ pub fn view(
} else {
item_style
};
let model = conversation
.model
.clone()
.unwrap_or_else(|| t(locale, "chat.model.none"));
button(
// sidebar_views.allium ChatListItemEntry RowLayout:
// line 2 is the date in relative format, smaller and muted.
let date_text =
text(format_relative_date(locale, conversation.updated_at, today))
.size(10)
.shaping(Shaping::Advanced)
.color(muted);
let open_button = button(
container(
column![
text(conversation.title.clone())
.size(12)
.shaping(Shaping::Advanced),
text(model).size(10).shaping(Shaping::Advanced).color(muted),
date_text,
]
.spacing(1),
)
@@ -1179,8 +1218,11 @@ pub fn view(
}))
.padding([5, 8])
.width(Length::Fill)
.style(style)
.into()
.style(style);
// Per action_patterns.allium confirmation_assignments chat
// delete has no confirmation; ChatDelete removes the
// conversation and closes its tab.
with_row_delete(open_button, Message::ChatDelete(conversation.id.clone()))
})
.collect::<Vec<Element<'static, Message>>>();
iced::widget::Column::with_children(items).spacing(1).into()

View File

@@ -329,6 +329,7 @@ tags-nav-manage = Tags verwalten
tags-nav-merge = Tags zusammenführen
tags-nav-discover = Tags entdecken
sidebar-chatYesterday = Gestern
sidebar-relativeDateMonthDay = { $day }. { $month }
modal-confirmDelete-title = Löschen bestätigen
modal-confirmDelete-warning = Diese Aktion kann nicht rückgängig gemacht werden.
modal-confirmDelete-cancel = Abbrechen
@@ -763,8 +764,6 @@ import-phase-complete = Import abgeschlossen
import-error-wxrRequired = Vor der Analyse eine WXR-Datei auswählen.
import-toast-complete = WordPress-Import abgeschlossen.
import-toast-mapped = KI hat { $count } Taxonomiebegriffe zugeordnet.
import-sidebar-analyzed = Analyse gespeichert
import-sidebar-pending = Nicht analysiert
import-deleteTitle = Importdefinition löschen?
import-deleteMessage = „{ $name }“ und die gespeicherte Analyse löschen? Bereits importierte Projektinhalte bleiben erhalten.
import-toast-deleted = Importdefinition gelöscht.

View File

@@ -329,6 +329,7 @@ tags-nav-manage = Manage Tags
tags-nav-merge = Merge Tags
tags-nav-discover = Discover Tags
sidebar-chatYesterday = Yesterday
sidebar-relativeDateMonthDay = { $month } { $day }
modal-confirmDelete-title = Confirm Delete
modal-confirmDelete-warning = This action cannot be undone.
modal-confirmDelete-cancel = Cancel
@@ -763,8 +764,6 @@ import-phase-complete = Import complete
import-error-wxrRequired = Select a WXR file before analyzing.
import-toast-complete = WordPress import completed.
import-toast-mapped = AI mapped { $count } taxonomy terms.
import-sidebar-analyzed = Analysis saved
import-sidebar-pending = Not analyzed
import-deleteTitle = Delete import definition?
import-deleteMessage = Delete “{ $name }” and its saved analysis? Imported project content is not removed.
import-toast-deleted = Import definition deleted.

View File

@@ -329,6 +329,7 @@ tags-nav-manage = Gestionar etiquetas
tags-nav-merge = Fusionar etiquetas
tags-nav-discover = Descubrir etiquetas
sidebar-chatYesterday = Ayer
sidebar-relativeDateMonthDay = { $day } { $month }
modal-confirmDelete-title = Confirmar eliminación
modal-confirmDelete-warning = Esta acción no se puede deshacer.
modal-confirmDelete-cancel = Cancelar
@@ -763,8 +764,6 @@ import-phase-complete = Importación completa
import-error-wxrRequired = Selecciona un archivo WXR antes de analizar.
import-toast-complete = Importación de WordPress completa.
import-toast-mapped = La IA asoció { $count } términos de taxonomía.
import-sidebar-analyzed = Análisis guardado
import-sidebar-pending = Sin analizar
import-deleteTitle = ¿Eliminar la definición de importación?
import-deleteMessage = ¿Eliminar «{ $name }» y su análisis guardado? El contenido ya importado se conservará.
import-toast-deleted = Definición de importación eliminada.

View File

@@ -329,6 +329,7 @@ tags-nav-manage = Gérer les tags
tags-nav-merge = Fusionner les tags
tags-nav-discover = Découvrir les tags
sidebar-chatYesterday = Hier
sidebar-relativeDateMonthDay = { $day } { $month }
modal-confirmDelete-title = Confirmer la suppression
modal-confirmDelete-warning = Cette action est irréversible.
modal-confirmDelete-cancel = Annuler
@@ -763,8 +764,6 @@ import-phase-complete = Import terminé
import-error-wxrRequired = Sélectionnez un fichier WXR avant lanalyse.
import-toast-complete = Import WordPress terminé.
import-toast-mapped = LIA a associé { $count } termes de taxonomie.
import-sidebar-analyzed = Analyse enregistrée
import-sidebar-pending = Non analysé
import-deleteTitle = Supprimer la définition dimport ?
import-deleteMessage = Supprimer « { $name } » et son analyse enregistrée ? Le contenu déjà importé sera conservé.
import-toast-deleted = Définition dimport supprimée.

View File

@@ -329,6 +329,7 @@ tags-nav-manage = Gestisci tag
tags-nav-merge = Unisci tag
tags-nav-discover = Scopri tag
sidebar-chatYesterday = Ieri
sidebar-relativeDateMonthDay = { $day } { $month }
modal-confirmDelete-title = Conferma eliminazione
modal-confirmDelete-warning = Questa azione non può essere annullata.
modal-confirmDelete-cancel = Annulla
@@ -763,8 +764,6 @@ import-phase-complete = Importazione completata
import-error-wxrRequired = Seleziona un file WXR prima dellanalisi.
import-toast-complete = Importazione WordPress completata.
import-toast-mapped = LIA ha associato { $count } termini di tassonomia.
import-sidebar-analyzed = Analisi salvata
import-sidebar-pending = Non analizzato
import-deleteTitle = Eliminare la definizione di importazione?
import-deleteMessage = Eliminare “{ $name }” e lanalisi salvata? I contenuti già importati resteranno invariati.
import-toast-deleted = Definizione di importazione eliminata.

View File

@@ -211,6 +211,8 @@ config {
confirmation_assignments: Set<ConfirmationAssignment> = {
ConfirmationAssignment(action: "post_delete", pattern: system_confirm),
ConfirmationAssignment(action: "post_discard", pattern: system_confirm),
ConfirmationAssignment(action: "template_delete", pattern: confirm_delete_modal),
-- shows template title, no reference list
ConfirmationAssignment(action: "template_delete_with_references", pattern: system_confirm),
ConfirmationAssignment(action: "media_delete", pattern: confirm_delete_modal),
-- shows linked posts

View File

@@ -40,6 +40,7 @@ surface TemplateEditorSurface {
when editor.can_publish
TemplateValidateRequested(editor.template_id)
TemplateDeleteRequested(editor.template_id)
TemplateDeleteConfirmed(editor.template_id)
TemplateForceDeleteConfirmed(editor.template_id)
@guarantee HeaderLayout
@@ -119,9 +120,20 @@ rule TemplateDelete {
-- and M tags. Force delete?" (action_patterns.allium
-- confirmation_assignments: template_delete_with_references)
else:
DeleteTemplateRequested(target)
closeTab(template_id)
-- no confirmation when nothing references the template
ConfirmDeleteModalOpened(template_id)
-- custom modal (not a native dialog): template title, no
-- reference list; Cancel + Delete (destructive red style)
-- — action_patterns.allium
-- confirmation_assignments: template_delete
}
rule TemplateDeleteExecute {
when: TemplateDeleteConfirmed(template_id)
let target = template/Template{id: template_id}
ensures: DeleteTemplateRequested(target)
-- DB record + .liquid file on disk
ensures: closeTab(template_id)
-- sidebar removes the item reactively
}
rule TemplateForceDelete {

View File

@@ -412,8 +412,13 @@ surface TemplateListItemEntry {
-- Row is focusable with tabIndex and has aria-label from title.
@guarantee DeleteConfirmation
-- If template is referenced by posts or tags, shows confirmation dialog
-- with reference counts before force-delete.
-- Delete always confirms before executing. Unreferenced templates
-- use the confirm-delete modal (template title, no reference list);
-- templates referenced by posts or tags use the force-delete
-- confirmation dialog with reference counts.
-- See editor_template.allium TemplateDelete and action_patterns.allium
-- confirmation_assignments (template_delete,
-- template_delete_with_references).
@guarantee CreateDefaults
-- New templates default to: kind=post, content='', enabled=true.