7102 lines
302 KiB
Rust
7102 lines
302 KiB
Rust
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
use base64::Engine as _;
|
|
use chrono::Datelike;
|
|
use iced::{Element, Subscription, Task, window};
|
|
use rusqlite::Error as SqlError;
|
|
use serde_json::json;
|
|
use uuid::Uuid;
|
|
|
|
use bds_core::db::Database;
|
|
use bds_core::engine::task::{TaskId, TaskManager, TaskStatus};
|
|
use bds_core::engine;
|
|
use bds_core::engine::ai::{self, AiEndpointConfig, AiEndpointKind};
|
|
use bds_core::i18n::{detect_os_locale, UiLocale};
|
|
use bds_core::model::{Media, Post, PostStatus, PostTranslation, Project, PublishingPreferences, Script, SshMode, Template};
|
|
|
|
use crate::components::webview::{self, WebViewConfig, WebViewController};
|
|
use crate::i18n::{t, tw};
|
|
use crate::platform::menu::{self, MenuAction, MenuRegistry};
|
|
use crate::state::navigation::{
|
|
handle_activity_click, OutputEntry, PanelTab, SidebarView, TaskSnapshot,
|
|
};
|
|
use crate::state::sidebar_filter::{
|
|
CalendarMonth, CalendarYear, MediaFilter, PostFilter,
|
|
};
|
|
use crate::state::tabs::{self, Tab, TabType};
|
|
use crate::state::toast::{Toast, ToastLevel};
|
|
use crate::views::{
|
|
modal, workspace,
|
|
post_editor::{LinkedMediaItem, PostEditorMsg, PostEditorState, ResolvedPostLink},
|
|
media_editor::{LinkedPostItem, MediaEditorState, MediaEditorMsg},
|
|
site_validation::SiteValidationState,
|
|
template_editor::{TemplateEditorState, TemplateEditorMsg},
|
|
script_editor::{ScriptEditorState, ScriptEditorMsg},
|
|
tags_view::{self, TagsMsg, TagsSection, TagsViewState},
|
|
settings_view::{default_category_rows, AiModelOption, SettingsCategoryRow, SettingsViewState, SettingsMsg},
|
|
dashboard::{DashboardCategory, DashboardRecentPost, DashboardState, DashboardStats, DashboardTag, DashboardTimelineMonth},
|
|
};
|
|
|
|
// ───────────────────────────────────────────────────────────
|
|
// Message
|
|
// ───────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum Message {
|
|
// Menu
|
|
MenuEvent(muda::MenuId),
|
|
|
|
// Navigation
|
|
SetActiveView(SidebarView),
|
|
ToggleSidebar,
|
|
TogglePanel,
|
|
OpenSettingsSection(crate::views::settings_view::SettingsSection),
|
|
|
|
// Sidebar resize
|
|
SidebarResizeStart,
|
|
SidebarResizeMove(f32),
|
|
SidebarResizeEnd,
|
|
|
|
// Tabs
|
|
OpenTab(Tab),
|
|
CloseTab(String),
|
|
SelectTab(String),
|
|
PinTab(String),
|
|
ClearTabs,
|
|
|
|
// Project
|
|
ProjectsLoaded(Vec<Project>),
|
|
SwitchProject(String),
|
|
ProjectSwitched(Result<String, String>),
|
|
RequestCreateProject,
|
|
CreateProject { name: String, data_path: Option<PathBuf> },
|
|
ProjectCreated(Result<String, String>),
|
|
DeleteProject(String),
|
|
ProjectDeleted(Result<String, String>),
|
|
|
|
// Dialogs
|
|
FolderPicked(Option<PathBuf>),
|
|
MediaFilesPicked(Option<Vec<PathBuf>>),
|
|
|
|
// Tasks
|
|
TaskTick,
|
|
|
|
// macOS lifecycle
|
|
FileOpenRequested(PathBuf),
|
|
UrlOpenRequested(String),
|
|
MainWindowLoaded(Option<window::Id>),
|
|
EmbeddedPreviewReady(Result<(), String>),
|
|
|
|
// Panel
|
|
SetPanelTab(PanelTab),
|
|
|
|
// Settings
|
|
SetOfflineMode(bool),
|
|
SetUiLocale(UiLocale),
|
|
ToggleLocaleDropdown,
|
|
ToggleProjectDropdown,
|
|
|
|
// Toast
|
|
ShowToast(ToastLevel, String),
|
|
DismissToast(u64),
|
|
ExpireToasts,
|
|
|
|
// Sidebar filters
|
|
PostSearchChanged(String),
|
|
TogglePostFilterPanel,
|
|
SetPostStatusFilter(Option<String>),
|
|
SetPostLanguageFilter(Option<String>),
|
|
SetPostCalendarYear(Option<i32>),
|
|
SetPostCalendarMonth(Option<u32>),
|
|
SetPostFromDate(String),
|
|
SetPostToDate(String),
|
|
TogglePostTagFilter(String),
|
|
TogglePostCategoryFilter(String),
|
|
ClearPostFilters,
|
|
MediaSearchChanged(String),
|
|
ToggleMediaFilterPanel,
|
|
SetMediaCalendarYear(Option<i32>),
|
|
SetMediaCalendarMonth(Option<u32>),
|
|
ToggleMediaTagFilter(String),
|
|
ClearMediaFilters,
|
|
|
|
// Modal
|
|
ShowModal(modal::ModalState),
|
|
DismissModal,
|
|
ConfirmModal(modal::ConfirmAction),
|
|
ToggleAiSuggestionField(usize, bool),
|
|
ApplyAiSuggestions(modal::AiEntityTarget, Vec<modal::AiSuggestionField>),
|
|
|
|
// Blog actions (dispatched to engine)
|
|
RebuildDatabase,
|
|
ReindexText,
|
|
RegenerateCalendar,
|
|
ValidateTranslations,
|
|
ValidateMedia,
|
|
GenerateSite,
|
|
RunMetadataDiff,
|
|
RunSiteValidation,
|
|
ApplySiteValidation,
|
|
EngineTaskDone { task_id: TaskId, label: String, result: Result<String, String> },
|
|
SiteValidationLoaded(Result<engine::validate_site::SiteValidationReport, String>),
|
|
SiteValidationApplied(Result<String, String>),
|
|
|
|
// Editor views
|
|
PostEditor(PostEditorMsg),
|
|
MediaEditor(MediaEditorMsg),
|
|
TemplateEditor(TemplateEditorMsg),
|
|
ScriptEditor(ScriptEditorMsg),
|
|
Tags(TagsMsg),
|
|
Settings(SettingsMsg),
|
|
|
|
// Editor data loading
|
|
PostLoaded(Result<Post, String>),
|
|
MediaLoaded(Result<Media, String>),
|
|
TemplateLoaded(Result<Template, String>),
|
|
ScriptLoaded(Result<Script, String>),
|
|
|
|
// Async sidebar data
|
|
SidebarPostsLoaded(Vec<Post>),
|
|
SidebarMediaLoaded {
|
|
items: Vec<Media>,
|
|
thumbs: HashMap<String, Option<std::path::PathBuf>>,
|
|
},
|
|
SidebarPostsAppended(Vec<Post>),
|
|
SidebarMediaAppended {
|
|
items: Vec<Media>,
|
|
thumbs: HashMap<String, Option<std::path::PathBuf>>,
|
|
},
|
|
LoadMorePosts,
|
|
LoadMoreMedia,
|
|
|
|
CreatePost,
|
|
CreatePage,
|
|
CreateMedia,
|
|
CreateScript,
|
|
CreateTemplate,
|
|
|
|
Noop,
|
|
InitMenuBar,
|
|
}
|
|
|
|
enum PersistedPostState {
|
|
Canonical(Post),
|
|
Translation(bds_core::model::PostTranslation),
|
|
}
|
|
|
|
const POST_AUTO_SAVE_DELAY_MS: i64 = 3_000;
|
|
|
|
fn persist_post_editor_state_impl(
|
|
db: &Database,
|
|
data_dir: &Path,
|
|
state: &PostEditorState,
|
|
) -> Result<PersistedPostState, String> {
|
|
if state.active_language != state.canonical_language {
|
|
let translation = engine::post::upsert_translation(
|
|
db.conn(),
|
|
data_dir,
|
|
&state.post_id,
|
|
&state.active_language,
|
|
&state.title,
|
|
if state.excerpt.is_empty() { None } else { Some(state.excerpt.as_str()) },
|
|
Some(&state.content),
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(PersistedPostState::Translation(translation))
|
|
} else {
|
|
let post = engine::post::update_post(
|
|
db.conn(),
|
|
data_dir,
|
|
&state.post_id,
|
|
Some(&state.title),
|
|
if state.published_at.is_some() {
|
|
None
|
|
} else {
|
|
Some(&state.slug)
|
|
},
|
|
Some(if state.excerpt.is_empty() { None } else { Some(state.excerpt.as_str()) }),
|
|
Some(&state.content),
|
|
Some(state.tags.clone()),
|
|
Some(state.categories.clone()),
|
|
Some(if state.author.is_empty() { None } else { Some(state.author.as_str()) }),
|
|
Some(if state.language.is_empty() { None } else { Some(state.language.as_str()) }),
|
|
Some(if state.template_slug.is_empty() { None } else { Some(state.template_slug.as_str()) }),
|
|
Some(state.do_not_translate),
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(PersistedPostState::Canonical(post))
|
|
}
|
|
}
|
|
|
|
fn persist_post_editor_preview_state_impl(
|
|
db: &Database,
|
|
state: &PostEditorState,
|
|
) -> Result<PersistedPostState, String> {
|
|
if state.active_language != state.canonical_language {
|
|
let post = bds_core::db::queries::post::get_post_by_id(db.conn(), &state.post_id)
|
|
.map_err(|e| e.to_string())?;
|
|
if post.do_not_translate {
|
|
return Err("cannot create translation for a do-not-translate post".to_string());
|
|
}
|
|
|
|
let now = bds_core::util::now_unix_ms();
|
|
match bds_core::db::queries::post_translation::get_post_translation_by_post_and_language(
|
|
db.conn(),
|
|
&state.post_id,
|
|
&state.active_language,
|
|
) {
|
|
Ok(mut translation) => {
|
|
translation.title = state.title.clone();
|
|
translation.excerpt = if state.excerpt.is_empty() {
|
|
None
|
|
} else {
|
|
Some(state.excerpt.clone())
|
|
};
|
|
translation.content = Some(state.content.clone());
|
|
translation.updated_at = now;
|
|
bds_core::db::queries::post_translation::update_post_translation(
|
|
db.conn(),
|
|
&translation,
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(PersistedPostState::Translation(translation))
|
|
}
|
|
Err(SqlError::QueryReturnedNoRows) => {
|
|
let translation = PostTranslation {
|
|
id: Uuid::new_v4().to_string(),
|
|
project_id: post.project_id,
|
|
translation_for: state.post_id.clone(),
|
|
language: state.active_language.clone(),
|
|
title: state.title.clone(),
|
|
excerpt: if state.excerpt.is_empty() {
|
|
None
|
|
} else {
|
|
Some(state.excerpt.clone())
|
|
},
|
|
content: Some(state.content.clone()),
|
|
status: PostStatus::Draft,
|
|
file_path: String::new(),
|
|
checksum: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
published_at: None,
|
|
};
|
|
bds_core::db::queries::post_translation::insert_post_translation(
|
|
db.conn(),
|
|
&translation,
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(PersistedPostState::Translation(translation))
|
|
}
|
|
Err(error) => Err(error.to_string()),
|
|
}
|
|
} else {
|
|
let mut post = bds_core::db::queries::post::get_post_by_id(db.conn(), &state.post_id)
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
if post.published_at.is_none() && state.slug != post.slug {
|
|
match bds_core::db::queries::post::get_post_by_project_and_slug(
|
|
db.conn(),
|
|
&post.project_id,
|
|
&state.slug,
|
|
) {
|
|
Ok(existing) if existing.id != post.id => {
|
|
return Err(format!(
|
|
"slug '{}' already exists in this project",
|
|
state.slug
|
|
));
|
|
}
|
|
Ok(_) | Err(SqlError::QueryReturnedNoRows) => {}
|
|
Err(error) => return Err(error.to_string()),
|
|
}
|
|
}
|
|
|
|
post.title = state.title.clone();
|
|
if post.published_at.is_none() {
|
|
post.slug = state.slug.clone();
|
|
}
|
|
post.excerpt = if state.excerpt.is_empty() {
|
|
None
|
|
} else {
|
|
Some(state.excerpt.clone())
|
|
};
|
|
post.content = Some(state.content.clone());
|
|
post.tags = state.tags.clone();
|
|
post.categories = state.categories.clone();
|
|
post.author = if state.author.is_empty() {
|
|
None
|
|
} else {
|
|
Some(state.author.clone())
|
|
};
|
|
post.language = if state.language.is_empty() {
|
|
None
|
|
} else {
|
|
Some(state.language.clone())
|
|
};
|
|
post.template_slug = if state.template_slug.is_empty() {
|
|
None
|
|
} else {
|
|
Some(state.template_slug.clone())
|
|
};
|
|
post.do_not_translate = state.do_not_translate;
|
|
if matches!(post.status, PostStatus::Published | PostStatus::Archived) {
|
|
post.status = PostStatus::Draft;
|
|
}
|
|
post.updated_at = bds_core::util::now_unix_ms();
|
|
|
|
bds_core::db::queries::post::update_post(db.conn(), &post).map_err(|e| e.to_string())?;
|
|
Ok(PersistedPostState::Canonical(post))
|
|
}
|
|
}
|
|
|
|
enum PersistedMediaState {
|
|
Canonical { media: Media, tags: Vec<String> },
|
|
Translation,
|
|
}
|
|
|
|
struct PreviewSession {
|
|
project_id: String,
|
|
handle: engine::preview::PreviewServerHandle,
|
|
}
|
|
|
|
struct EmbeddedPreviewState {
|
|
controller: WebViewController,
|
|
current_url: Option<String>,
|
|
}
|
|
|
|
fn persist_media_editor_state_impl(
|
|
db: &Database,
|
|
data_dir: &Path,
|
|
state: &MediaEditorState,
|
|
) -> Result<PersistedMediaState, String> {
|
|
if state.active_language != state.canonical_language {
|
|
engine::media::upsert_media_translation(
|
|
db.conn(),
|
|
data_dir,
|
|
&state.media_id,
|
|
&state.active_language,
|
|
if state.title.is_empty() { None } else { Some(state.title.as_str()) },
|
|
if state.alt.is_empty() { None } else { Some(state.alt.as_str()) },
|
|
if state.caption.is_empty() { None } else { Some(state.caption.as_str()) },
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(PersistedMediaState::Translation)
|
|
} else {
|
|
let tags = state
|
|
.tags_input
|
|
.split(',')
|
|
.map(str::trim)
|
|
.filter(|tag| !tag.is_empty())
|
|
.map(|tag| tag.to_string())
|
|
.collect::<Vec<_>>();
|
|
let media = engine::media::update_media(
|
|
db.conn(),
|
|
data_dir,
|
|
&state.media_id,
|
|
Some(if state.title.is_empty() { None } else { Some(state.title.as_str()) }),
|
|
Some(if state.alt.is_empty() { None } else { Some(state.alt.as_str()) }),
|
|
Some(if state.caption.is_empty() { None } else { Some(state.caption.as_str()) }),
|
|
Some(if state.author.is_empty() { None } else { Some(state.author.as_str()) }),
|
|
Some(if state.language.is_empty() { None } else { Some(state.language.as_str()) }),
|
|
Some(tags.clone()),
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(PersistedMediaState::Canonical { media, tags })
|
|
}
|
|
}
|
|
|
|
fn load_generation_post_body(data_dir: &Path, post: &Post) -> Result<String, String> {
|
|
if let Some(content) = &post.content {
|
|
return Ok(content.clone());
|
|
}
|
|
let raw = std::fs::read_to_string(data_dir.join(&post.file_path)).map_err(|e| e.to_string())?;
|
|
let (_frontmatter, body) = bds_core::util::frontmatter::read_post_file(&raw)?;
|
|
Ok(body)
|
|
}
|
|
|
|
fn default_post_editor_mode(settings_state: Option<&SettingsViewState>) -> &str {
|
|
settings_state
|
|
.map(|state| state.default_mode.as_str())
|
|
.unwrap_or("markdown")
|
|
}
|
|
|
|
fn referenced_media_ids(content: &str) -> Vec<String> {
|
|
let mut ids = Vec::new();
|
|
let mut rest = content;
|
|
|
|
while let Some(start) = rest.find("bds-media://") {
|
|
let suffix = &rest[start + "bds-media://".len()..];
|
|
let end = suffix
|
|
.find(|ch: char| ch == ')' || ch == ']' || ch.is_whitespace())
|
|
.unwrap_or(suffix.len());
|
|
let media_id = suffix[..end].trim();
|
|
if !media_id.is_empty() && !ids.iter().any(|existing| existing == media_id) {
|
|
ids.push(media_id.to_string());
|
|
}
|
|
rest = &suffix[end..];
|
|
}
|
|
|
|
ids
|
|
}
|
|
|
|
fn draft_preview_url(post_id: &str, language: &str) -> String {
|
|
format!(
|
|
"http://{}:{}/__draft/{}?language={}",
|
|
engine::preview::PREVIEW_HOST,
|
|
engine::preview::PREVIEW_PORT,
|
|
post_id,
|
|
language
|
|
)
|
|
}
|
|
|
|
fn save_template_editor_state_impl(
|
|
db: &Database,
|
|
project_id: &str,
|
|
state: &TemplateEditorState,
|
|
) -> Result<Template, String> {
|
|
engine::template::validate_template(&state.content)?;
|
|
engine::template::update_template(
|
|
db.conn(),
|
|
&state.template_id,
|
|
project_id,
|
|
Some(&state.title),
|
|
Some(&state.slug),
|
|
Some(state.kind.clone()),
|
|
Some(state.enabled),
|
|
Some(&state.content),
|
|
)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
fn save_script_editor_state_impl(
|
|
db: &Database,
|
|
project_id: &str,
|
|
state: &ScriptEditorState,
|
|
) -> Result<Script, String> {
|
|
engine::script::validate_script_syntax(&state.content)?;
|
|
engine::script::update_script(
|
|
db.conn(),
|
|
&state.script_id,
|
|
project_id,
|
|
Some(&state.title),
|
|
Some(&state.slug),
|
|
Some(state.kind.clone()),
|
|
Some(&state.entrypoint),
|
|
Some(state.enabled),
|
|
Some(&state.content),
|
|
)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
fn save_editor_settings_state_impl(
|
|
db: &Database,
|
|
state: &SettingsViewState,
|
|
) -> Result<(), String> {
|
|
let now = bds_core::util::now_unix_ms();
|
|
[
|
|
bds_core::db::queries::setting::set_setting_value(db.conn(), "editor.default_mode", &state.default_mode, now),
|
|
bds_core::db::queries::setting::set_setting_value(db.conn(), "editor.diff_view_style", &state.diff_view_style, now),
|
|
bds_core::db::queries::setting::set_setting_value(db.conn(), "editor.wrap_long_lines", if state.wrap_long_lines { "true" } else { "false" }, now),
|
|
bds_core::db::queries::setting::set_setting_value(db.conn(), "editor.hide_unchanged_regions", if state.hide_unchanged_regions { "true" } else { "false" }, now),
|
|
]
|
|
.into_iter()
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map(|_| ())
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
fn month_abbreviation(month: u32) -> String {
|
|
match month {
|
|
1 => "Jan",
|
|
2 => "Feb",
|
|
3 => "Mar",
|
|
4 => "Apr",
|
|
5 => "May",
|
|
6 => "Jun",
|
|
7 => "Jul",
|
|
8 => "Aug",
|
|
9 => "Sep",
|
|
10 => "Oct",
|
|
11 => "Nov",
|
|
12 => "Dec",
|
|
_ => "?",
|
|
}
|
|
.to_string()
|
|
}
|
|
|
|
fn format_timestamp(timestamp_ms: i64) -> String {
|
|
let secs = timestamp_ms / 1000;
|
|
let (year, month, day) = bds_core::util::timestamp::year_month_day_from_unix_ms(timestamp_ms);
|
|
let hour = ((secs % 86_400) / 3_600) as u32;
|
|
let minute = ((secs % 3_600) / 60) as u32;
|
|
format!("{year}-{month:02}-{day:02} {hour:02}:{minute:02}")
|
|
}
|
|
|
|
fn format_bytes(size: i64) -> String {
|
|
let size = size.max(0) as f64;
|
|
if size < 1024.0 {
|
|
return format!("{} B", size as i64);
|
|
}
|
|
if size < 1024.0 * 1024.0 {
|
|
return format!("{:.1} KB", size / 1024.0);
|
|
}
|
|
if size < 1024.0 * 1024.0 * 1024.0 {
|
|
return format!("{:.1} MB", size / (1024.0 * 1024.0));
|
|
}
|
|
format!("{:.1} GB", size / (1024.0 * 1024.0 * 1024.0))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{month_abbreviation, persist_media_editor_state_impl, persist_post_editor_preview_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::post_editor::PostEditorState;
|
|
use crate::views::script_editor::ScriptEditorState;
|
|
use crate::views::settings_view::SettingsViewState;
|
|
use crate::views::template_editor::TemplateEditorState;
|
|
use chrono::{Datelike, TimeZone};
|
|
use bds_core::db::fts::ensure_fts_tables;
|
|
use bds_core::db::queries::project::insert_project;
|
|
use bds_core::db::Database;
|
|
use bds_core::engine::{ai, media, post, script, template};
|
|
use bds_core::model::{Project, ScriptKind, TemplateKind};
|
|
use std::io::{Read, Write};
|
|
use std::net::TcpListener;
|
|
use std::thread;
|
|
use tempfile::TempDir;
|
|
|
|
fn make_project() -> Project {
|
|
Project {
|
|
id: "p1".to_string(),
|
|
name: "Test Project".to_string(),
|
|
slug: "test-project".to_string(),
|
|
description: Some("desc".to_string()),
|
|
data_path: None,
|
|
is_active: true,
|
|
created_at: 1000,
|
|
updated_at: 1000,
|
|
}
|
|
}
|
|
|
|
fn tiny_png_bytes() -> &'static [u8] {
|
|
&[
|
|
0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A,
|
|
0x00, 0x00, 0x00, 0x0D, b'I', b'H', b'D', b'R',
|
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
|
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4,
|
|
0x89, 0x00, 0x00, 0x00, 0x0D, b'I', b'D', b'A',
|
|
b'T', 0x78, 0x9C, 0x63, 0xF8, 0xCF, 0xC0, 0xF0,
|
|
0x1F, 0x00, 0x05, 0x00, 0x01, 0xFF, 0x89, 0x99,
|
|
0x3D, 0x1D, 0x00, 0x00, 0x00, 0x00, b'I', b'E',
|
|
b'N', b'D', 0xAE, 0x42, 0x60, 0x82,
|
|
]
|
|
}
|
|
|
|
fn setup() -> (Database, Project, TempDir) {
|
|
let mut db = Database::open_in_memory().unwrap();
|
|
db.migrate().unwrap();
|
|
ensure_fts_tables(db.conn()).unwrap();
|
|
let project = make_project();
|
|
insert_project(db.conn(), &project).unwrap();
|
|
let tempdir = TempDir::new().unwrap();
|
|
std::fs::create_dir_all(tempdir.path().join("meta")).unwrap();
|
|
std::fs::write(
|
|
tempdir.path().join("meta/project.json"),
|
|
r#"{"name":"Test Project","maxPostsPerPage":50,"blogLanguages":["en"],"semanticSimilarityEnabled":false}"#,
|
|
)
|
|
.unwrap();
|
|
std::fs::write(tempdir.path().join("meta/publishing.json"), "{}\n").unwrap();
|
|
std::fs::write(
|
|
tempdir.path().join("meta/categories.json"),
|
|
r#"["article","aside","page","picture"]"#,
|
|
)
|
|
.unwrap();
|
|
std::fs::write(tempdir.path().join("meta/category-meta.json"), "{}\n").unwrap();
|
|
(db, project, tempdir)
|
|
}
|
|
|
|
fn spawn_models_server() -> String {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
thread::spawn(move || {
|
|
if let Some(stream) = listener.incoming().next() {
|
|
let mut stream = stream.unwrap();
|
|
let mut buffer = [0_u8; 8192];
|
|
let size = stream.read(&mut buffer).unwrap();
|
|
let request = String::from_utf8_lossy(&buffer[..size]).to_string();
|
|
assert!(request.starts_with("GET /v1/models HTTP/1.1"));
|
|
let body = r#"{"data":[{"id":"llama3.2","name":"Llama 3.2"}]}"#;
|
|
let response = format!(
|
|
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
|
body.len(),
|
|
body,
|
|
);
|
|
stream.write_all(response.as_bytes()).unwrap();
|
|
}
|
|
});
|
|
format!("http://{}", addr)
|
|
}
|
|
|
|
fn make_app(db: Database, project: Project, tmp: &TempDir) -> BdsApp {
|
|
BdsApp::new_for_tests(db, project, tmp.path().to_path_buf())
|
|
}
|
|
|
|
#[test]
|
|
fn post_editor_save_flow_persists_changes() {
|
|
let (db, project, tmp) = setup();
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Original",
|
|
Some("Body"),
|
|
vec!["rust".to_string()],
|
|
vec!["article".to_string()],
|
|
Some("Alice"),
|
|
Some("en"),
|
|
None,
|
|
).unwrap();
|
|
|
|
let editor_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap();
|
|
let mut editor = PostEditorState::from_post(&editor_post, "markdown", &["en".to_string(), "de".to_string()], &[], Vec::new(), Vec::new(), Vec::new());
|
|
editor.title = "Updated Post".to_string();
|
|
editor.content = "Updated body".to_string();
|
|
editor.tags = vec!["rust".to_string(), "lua".to_string()];
|
|
|
|
let result = persist_post_editor_state_impl(&db, tmp.path(), &editor).unwrap();
|
|
match result {
|
|
PersistedPostState::Canonical(post) => assert_eq!(post.title, "Updated Post"),
|
|
PersistedPostState::Translation(_) => panic!("expected canonical post save"),
|
|
}
|
|
|
|
let saved = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap();
|
|
assert_eq!(saved.title, "Updated Post");
|
|
assert_eq!(saved.content.as_deref(), Some("Updated body"));
|
|
assert_eq!(saved.tags, vec!["rust".to_string(), "lua".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn persist_post_editor_state_allows_published_posts_without_slug_conflict() {
|
|
let (db, project, tmp) = setup();
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Published",
|
|
Some("Body"),
|
|
Vec::new(),
|
|
vec!["article".to_string()],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let published = post::publish_post(db.conn(), tmp.path(), &created.id).unwrap();
|
|
|
|
let mut editor_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &published.id).unwrap();
|
|
let raw = std::fs::read_to_string(tmp.path().join(&editor_post.file_path)).unwrap();
|
|
let (_frontmatter, body) = bds_core::util::frontmatter::read_post_file(&raw).unwrap();
|
|
editor_post.content = Some(body);
|
|
|
|
let editor = PostEditorState::from_post(
|
|
&editor_post,
|
|
"preview",
|
|
&["en".to_string()],
|
|
&[],
|
|
Vec::new(),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
);
|
|
|
|
let result = persist_post_editor_state_impl(&db, tmp.path(), &editor).unwrap();
|
|
|
|
match result {
|
|
PersistedPostState::Canonical(post) => {
|
|
assert_eq!(post.slug, created.slug);
|
|
assert_eq!(post.title, created.title);
|
|
}
|
|
PersistedPostState::Translation(_) => panic!("expected canonical post save"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn preview_persist_bypasses_fts_for_published_canonical_post() {
|
|
let (db, project, tmp) = setup();
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Published",
|
|
Some("Body"),
|
|
Vec::new(),
|
|
vec!["article".to_string()],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let published = post::publish_post(db.conn(), tmp.path(), &created.id).unwrap();
|
|
db.conn().execute("DROP TABLE posts_fts", []).unwrap();
|
|
|
|
let mut editor_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &published.id).unwrap();
|
|
let raw = std::fs::read_to_string(tmp.path().join(&editor_post.file_path)).unwrap();
|
|
let (_frontmatter, body) = bds_core::util::frontmatter::read_post_file(&raw).unwrap();
|
|
editor_post.content = Some(body);
|
|
|
|
let mut editor = PostEditorState::from_post(
|
|
&editor_post,
|
|
"preview",
|
|
&["en".to_string()],
|
|
&[],
|
|
Vec::new(),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
);
|
|
editor.title = "Preview Draft".to_string();
|
|
editor.slug = "changed-slug".to_string();
|
|
editor.content = "Preview-only body".to_string();
|
|
|
|
let result = persist_post_editor_preview_state_impl(&db, &editor).unwrap();
|
|
|
|
match result {
|
|
PersistedPostState::Canonical(post) => {
|
|
assert_eq!(post.slug, created.slug);
|
|
assert_eq!(post.title, "Preview Draft");
|
|
assert_eq!(post.content.as_deref(), Some("Preview-only body"));
|
|
assert_eq!(post.status, PostStatus::Draft);
|
|
}
|
|
PersistedPostState::Translation(_) => panic!("expected canonical post save"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn preview_persist_bypasses_fts_for_translation_updates() {
|
|
let (db, project, tmp) = setup();
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Canonical",
|
|
Some("Body"),
|
|
Vec::new(),
|
|
vec!["article".to_string()],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
post::upsert_translation(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&created.id,
|
|
"de",
|
|
"Alt",
|
|
Some("Auszug"),
|
|
Some("Inhalt"),
|
|
)
|
|
.unwrap();
|
|
db.conn().execute("DROP TABLE posts_fts", []).unwrap();
|
|
|
|
let editor_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap();
|
|
let translations = bds_core::db::queries::post_translation::list_post_translations_by_post(
|
|
db.conn(),
|
|
&created.id,
|
|
)
|
|
.unwrap();
|
|
let mut editor = PostEditorState::from_post(
|
|
&editor_post,
|
|
"preview",
|
|
&["en".to_string(), "de".to_string()],
|
|
&translations,
|
|
Vec::new(),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
);
|
|
editor.switch_language("de");
|
|
editor.title = "Vorschau".to_string();
|
|
editor.excerpt = "Kurz".to_string();
|
|
editor.content = "Nur Vorschau".to_string();
|
|
|
|
let result = persist_post_editor_preview_state_impl(&db, &editor).unwrap();
|
|
|
|
match result {
|
|
PersistedPostState::Translation(translation) => {
|
|
assert_eq!(translation.language, "de");
|
|
assert_eq!(translation.title, "Vorschau");
|
|
assert_eq!(translation.content.as_deref(), Some("Nur Vorschau"));
|
|
}
|
|
PersistedPostState::Canonical(_) => panic!("expected translation save"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn save_ai_settings_allows_airplane_only_configuration() {
|
|
let (db, _project, _tmp) = setup();
|
|
let mut state = SettingsViewState::default();
|
|
state.airplane_endpoint_url = spawn_models_server();
|
|
state.airplane_endpoint_model = "llama3.2".to_string();
|
|
state.system_prompt = iced::widget::text_editor::Content::with_text("Use JSON only.");
|
|
|
|
BdsApp::save_ai_settings_state(&db, &mut state).unwrap();
|
|
|
|
let settings = ai::load_ai_settings(db.conn(), false).unwrap();
|
|
assert!(settings.online_endpoint.url.is_empty());
|
|
assert!(settings.online_endpoint.model.is_empty());
|
|
assert_eq!(settings.airplane_endpoint.url, state.airplane_endpoint_url);
|
|
assert_eq!(settings.airplane_endpoint.model, "llama3.2");
|
|
assert_eq!(settings.system_prompt.trim_end(), "Use JSON only.");
|
|
}
|
|
|
|
#[test]
|
|
fn media_editor_save_flow_persists_changes() {
|
|
let (db, project, tmp) = setup();
|
|
let source = tmp.path().join("tiny.png");
|
|
std::fs::write(&source, tiny_png_bytes()).unwrap();
|
|
let imported = media::import_media(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
&source,
|
|
"tiny.png",
|
|
Some("Tiny"),
|
|
Some("Alt"),
|
|
None,
|
|
None,
|
|
Some("en"),
|
|
vec!["photo".to_string()],
|
|
).unwrap();
|
|
|
|
let media_record = bds_core::db::queries::media::get_media_by_id(db.conn(), &imported.id).unwrap();
|
|
let mut editor = MediaEditorState::from_media(&media_record, &["en".to_string()], &[], Vec::new());
|
|
editor.title = "Tiny Updated".to_string();
|
|
editor.tags_input = "photo, lua".to_string();
|
|
|
|
let result = persist_media_editor_state_impl(&db, tmp.path(), &editor).unwrap();
|
|
match result {
|
|
PersistedMediaState::Canonical { media, tags } => {
|
|
assert_eq!(media.title.as_deref(), Some("Tiny Updated"));
|
|
assert_eq!(tags, vec!["photo".to_string(), "lua".to_string()]);
|
|
}
|
|
PersistedMediaState::Translation => panic!("expected canonical media save"),
|
|
}
|
|
|
|
let saved = bds_core::db::queries::media::get_media_by_id(db.conn(), &imported.id).unwrap();
|
|
assert_eq!(saved.title.as_deref(), Some("Tiny Updated"));
|
|
assert_eq!(saved.tags, vec!["photo".to_string(), "lua".to_string()]);
|
|
assert!(tmp.path().join(saved.sidecar_path).exists());
|
|
}
|
|
|
|
#[test]
|
|
fn template_editor_save_flow_persists_changes() {
|
|
let (db, project, _tmp) = setup();
|
|
let created = template::create_template(
|
|
db.conn(),
|
|
&project.id,
|
|
"Post Template",
|
|
TemplateKind::Post,
|
|
"<article>{{ title }}</article>",
|
|
).unwrap();
|
|
|
|
let template_record = bds_core::db::queries::template::get_template_by_id(db.conn(), &created.id).unwrap();
|
|
let mut editor = TemplateEditorState::from_template(&template_record);
|
|
editor.title = "Updated Template".to_string();
|
|
editor.content = "<main>{{ title }}</main>".to_string();
|
|
|
|
let saved_template = save_template_editor_state_impl(&db, &project.id, &editor).unwrap();
|
|
assert_eq!(saved_template.title, "Updated Template");
|
|
|
|
let saved = bds_core::db::queries::template::get_template_by_id(db.conn(), &created.id).unwrap();
|
|
assert_eq!(saved.title, "Updated Template");
|
|
assert_eq!(saved.content.as_deref(), Some("<main>{{ title }}</main>"));
|
|
}
|
|
|
|
#[test]
|
|
fn template_editor_save_rejects_invalid_content() {
|
|
let (db, project, _tmp) = setup();
|
|
let created = template::create_template(
|
|
db.conn(),
|
|
&project.id,
|
|
"Broken Template",
|
|
TemplateKind::Post,
|
|
"<article>{{ title }}</article>",
|
|
).unwrap();
|
|
|
|
let template_record = bds_core::db::queries::template::get_template_by_id(db.conn(), &created.id).unwrap();
|
|
let mut editor = TemplateEditorState::from_template(&template_record);
|
|
editor.content = "{% if title %}".to_string();
|
|
|
|
let error = save_template_editor_state_impl(&db, &project.id, &editor).unwrap_err();
|
|
assert!(error.contains("endif") || error.contains("unclosed") || error.contains("missing"));
|
|
|
|
let saved = bds_core::db::queries::template::get_template_by_id(db.conn(), &created.id).unwrap();
|
|
assert_eq!(saved.content.as_deref(), Some("<article>{{ title }}</article>"));
|
|
}
|
|
|
|
#[test]
|
|
fn script_editor_save_flow_persists_changes() {
|
|
let (db, project, _tmp) = setup();
|
|
let created = script::create_script(
|
|
db.conn(),
|
|
&project.id,
|
|
"Utility Script",
|
|
ScriptKind::Utility,
|
|
"function main()\n return 'ok'\nend",
|
|
Some("main"),
|
|
).unwrap();
|
|
|
|
let script_record = bds_core::db::queries::script::get_script_by_id(db.conn(), &created.id).unwrap();
|
|
let mut editor = ScriptEditorState::from_script(&script_record);
|
|
editor.title = "Updated Script".to_string();
|
|
editor.content = "function main()\n return 'lua'\nend".to_string();
|
|
editor.entrypoint = "main".to_string();
|
|
|
|
let saved_script = save_script_editor_state_impl(&db, &project.id, &editor).unwrap();
|
|
assert_eq!(saved_script.title, "Updated Script");
|
|
|
|
let saved = bds_core::db::queries::script::get_script_by_id(db.conn(), &created.id).unwrap();
|
|
assert_eq!(saved.title, "Updated Script");
|
|
assert_eq!(saved.content.as_deref(), Some("function main()\n return 'lua'\nend"));
|
|
}
|
|
|
|
#[test]
|
|
fn script_editor_save_rejects_invalid_content() {
|
|
let (db, project, _tmp) = setup();
|
|
let created = script::create_script(
|
|
db.conn(),
|
|
&project.id,
|
|
"Broken Script",
|
|
ScriptKind::Utility,
|
|
"function main()\n return 'ok'\nend",
|
|
Some("main"),
|
|
).unwrap();
|
|
|
|
let script_record = bds_core::db::queries::script::get_script_by_id(db.conn(), &created.id).unwrap();
|
|
let mut editor = ScriptEditorState::from_script(&script_record);
|
|
editor.content = "function main()\n return 'oops'".to_string();
|
|
|
|
let error = save_script_editor_state_impl(&db, &project.id, &editor).unwrap_err();
|
|
assert!(error.contains("end") || error.contains("unclosed") || error.contains("missing"));
|
|
|
|
let saved = bds_core::db::queries::script::get_script_by_id(db.conn(), &created.id).unwrap();
|
|
assert_eq!(saved.content.as_deref(), Some("function main()\n return 'ok'\nend"));
|
|
}
|
|
|
|
#[test]
|
|
fn settings_editor_save_flow_persists_values() {
|
|
let (db, _project, _tmp) = setup();
|
|
let settings = SettingsViewState {
|
|
default_mode: "markdown".to_string(),
|
|
diff_view_style: "side-by-side".to_string(),
|
|
wrap_long_lines: false,
|
|
hide_unchanged_regions: true,
|
|
..SettingsViewState::default()
|
|
};
|
|
|
|
save_editor_settings_state_impl(&db, &settings).unwrap();
|
|
|
|
let wrap = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "editor.wrap_long_lines").unwrap();
|
|
let hide = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "editor.hide_unchanged_regions").unwrap();
|
|
let diff = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "editor.diff_view_style").unwrap();
|
|
|
|
assert_eq!(wrap.value, "false");
|
|
assert_eq!(hide.value, "true");
|
|
assert_eq!(diff.value, "side-by-side");
|
|
}
|
|
|
|
#[test]
|
|
fn load_post_media_items_includes_media_referenced_only_in_markdown() {
|
|
let (db, project, tmp) = setup();
|
|
let source = tmp.path().join("tiny.png");
|
|
std::fs::write(&source, tiny_png_bytes()).unwrap();
|
|
let imported = media::import_media(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
&source,
|
|
"tiny.png",
|
|
Some("Tiny"),
|
|
Some("Alt"),
|
|
None,
|
|
None,
|
|
Some("en"),
|
|
vec!["photo".to_string()],
|
|
)
|
|
.unwrap();
|
|
let post = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Referenced",
|
|
Some(&format!("", imported.id)),
|
|
Vec::new(),
|
|
vec!["article".to_string()],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let app = make_app(db, project, &tmp);
|
|
|
|
let linked_media = app.load_post_media_items(&post.id, post.content.as_deref());
|
|
|
|
assert_eq!(linked_media.len(), 1);
|
|
assert_eq!(linked_media[0].media_id, imported.id);
|
|
assert!(linked_media[0].is_image);
|
|
}
|
|
|
|
#[test]
|
|
fn draft_preview_url_points_at_local_preview_server() {
|
|
let url = super::draft_preview_url("post-42", "de");
|
|
|
|
assert_eq!(
|
|
url,
|
|
format!(
|
|
"http://{}:{}/__draft/post-42?language=de",
|
|
bds_core::engine::preview::PREVIEW_HOST,
|
|
bds_core::engine::preview::PREVIEW_PORT,
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn active_post_uses_embedded_preview_only_for_preview_mode_posts() {
|
|
let (db, project, tmp) = setup();
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Previewed",
|
|
Some("Body"),
|
|
Vec::new(),
|
|
vec!["article".to_string()],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let editor_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap();
|
|
let mut editor = PostEditorState::from_post(
|
|
&editor_post,
|
|
"markdown",
|
|
&["en".to_string()],
|
|
&[],
|
|
Vec::new(),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
);
|
|
let mut app = make_app(db, project, &tmp);
|
|
app.tabs.push(crate::state::tabs::Tab {
|
|
id: created.id.clone(),
|
|
tab_type: crate::state::tabs::TabType::Post,
|
|
title: created.title.clone(),
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
});
|
|
app.active_tab = Some(created.id.clone());
|
|
app.post_editors.insert(created.id.clone(), editor.clone());
|
|
|
|
assert!(!app.active_post_uses_embedded_preview());
|
|
|
|
editor.set_editor_mode("preview");
|
|
app.post_editors.insert(created.id.clone(), editor);
|
|
|
|
assert!(app.active_post_uses_embedded_preview());
|
|
}
|
|
|
|
#[test]
|
|
fn task_tick_autosaves_dirty_post_editor() {
|
|
let (db, project, tmp) = setup();
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Original",
|
|
Some("Body"),
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
).unwrap();
|
|
|
|
let editor_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap();
|
|
let mut editor = PostEditorState::from_post(&editor_post, "markdown", &["en".to_string()], &[], Vec::new(), Vec::new(), Vec::new());
|
|
editor.title = "Autosaved".to_string();
|
|
editor.mark_dirty();
|
|
editor.last_edit_at_ms = bds_core::util::now_unix_ms() - POST_AUTO_SAVE_DELAY_MS - 100;
|
|
|
|
let mut app = make_app(db, project, &tmp);
|
|
app.post_editors.insert(created.id.clone(), editor);
|
|
app.tabs.push(crate::state::tabs::Tab {
|
|
id: created.id.clone(),
|
|
tab_type: crate::state::tabs::TabType::Post,
|
|
title: "Original".to_string(),
|
|
is_transient: false,
|
|
is_dirty: true,
|
|
});
|
|
app.active_tab = Some(created.id.clone());
|
|
|
|
let _ = app.update(Message::TaskTick);
|
|
|
|
let saved = bds_core::db::queries::post::get_post_by_id(app.db.as_ref().unwrap().conn(), &created.id).unwrap();
|
|
assert_eq!(saved.title, "Autosaved");
|
|
assert!(!app.post_editors.get(&created.id).unwrap().is_dirty);
|
|
}
|
|
|
|
#[test]
|
|
fn switching_tabs_flushes_active_post_editor() {
|
|
let (db, project, tmp) = setup();
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Original",
|
|
Some("Body"),
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
).unwrap();
|
|
|
|
let editor_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap();
|
|
let mut editor = PostEditorState::from_post(&editor_post, "markdown", &["en".to_string()], &[], Vec::new(), Vec::new(), Vec::new());
|
|
editor.title = "Switched".to_string();
|
|
editor.mark_dirty();
|
|
|
|
let mut app = make_app(db, project, &tmp);
|
|
app.post_editors.insert(created.id.clone(), editor);
|
|
app.tabs.push(crate::state::tabs::Tab {
|
|
id: created.id.clone(),
|
|
tab_type: crate::state::tabs::TabType::Post,
|
|
title: "Original".to_string(),
|
|
is_transient: false,
|
|
is_dirty: true,
|
|
});
|
|
app.tabs.push(crate::state::tabs::Tab {
|
|
id: "settings".to_string(),
|
|
tab_type: crate::state::tabs::TabType::Settings,
|
|
title: "Settings".to_string(),
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
});
|
|
app.active_tab = Some(created.id.clone());
|
|
|
|
let _ = app.update(Message::SelectTab("settings".to_string()));
|
|
|
|
let saved = bds_core::db::queries::post::get_post_by_id(app.db.as_ref().unwrap().conn(), &created.id).unwrap();
|
|
assert_eq!(saved.title, "Switched");
|
|
assert_eq!(app.active_tab.as_deref(), Some("settings"));
|
|
}
|
|
|
|
#[test]
|
|
fn media_editor_link_and_unlink_post_refreshes_relationships() {
|
|
let (db, project, tmp) = setup();
|
|
let created_post = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Linked Post",
|
|
Some("Body"),
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
).unwrap();
|
|
let source = tmp.path().join("tiny.png");
|
|
std::fs::write(&source, tiny_png_bytes()).unwrap();
|
|
let imported = media::import_media(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
&source,
|
|
"tiny.png",
|
|
Some("Tiny"),
|
|
Some("Alt"),
|
|
None,
|
|
None,
|
|
Some("en"),
|
|
vec![],
|
|
).unwrap();
|
|
|
|
let media_record = bds_core::db::queries::media::get_media_by_id(db.conn(), &imported.id).unwrap();
|
|
let mut app = make_app(db, project, &tmp);
|
|
app.media_editors.insert(
|
|
imported.id.clone(),
|
|
MediaEditorState::from_media(&media_record, &["en".to_string()], &[], Vec::new()),
|
|
);
|
|
app.tabs.push(crate::state::tabs::Tab {
|
|
id: imported.id.clone(),
|
|
tab_type: crate::state::tabs::TabType::Media,
|
|
title: "Tiny".to_string(),
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
});
|
|
app.active_tab = Some(imported.id.clone());
|
|
|
|
let _ = app.update(Message::MediaEditor(MediaEditorMsg::LinkPost(created_post.id.clone())));
|
|
|
|
let linked = bds_core::engine::post_media::list_posts_for_media(app.db.as_ref().unwrap().conn(), &imported.id).unwrap();
|
|
assert_eq!(linked.len(), 1);
|
|
assert_eq!(linked[0].id, created_post.id);
|
|
assert_eq!(app.media_editors.get(&imported.id).unwrap().linked_posts.len(), 1);
|
|
|
|
let _ = app.update(Message::MediaEditor(MediaEditorMsg::UnlinkPost(created_post.id.clone())));
|
|
|
|
let linked = bds_core::engine::post_media::list_posts_for_media(app.db.as_ref().unwrap().conn(), &imported.id).unwrap();
|
|
assert!(linked.is_empty());
|
|
assert!(app.media_editors.get(&imported.id).unwrap().linked_posts.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_counts_populates_dashboard_state() {
|
|
let (db, project, tmp) = setup();
|
|
let first = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"First",
|
|
Some("Body"),
|
|
vec!["rust".to_string()],
|
|
vec!["article".to_string()],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let second = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Second",
|
|
Some("Body"),
|
|
vec!["lua".to_string()],
|
|
vec!["aside".to_string()],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let mut second_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &second.id).unwrap();
|
|
second_post.status = PostStatus::Published;
|
|
bds_core::db::queries::post::update_post(db.conn(), &second_post).unwrap();
|
|
bds_core::engine::tag::discover_tags(db.conn(), tmp.path(), &project.id).unwrap();
|
|
|
|
let source = tmp.path().join("tiny.png");
|
|
std::fs::write(&source, tiny_png_bytes()).unwrap();
|
|
media::import_media(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
&source,
|
|
"tiny.png",
|
|
Some("Tiny"),
|
|
Some("Alt"),
|
|
None,
|
|
None,
|
|
Some("en"),
|
|
vec!["cover".to_string()],
|
|
)
|
|
.unwrap();
|
|
|
|
let mut app = make_app(db, project, &tmp);
|
|
let _ = app.refresh_counts();
|
|
|
|
let dash = app.dashboard_state.expect("dashboard state should be set");
|
|
let now = chrono::Utc::now();
|
|
assert_eq!(dash.stats.total_posts, 2);
|
|
assert_eq!(dash.stats.published_count, 1);
|
|
assert_eq!(dash.stats.media_count, 1);
|
|
assert_eq!(dash.stats.tag_count, 2);
|
|
assert_eq!(dash.timeline.len(), 12);
|
|
assert_eq!(dash.timeline.last().map(|month| month.year), Some(now.year()));
|
|
assert_eq!(dash.timeline.last().map(|month| month.label.clone()), Some(month_abbreviation(now.month())));
|
|
assert_eq!(dash.timeline.iter().map(|month| month.count).sum::<usize>(), 2);
|
|
assert_eq!(dash.recent_posts.len(), 2);
|
|
assert!(!dash.category_cloud.is_empty());
|
|
assert_eq!(dash.recent_posts[0].title, "Second");
|
|
assert_eq!(first.title, "First");
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_timeline_uses_created_month_not_updated_month() {
|
|
let (db, project, tmp) = setup();
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"March Post",
|
|
Some("Body"),
|
|
vec![],
|
|
vec!["article".to_string()],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
let march_created_at = chrono::Utc
|
|
.with_ymd_and_hms(2026, 3, 15, 12, 0, 0)
|
|
.single()
|
|
.unwrap()
|
|
.timestamp_millis();
|
|
let april_updated_at = chrono::Utc
|
|
.with_ymd_and_hms(2026, 4, 2, 12, 0, 0)
|
|
.single()
|
|
.unwrap()
|
|
.timestamp_millis();
|
|
|
|
let mut saved = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap();
|
|
saved.created_at = march_created_at;
|
|
saved.updated_at = april_updated_at;
|
|
bds_core::db::queries::post::update_post(db.conn(), &saved).unwrap();
|
|
|
|
let app = make_app(db, project, &tmp);
|
|
let dash = app.hydrate_dashboard_state();
|
|
let march = dash
|
|
.timeline
|
|
.iter()
|
|
.find(|month| month.year == 2026 && month.label == "Mar")
|
|
.expect("march bucket should exist");
|
|
let april = dash
|
|
.timeline
|
|
.iter()
|
|
.find(|month| month.year == 2026 && month.label == "Apr")
|
|
.expect("april bucket should exist");
|
|
|
|
assert_eq!(march.count, 1);
|
|
assert_eq!(april.count, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn save_project_persists_languages_and_blogmark_category() {
|
|
let (db, project, tmp) = setup();
|
|
let mut app = make_app(db, project, &tmp);
|
|
let mut state = app.hydrate_settings_state();
|
|
state.project_name = "Localized Project".to_string();
|
|
state.main_language = "de".to_string();
|
|
state.blog_languages = vec!["de".to_string(), "en".to_string()];
|
|
state.blogmark_category = "article".to_string();
|
|
state.semantic_similarity_enabled = true;
|
|
app.settings_state = Some(state);
|
|
|
|
let _ = app.handle_settings_msg(SettingsMsg::SaveProject);
|
|
|
|
let meta = bds_core::engine::meta::read_project_json(tmp.path()).unwrap();
|
|
assert_eq!(meta.name, "Localized Project");
|
|
assert_eq!(meta.main_language.as_deref(), Some("de"));
|
|
assert_eq!(meta.blog_languages, vec!["de".to_string(), "en".to_string()]);
|
|
assert_eq!(meta.blogmark_category.as_deref(), Some("article"));
|
|
assert!(meta.semantic_similarity_enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn add_category_and_reset_defaults_updates_metadata_files() {
|
|
let (db, project, tmp) = setup();
|
|
let mut app = make_app(db, project, &tmp);
|
|
app.settings_state = Some(app.hydrate_settings_state());
|
|
|
|
let _ = app.handle_settings_msg(SettingsMsg::AddCategoryNameChanged("news".to_string()));
|
|
let _ = app.handle_settings_msg(SettingsMsg::AddCategory);
|
|
|
|
let categories = bds_core::engine::meta::read_categories_json(tmp.path()).unwrap();
|
|
assert!(categories.iter().any(|category| category == "news"));
|
|
|
|
let _ = app.handle_settings_msg(SettingsMsg::ResetCategoriesToDefaults);
|
|
|
|
let categories = bds_core::engine::meta::read_categories_json(tmp.path()).unwrap();
|
|
assert_eq!(categories, vec![
|
|
"article".to_string(),
|
|
"aside".to_string(),
|
|
"page".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());
|
|
}
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────────────
|
|
// App State
|
|
// ───────────────────────────────────────────────────────────
|
|
|
|
pub struct BdsApp {
|
|
// Database
|
|
db: Option<Database>,
|
|
db_path: PathBuf,
|
|
|
|
// Project
|
|
active_project: Option<Project>,
|
|
projects: Vec<Project>,
|
|
data_dir: Option<PathBuf>,
|
|
|
|
// Counts
|
|
post_count: usize,
|
|
media_count: usize,
|
|
|
|
// Sidebar data
|
|
sidebar_posts: Vec<Post>,
|
|
sidebar_media: Vec<Media>,
|
|
sidebar_scripts: Vec<Script>,
|
|
sidebar_templates: Vec<Template>,
|
|
sidebar_media_thumbs: HashMap<String, Option<std::path::PathBuf>>,
|
|
sidebar_posts_has_more: bool,
|
|
sidebar_media_has_more: bool,
|
|
|
|
// Sidebar filters (per sidebar_views.allium PostsView / MediaView)
|
|
post_filter: PostFilter,
|
|
page_filter: PostFilter,
|
|
media_filter: MediaFilter,
|
|
|
|
// Navigation
|
|
sidebar_view: SidebarView,
|
|
sidebar_visible: bool,
|
|
sidebar_width: f32,
|
|
sidebar_dragging: bool,
|
|
|
|
// Tabs
|
|
tabs: Vec<Tab>,
|
|
active_tab: Option<String>,
|
|
|
|
// Panel
|
|
panel_visible: bool,
|
|
panel_tab: PanelTab,
|
|
|
|
// Tasks
|
|
task_manager: Arc<TaskManager>,
|
|
task_snapshots: Vec<TaskSnapshot>,
|
|
output_entries: Vec<OutputEntry>,
|
|
|
|
// Platform
|
|
_menu_bar: Option<muda::Menu>,
|
|
menu_registry: MenuRegistry,
|
|
|
|
// i18n
|
|
ui_locale: UiLocale,
|
|
/// Content/render language — the blog's main_language from project.json.
|
|
/// Separate from ui_locale per i18n.allium TwoLocaleAxes.
|
|
content_language: String,
|
|
/// All blog languages from project.json (for translation flags).
|
|
blog_languages: Vec<String>,
|
|
|
|
// Flags
|
|
offline_mode: bool,
|
|
locale_dropdown_open: bool,
|
|
project_dropdown_open: bool,
|
|
theme_badge: String,
|
|
|
|
// Toasts
|
|
toasts: Vec<Toast>,
|
|
|
|
// Modal
|
|
active_modal: Option<modal::ModalState>,
|
|
|
|
// Local preview
|
|
preview_session: Option<PreviewSession>,
|
|
embedded_preview: Option<EmbeddedPreviewState>,
|
|
main_window_id: Option<window::Id>,
|
|
|
|
// Editor states (keyed by entity id)
|
|
post_editors: HashMap<String, PostEditorState>,
|
|
media_editors: HashMap<String, MediaEditorState>,
|
|
template_editors: HashMap<String, TemplateEditorState>,
|
|
script_editors: HashMap<String, ScriptEditorState>,
|
|
tags_view_state: Option<TagsViewState>,
|
|
settings_state: Option<SettingsViewState>,
|
|
dashboard_state: Option<DashboardState>,
|
|
site_validation_state: SiteValidationState,
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────────────
|
|
// App Implementation
|
|
// ───────────────────────────────────────────────────────────
|
|
|
|
impl BdsApp {
|
|
pub fn new() -> (Self, Task<Message>) {
|
|
let locale = detect_os_locale();
|
|
let (menu_bar, registry) = menu::build_menu_bar(locale);
|
|
|
|
// Open or create the database
|
|
let db_path = dirs::data_dir()
|
|
.unwrap_or_else(|| PathBuf::from("."))
|
|
.join("bds")
|
|
.join("bds.db");
|
|
|
|
// Ensure parent directory exists
|
|
if let Some(parent) = db_path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
|
|
let mut db = Database::open(&db_path).ok();
|
|
if let Some(ref mut db) = db {
|
|
let _ = db.migrate();
|
|
}
|
|
|
|
// Load projects
|
|
let projects = db
|
|
.as_ref()
|
|
.and_then(|d| engine::project::list_projects(d.conn()).ok())
|
|
.unwrap_or_default();
|
|
|
|
let active_project = db
|
|
.as_ref()
|
|
.and_then(|d| engine::project::get_active_project(d.conn()).ok())
|
|
.flatten();
|
|
|
|
let data_dir = active_project
|
|
.as_ref()
|
|
.and_then(|p| p.data_path.as_ref())
|
|
.map(PathBuf::from);
|
|
|
|
// If no projects exist, ensure the default project per spec
|
|
let init_task = if projects.is_empty() {
|
|
if let Some(ref db) = db {
|
|
let default_data = dirs::data_dir()
|
|
.unwrap_or_else(|| PathBuf::from("."))
|
|
.join("bds")
|
|
.join("projects")
|
|
.join("my-blog");
|
|
match engine::project::ensure_default_project(
|
|
db.conn(),
|
|
Some(&default_data),
|
|
) {
|
|
Ok(project) => {
|
|
Task::done(Message::ProjectsLoaded(vec![project]))
|
|
}
|
|
Err(_) => Task::none(),
|
|
}
|
|
} else {
|
|
Task::none()
|
|
}
|
|
} else {
|
|
Task::done(Message::ProjectsLoaded(projects.clone()))
|
|
};
|
|
|
|
// Chain menu initialization after project loading
|
|
// (must happen after the event loop has started for macOS)
|
|
let init_task = Task::batch([
|
|
init_task,
|
|
Task::done(Message::InitMenuBar),
|
|
window::get_oldest().map(Message::MainWindowLoaded),
|
|
]);
|
|
registry.set_enabled(MenuAction::Save, false);
|
|
registry.set_enabled(MenuAction::PublishSelected, false);
|
|
registry.set_enabled(MenuAction::PreviewPost, false);
|
|
registry.set_enabled(MenuAction::Find, false);
|
|
registry.set_enabled(MenuAction::Replace, false);
|
|
registry.set_enabled(MenuAction::OpenInBrowser, false);
|
|
|
|
(
|
|
Self {
|
|
db,
|
|
db_path,
|
|
active_project: active_project.clone(),
|
|
projects,
|
|
data_dir,
|
|
post_count: 0,
|
|
media_count: 0,
|
|
sidebar_posts: Vec::new(),
|
|
sidebar_media: Vec::new(),
|
|
sidebar_scripts: Vec::new(),
|
|
sidebar_templates: Vec::new(),
|
|
sidebar_media_thumbs: HashMap::new(),
|
|
sidebar_posts_has_more: false,
|
|
sidebar_media_has_more: false,
|
|
post_filter: PostFilter::default(),
|
|
page_filter: PostFilter::default(),
|
|
media_filter: MediaFilter::default(),
|
|
sidebar_view: SidebarView::Posts,
|
|
sidebar_visible: true,
|
|
sidebar_width: 280.0,
|
|
sidebar_dragging: false,
|
|
tabs: Vec::new(),
|
|
active_tab: None,
|
|
panel_visible: false,
|
|
panel_tab: PanelTab::Tasks,
|
|
task_manager: Arc::new(TaskManager::default()),
|
|
task_snapshots: Vec::new(),
|
|
output_entries: Vec::new(),
|
|
_menu_bar: Some(menu_bar),
|
|
menu_registry: registry,
|
|
ui_locale: locale,
|
|
content_language: "en".to_string(),
|
|
blog_languages: Vec::new(),
|
|
offline_mode: false,
|
|
locale_dropdown_open: false,
|
|
project_dropdown_open: false,
|
|
theme_badge: String::from("pico"),
|
|
toasts: Vec::new(),
|
|
active_modal: None,
|
|
preview_session: None,
|
|
embedded_preview: None,
|
|
main_window_id: None,
|
|
post_editors: HashMap::new(),
|
|
media_editors: HashMap::new(),
|
|
template_editors: HashMap::new(),
|
|
script_editors: HashMap::new(),
|
|
tags_view_state: None,
|
|
settings_state: None,
|
|
dashboard_state: None,
|
|
site_validation_state: SiteValidationState::default(),
|
|
},
|
|
init_task,
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn new_for_tests(db: Database, project: Project, data_dir: PathBuf) -> Self {
|
|
Self {
|
|
db: Some(db),
|
|
db_path: data_dir.join("bds.db"),
|
|
active_project: Some(project.clone()),
|
|
projects: vec![project],
|
|
data_dir: Some(data_dir),
|
|
post_count: 0,
|
|
media_count: 0,
|
|
sidebar_posts: Vec::new(),
|
|
sidebar_media: Vec::new(),
|
|
sidebar_scripts: Vec::new(),
|
|
sidebar_templates: Vec::new(),
|
|
sidebar_media_thumbs: HashMap::new(),
|
|
sidebar_posts_has_more: false,
|
|
sidebar_media_has_more: false,
|
|
post_filter: PostFilter::default(),
|
|
page_filter: PostFilter::default(),
|
|
media_filter: MediaFilter::default(),
|
|
sidebar_view: SidebarView::Posts,
|
|
sidebar_visible: true,
|
|
sidebar_width: 280.0,
|
|
sidebar_dragging: false,
|
|
tabs: Vec::new(),
|
|
active_tab: None,
|
|
panel_visible: false,
|
|
panel_tab: PanelTab::Tasks,
|
|
task_manager: Arc::new(TaskManager::default()),
|
|
task_snapshots: Vec::new(),
|
|
output_entries: Vec::new(),
|
|
_menu_bar: None,
|
|
menu_registry: MenuRegistry::empty(),
|
|
ui_locale: UiLocale::En,
|
|
content_language: "en".to_string(),
|
|
blog_languages: Vec::new(),
|
|
offline_mode: false,
|
|
locale_dropdown_open: false,
|
|
project_dropdown_open: false,
|
|
theme_badge: String::from("pico"),
|
|
toasts: Vec::new(),
|
|
active_modal: None,
|
|
preview_session: None,
|
|
embedded_preview: None,
|
|
main_window_id: None,
|
|
post_editors: HashMap::new(),
|
|
media_editors: HashMap::new(),
|
|
template_editors: HashMap::new(),
|
|
script_editors: HashMap::new(),
|
|
tags_view_state: None,
|
|
settings_state: None,
|
|
dashboard_state: None,
|
|
site_validation_state: SiteValidationState::default(),
|
|
}
|
|
}
|
|
|
|
pub fn update(&mut self, message: Message) -> Task<Message> {
|
|
match message {
|
|
// ── Menu event dispatch ──
|
|
Message::MenuEvent(id) => {
|
|
if let Some(action) = self.menu_registry.lookup(&id) {
|
|
return self.dispatch_menu_action(action);
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
// ── Navigation ──
|
|
Message::SetActiveView(view) => {
|
|
let (new_view, new_visible) =
|
|
handle_activity_click(self.sidebar_view, self.sidebar_visible, view);
|
|
let old_view = self.sidebar_view;
|
|
self.sidebar_view = new_view;
|
|
self.sidebar_visible = new_visible;
|
|
// When switching to/from Posts/Pages, re-query with correct filter
|
|
let needs_post_refresh = old_view != new_view
|
|
&& matches!(
|
|
new_view,
|
|
SidebarView::Posts | SidebarView::Pages
|
|
);
|
|
if needs_post_refresh {
|
|
self.refresh_sidebar_posts()
|
|
} else {
|
|
Task::none()
|
|
}
|
|
}
|
|
Message::ToggleSidebar => {
|
|
self.sidebar_visible = !self.sidebar_visible;
|
|
Task::none()
|
|
}
|
|
Message::TogglePanel => {
|
|
self.panel_visible = !self.panel_visible;
|
|
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) => {
|
|
self.sidebar_view = SidebarView::Settings;
|
|
self.sidebar_visible = true;
|
|
self.open_singleton_tab(TabType::Settings, "common.settings");
|
|
if self.settings_state.is_none() {
|
|
self.settings_state = Some(self.hydrate_settings_state());
|
|
}
|
|
if let Some(state) = self.settings_state.as_mut() {
|
|
state.focus_section(section);
|
|
}
|
|
self.sync_menu_state();
|
|
Task::none()
|
|
}
|
|
Message::SidebarResizeStart => {
|
|
self.sidebar_dragging = true;
|
|
Task::none()
|
|
}
|
|
Message::SidebarResizeMove(x) => {
|
|
if self.sidebar_dragging {
|
|
// x is global cursor position; subtract activity bar width (~48px)
|
|
let effective = x - 48.0;
|
|
self.sidebar_width = effective.clamp(200.0, 500.0);
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::SidebarResizeEnd => {
|
|
self.sidebar_dragging = false;
|
|
Task::none()
|
|
}
|
|
|
|
// ── Tabs ──
|
|
Message::OpenTab(tab) => {
|
|
self.flush_active_post_editor();
|
|
let idx = tabs::open_tab(&mut self.tabs, tab);
|
|
if let Some(t) = self.tabs.get(idx) {
|
|
self.active_tab = Some(t.id.clone());
|
|
let tab_clone = t.clone();
|
|
self.load_editor_for_tab(&tab_clone);
|
|
}
|
|
self.enforce_panel_tab_fallback();
|
|
self.sync_menu_state();
|
|
self.sync_embedded_preview_for_active_post()
|
|
}
|
|
Message::CloseTab(id) => {
|
|
if self.active_tab.as_deref() == Some(id.as_str()) {
|
|
self.flush_active_post_editor();
|
|
}
|
|
if let Some(next_idx) = tabs::close_tab(&mut self.tabs, &id) {
|
|
self.active_tab = self.tabs.get(next_idx).map(|t| t.id.clone());
|
|
} else {
|
|
self.active_tab = None;
|
|
}
|
|
self.enforce_panel_tab_fallback();
|
|
self.sync_menu_state();
|
|
self.sync_embedded_preview_for_active_post()
|
|
}
|
|
Message::SelectTab(id) => {
|
|
if self.tabs.iter().any(|t| t.id == id) {
|
|
if self.active_tab.as_deref() != Some(id.as_str()) {
|
|
self.flush_active_post_editor();
|
|
}
|
|
self.active_tab = Some(id);
|
|
}
|
|
self.enforce_panel_tab_fallback();
|
|
self.sync_embedded_preview_for_active_post()
|
|
}
|
|
Message::PinTab(id) => {
|
|
tabs::pin_tab(&mut self.tabs, &id);
|
|
Task::none()
|
|
}
|
|
Message::ClearTabs => {
|
|
self.flush_active_post_editor();
|
|
self.tabs.clear();
|
|
self.active_tab = None;
|
|
self.hide_embedded_preview();
|
|
Task::none()
|
|
}
|
|
|
|
// ── Project management ──
|
|
Message::ProjectsLoaded(projects) => {
|
|
self.projects = projects;
|
|
if let Some(ref db) = self.db {
|
|
self.active_project = engine::project::get_active_project(db.conn())
|
|
.ok()
|
|
.flatten();
|
|
self.data_dir = self
|
|
.active_project
|
|
.as_ref()
|
|
.and_then(|p| p.data_path.as_ref())
|
|
.map(PathBuf::from);
|
|
}
|
|
// Per metadata.allium StartupSync: sync metadata from filesystem
|
|
if let Some(data_dir) = self.data_dir.clone() {
|
|
if let Err(e) = engine::meta::startup_sync(&data_dir) {
|
|
self.add_output(&format!("Metadata sync failed: {e}"));
|
|
}
|
|
// Extract content language from project metadata
|
|
if let Ok(meta) = engine::meta::read_project_json(&data_dir) {
|
|
let main_lang = meta.main_language.unwrap_or_else(|| "en".to_string());
|
|
self.content_language = main_lang.clone();
|
|
self.blog_languages = meta.blog_languages;
|
|
if !self.blog_languages.contains(&main_lang) {
|
|
self.blog_languages.insert(0, main_lang);
|
|
}
|
|
}
|
|
}
|
|
let sidebar_task = self.refresh_counts();
|
|
self.sync_menu_state();
|
|
sidebar_task
|
|
}
|
|
Message::SwitchProject(project_id) => {
|
|
self.project_dropdown_open = false;
|
|
if let Some(ref db) = self.db {
|
|
match engine::project::set_active_project(db.conn(), &project_id) {
|
|
Ok(()) => {
|
|
self.active_project = self.projects.iter().find(|p| p.id == project_id).cloned();
|
|
self.preview_session = None;
|
|
self.hide_embedded_preview();
|
|
self.data_dir = self
|
|
.active_project
|
|
.as_ref()
|
|
.and_then(|p| p.data_path.as_ref())
|
|
.map(PathBuf::from);
|
|
// Per metadata.allium StartupSync
|
|
if let Some(data_dir) = self.data_dir.clone() {
|
|
let _ = engine::meta::startup_sync(&data_dir);
|
|
if let Ok(meta) = engine::meta::read_project_json(&data_dir) {
|
|
let main_lang = meta.main_language.unwrap_or_else(|| "en".to_string());
|
|
self.content_language = main_lang.clone();
|
|
self.blog_languages = meta.blog_languages;
|
|
if !self.blog_languages.contains(&main_lang) {
|
|
self.blog_languages.insert(0, main_lang);
|
|
}
|
|
}
|
|
}
|
|
let name = self.active_project.as_ref().map(|p| p.name.clone()).unwrap_or_default();
|
|
self.notify(ToastLevel::Success, &tw(self.ui_locale, "projectSelector.toast.switched", &[("name", &name)]));
|
|
}
|
|
Err(_) => {
|
|
self.notify(ToastLevel::Error, &t(self.ui_locale, "projectSelector.toast.switchFailed"));
|
|
}
|
|
}
|
|
}
|
|
self.sync_menu_state();
|
|
Task::none()
|
|
}
|
|
Message::ProjectSwitched(result) => {
|
|
match result {
|
|
Ok(name) => self.notify(ToastLevel::Success, &tw(self.ui_locale, "projectSelector.toast.switched", &[("name", &name)])),
|
|
Err(msg) => self.notify(ToastLevel::Error, &msg),
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::RequestCreateProject => {
|
|
crate::platform::dialog::pick_folder(t(self.ui_locale, "dialog.selectFolder"))
|
|
}
|
|
Message::CreateProject { name, data_path } => {
|
|
if let Some(ref db) = self.db {
|
|
let path_str = data_path.as_ref().map(|p| p.to_string_lossy().to_string());
|
|
match engine::project::create_project(
|
|
db.conn(),
|
|
&name,
|
|
path_str.as_deref(),
|
|
) {
|
|
Ok(project) => {
|
|
let _ = engine::project::set_active_project(db.conn(), &project.id);
|
|
self.projects = engine::project::list_projects(db.conn()).unwrap_or_default();
|
|
self.active_project = Some(project.clone());
|
|
self.preview_session = None;
|
|
self.data_dir = project.data_path.as_ref().map(PathBuf::from);
|
|
let msg = tw(self.ui_locale, "projectSelector.toast.created", &[("name", &project.name)]);
|
|
self.notify(ToastLevel::Success, &msg);
|
|
}
|
|
Err(_) => {
|
|
self.notify(ToastLevel::Error, &t(self.ui_locale, "projectSelector.toast.createFailed"));
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ProjectCreated(result) => {
|
|
match result {
|
|
Ok(name) => self.notify(ToastLevel::Success, &tw(self.ui_locale, "projectSelector.toast.created", &[("name", &name)])),
|
|
Err(msg) => self.notify(ToastLevel::Error, &msg),
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::DeleteProject(project_id) => {
|
|
if let Some(ref db) = self.db {
|
|
let data_path = self.projects.iter()
|
|
.find(|p| p.id == project_id)
|
|
.and_then(|p| p.data_path.as_ref())
|
|
.map(PathBuf::from);
|
|
match engine::project::delete_project(db.conn(), &project_id, data_path.as_deref()) {
|
|
Ok(()) => {
|
|
self.projects.retain(|p| p.id != project_id);
|
|
}
|
|
Err(_) => {
|
|
self.notify(ToastLevel::Error, &t(self.ui_locale, "projectSelector.toast.deleteFailed"));
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ProjectDeleted(result) => {
|
|
if let Err(msg) = result {
|
|
self.notify(ToastLevel::Error, &msg);
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
// ── Dialogs ──
|
|
Message::FolderPicked(path) => {
|
|
if let Some(path) = path {
|
|
let name = path
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| "New Project".to_string());
|
|
return Task::done(Message::CreateProject {
|
|
name,
|
|
data_path: Some(path),
|
|
});
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::MediaFilesPicked(_paths) => {
|
|
// Media import will be expanded in later milestones
|
|
Task::none()
|
|
}
|
|
Message::MainWindowLoaded(window_id) => {
|
|
self.main_window_id = window_id;
|
|
if self.active_post_uses_embedded_preview() {
|
|
self.sync_embedded_preview_for_active_post()
|
|
} else {
|
|
Task::none()
|
|
}
|
|
}
|
|
Message::EmbeddedPreviewReady(result) => {
|
|
match result {
|
|
Ok(()) => {
|
|
let visible = self.active_post_uses_embedded_preview();
|
|
if let Some(preview) = &mut self.embedded_preview {
|
|
preview.controller.take_staged();
|
|
if let Some(url) = preview.current_url.as_deref() {
|
|
preview.controller.navigate(url);
|
|
}
|
|
preview.controller.set_visible(visible);
|
|
}
|
|
}
|
|
Err(error) => {
|
|
self.notify(ToastLevel::Error, &error);
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
// ── Tasks ──
|
|
Message::TaskTick => {
|
|
self.refresh_task_snapshots();
|
|
self.auto_save_due_post_editors();
|
|
Task::none()
|
|
}
|
|
|
|
// ── macOS lifecycle ──
|
|
Message::FileOpenRequested(_path) => {
|
|
// File open handling deferred to later milestones
|
|
Task::none()
|
|
}
|
|
Message::UrlOpenRequested(url) => {
|
|
let _ = open::that(url);
|
|
Task::none()
|
|
}
|
|
|
|
// ── Panel ──
|
|
Message::SetPanelTab(tab) => {
|
|
self.panel_tab = tab;
|
|
Task::none()
|
|
}
|
|
|
|
// ── Settings ──
|
|
Message::SetOfflineMode(mode) => {
|
|
self.offline_mode = mode;
|
|
self.sync_menu_state();
|
|
Task::none()
|
|
}
|
|
Message::SetUiLocale(locale) => {
|
|
self.ui_locale = locale;
|
|
self.locale_dropdown_open = false;
|
|
menu::update_menu_labels(&self.menu_registry, locale);
|
|
// Re-translate singleton tab titles per tabs.allium
|
|
for tab in &mut self.tabs {
|
|
if let Some(key) = tab.tab_type.i18n_key() {
|
|
tab.title = t(locale, key);
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ToggleLocaleDropdown => {
|
|
self.locale_dropdown_open = !self.locale_dropdown_open;
|
|
self.project_dropdown_open = false;
|
|
Task::none()
|
|
}
|
|
Message::ToggleProjectDropdown => {
|
|
self.project_dropdown_open = !self.project_dropdown_open;
|
|
self.locale_dropdown_open = false;
|
|
Task::none()
|
|
}
|
|
|
|
// ── Blog engine actions (async via TaskManager) ──
|
|
Message::RebuildDatabase => {
|
|
self.spawn_engine_task(
|
|
"engine.rebuildStarted",
|
|
|db_path, project_id, data_dir, tm, tid| {
|
|
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
|
let on_progress: engine::rebuild::ProgressFn = Arc::new(move |pct, msg| {
|
|
tm.report_progress(tid, Some(pct), Some(msg.to_string()));
|
|
});
|
|
let report = engine::rebuild::rebuild_from_filesystem_with_progress(
|
|
db.conn(), &data_dir, &project_id, Some(on_progress),
|
|
).map_err(|e| e.to_string())?;
|
|
let posts = report.posts_created + report.posts_updated;
|
|
let media = report.media_created + report.media_updated;
|
|
let templates = report.templates_created + report.templates_updated;
|
|
let scripts = report.scripts_created + report.scripts_updated;
|
|
Ok(format!("posts={posts}, media={media}, templates={templates}, scripts={scripts}"))
|
|
},
|
|
)
|
|
}
|
|
Message::ReindexText => {
|
|
self.spawn_engine_task(
|
|
"engine.reindexStarted",
|
|
|db_path, project_id, data_dir, tm, tid| {
|
|
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
|
tm.report_progress(tid, Some(0.0), Some("Reading project config...".into()));
|
|
let main_lang = engine::meta::read_project_json(&data_dir)
|
|
.ok()
|
|
.and_then(|m| m.main_language)
|
|
.unwrap_or_else(|| "en".to_string());
|
|
let tm2 = Arc::clone(&tm);
|
|
let on_item: engine::search::ItemProgressFn = Box::new(move |current, total, name| {
|
|
let pct = if total > 0 { current as f32 / total as f32 } else { 1.0 };
|
|
let msg = format!("Indexing: {current}/{total} \u{2014} {name}");
|
|
tm2.report_progress(tid, Some(pct), Some(msg));
|
|
});
|
|
let report = engine::search::reindex_all_with_progress(
|
|
db.conn(), &project_id, &main_lang, Some(on_item),
|
|
).map_err(|e| e.to_string())?;
|
|
Ok(format!("posts={}, media={}", report.posts_indexed, report.media_indexed))
|
|
},
|
|
)
|
|
}
|
|
Message::RegenerateCalendar => {
|
|
self.spawn_engine_task(
|
|
"engine.calendarStarted",
|
|
|db_path, project_id, data_dir, tm, tid| {
|
|
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
|
tm.report_progress(tid, Some(0.20), Some("Loading posts...".into()));
|
|
engine::calendar::regenerate_calendar(db.conn(), &data_dir, &project_id)
|
|
.map_err(|e| e.to_string())?;
|
|
tm.report_progress(tid, Some(0.90), Some("Writing calendar JSON...".into()));
|
|
Ok("done".to_string())
|
|
},
|
|
)
|
|
}
|
|
Message::ValidateTranslations => {
|
|
self.open_singleton_tab(TabType::TranslationValidation, "tabBar.translationValidation");
|
|
self.spawn_engine_task(
|
|
"engine.validateTranslationsStarted",
|
|
|db_path, project_id, data_dir, tm, tid| {
|
|
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
|
let meta = engine::meta::read_project_json(&data_dir)
|
|
.map_err(|e| e.to_string())?;
|
|
let main_lang = meta.main_language.as_deref().unwrap_or("en");
|
|
let blog_langs = meta.blog_languages.clone();
|
|
let tm2 = Arc::clone(&tm);
|
|
let on_item: engine::validate_translations::ItemProgressFn = Box::new(move |current, total, name| {
|
|
let pct = if total > 0 { current as f32 / total as f32 } else { 1.0 };
|
|
let msg = format!("Checking: {current}/{total} \u{2014} {name}");
|
|
tm2.report_progress(tid, Some(pct), Some(msg));
|
|
});
|
|
let report = engine::validate_translations::validate_translations_with_progress(
|
|
db.conn(), &data_dir, &project_id, &blog_langs, main_lang, Some(on_item),
|
|
).map_err(|e| e.to_string())?;
|
|
Ok(format!("db_issues={}, fs_issues={}", report.db_issues.len(), report.fs_issues.len()))
|
|
},
|
|
)
|
|
}
|
|
Message::ValidateMedia => {
|
|
self.spawn_engine_task(
|
|
"engine.validateMediaStarted",
|
|
|db_path, project_id, _data_dir, tm, tid| {
|
|
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
|
let on_item: engine::validate_media::ProgressFn = Box::new(move |current, total, name| {
|
|
let pct = if total > 0 { current as f32 / total as f32 } else { 1.0 };
|
|
let msg = format!("Checking: {current}/{total} \u{2014} {name}");
|
|
tm.report_progress(tid, Some(pct), Some(msg));
|
|
});
|
|
let report = engine::validate_media::validate_media(
|
|
db.conn(), &_data_dir, &project_id, Some(on_item),
|
|
).map_err(|e| e.to_string())?;
|
|
Ok(format!("checked={}, issues={}", report.total_checked, report.issues.len()))
|
|
},
|
|
)
|
|
}
|
|
Message::GenerateSite => {
|
|
self.spawn_engine_task(
|
|
"engine.generateSiteStarted",
|
|
|db_path, project_id, data_dir, tm, tid| {
|
|
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
|
let metadata = engine::meta::read_project_json(&data_dir).map_err(|e| e.to_string())?;
|
|
if metadata.public_url.as_deref().unwrap_or("").trim().is_empty() {
|
|
return Err("public URL is required before generating the site".to_string());
|
|
}
|
|
let main_language = metadata.main_language.clone().unwrap_or_else(|| "en".to_string());
|
|
let all_posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id)
|
|
.map_err(|e| e.to_string())?;
|
|
let published_posts = all_posts
|
|
.into_iter()
|
|
.filter(|post| post.status == PostStatus::Published)
|
|
.collect::<Vec<_>>();
|
|
let total = published_posts.len().max(1) as f32;
|
|
let mut sources = Vec::new();
|
|
for (index, post) in published_posts.into_iter().enumerate() {
|
|
tm.report_progress(
|
|
tid,
|
|
Some(((index as f32) / total) * 0.7),
|
|
Some(format!("Rendering {}", post.slug)),
|
|
);
|
|
let body_markdown = load_generation_post_body(&data_dir, &post)?;
|
|
sources.push(engine::generation::PublishedPostSource { post, body_markdown });
|
|
}
|
|
let output_dir = data_dir.join("html");
|
|
std::fs::create_dir_all(&output_dir).map_err(|e| e.to_string())?;
|
|
tm.report_progress(tid, Some(0.85), Some("Writing generated files".into()));
|
|
let report = engine::generation::generate_starter_site(
|
|
db.conn(),
|
|
&output_dir,
|
|
&project_id,
|
|
&metadata,
|
|
&sources,
|
|
&main_language,
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
tm.report_progress(tid, Some(1.0), Some("Site generation complete".into()));
|
|
Ok(format!(
|
|
"written={}, skipped={}, output={}",
|
|
report.written_paths.len(),
|
|
report.skipped_paths.len(),
|
|
output_dir.display(),
|
|
))
|
|
},
|
|
)
|
|
}
|
|
Message::RunMetadataDiff => {
|
|
self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff");
|
|
Task::none()
|
|
}
|
|
Message::RunSiteValidation => self.start_site_validation(),
|
|
Message::ApplySiteValidation => self.apply_site_validation(),
|
|
Message::EngineTaskDone { task_id, label, result } => {
|
|
match &result {
|
|
Ok(detail) => {
|
|
self.task_manager.complete(task_id);
|
|
self.notify(ToastLevel::Success, &format!("{label}: {detail}"));
|
|
}
|
|
Err(err) => {
|
|
self.task_manager.fail(task_id, err.clone());
|
|
self.notify(ToastLevel::Error, &format!("{label} failed: {err}"));
|
|
}
|
|
}
|
|
let sidebar_task = self.refresh_counts();
|
|
self.refresh_task_snapshots();
|
|
sidebar_task
|
|
}
|
|
Message::SiteValidationLoaded(result) => {
|
|
self.site_validation_state.is_running = false;
|
|
self.site_validation_state.has_run = true;
|
|
match result {
|
|
Ok(report) => {
|
|
self.site_validation_state.error_message = None;
|
|
self.site_validation_state.missing_files = report.missing_pages;
|
|
self.site_validation_state.extra_files = report.extra_pages;
|
|
self.site_validation_state.stale_files = report.stale_pages;
|
|
self.notify(
|
|
ToastLevel::Success,
|
|
&format!(
|
|
"{}: missing={}, extra={}, stale={}",
|
|
t(self.ui_locale, "tabBar.siteValidation"),
|
|
self.site_validation_state.missing_files.len(),
|
|
self.site_validation_state.extra_files.len(),
|
|
self.site_validation_state.stale_files.len(),
|
|
),
|
|
);
|
|
}
|
|
Err(error) => {
|
|
self.site_validation_state.error_message = Some(error.clone());
|
|
self.site_validation_state.missing_files.clear();
|
|
self.site_validation_state.extra_files.clear();
|
|
self.site_validation_state.stale_files.clear();
|
|
self.notify(ToastLevel::Error, &error);
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::SiteValidationApplied(result) => {
|
|
self.site_validation_state.is_applying = false;
|
|
match result {
|
|
Ok(detail) => {
|
|
self.site_validation_state.error_message = None;
|
|
self.notify(ToastLevel::Success, &detail);
|
|
self.start_site_validation()
|
|
}
|
|
Err(error) => {
|
|
self.site_validation_state.error_message = Some(error.clone());
|
|
self.notify(ToastLevel::Error, &error);
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Toast ──
|
|
Message::ShowToast(level, msg) => {
|
|
self.toasts.push(Toast::new(level, msg));
|
|
Task::none()
|
|
}
|
|
Message::DismissToast(id) => {
|
|
self.toasts.retain(|t| t.id != id);
|
|
Task::none()
|
|
}
|
|
Message::ExpireToasts => {
|
|
self.toasts.retain(|t| !t.is_expired());
|
|
Task::none()
|
|
}
|
|
|
|
// ── Sidebar filters ──
|
|
Message::PostSearchChanged(query) => {
|
|
let filter = match self.sidebar_view {
|
|
SidebarView::Pages => &mut self.page_filter,
|
|
_ => &mut self.post_filter,
|
|
};
|
|
filter.search_query = query;
|
|
self.refresh_sidebar_posts()
|
|
}
|
|
Message::TogglePostFilterPanel => {
|
|
let filter = match self.sidebar_view {
|
|
SidebarView::Pages => &mut self.page_filter,
|
|
_ => &mut self.post_filter,
|
|
};
|
|
filter.filter_panel_visible = !filter.filter_panel_visible;
|
|
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) => {
|
|
let filter = match self.sidebar_view {
|
|
SidebarView::Pages => &mut self.page_filter,
|
|
_ => &mut self.post_filter,
|
|
};
|
|
filter.calendar.selected_year = year;
|
|
filter.calendar.selected_month = None;
|
|
self.refresh_sidebar_posts()
|
|
}
|
|
Message::SetPostCalendarMonth(month) => {
|
|
let filter = match self.sidebar_view {
|
|
SidebarView::Pages => &mut self.page_filter,
|
|
_ => &mut self.post_filter,
|
|
};
|
|
filter.calendar.selected_month = month;
|
|
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) => {
|
|
let filter = match self.sidebar_view {
|
|
SidebarView::Pages => &mut self.page_filter,
|
|
_ => &mut self.post_filter,
|
|
};
|
|
if let Some(pos) = filter.tag_filter.iter().position(|t| *t == tag) {
|
|
filter.tag_filter.remove(pos);
|
|
} else {
|
|
filter.tag_filter.push(tag);
|
|
}
|
|
self.refresh_sidebar_posts()
|
|
}
|
|
Message::TogglePostCategoryFilter(cat) => {
|
|
let filter = match self.sidebar_view {
|
|
SidebarView::Pages => &mut self.page_filter,
|
|
_ => &mut self.post_filter,
|
|
};
|
|
if let Some(pos) = filter.category_filter.iter().position(|c| *c == cat) {
|
|
filter.category_filter.remove(pos);
|
|
} else {
|
|
filter.category_filter.push(cat);
|
|
}
|
|
self.refresh_sidebar_posts()
|
|
}
|
|
Message::ClearPostFilters => {
|
|
let filter = match self.sidebar_view {
|
|
SidebarView::Pages => &mut self.page_filter,
|
|
_ => &mut self.post_filter,
|
|
};
|
|
filter.clear();
|
|
self.refresh_sidebar_posts()
|
|
}
|
|
Message::MediaSearchChanged(query) => {
|
|
self.media_filter.search_query = query;
|
|
self.refresh_sidebar_media()
|
|
}
|
|
Message::ToggleMediaFilterPanel => {
|
|
self.media_filter.filter_panel_visible = !self.media_filter.filter_panel_visible;
|
|
Task::none()
|
|
}
|
|
Message::SetMediaCalendarYear(year) => {
|
|
self.media_filter.calendar.selected_year = year;
|
|
self.media_filter.calendar.selected_month = None;
|
|
self.refresh_sidebar_media()
|
|
}
|
|
Message::SetMediaCalendarMonth(month) => {
|
|
self.media_filter.calendar.selected_month = month;
|
|
self.refresh_sidebar_media()
|
|
}
|
|
Message::ToggleMediaTagFilter(tag) => {
|
|
if let Some(pos) = self.media_filter.tag_filter.iter().position(|t| *t == tag) {
|
|
self.media_filter.tag_filter.remove(pos);
|
|
} else {
|
|
self.media_filter.tag_filter.push(tag);
|
|
}
|
|
self.refresh_sidebar_media()
|
|
}
|
|
Message::ClearMediaFilters => {
|
|
self.media_filter.clear();
|
|
self.refresh_sidebar_media()
|
|
}
|
|
|
|
// ── Async sidebar data ──
|
|
Message::SidebarPostsLoaded(mut posts) => {
|
|
self.sidebar_posts_has_more = posts.len() > Self::SIDEBAR_PAGE_SIZE as usize;
|
|
posts.truncate(Self::SIDEBAR_PAGE_SIZE as usize);
|
|
self.sidebar_posts = posts;
|
|
Task::none()
|
|
}
|
|
Message::SidebarMediaLoaded { mut items, thumbs } => {
|
|
self.sidebar_media_has_more = items.len() > Self::SIDEBAR_PAGE_SIZE as usize;
|
|
items.truncate(Self::SIDEBAR_PAGE_SIZE as usize);
|
|
self.sidebar_media = items;
|
|
self.sidebar_media_thumbs = thumbs;
|
|
Task::none()
|
|
}
|
|
Message::SidebarPostsAppended(mut posts) => {
|
|
self.sidebar_posts_has_more = posts.len() > Self::SIDEBAR_PAGE_SIZE as usize;
|
|
posts.truncate(Self::SIDEBAR_PAGE_SIZE as usize);
|
|
self.sidebar_posts.extend(posts);
|
|
Task::none()
|
|
}
|
|
Message::SidebarMediaAppended { mut items, thumbs } => {
|
|
self.sidebar_media_has_more = items.len() > Self::SIDEBAR_PAGE_SIZE as usize;
|
|
items.truncate(Self::SIDEBAR_PAGE_SIZE as usize);
|
|
self.sidebar_media.extend(items);
|
|
self.sidebar_media_thumbs.extend(thumbs);
|
|
Task::none()
|
|
}
|
|
Message::LoadMorePosts => {
|
|
self.load_more_sidebar_posts()
|
|
}
|
|
Message::LoadMoreMedia => {
|
|
self.load_more_sidebar_media()
|
|
}
|
|
|
|
// ── Modal ──
|
|
Message::ShowModal(state) => {
|
|
self.active_modal = Some(state);
|
|
Task::none()
|
|
}
|
|
Message::DismissModal => {
|
|
self.active_modal = None;
|
|
Task::none()
|
|
}
|
|
Message::ConfirmModal(action) => {
|
|
self.active_modal = None;
|
|
match action {
|
|
modal::ConfirmAction::DeleteProject(id) => {
|
|
Task::done(Message::DeleteProject(id))
|
|
}
|
|
modal::ConfirmAction::DeletePost(id) => self.delete_post_editor(&id),
|
|
modal::ConfirmAction::DeleteMedia(id) => self.delete_media_editor(&id),
|
|
modal::ConfirmAction::DeleteScript(id) => self.delete_script_editor(&id),
|
|
modal::ConfirmAction::DeleteTemplate(id) => self.delete_template_editor(&id, false),
|
|
modal::ConfirmAction::ForceDeleteTemplate(id) => self.delete_template_editor(&id, true),
|
|
modal::ConfirmAction::DeleteTag(id) => self.delete_tag(&id),
|
|
modal::ConfirmAction::MergeTags { sources, target } => {
|
|
self.merge_tags(&sources, &target)
|
|
}
|
|
}
|
|
}
|
|
Message::ToggleAiSuggestionField(index, accepted) => {
|
|
if let Some(modal::ModalState::AISuggestions { fields, .. }) = self.active_modal.as_mut() {
|
|
if let Some(field) = fields.get_mut(index) {
|
|
if !field.locked {
|
|
field.accepted = accepted;
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ApplyAiSuggestions(target, fields) => {
|
|
self.active_modal = None;
|
|
self.apply_ai_suggestions(target, &fields)
|
|
}
|
|
|
|
// ── Editor view messages ──
|
|
Message::PostEditor(msg) => {
|
|
enum DeferredPostAction {
|
|
None,
|
|
SyncEmbeddedPreview,
|
|
Analyze(String),
|
|
AnalyzeTaxonomy(String),
|
|
DetectLanguage(String),
|
|
OpenTranslate(String),
|
|
TranslateTo { post_id: String, target_language: String },
|
|
Save(String),
|
|
Publish(String),
|
|
Duplicate(String),
|
|
Discard(String),
|
|
ShowDelete { tab_id: String, name: String },
|
|
OpenInsertLink(String),
|
|
OpenInsertMedia { post_id: String, link_only: bool },
|
|
OpenGallery(String),
|
|
OpenLinkedMedia(String),
|
|
UnlinkLinkedMedia { post_id: String, media_id: String },
|
|
InsertSelectedLink { post_id: String, linked_post_id: String },
|
|
CreateLinkedPost(String),
|
|
InsertSelectedMedia { post_id: String, media_id: String },
|
|
SetLinkTab(modal::PostInsertLinkTab),
|
|
SetLinkSearch(String),
|
|
SetExternalUrl(String),
|
|
SetExternalText(String),
|
|
InsertExternalLink,
|
|
SetMediaSearch(String),
|
|
SelectGalleryImage(usize),
|
|
GalleryPrevious,
|
|
GalleryNext,
|
|
GalleryCloseLightbox,
|
|
}
|
|
|
|
let mut deferred = DeferredPostAction::None;
|
|
let mut refresh_linked_media: Option<(String, String)> = None;
|
|
if let Some(tab_id) = self.active_tab.clone() {
|
|
if let Some(state) = self.post_editors.get_mut(&tab_id) {
|
|
match msg {
|
|
PostEditorMsg::ToggleQuickActions => {
|
|
state.quick_actions_open = !state.quick_actions_open;
|
|
}
|
|
PostEditorMsg::AnalyzeWithAi => {
|
|
state.quick_actions_open = false;
|
|
deferred = DeferredPostAction::Analyze(tab_id.clone());
|
|
}
|
|
PostEditorMsg::AnalyzeTaxonomy => {
|
|
state.quick_actions_open = false;
|
|
deferred = DeferredPostAction::AnalyzeTaxonomy(tab_id.clone());
|
|
}
|
|
PostEditorMsg::SwitchEditorMode(mode) => {
|
|
state.set_editor_mode(&mode);
|
|
deferred = DeferredPostAction::SyncEmbeddedPreview;
|
|
}
|
|
PostEditorMsg::DetectLanguage => {
|
|
state.quick_actions_open = false;
|
|
deferred = DeferredPostAction::DetectLanguage(tab_id.clone());
|
|
}
|
|
PostEditorMsg::Translate => {
|
|
state.quick_actions_open = false;
|
|
deferred = DeferredPostAction::OpenTranslate(tab_id.clone());
|
|
}
|
|
PostEditorMsg::TranslateTo(target_language) => {
|
|
deferred = DeferredPostAction::TranslateTo { post_id: tab_id.clone(), target_language };
|
|
}
|
|
PostEditorMsg::TitleChanged(s) => { state.title = s; state.mark_dirty(); }
|
|
PostEditorMsg::SlugChanged(s) => { state.slug = s; state.mark_dirty(); }
|
|
PostEditorMsg::ExcerptChanged(s) => { state.excerpt = s; state.mark_dirty(); }
|
|
PostEditorMsg::ContentChanged(new_text) => {
|
|
state.content = new_text;
|
|
state.mark_dirty();
|
|
refresh_linked_media = Some((state.post_id.clone(), state.content.clone()));
|
|
}
|
|
PostEditorMsg::AuthorChanged(s) => { state.author = s; state.mark_dirty(); }
|
|
PostEditorMsg::LanguageChanged(s) => { state.language = s; state.mark_dirty(); }
|
|
PostEditorMsg::TemplateSlugChanged(s) => { state.template_slug = s; state.mark_dirty(); }
|
|
PostEditorMsg::ToggleDoNotTranslate(b) => { state.do_not_translate = b; state.mark_dirty(); }
|
|
PostEditorMsg::ToggleMetadata => { state.metadata_expanded = !state.metadata_expanded; }
|
|
PostEditorMsg::ToggleExcerpt => { state.excerpt_expanded = !state.excerpt_expanded; }
|
|
PostEditorMsg::SwitchLanguage(lang) => {
|
|
state.switch_language(&lang);
|
|
if state.editor_mode == "preview" {
|
|
deferred = DeferredPostAction::SyncEmbeddedPreview;
|
|
}
|
|
}
|
|
PostEditorMsg::TagsInputChanged(s) => { state.tags_input = s; }
|
|
PostEditorMsg::TagsInputSubmit => {
|
|
let tag = state.tags_input.trim().to_string();
|
|
if !tag.is_empty() && !state.tags.contains(&tag) {
|
|
state.tags.push(tag);
|
|
state.mark_dirty();
|
|
}
|
|
state.tags_input.clear();
|
|
}
|
|
PostEditorMsg::RemoveTag(tag) => {
|
|
state.tags.retain(|t| t != &tag);
|
|
state.mark_dirty();
|
|
}
|
|
PostEditorMsg::CategoriesInputChanged(s) => { state.categories_input = s; }
|
|
PostEditorMsg::CategoriesInputSubmit => {
|
|
let cat = state.categories_input.trim().to_string();
|
|
if !cat.is_empty() && !state.categories.contains(&cat) {
|
|
state.categories.push(cat);
|
|
state.mark_dirty();
|
|
}
|
|
state.categories_input.clear();
|
|
}
|
|
PostEditorMsg::RemoveCategory(cat) => {
|
|
state.categories.retain(|c| c != &cat);
|
|
state.mark_dirty();
|
|
}
|
|
PostEditorMsg::Save => {
|
|
deferred = DeferredPostAction::Save(tab_id.clone());
|
|
}
|
|
PostEditorMsg::Publish => {
|
|
deferred = DeferredPostAction::Publish(tab_id.clone());
|
|
}
|
|
PostEditorMsg::Duplicate => {
|
|
deferred = DeferredPostAction::Duplicate(tab_id.clone());
|
|
}
|
|
PostEditorMsg::Discard => {
|
|
deferred = DeferredPostAction::Discard(tab_id.clone());
|
|
}
|
|
PostEditorMsg::Delete => {
|
|
deferred = DeferredPostAction::ShowDelete {
|
|
tab_id: tab_id.clone(),
|
|
name: state.title.clone(),
|
|
};
|
|
}
|
|
PostEditorMsg::InsertLink => {
|
|
deferred = DeferredPostAction::OpenInsertLink(state.post_id.clone());
|
|
}
|
|
PostEditorMsg::InsertMedia => {
|
|
deferred = DeferredPostAction::OpenInsertMedia {
|
|
post_id: state.post_id.clone(),
|
|
link_only: false,
|
|
};
|
|
}
|
|
PostEditorMsg::Gallery => {
|
|
deferred = DeferredPostAction::OpenGallery(state.post_id.clone());
|
|
}
|
|
PostEditorMsg::LinkExistingMedia => {
|
|
deferred = DeferredPostAction::OpenInsertMedia {
|
|
post_id: state.post_id.clone(),
|
|
link_only: true,
|
|
};
|
|
}
|
|
PostEditorMsg::OpenLinkedMedia(media_id) => {
|
|
deferred = DeferredPostAction::OpenLinkedMedia(media_id);
|
|
}
|
|
PostEditorMsg::UnlinkLinkedMedia(media_id) => {
|
|
deferred = DeferredPostAction::UnlinkLinkedMedia {
|
|
post_id: state.post_id.clone(),
|
|
media_id,
|
|
};
|
|
}
|
|
PostEditorMsg::PostInsertLinkSelected(linked_post_id) => {
|
|
deferred = DeferredPostAction::InsertSelectedLink {
|
|
post_id: state.post_id.clone(),
|
|
linked_post_id,
|
|
};
|
|
}
|
|
PostEditorMsg::PostInsertLinkCreate => {
|
|
deferred = DeferredPostAction::CreateLinkedPost(state.post_id.clone());
|
|
}
|
|
PostEditorMsg::PostInsertMediaSelected(media_id) => {
|
|
deferred = DeferredPostAction::InsertSelectedMedia {
|
|
post_id: state.post_id.clone(),
|
|
media_id,
|
|
};
|
|
}
|
|
PostEditorMsg::PostGalleryImageSelected(index) => {
|
|
deferred = DeferredPostAction::SelectGalleryImage(index);
|
|
}
|
|
PostEditorMsg::PostInsertLinkTabSwitch(tab) => {
|
|
deferred = DeferredPostAction::SetLinkTab(tab);
|
|
}
|
|
PostEditorMsg::PostInsertLinkSearch(query) => {
|
|
deferred = DeferredPostAction::SetLinkSearch(query);
|
|
}
|
|
PostEditorMsg::PostInsertLinkUrlChanged(url) => {
|
|
deferred = DeferredPostAction::SetExternalUrl(url);
|
|
}
|
|
PostEditorMsg::PostInsertLinkTextChanged(text) => {
|
|
deferred = DeferredPostAction::SetExternalText(text);
|
|
}
|
|
PostEditorMsg::PostInsertLinkExternalInsert => {
|
|
deferred = DeferredPostAction::InsertExternalLink;
|
|
}
|
|
PostEditorMsg::PostInsertMediaSearch(query) => {
|
|
deferred = DeferredPostAction::SetMediaSearch(query);
|
|
}
|
|
PostEditorMsg::PostGalleryPrevious => {
|
|
deferred = DeferredPostAction::GalleryPrevious;
|
|
}
|
|
PostEditorMsg::PostGalleryNext => {
|
|
deferred = DeferredPostAction::GalleryNext;
|
|
}
|
|
PostEditorMsg::PostGalleryCloseLightbox => {
|
|
deferred = DeferredPostAction::GalleryCloseLightbox;
|
|
}
|
|
}
|
|
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == state.post_id) {
|
|
tab.is_dirty = state.is_dirty;
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some((post_id, content)) = refresh_linked_media {
|
|
let linked_media = self.load_post_media_items(&post_id, Some(&content));
|
|
if let Some(state) = self.post_editors.get_mut(&post_id) {
|
|
state.linked_media = linked_media;
|
|
}
|
|
}
|
|
|
|
match deferred {
|
|
DeferredPostAction::None => Task::none(),
|
|
DeferredPostAction::SyncEmbeddedPreview => self.sync_embedded_preview_for_active_post(),
|
|
DeferredPostAction::Analyze(tab_id) => self.run_post_ai_analysis(&tab_id),
|
|
DeferredPostAction::AnalyzeTaxonomy(tab_id) => self.run_post_taxonomy_analysis(&tab_id),
|
|
DeferredPostAction::DetectLanguage(tab_id) => self.detect_post_language(&tab_id),
|
|
DeferredPostAction::OpenTranslate(tab_id) => self.open_post_translation_modal(&tab_id),
|
|
DeferredPostAction::TranslateTo { post_id, target_language } => {
|
|
self.translate_post_to(&post_id, &target_language)
|
|
}
|
|
DeferredPostAction::Save(tab_id) => self.save_post_editor(&tab_id),
|
|
DeferredPostAction::Publish(tab_id) => self.publish_post_editor(&tab_id),
|
|
DeferredPostAction::Duplicate(tab_id) => self.duplicate_post_editor(&tab_id),
|
|
DeferredPostAction::Discard(tab_id) => self.discard_post_editor(&tab_id),
|
|
DeferredPostAction::ShowDelete { tab_id, name } => Task::done(Message::ShowModal(
|
|
modal::ModalState::ConfirmDelete {
|
|
entity_name: name,
|
|
references: Vec::new(),
|
|
on_confirm: modal::ConfirmAction::DeletePost(tab_id),
|
|
},
|
|
)),
|
|
DeferredPostAction::OpenInsertLink(post_id) => self.insert_link_modal(&post_id),
|
|
DeferredPostAction::OpenInsertMedia { post_id, link_only } => {
|
|
self.insert_media_modal(&post_id, link_only)
|
|
}
|
|
DeferredPostAction::OpenGallery(post_id) => self.post_gallery(&post_id),
|
|
DeferredPostAction::OpenLinkedMedia(media_id) => {
|
|
let title = self
|
|
.db
|
|
.as_ref()
|
|
.and_then(|db| {
|
|
bds_core::db::queries::media::get_media_by_id(db.conn(), &media_id)
|
|
.ok()
|
|
.map(|media| media.title.unwrap_or(media.original_name))
|
|
})
|
|
.unwrap_or_else(|| media_id.clone());
|
|
Task::done(Message::OpenTab(Tab {
|
|
id: media_id,
|
|
tab_type: TabType::Media,
|
|
title,
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
}))
|
|
}
|
|
DeferredPostAction::UnlinkLinkedMedia { post_id, media_id } => {
|
|
if let (Some(db), Some(data_dir)) = (&self.db, &self.data_dir) {
|
|
if let Err(err) = engine::post_media::unlink_media_from_post(
|
|
db.conn(),
|
|
data_dir,
|
|
&post_id,
|
|
&media_id,
|
|
) {
|
|
self.notify(ToastLevel::Error, &format!("Failed to unlink media: {err}"));
|
|
return Task::none();
|
|
}
|
|
self.refresh_post_relationships(&post_id);
|
|
}
|
|
Task::none()
|
|
}
|
|
DeferredPostAction::InsertSelectedLink { post_id, linked_post_id } => {
|
|
self.insert_selected_post_link(&post_id, &linked_post_id)
|
|
}
|
|
DeferredPostAction::CreateLinkedPost(post_id) => self.insert_created_post_link(&post_id),
|
|
DeferredPostAction::InsertSelectedMedia { post_id, media_id } => {
|
|
self.insert_selected_media(&post_id, &media_id)
|
|
}
|
|
DeferredPostAction::SetLinkTab(tab) => {
|
|
self.refresh_post_insert_link_modal(Some(tab), None, None, None);
|
|
Task::none()
|
|
}
|
|
DeferredPostAction::SetLinkSearch(query) => {
|
|
self.refresh_post_insert_link_modal(None, Some(query), None, None);
|
|
Task::none()
|
|
}
|
|
DeferredPostAction::SetExternalUrl(url) => {
|
|
self.refresh_post_insert_link_modal(None, None, Some(url), None);
|
|
Task::none()
|
|
}
|
|
DeferredPostAction::SetExternalText(text) => {
|
|
self.refresh_post_insert_link_modal(None, None, None, Some(text));
|
|
Task::none()
|
|
}
|
|
DeferredPostAction::InsertExternalLink => {
|
|
if let Some(modal::ModalState::PostInsertLink {
|
|
post_id,
|
|
external_url,
|
|
external_text,
|
|
..
|
|
}) = self.active_modal.clone()
|
|
{
|
|
if let Some(markdown) = modal::external_link_markdown(&external_url, &external_text) {
|
|
self.insert_markdown_into_post(&post_id, &markdown)
|
|
} else {
|
|
self.notify(ToastLevel::Error, &t(self.ui_locale, "modal.postInsertLink.urlRequired"));
|
|
Task::none()
|
|
}
|
|
} else {
|
|
Task::none()
|
|
}
|
|
}
|
|
DeferredPostAction::SetMediaSearch(query) => {
|
|
self.refresh_insert_media_modal(query);
|
|
Task::none()
|
|
}
|
|
DeferredPostAction::SelectGalleryImage(index) => {
|
|
self.update_gallery_selection(Some(index));
|
|
Task::none()
|
|
}
|
|
DeferredPostAction::GalleryPrevious => {
|
|
self.step_gallery_selection(-1);
|
|
Task::none()
|
|
}
|
|
DeferredPostAction::GalleryNext => {
|
|
self.step_gallery_selection(1);
|
|
Task::none()
|
|
}
|
|
DeferredPostAction::GalleryCloseLightbox => {
|
|
self.update_gallery_selection(None);
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
Message::MediaEditor(msg) => {
|
|
enum DeferredMediaAction {
|
|
None,
|
|
Analyze(String),
|
|
DetectLanguage(String),
|
|
OpenTranslate(String),
|
|
TranslateTo { media_id: String, target_language: String },
|
|
LinkPost { media_id: String, post_id: String },
|
|
OpenLinkedPost(String),
|
|
UnlinkPost { media_id: String, post_id: String },
|
|
Save(String),
|
|
Delete { tab_id: String, name: String },
|
|
}
|
|
|
|
let mut deferred = DeferredMediaAction::None;
|
|
let mut picker_refresh: Option<(String, String)> = None;
|
|
if let Some(tab_id) = self.active_tab.clone() {
|
|
if let Some(state) = self.media_editors.get_mut(&tab_id) {
|
|
match msg {
|
|
MediaEditorMsg::AnalyzeWithAi => {
|
|
deferred = DeferredMediaAction::Analyze(tab_id.clone());
|
|
}
|
|
MediaEditorMsg::DetectLanguage => {
|
|
deferred = DeferredMediaAction::DetectLanguage(tab_id.clone());
|
|
}
|
|
MediaEditorMsg::TranslateMetadata => {
|
|
deferred = DeferredMediaAction::OpenTranslate(tab_id.clone());
|
|
}
|
|
MediaEditorMsg::TranslateTo(target_language) => {
|
|
deferred = DeferredMediaAction::TranslateTo { media_id: tab_id.clone(), target_language };
|
|
}
|
|
MediaEditorMsg::TitleChanged(s) => { state.title = s; state.is_dirty = true; }
|
|
MediaEditorMsg::AltChanged(s) => { state.alt = s; state.is_dirty = true; }
|
|
MediaEditorMsg::CaptionChanged(s) => { state.caption = s; state.is_dirty = true; }
|
|
MediaEditorMsg::AuthorChanged(s) => { state.author = s; state.is_dirty = true; }
|
|
MediaEditorMsg::LanguageChanged(s) => { state.language = s; state.is_dirty = true; }
|
|
MediaEditorMsg::TagsChanged(s) => { state.tags_input = s; state.is_dirty = true; }
|
|
MediaEditorMsg::SwitchLanguage(lang) => { state.switch_language(&lang); }
|
|
MediaEditorMsg::TogglePostPicker => {
|
|
let next_open = !state.post_picker_open;
|
|
state.post_picker_open = next_open;
|
|
if !next_open {
|
|
state.post_picker_results.clear();
|
|
} else {
|
|
picker_refresh = Some((state.media_id.clone(), state.post_picker_search.clone()));
|
|
}
|
|
}
|
|
MediaEditorMsg::PostPickerSearchChanged(search) => {
|
|
state.post_picker_search = search;
|
|
if state.post_picker_open {
|
|
picker_refresh = Some((state.media_id.clone(), state.post_picker_search.clone()));
|
|
}
|
|
}
|
|
MediaEditorMsg::LinkPost(post_id) => {
|
|
deferred = DeferredMediaAction::LinkPost {
|
|
media_id: state.media_id.clone(),
|
|
post_id,
|
|
};
|
|
}
|
|
MediaEditorMsg::OpenLinkedPost(post_id) => {
|
|
deferred = DeferredMediaAction::OpenLinkedPost(post_id);
|
|
}
|
|
MediaEditorMsg::UnlinkPost(post_id) => {
|
|
deferred = DeferredMediaAction::UnlinkPost {
|
|
media_id: state.media_id.clone(),
|
|
post_id,
|
|
};
|
|
}
|
|
MediaEditorMsg::Save => {
|
|
deferred = DeferredMediaAction::Save(tab_id.clone());
|
|
}
|
|
MediaEditorMsg::Delete => {
|
|
deferred = DeferredMediaAction::Delete {
|
|
tab_id: tab_id.clone(),
|
|
name: state.title.clone(),
|
|
};
|
|
}
|
|
}
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == *state.media_id.as_str()) {
|
|
tab.is_dirty = state.is_dirty;
|
|
}
|
|
}
|
|
}
|
|
if let Some((media_id, search)) = picker_refresh {
|
|
let results = self.query_media_post_picker_results(&media_id, &search);
|
|
if let Some(state) = self.media_editors.get_mut(&media_id) {
|
|
state.post_picker_results = results;
|
|
}
|
|
}
|
|
match deferred {
|
|
DeferredMediaAction::None => Task::none(),
|
|
DeferredMediaAction::Analyze(media_id) => self.run_media_ai_analysis(&media_id),
|
|
DeferredMediaAction::DetectLanguage(media_id) => self.detect_media_language(&media_id),
|
|
DeferredMediaAction::OpenTranslate(media_id) => self.open_media_translation_modal(&media_id),
|
|
DeferredMediaAction::TranslateTo { media_id, target_language } => {
|
|
self.translate_media_to(&media_id, &target_language)
|
|
}
|
|
DeferredMediaAction::LinkPost { media_id, post_id } => self.link_media_to_post(&media_id, &post_id),
|
|
DeferredMediaAction::OpenLinkedPost(post_id) => {
|
|
let title = self
|
|
.db
|
|
.as_ref()
|
|
.and_then(|db| bds_core::db::queries::post::get_post_by_id(db.conn(), &post_id).ok())
|
|
.map(|post| post.title)
|
|
.unwrap_or_else(|| post_id.clone());
|
|
Task::done(Message::OpenTab(Tab {
|
|
id: post_id,
|
|
tab_type: TabType::Post,
|
|
title,
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
}))
|
|
}
|
|
DeferredMediaAction::UnlinkPost { media_id, post_id } => self.unlink_media_from_post(&media_id, &post_id),
|
|
DeferredMediaAction::Save(tab_id) => self.save_media_editor(&tab_id),
|
|
DeferredMediaAction::Delete { tab_id, name } => Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete {
|
|
entity_name: name,
|
|
references: Vec::new(),
|
|
on_confirm: modal::ConfirmAction::DeleteMedia(tab_id),
|
|
})),
|
|
}
|
|
}
|
|
Message::TemplateEditor(msg) => {
|
|
if let Some(tab_id) = self.active_tab.clone() {
|
|
if let Some(state) = self.template_editors.get_mut(&tab_id) {
|
|
match msg {
|
|
TemplateEditorMsg::TitleChanged(s) => { state.title = s; state.is_dirty = true; }
|
|
TemplateEditorMsg::SlugChanged(s) => { state.slug = s; state.is_dirty = true; }
|
|
TemplateEditorMsg::KindChanged(k) => { state.kind = k.0; state.is_dirty = true; }
|
|
TemplateEditorMsg::EnabledChanged(b) => { state.enabled = b; state.is_dirty = true; }
|
|
TemplateEditorMsg::ContentChanged(new_text) => {
|
|
state.content = new_text;
|
|
state.is_dirty = true;
|
|
}
|
|
TemplateEditorMsg::Save => {
|
|
return self.save_template_editor(&tab_id);
|
|
}
|
|
TemplateEditorMsg::Validate => {
|
|
if let Some(st) = self.template_editors.get_mut(&tab_id) {
|
|
match engine::template::validate_template(&st.content) {
|
|
Ok(()) => { st.validation_error = None; }
|
|
Err(e) => { st.validation_error = Some(e); }
|
|
}
|
|
}
|
|
}
|
|
TemplateEditorMsg::Delete => {
|
|
return self.show_template_delete_confirmation(&tab_id);
|
|
}
|
|
}
|
|
if let Some(st) = self.template_editors.get(&tab_id) {
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
|
tab.is_dirty = st.is_dirty;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ScriptEditor(msg) => {
|
|
if let Some(tab_id) = self.active_tab.clone() {
|
|
if let Some(state) = self.script_editors.get_mut(&tab_id) {
|
|
match msg {
|
|
ScriptEditorMsg::TitleChanged(s) => { state.title = s; state.is_dirty = true; }
|
|
ScriptEditorMsg::SlugChanged(s) => { state.slug = s; state.is_dirty = true; }
|
|
ScriptEditorMsg::KindChanged(k) => { state.kind = k.0; state.is_dirty = true; }
|
|
ScriptEditorMsg::EntrypointChanged(s) => { state.entrypoint = s; state.is_dirty = true; }
|
|
ScriptEditorMsg::EnabledChanged(b) => { state.enabled = b; state.is_dirty = true; }
|
|
ScriptEditorMsg::ContentChanged(new_text) => {
|
|
state.discovered_entrypoints = engine::script::discover_entrypoints(&new_text);
|
|
state.content = new_text;
|
|
state.is_dirty = true;
|
|
}
|
|
ScriptEditorMsg::Save => {
|
|
return self.save_script_editor(&tab_id);
|
|
}
|
|
ScriptEditorMsg::CheckSyntax => {
|
|
if let Some(st) = self.script_editors.get_mut(&tab_id) {
|
|
match engine::script::validate_script_syntax(&st.content) {
|
|
Ok(()) => { st.validation_error = None; }
|
|
Err(e) => { st.validation_error = Some(e); }
|
|
}
|
|
}
|
|
}
|
|
ScriptEditorMsg::Run => {
|
|
self.notify(ToastLevel::Info, &t(self.ui_locale, "editor.scriptRunNotYet"));
|
|
}
|
|
ScriptEditorMsg::Delete => {
|
|
let name = state.title.clone();
|
|
return Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete {
|
|
entity_name: name,
|
|
references: Vec::new(),
|
|
on_confirm: modal::ConfirmAction::DeleteScript(tab_id),
|
|
}));
|
|
}
|
|
}
|
|
if let Some(st) = self.script_editors.get(&tab_id) {
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
|
tab.is_dirty = st.is_dirty;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::Tags(msg) => {
|
|
self.handle_tags_msg(msg)
|
|
}
|
|
Message::Settings(msg) => {
|
|
self.handle_settings_msg(msg)
|
|
}
|
|
|
|
// ── Editor data loading ──
|
|
Message::PostLoaded(result) => {
|
|
match result {
|
|
Ok(post) => {
|
|
let mut translations = self.db.as_ref()
|
|
.and_then(|db| bds_core::db::queries::post_translation::list_post_translations_by_post(
|
|
db.conn(), &post.id,
|
|
).ok())
|
|
.unwrap_or_default();
|
|
// Published translations don't store body in DB — read from file
|
|
if let Some(ref data_dir) = self.data_dir {
|
|
for tr in &mut translations {
|
|
if tr.content.is_none() {
|
|
let rel = bds_core::util::paths::translation_file_path(
|
|
post.created_at,
|
|
&post.slug,
|
|
&tr.language,
|
|
);
|
|
let path = data_dir.join(&rel);
|
|
if let Ok(raw) = std::fs::read_to_string(&path) {
|
|
if let Ok((_fm, body)) =
|
|
bds_core::util::frontmatter::read_translation_file(&raw)
|
|
{
|
|
tr.content = Some(body);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let (outlinks, backlinks) = self.load_post_links(&post.id);
|
|
let linked_media = self.load_post_media_items(&post.id, post.content.as_deref());
|
|
let state = PostEditorState::from_post(
|
|
&post,
|
|
default_post_editor_mode(self.settings_state.as_ref()),
|
|
&self.blog_languages,
|
|
&translations,
|
|
outlinks,
|
|
backlinks,
|
|
linked_media,
|
|
);
|
|
self.post_editors.insert(post.id.clone(), state);
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &e),
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::MediaLoaded(result) => {
|
|
match result {
|
|
Ok(media) => {
|
|
let translations = self.db.as_ref()
|
|
.and_then(|db| bds_core::db::queries::media_translation::list_media_translations_by_media(
|
|
db.conn(), &media.id,
|
|
).ok())
|
|
.unwrap_or_default();
|
|
let linked_posts = self.load_media_linked_posts(&media.id);
|
|
let state = MediaEditorState::from_media(&media, &self.blog_languages, &translations, linked_posts);
|
|
self.media_editors.insert(media.id.clone(), state);
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &e),
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::TemplateLoaded(result) => {
|
|
match result {
|
|
Ok(template) => {
|
|
let state = TemplateEditorState::from_template(&template);
|
|
self.template_editors.insert(template.id.clone(), state);
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &e),
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ScriptLoaded(result) => {
|
|
match result {
|
|
Ok(script) => {
|
|
let state = ScriptEditorState::from_script(&script);
|
|
self.script_editors.insert(script.id.clone(), state);
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &e),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
Message::Noop => Task::none(),
|
|
Message::InitMenuBar => {
|
|
#[cfg(target_os = "macos")]
|
|
if let Some(menu_bar) = self._menu_bar.as_ref() {
|
|
menu::init_menu_for_nsapp(menu_bar);
|
|
}
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn view(&self) -> Element<'_, Message> {
|
|
let active_name = self.active_project.as_ref().map(|p| p.name.as_str());
|
|
let active_post_filter = match self.sidebar_view {
|
|
SidebarView::Pages => &self.page_filter,
|
|
_ => &self.post_filter,
|
|
};
|
|
let post_preview_widget = if self.active_post_uses_embedded_preview() {
|
|
self.embedded_preview
|
|
.as_ref()
|
|
.map(|preview| webview::webview(&preview.controller).into())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
workspace::view(
|
|
self.sidebar_view,
|
|
self.sidebar_visible,
|
|
self.sidebar_width,
|
|
&self.tabs,
|
|
self.active_tab.as_deref(),
|
|
self.panel_visible,
|
|
self.panel_tab,
|
|
&self.task_snapshots,
|
|
&self.output_entries,
|
|
&self.sidebar_posts,
|
|
&self.sidebar_media,
|
|
&self.sidebar_scripts,
|
|
&self.sidebar_templates,
|
|
active_post_filter,
|
|
&self.media_filter,
|
|
&self.sidebar_media_thumbs,
|
|
self.sidebar_posts_has_more,
|
|
self.sidebar_media_has_more,
|
|
active_name,
|
|
&self.projects,
|
|
self.active_project.as_ref().map(|p| p.id.as_str()),
|
|
self.post_count,
|
|
self.media_count,
|
|
self.offline_mode,
|
|
self.locale_dropdown_open,
|
|
self.project_dropdown_open,
|
|
&self.theme_badge,
|
|
self.ui_locale,
|
|
&self.toasts,
|
|
self.active_modal.clone(),
|
|
self.data_dir.as_deref(),
|
|
post_preview_widget,
|
|
&self.post_editors,
|
|
&self.media_editors,
|
|
&self.template_editors,
|
|
&self.script_editors,
|
|
self.tags_view_state.as_ref(),
|
|
self.settings_state.as_ref(),
|
|
self.dashboard_state.as_ref(),
|
|
&self.site_validation_state,
|
|
)
|
|
}
|
|
|
|
pub fn subscription(&self) -> Subscription<Message> {
|
|
let menu_sub = menu::menu_subscription();
|
|
|
|
let task_tick = iced::time::every(std::time::Duration::from_millis(500))
|
|
.map(|_| Message::TaskTick);
|
|
|
|
let toast_tick = if !self.toasts.is_empty() {
|
|
iced::time::every(std::time::Duration::from_millis(250))
|
|
.map(|_| Message::ExpireToasts)
|
|
} else {
|
|
Subscription::none()
|
|
};
|
|
|
|
// Global mouse tracking for sidebar resize dragging.
|
|
// The 4px drag handle mouse_area only fires on_press; move/release
|
|
// are captured here so dragging works even when the cursor leaves
|
|
// the narrow handle strip.
|
|
let drag_sub = if self.sidebar_dragging {
|
|
iced::event::listen_with(|event, _status, _id| match event {
|
|
iced::Event::Mouse(iced::mouse::Event::CursorMoved { position }) => {
|
|
Some(Message::SidebarResizeMove(position.x))
|
|
}
|
|
iced::Event::Mouse(iced::mouse::Event::ButtonReleased(
|
|
iced::mouse::Button::Left,
|
|
)) => Some(Message::SidebarResizeEnd),
|
|
_ => None,
|
|
})
|
|
} else {
|
|
Subscription::none()
|
|
};
|
|
|
|
Subscription::batch([menu_sub, task_tick, toast_tick, drag_sub])
|
|
}
|
|
|
|
// ── Private helpers ──
|
|
|
|
fn dispatch_menu_action(&mut self, action: MenuAction) -> Task<Message> {
|
|
match action {
|
|
// File
|
|
MenuAction::NewPost => self.create_sidebar_post(false),
|
|
MenuAction::ImportMedia => crate::platform::dialog::pick_media_files(
|
|
t(self.ui_locale, "dialog.importMedia"),
|
|
t(self.ui_locale, "dialog.imageFilter"),
|
|
),
|
|
MenuAction::Save => Task::none(), // Disabled in M2
|
|
MenuAction::OpenInBrowser => self.preview_active_post(),
|
|
MenuAction::OpenDataFolder => {
|
|
if let Some(ref dir) = self.data_dir {
|
|
let _ = open::that(dir);
|
|
}
|
|
Task::none()
|
|
}
|
|
// Edit
|
|
MenuAction::Find => Task::none(),
|
|
MenuAction::Replace => Task::none(),
|
|
MenuAction::EditPreferences => {
|
|
self.open_singleton_tab(TabType::Settings, "common.settings");
|
|
Task::none()
|
|
}
|
|
// View
|
|
MenuAction::ViewPosts => {
|
|
Task::done(Message::SetActiveView(SidebarView::Posts))
|
|
}
|
|
MenuAction::ViewMedia => {
|
|
Task::done(Message::SetActiveView(SidebarView::Media))
|
|
}
|
|
MenuAction::ToggleSidebar => {
|
|
Task::done(Message::ToggleSidebar)
|
|
}
|
|
MenuAction::TogglePanel => {
|
|
Task::done(Message::TogglePanel)
|
|
}
|
|
// Blog
|
|
MenuAction::PublishSelected => Task::none(), // Disabled in M2
|
|
MenuAction::PreviewPost => self.preview_active_post(),
|
|
MenuAction::EditMenu => {
|
|
self.open_singleton_tab(TabType::MenuEditor, "tabBar.menuEditor");
|
|
Task::none()
|
|
}
|
|
MenuAction::RebuildDatabase => Task::done(Message::RebuildDatabase),
|
|
MenuAction::ReindexText => Task::done(Message::ReindexText),
|
|
MenuAction::MetadataDiff => Task::done(Message::RunMetadataDiff),
|
|
MenuAction::RegenerateCalendar => Task::done(Message::RegenerateCalendar),
|
|
MenuAction::ValidateTranslations => Task::done(Message::ValidateTranslations),
|
|
MenuAction::FillMissingTranslations => {
|
|
if self.offline_mode {
|
|
self.notify(ToastLevel::Warning, &t(self.ui_locale, "engine.fillMissingTranslationsOffline"));
|
|
} else {
|
|
self.notify(ToastLevel::Warning, &t(self.ui_locale, "engine.fillMissingTranslationsNoAi"));
|
|
}
|
|
Task::none()
|
|
}
|
|
MenuAction::GenerateSitemap => Task::done(Message::GenerateSite),
|
|
MenuAction::ValidateSite => {
|
|
self.open_singleton_tab(TabType::SiteValidation, "tabBar.siteValidation");
|
|
self.start_site_validation()
|
|
}
|
|
MenuAction::UploadSite => {
|
|
if self.offline_mode {
|
|
self.notify(ToastLevel::Warning, &t(self.ui_locale, "engine.uploadOffline"));
|
|
} else if let Some(data_dir) = &self.data_dir {
|
|
let pub_prefs = engine::meta::read_publishing_json(data_dir).ok();
|
|
let has_creds = pub_prefs
|
|
.as_ref()
|
|
.map(|p| {
|
|
p.ssh_host.as_ref().map_or(false, |h| !h.is_empty())
|
|
&& p.ssh_user.as_ref().map_or(false, |u| !u.is_empty())
|
|
})
|
|
.unwrap_or(false);
|
|
if !has_creds {
|
|
self.notify(ToastLevel::Warning, &t(self.ui_locale, "engine.uploadMissingCredentials"));
|
|
} else {
|
|
self.notify(ToastLevel::Info, &t(self.ui_locale, "engine.uploadStarted"));
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
// Help
|
|
MenuAction::About => {
|
|
self.add_output(&t(self.ui_locale, "menu.item.about"));
|
|
Task::none()
|
|
}
|
|
MenuAction::OpenDocumentation => {
|
|
self.open_singleton_tab(TabType::Documentation, "tabBar.documentation");
|
|
Task::none()
|
|
}
|
|
MenuAction::ViewOnGitHub => {
|
|
let _ = open::that("https://github.com/nickarumern/bds");
|
|
Task::none()
|
|
}
|
|
MenuAction::ReportIssue => {
|
|
let _ = open::that("https://github.com/nickarumern/bds/issues");
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn open_singleton_tab(&mut self, tab_type: TabType, i18n_key: &str) {
|
|
let title = t(self.ui_locale, i18n_key);
|
|
let tab = Tab {
|
|
id: tab_type.singleton_id().to_string(),
|
|
tab_type,
|
|
title,
|
|
is_transient: false,
|
|
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());
|
|
}
|
|
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) {
|
|
self.task_snapshots = self
|
|
.task_manager
|
|
.snapshots()
|
|
.into_iter()
|
|
.map(|(id, label, status, progress, message)| {
|
|
let status_str = match &status {
|
|
TaskStatus::Pending => "pending".to_string(),
|
|
TaskStatus::Running => "running".to_string(),
|
|
TaskStatus::Completed => "completed".to_string(),
|
|
TaskStatus::Failed(e) => format!("failed: {e}"),
|
|
TaskStatus::Cancelled => "cancelled".to_string(),
|
|
};
|
|
TaskSnapshot {
|
|
id,
|
|
label,
|
|
status: status_str,
|
|
progress,
|
|
message,
|
|
}
|
|
})
|
|
.collect();
|
|
}
|
|
|
|
fn flush_active_post_editor(&mut self) {
|
|
let Some(active_id) = self.active_tab.clone() else { return };
|
|
let is_dirty_post = self
|
|
.tabs
|
|
.iter()
|
|
.find(|tab| tab.id == active_id)
|
|
.map(|tab| tab.tab_type == TabType::Post)
|
|
.unwrap_or(false)
|
|
&& self
|
|
.post_editors
|
|
.get(&active_id)
|
|
.map(|state| state.is_dirty)
|
|
.unwrap_or(false);
|
|
if is_dirty_post {
|
|
let _ = self.persist_post_editor_state(&active_id);
|
|
}
|
|
}
|
|
|
|
fn auto_save_due_post_editors(&mut self) {
|
|
let now = bds_core::util::now_unix_ms();
|
|
let due_ids: Vec<String> = self
|
|
.post_editors
|
|
.iter()
|
|
.filter_map(|(post_id, state)| {
|
|
(state.is_dirty
|
|
&& state.last_edit_at_ms > 0
|
|
&& now - state.last_edit_at_ms >= POST_AUTO_SAVE_DELAY_MS)
|
|
.then(|| post_id.clone())
|
|
})
|
|
.collect();
|
|
|
|
for post_id in due_ids {
|
|
if let Err(error) = self.persist_post_editor_state(&post_id) {
|
|
self.notify(ToastLevel::Error, &format!("Auto-save failed: {error}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn add_output(&mut self, text: &str) {
|
|
self.output_entries.push(OutputEntry {
|
|
timestamp: chrono::Utc::now().timestamp(),
|
|
text: text.to_string(),
|
|
});
|
|
}
|
|
|
|
/// Show a toast notification AND log to output panel.
|
|
fn notify(&mut self, level: ToastLevel, text: &str) {
|
|
self.toasts.push(Toast::new(level, text.to_string()));
|
|
self.add_output(text);
|
|
}
|
|
|
|
fn start_site_validation(&mut self) -> Task<Message> {
|
|
self.open_singleton_tab(TabType::SiteValidation, "tabBar.siteValidation");
|
|
let Some(_db) = self.db.as_ref() else {
|
|
self.site_validation_state.is_running = false;
|
|
self.site_validation_state.error_message = Some(t(self.ui_locale, "engine.generateSiteNoProject"));
|
|
return Task::none();
|
|
};
|
|
let Some(project_id) = self.active_project.as_ref().map(|project| project.id.clone()) else {
|
|
self.site_validation_state.is_running = false;
|
|
self.site_validation_state.error_message = Some(t(self.ui_locale, "engine.generateSiteNoProject"));
|
|
return Task::none();
|
|
};
|
|
let Some(data_dir) = self.data_dir.clone() else {
|
|
self.site_validation_state.is_running = false;
|
|
self.site_validation_state.error_message = Some(t(self.ui_locale, "engine.previewDataDirUnavailable"));
|
|
return Task::none();
|
|
};
|
|
|
|
self.site_validation_state.is_running = true;
|
|
self.site_validation_state.error_message = None;
|
|
let db_path = self.db_path.clone();
|
|
|
|
Task::perform(
|
|
async move {
|
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
|
engine::validate_site::validate_site(db.conn(), &data_dir, &project_id)
|
|
.map_err(|error| error.to_string())
|
|
},
|
|
Message::SiteValidationLoaded,
|
|
)
|
|
}
|
|
|
|
fn apply_site_validation(&mut self) -> Task<Message> {
|
|
if self.site_validation_state.is_running || self.site_validation_state.is_applying {
|
|
return Task::none();
|
|
}
|
|
|
|
let report = engine::validate_site::SiteValidationReport {
|
|
missing_pages: self.site_validation_state.missing_files.clone(),
|
|
extra_pages: self.site_validation_state.extra_files.clone(),
|
|
stale_pages: self.site_validation_state.stale_files.clone(),
|
|
};
|
|
let sections = engine::generation::sections_from_validation_report(&report);
|
|
if sections.is_empty() {
|
|
return Task::none();
|
|
}
|
|
|
|
let Some(project_id) = self.active_project.as_ref().map(|project| project.id.clone()) else {
|
|
self.site_validation_state.error_message = Some(t(self.ui_locale, "engine.generateSiteNoProject"));
|
|
return Task::none();
|
|
};
|
|
let Some(data_dir) = self.data_dir.clone() else {
|
|
self.site_validation_state.error_message = Some(t(self.ui_locale, "engine.previewDataDirUnavailable"));
|
|
return Task::none();
|
|
};
|
|
|
|
self.site_validation_state.is_applying = true;
|
|
self.site_validation_state.error_message = None;
|
|
let db_path = self.db_path.clone();
|
|
let applied_label = t(self.ui_locale, "siteValidation.apply");
|
|
|
|
Task::perform(
|
|
async move {
|
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
|
let metadata = engine::meta::read_project_json(&data_dir).map_err(|error| error.to_string())?;
|
|
let all_posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id)
|
|
.map_err(|error| error.to_string())?;
|
|
let mut sources = Vec::new();
|
|
for post in all_posts.into_iter().filter(|post| post.status == PostStatus::Published) {
|
|
let body_markdown = load_generation_post_body(&data_dir, &post)?;
|
|
sources.push(engine::generation::PublishedPostSource { post, body_markdown });
|
|
}
|
|
let output_dir = data_dir.join("html");
|
|
std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?;
|
|
let apply_report = engine::generation::apply_validation_sections(
|
|
db.conn(),
|
|
&output_dir,
|
|
&project_id,
|
|
&metadata,
|
|
&sources,
|
|
§ions,
|
|
)
|
|
.map_err(|error| error.to_string())?;
|
|
Ok(format!(
|
|
"{}: written={}, skipped={}, deleted={}, output={}",
|
|
applied_label,
|
|
apply_report.written_paths.len(),
|
|
apply_report.skipped_paths.len(),
|
|
apply_report.deleted_paths.len(),
|
|
output_dir.display(),
|
|
))
|
|
},
|
|
Message::SiteValidationApplied,
|
|
)
|
|
}
|
|
|
|
/// Spawn a blocking engine operation on a background thread via TaskManager.
|
|
///
|
|
/// Returns `Task::none()` if no active project/db/data_dir.
|
|
/// Otherwise registers the task, logs the start message, and returns an
|
|
/// async `Task` that opens a fresh DB connection on a worker thread.
|
|
///
|
|
/// The closure receives `(db_path, project_id, data_dir, task_manager, task_id)`.
|
|
/// Use `task_manager.report_progress(task_id, percent, message)` for live updates.
|
|
fn spawn_engine_task<F>(
|
|
&mut self,
|
|
label_key: &str,
|
|
work: F,
|
|
) -> Task<Message>
|
|
where
|
|
F: FnOnce(PathBuf, String, PathBuf, Arc<TaskManager>, TaskId) -> Result<String, String>
|
|
+ Send
|
|
+ 'static,
|
|
{
|
|
let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else {
|
|
return Task::none();
|
|
};
|
|
|
|
let db_path = self.db_path.clone();
|
|
let project_id = project.id.clone();
|
|
let data_dir = data_dir.clone();
|
|
|
|
let label = t(self.ui_locale, label_key);
|
|
self.add_output(&label);
|
|
|
|
let task_id = self.task_manager.submit(&label);
|
|
self.refresh_task_snapshots();
|
|
|
|
let label_for_msg = label.clone();
|
|
let tm = Arc::clone(&self.task_manager);
|
|
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || work(db_path, project_id, data_dir, tm, task_id))
|
|
.await
|
|
.unwrap_or_else(|e| Err(format!("task panicked: {e}")))
|
|
},
|
|
move |result| Message::EngineTaskDone {
|
|
task_id,
|
|
label: label_for_msg.clone(),
|
|
result,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn refresh_counts(&mut self) -> Task<Message> {
|
|
if let (Some(db), Some(project)) = (&self.db, &self.active_project) {
|
|
self.post_count = bds_core::db::queries::post::count_posts_by_project(
|
|
db.conn(),
|
|
&project.id,
|
|
)
|
|
.unwrap_or(0) as usize;
|
|
self.media_count = bds_core::db::queries::media::count_media_by_project(
|
|
db.conn(),
|
|
&project.id,
|
|
)
|
|
.unwrap_or(0) as usize;
|
|
|
|
self.sidebar_scripts = bds_core::db::queries::script::list_scripts_by_project(
|
|
db.conn(),
|
|
&project.id,
|
|
)
|
|
.unwrap_or_default();
|
|
self.sidebar_templates = bds_core::db::queries::template::list_templates_by_project(
|
|
db.conn(),
|
|
&project.id,
|
|
)
|
|
.unwrap_or_default();
|
|
|
|
// Read pico theme from project metadata for status bar badge
|
|
if let Some(ref data_dir) = self.data_dir {
|
|
if let Ok(meta) = engine::meta::read_project_json(data_dir) {
|
|
if let Some(theme) = meta.pico_theme {
|
|
self.theme_badge = theme;
|
|
}
|
|
}
|
|
}
|
|
|
|
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
|
}
|
|
|
|
// Refresh sidebar data with current filters (async — off main thread)
|
|
let t1 = self.refresh_sidebar_posts();
|
|
let t2 = self.refresh_sidebar_media();
|
|
self.refresh_filter_metadata();
|
|
Task::batch([t1, t2])
|
|
}
|
|
|
|
fn hydrate_dashboard_state(&self) -> DashboardState {
|
|
let Some(project) = &self.active_project else {
|
|
return DashboardState::new(String::new());
|
|
};
|
|
let Some(db) = &self.db else {
|
|
return DashboardState::new(project.name.clone());
|
|
};
|
|
|
|
let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id)
|
|
.unwrap_or_default();
|
|
let media = bds_core::db::queries::media::list_media_by_project(db.conn(), &project.id)
|
|
.unwrap_or_default();
|
|
let tags = bds_core::db::queries::tag::list_tags_by_project(db.conn(), &project.id)
|
|
.unwrap_or_default();
|
|
|
|
let mut draft_count = 0usize;
|
|
let mut published_count = 0usize;
|
|
let mut archived_count = 0usize;
|
|
let mut monthly_counts: std::collections::BTreeMap<(i32, u32), usize> = std::collections::BTreeMap::new();
|
|
let mut category_counts: HashMap<String, usize> = HashMap::new();
|
|
let mut tag_counts: HashMap<String, usize> = HashMap::new();
|
|
|
|
for post in &posts {
|
|
match post.status {
|
|
PostStatus::Draft => draft_count += 1,
|
|
PostStatus::Published => published_count += 1,
|
|
PostStatus::Archived => archived_count += 1,
|
|
}
|
|
|
|
let (year, month, _) = bds_core::util::timestamp::year_month_day_from_unix_ms(post.created_at);
|
|
let year = year.parse::<i32>().unwrap_or(0);
|
|
let month = month.parse::<u32>().unwrap_or(0);
|
|
*monthly_counts.entry((year, month)).or_insert(0) += 1;
|
|
|
|
for category in &post.categories {
|
|
*category_counts.entry(category.clone()).or_insert(0) += 1;
|
|
}
|
|
for tag in &post.tags {
|
|
*tag_counts.entry(tag.to_lowercase()).or_insert(0) += 1;
|
|
}
|
|
}
|
|
|
|
let image_count = media.iter().filter(|item| item.mime_type.starts_with("image/")).count();
|
|
let total_media_size = media.iter().map(|item| item.size).sum::<i64>();
|
|
|
|
let now = chrono::Utc::now();
|
|
let current_year = now.year();
|
|
let current_month = now.month() as i32;
|
|
let timeline = (0..12)
|
|
.rev()
|
|
.map(|offset| {
|
|
let total_month_index = current_year * 12 + (current_month - 1) - offset;
|
|
let year = total_month_index.div_euclid(12);
|
|
let month = total_month_index.rem_euclid(12) + 1;
|
|
let count = monthly_counts
|
|
.get(&(year, month as u32))
|
|
.copied()
|
|
.unwrap_or(0);
|
|
|
|
DashboardTimelineMonth {
|
|
label: month_abbreviation(month as u32),
|
|
year,
|
|
count,
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
let mut tag_cloud = tags
|
|
.into_iter()
|
|
.map(|tag| DashboardTag {
|
|
count: tag_counts.get(&tag.name.to_lowercase()).copied().unwrap_or(0),
|
|
name: tag.name,
|
|
color: tag.color,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
tag_cloud.sort_by(|left, right| left.name.to_lowercase().cmp(&right.name.to_lowercase()));
|
|
let tag_overflow_count = tag_cloud.len().saturating_sub(40);
|
|
tag_cloud.truncate(40);
|
|
|
|
let mut category_cloud = category_counts
|
|
.into_iter()
|
|
.map(|(name, count)| DashboardCategory { name, count })
|
|
.collect::<Vec<_>>();
|
|
category_cloud.sort_by(|left, right| left.name.to_lowercase().cmp(&right.name.to_lowercase()));
|
|
|
|
let mut sorted_posts = posts;
|
|
sorted_posts.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
|
|
let mut recent_posts = sorted_posts
|
|
.into_iter()
|
|
.map(|post| DashboardRecentPost {
|
|
post_id: post.id,
|
|
title: if post.title.trim().is_empty() { "Untitled".to_string() } else { post.title },
|
|
status: match post.status {
|
|
PostStatus::Published => "published".to_string(),
|
|
_ => "draft".to_string(),
|
|
},
|
|
date: format_timestamp(post.updated_at),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
recent_posts.truncate(5);
|
|
|
|
DashboardState {
|
|
title: t(self.ui_locale, "dashboard.overview"),
|
|
subtitle: project.name.clone(),
|
|
stats: DashboardStats {
|
|
total_posts: draft_count + published_count + archived_count,
|
|
published_count,
|
|
draft_count,
|
|
archived_count,
|
|
media_count: media.len(),
|
|
image_count,
|
|
total_media_size: format_bytes(total_media_size),
|
|
tag_count: tag_counts.len(),
|
|
category_count: category_cloud.len(),
|
|
},
|
|
timeline,
|
|
tag_cloud,
|
|
tag_overflow_count,
|
|
category_cloud,
|
|
recent_posts,
|
|
}
|
|
}
|
|
|
|
/// Number of items to load per sidebar page.
|
|
/// Matches the TypeScript app's limit of 500 for initial load.
|
|
const SIDEBAR_PAGE_SIZE: i64 = 500;
|
|
|
|
/// Refresh only sidebar posts using current filter state (async).
|
|
fn refresh_sidebar_posts(&mut self) -> Task<Message> {
|
|
let (Some(_), Some(project)) = (&self.db, &self.active_project) else {
|
|
return Task::none();
|
|
};
|
|
|
|
let db_path = self.db_path.clone();
|
|
let project_id = project.id.clone();
|
|
let content_language = self.content_language.clone();
|
|
let filter = match self.sidebar_view {
|
|
SidebarView::Pages => self.page_filter.clone(),
|
|
_ => self.post_filter.clone(),
|
|
};
|
|
let is_pages = self.sidebar_view == SidebarView::Pages;
|
|
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
Self::query_sidebar_posts_blocking(
|
|
&db_path,
|
|
&project_id,
|
|
&content_language,
|
|
&filter,
|
|
is_pages,
|
|
Self::SIDEBAR_PAGE_SIZE + 1,
|
|
0,
|
|
)
|
|
}).await.unwrap_or_default()
|
|
},
|
|
Message::SidebarPostsLoaded,
|
|
)
|
|
}
|
|
|
|
/// Refresh only sidebar media using current filter state (async).
|
|
fn refresh_sidebar_media(&mut self) -> Task<Message> {
|
|
let (Some(_), Some(project)) = (&self.db, &self.active_project) else {
|
|
return Task::none();
|
|
};
|
|
|
|
use bds_core::db::queries::media::MediaFilterParams;
|
|
|
|
let db_path = self.db_path.clone();
|
|
let project_id = project.id.clone();
|
|
let data_dir = self.data_dir.clone();
|
|
|
|
let params = MediaFilterParams {
|
|
search_query: self.media_filter.search_query.clone(),
|
|
year: self.media_filter.calendar.selected_year,
|
|
month: self.media_filter.calendar.selected_month,
|
|
tags: self.media_filter.tag_filter.clone(),
|
|
};
|
|
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
let db = Database::open(&db_path).ok();
|
|
let items = db.and_then(|db| {
|
|
bds_core::db::queries::media::list_media_filtered(
|
|
db.conn(), &project_id, ¶ms,
|
|
Self::SIDEBAR_PAGE_SIZE + 1, 0,
|
|
).ok()
|
|
}).unwrap_or_default();
|
|
|
|
// Pre-resolve thumbnail paths off the main thread
|
|
let thumbs: HashMap<String, Option<std::path::PathBuf>> = items.iter().map(|m| {
|
|
let thumb = data_dir.as_ref().and_then(|dir| {
|
|
if !m.mime_type.starts_with("image/") { return None; }
|
|
let rel = bds_core::util::paths::thumbnail_path(&m.id, "small", "webp");
|
|
let full = dir.join(&rel);
|
|
if full.exists() { Some(full) } else { None }
|
|
});
|
|
(m.id.clone(), thumb)
|
|
}).collect();
|
|
|
|
(items, thumbs)
|
|
}).await.unwrap_or_default()
|
|
},
|
|
|(items, thumbs)| Message::SidebarMediaLoaded { items, thumbs },
|
|
)
|
|
}
|
|
|
|
/// Load more posts (append to existing sidebar data).
|
|
fn load_more_sidebar_posts(&mut self) -> Task<Message> {
|
|
let (Some(_), Some(project)) = (&self.db, &self.active_project) else {
|
|
return Task::none();
|
|
};
|
|
|
|
let db_path = self.db_path.clone();
|
|
let project_id = project.id.clone();
|
|
let content_language = self.content_language.clone();
|
|
let offset = self.sidebar_posts.len() as i64;
|
|
let filter = match self.sidebar_view {
|
|
SidebarView::Pages => self.page_filter.clone(),
|
|
_ => self.post_filter.clone(),
|
|
};
|
|
let is_pages = self.sidebar_view == SidebarView::Pages;
|
|
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
Self::query_sidebar_posts_blocking(
|
|
&db_path,
|
|
&project_id,
|
|
&content_language,
|
|
&filter,
|
|
is_pages,
|
|
Self::SIDEBAR_PAGE_SIZE + 1,
|
|
offset,
|
|
)
|
|
}).await.unwrap_or_default()
|
|
},
|
|
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).
|
|
fn load_more_sidebar_media(&mut self) -> Task<Message> {
|
|
let (Some(_), Some(project)) = (&self.db, &self.active_project) else {
|
|
return Task::none();
|
|
};
|
|
|
|
use bds_core::db::queries::media::MediaFilterParams;
|
|
|
|
let db_path = self.db_path.clone();
|
|
let project_id = project.id.clone();
|
|
let offset = self.sidebar_media.len() as i64;
|
|
let data_dir = self.data_dir.clone();
|
|
|
|
let params = MediaFilterParams {
|
|
search_query: self.media_filter.search_query.clone(),
|
|
year: self.media_filter.calendar.selected_year,
|
|
month: self.media_filter.calendar.selected_month,
|
|
tags: self.media_filter.tag_filter.clone(),
|
|
};
|
|
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
let db = Database::open(&db_path).ok();
|
|
let items = db.and_then(|db| {
|
|
bds_core::db::queries::media::list_media_filtered(
|
|
db.conn(), &project_id, ¶ms,
|
|
Self::SIDEBAR_PAGE_SIZE + 1, offset,
|
|
).ok()
|
|
}).unwrap_or_default();
|
|
|
|
let thumbs: HashMap<String, Option<std::path::PathBuf>> = items.iter().map(|m| {
|
|
let thumb = data_dir.as_ref().and_then(|dir| {
|
|
if !m.mime_type.starts_with("image/") { return None; }
|
|
let rel = bds_core::util::paths::thumbnail_path(&m.id, "small", "webp");
|
|
let full = dir.join(&rel);
|
|
if full.exists() { Some(full) } else { None }
|
|
});
|
|
(m.id.clone(), thumb)
|
|
}).collect();
|
|
|
|
(items, thumbs)
|
|
}).await.unwrap_or_default()
|
|
},
|
|
|(items, thumbs)| Message::SidebarMediaAppended { items, thumbs },
|
|
)
|
|
}
|
|
|
|
/// Refresh available tags, categories, and calendar data for filter widgets.
|
|
fn refresh_filter_metadata(&mut self) {
|
|
if let (Some(db), Some(project)) = (&self.db, &self.active_project) {
|
|
use bds_core::db::queries::post;
|
|
use bds_core::db::queries::media;
|
|
|
|
// Post filter metadata
|
|
let all_tags = post::distinct_post_tags(db.conn(), &project.id)
|
|
.unwrap_or_default();
|
|
let all_cats = post::distinct_post_categories(db.conn(), &project.id)
|
|
.unwrap_or_default();
|
|
|
|
// Calendar counts for posts (excluding pages)
|
|
let post_cal = post::post_calendar_counts(
|
|
db.conn(), &project.id, false, true,
|
|
).unwrap_or_default();
|
|
self.post_filter.available_tags = all_tags.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);
|
|
|
|
// Calendar counts for pages only
|
|
let page_cal = post::post_calendar_counts(
|
|
db.conn(), &project.id, true, false,
|
|
).unwrap_or_default();
|
|
self.page_filter.available_tags = all_tags;
|
|
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);
|
|
|
|
// Media filter metadata
|
|
self.media_filter.available_tags = media::distinct_media_tags(
|
|
db.conn(), &project.id,
|
|
).unwrap_or_default();
|
|
let media_cal = media::media_calendar_counts(
|
|
db.conn(), &project.id,
|
|
).unwrap_or_default();
|
|
self.media_filter.calendar_years = Self::build_calendar_tree(&media_cal);
|
|
}
|
|
}
|
|
|
|
/// Convert (year, month, count) tuples into CalendarYear/CalendarMonth tree.
|
|
fn build_calendar_tree(data: &[(i32, u32, usize)]) -> Vec<CalendarYear> {
|
|
let mut years: Vec<CalendarYear> = Vec::new();
|
|
for &(y, m, c) in data {
|
|
if let Some(cy) = years.iter_mut().find(|cy| cy.year == y) {
|
|
cy.months.push(CalendarMonth { month: m, count: c });
|
|
} else {
|
|
years.push(CalendarYear {
|
|
year: y,
|
|
months: vec![CalendarMonth { month: m, count: c }],
|
|
});
|
|
}
|
|
}
|
|
years
|
|
}
|
|
|
|
/// Per layout.allium PanelTabFallback invariant: if the active panel tab
|
|
/// becomes unavailable (post_links when no post tab active, git_log when
|
|
/// neither post nor media tab active), fall back to Tasks.
|
|
fn enforce_panel_tab_fallback(&mut self) {
|
|
let active_tab_type = self.active_tab.as_ref().and_then(|id| {
|
|
self.tabs.iter().find(|t| t.id == *id).map(|t| &t.tab_type)
|
|
});
|
|
let is_post = active_tab_type == Some(&TabType::Post);
|
|
let is_post_or_media = is_post || active_tab_type == Some(&TabType::Media);
|
|
|
|
match self.panel_tab {
|
|
PanelTab::PostLinks if !is_post => self.panel_tab = PanelTab::Tasks,
|
|
PanelTab::GitLog if !is_post_or_media => self.panel_tab = PanelTab::Tasks,
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Synchronise menu enabled/disabled state with current app state.
|
|
///
|
|
/// Called after state-changing operations (project switch, tab open/close,
|
|
/// offline toggle) so that menu items reflect what's actually possible.
|
|
fn sync_menu_state(&self) {
|
|
let has_project = self.active_project.is_some();
|
|
let active_tab_type = self.active_tab.as_ref().and_then(|id| {
|
|
self.tabs.iter().find(|t| t.id == *id).map(|t| &t.tab_type)
|
|
});
|
|
let has_tab = active_tab_type.is_some();
|
|
let has_post_tab = active_tab_type == Some(&TabType::Post);
|
|
|
|
// File group: need active project for most, need open tab for Save
|
|
self.menu_registry.set_enabled(MenuAction::NewPost, has_project);
|
|
self.menu_registry.set_enabled(MenuAction::ImportMedia, has_project);
|
|
self.menu_registry.set_enabled(MenuAction::Save, has_tab);
|
|
self.menu_registry.set_enabled(MenuAction::OpenInBrowser, has_project && has_post_tab);
|
|
self.menu_registry.set_enabled(MenuAction::OpenDataFolder, has_project);
|
|
|
|
// Edit: Find/Replace need an open tab
|
|
self.menu_registry.set_enabled(MenuAction::Find, has_tab);
|
|
self.menu_registry.set_enabled(MenuAction::Replace, has_tab);
|
|
|
|
// Blog group: need active project
|
|
self.menu_registry.set_enabled(MenuAction::PublishSelected, has_project && has_tab);
|
|
self.menu_registry.set_enabled(MenuAction::PreviewPost, has_project && has_post_tab);
|
|
self.menu_registry.set_enabled(MenuAction::EditMenu, has_project);
|
|
self.menu_registry.set_enabled(MenuAction::RebuildDatabase, has_project);
|
|
self.menu_registry.set_enabled(MenuAction::ReindexText, has_project);
|
|
self.menu_registry.set_enabled(MenuAction::MetadataDiff, has_project);
|
|
self.menu_registry.set_enabled(MenuAction::RegenerateCalendar, has_project);
|
|
self.menu_registry.set_enabled(MenuAction::ValidateTranslations, has_project);
|
|
self.menu_registry.set_enabled(MenuAction::FillMissingTranslations, has_project && !self.offline_mode);
|
|
self.menu_registry.set_enabled(MenuAction::GenerateSitemap, has_project);
|
|
self.menu_registry.set_enabled(MenuAction::ValidateSite, has_project);
|
|
self.menu_registry.set_enabled(MenuAction::UploadSite, has_project && !self.offline_mode);
|
|
}
|
|
|
|
// ── Editor save/publish helpers ──
|
|
|
|
fn save_post_editor(&mut self, post_id: &str) -> Task<Message> {
|
|
match self.persist_post_editor_state(post_id) {
|
|
Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")),
|
|
Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn publish_post_editor(&mut self, post_id: &str) -> Task<Message> {
|
|
if let Err(e) = self.persist_post_editor_state(post_id) {
|
|
self.notify(ToastLevel::Error, &format!("Publish failed: {e}"));
|
|
return Task::none();
|
|
}
|
|
let Some(ref db) = self.db else { return Task::none() };
|
|
let Some(ref data_dir) = self.data_dir else { return Task::none() };
|
|
match engine::post::publish_post(db.conn(), data_dir, post_id) {
|
|
Ok(post) => {
|
|
if let Some(s) = self.post_editors.get_mut(post_id) {
|
|
s.status = post.status.clone();
|
|
s.is_dirty = false;
|
|
s.updated_at = post.updated_at;
|
|
s.published_at = post.published_at;
|
|
for draft in s.translation_drafts.values_mut() {
|
|
draft.status = bds_core::model::PostStatus::Published;
|
|
draft.is_dirty = false;
|
|
}
|
|
}
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.published"));
|
|
}
|
|
Err(e) => {
|
|
self.notify(ToastLevel::Error, &format!("Publish failed: {e}"));
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn insert_link_modal(&mut self, post_id: &str) -> Task<Message> {
|
|
let state = match self.post_editors.get(post_id) {
|
|
Some(s) => s.clone(),
|
|
None => return Task::none(),
|
|
};
|
|
|
|
self.active_modal = Some(modal::ModalState::PostInsertLink {
|
|
post_id: post_id.to_string(),
|
|
title: state.title,
|
|
results: self.query_post_link_results(post_id, ""),
|
|
search_query: String::new(),
|
|
active_tab: modal::PostInsertLinkTab::Internal,
|
|
external_url: String::new(),
|
|
external_text: String::new(),
|
|
});
|
|
Task::none()
|
|
}
|
|
|
|
fn insert_media_modal(&mut self, post_id: &str, link_only: bool) -> Task<Message> {
|
|
let state = match self.post_editors.get(post_id) {
|
|
Some(s) => s.clone(),
|
|
None => return Task::none(),
|
|
};
|
|
|
|
self.active_modal = Some(modal::ModalState::InsertMedia {
|
|
post_id: post_id.to_string(),
|
|
title: state.title,
|
|
media_list: self.query_post_insert_media_results(""),
|
|
search_query: String::new(),
|
|
link_only,
|
|
});
|
|
Task::none()
|
|
}
|
|
|
|
fn post_gallery(&mut self, post_id: &str) -> Task<Message> {
|
|
let state = match self.post_editors.get(post_id) {
|
|
Some(s) => s.clone(),
|
|
None => return Task::none(),
|
|
};
|
|
|
|
let media_list = if let Some(ref db) = self.db {
|
|
engine::post_media::list_media_for_post(db.conn(), post_id).unwrap_or_default()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
self.active_modal = Some(modal::ModalState::PostGallery {
|
|
post_id: post_id.to_string(),
|
|
title: state.title,
|
|
media_list,
|
|
selected_index: None,
|
|
});
|
|
Task::none()
|
|
}
|
|
|
|
fn query_post_link_results(&self, current_post_id: &str, search_query: &str) -> Vec<modal::InsertLinkResult> {
|
|
let (Some(db), Some(project)) = (&self.db, &self.active_project) else {
|
|
return Vec::new();
|
|
};
|
|
let query = search_query.trim();
|
|
if query.chars().count() < 2 {
|
|
return Vec::new();
|
|
}
|
|
|
|
let filters = bds_core::db::fts::PostSearchFilters {
|
|
limit: Some(20),
|
|
..Default::default()
|
|
};
|
|
|
|
let ids = bds_core::db::fts::search_posts_filtered(
|
|
db.conn(),
|
|
query,
|
|
&self.content_language,
|
|
&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 && post.id != current_post_id)
|
|
.map(|post| modal::InsertLinkResult {
|
|
post_id: post.id,
|
|
title: post.title,
|
|
status: match post.status {
|
|
bds_core::model::PostStatus::Draft => "draft".to_string(),
|
|
bds_core::model::PostStatus::Published => "published".to_string(),
|
|
bds_core::model::PostStatus::Archived => "archived".to_string(),
|
|
},
|
|
canonical_url: bds_core::engine::post::canonical_url(post.created_at, &post.slug),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn query_post_insert_media_results(&self, search_query: &str) -> Vec<Media> {
|
|
let (Some(db), Some(project)) = (&self.db, &self.active_project) else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let filters = bds_core::db::queries::media::MediaFilterParams {
|
|
search_query: search_query.trim().to_string(),
|
|
..Default::default()
|
|
};
|
|
|
|
bds_core::db::queries::media::list_media_filtered(db.conn(), &project.id, &filters, 24, 0)
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn query_media_post_picker_results(&self, media_id: &str, search_query: &str) -> Vec<LinkedPostItem> {
|
|
let (Some(db), Some(project)) = (&self.db, &self.active_project) else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let linked_ids: std::collections::HashSet<String> = bds_core::engine::post_media::list_posts_for_media(db.conn(), media_id)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|post| post.id)
|
|
.collect();
|
|
|
|
let query = search_query.trim().to_lowercase();
|
|
bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter(|post| !linked_ids.contains(&post.id))
|
|
.filter(|post| query.is_empty() || post.title.to_lowercase().contains(&query))
|
|
.take(10)
|
|
.map(|post| LinkedPostItem {
|
|
post_id: post.id,
|
|
title: if post.title.is_empty() {
|
|
t(self.ui_locale, "editor.untitled")
|
|
} else {
|
|
post.title
|
|
},
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn insert_markdown_into_post(&mut self, post_id: &str, markdown: &str) -> Task<Message> {
|
|
let Some(state) = self.post_editors.get_mut(post_id) else { return Task::none() };
|
|
state.insert_markdown_at_cursor(markdown);
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == post_id) {
|
|
tab.is_dirty = true;
|
|
}
|
|
self.active_modal = None;
|
|
self.save_post_editor(post_id)
|
|
}
|
|
|
|
fn insert_selected_post_link(&mut self, post_id: &str, linked_post_id: &str) -> Task<Message> {
|
|
let Some(ref db) = self.db else { return Task::none() };
|
|
let Ok(linked_post) = bds_core::db::queries::post::get_post_by_id(db.conn(), linked_post_id) else {
|
|
self.notify(ToastLevel::Error, &t(self.ui_locale, "modal.postInsertLink.loadFailed"));
|
|
return Task::none();
|
|
};
|
|
|
|
let markdown = bds_core::engine::post::post_insert_link(&linked_post.slug)
|
|
.replacen("title", &linked_post.title, 1);
|
|
self.insert_markdown_into_post(post_id, &markdown)
|
|
}
|
|
|
|
fn insert_created_post_link(&mut self, post_id: &str) -> Task<Message> {
|
|
let Some(modal::ModalState::PostInsertLink { search_query, .. }) = self.active_modal.clone() else {
|
|
return Task::none();
|
|
};
|
|
let title = search_query.trim();
|
|
if title.is_empty() {
|
|
self.notify(ToastLevel::Error, &t(self.ui_locale, "modal.postInsertLink.titleRequired"));
|
|
return Task::none();
|
|
}
|
|
|
|
let Some(ref db) = self.db else { return Task::none() };
|
|
let Some(ref data_dir) = self.data_dir else { return Task::none() };
|
|
let Some(ref project) = self.active_project else { return Task::none() };
|
|
|
|
match engine::post::create_post(
|
|
db.conn(),
|
|
data_dir,
|
|
&project.id,
|
|
title,
|
|
Some(""),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
None,
|
|
None,
|
|
None,
|
|
) {
|
|
Ok(post) => {
|
|
let markdown = bds_core::engine::post::post_insert_link(&post.slug)
|
|
.replacen("title", &post.title, 1);
|
|
self.insert_markdown_into_post(post_id, &markdown)
|
|
}
|
|
Err(_) => {
|
|
self.notify(ToastLevel::Error, &t(self.ui_locale, "modal.postInsertLink.createFailed"));
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn insert_selected_media(&mut self, post_id: &str, media_id: &str) -> Task<Message> {
|
|
let Some(ref db) = self.db else { return Task::none() };
|
|
let Ok(media) = bds_core::db::queries::media::get_media_by_id(db.conn(), media_id) else {
|
|
self.notify(ToastLevel::Error, &t(self.ui_locale, "modal.insertMedia.loadFailed"));
|
|
return Task::none();
|
|
};
|
|
|
|
let link_only = matches!(
|
|
self.active_modal,
|
|
Some(modal::ModalState::InsertMedia { link_only: true, .. })
|
|
);
|
|
|
|
if let (Some(data_dir), Some(project)) = (&self.data_dir, &self.active_project) {
|
|
let already_linked = engine::post_media::list_media_for_post(db.conn(), post_id)
|
|
.map(|items| items.into_iter().any(|item| item.id == media_id))
|
|
.unwrap_or(false);
|
|
if !already_linked {
|
|
let sort_order = engine::post_media::list_media_for_post(db.conn(), post_id)
|
|
.map(|items| items.len() as i32)
|
|
.unwrap_or(0);
|
|
let _ = engine::post_media::link_media_to_post(
|
|
db.conn(),
|
|
data_dir,
|
|
&project.id,
|
|
post_id,
|
|
media_id,
|
|
sort_order,
|
|
);
|
|
}
|
|
}
|
|
|
|
self.refresh_post_relationships(post_id);
|
|
|
|
if link_only {
|
|
self.active_modal = None;
|
|
return Task::none();
|
|
}
|
|
|
|
let markdown = bds_core::engine::post::post_insert_media(
|
|
&media.id,
|
|
media.mime_type.starts_with("image/"),
|
|
&media.original_name,
|
|
);
|
|
self.insert_markdown_into_post(post_id, &markdown)
|
|
}
|
|
|
|
fn refresh_post_insert_link_modal(
|
|
&mut self,
|
|
active_tab: Option<modal::PostInsertLinkTab>,
|
|
search_query: Option<String>,
|
|
external_url: Option<String>,
|
|
external_text: Option<String>,
|
|
) {
|
|
let Some(modal::ModalState::PostInsertLink {
|
|
post_id,
|
|
title,
|
|
search_query: current_query,
|
|
active_tab: current_tab,
|
|
external_url: current_url,
|
|
external_text: current_text,
|
|
..
|
|
}) = self.active_modal.clone() else {
|
|
return;
|
|
};
|
|
|
|
let next_query = search_query.unwrap_or(current_query);
|
|
let next_tab = active_tab.unwrap_or(current_tab);
|
|
let next_url = external_url.unwrap_or(current_url);
|
|
let next_text = external_text.unwrap_or(current_text);
|
|
|
|
self.active_modal = Some(modal::ModalState::PostInsertLink {
|
|
post_id: post_id.clone(),
|
|
title,
|
|
results: self.query_post_link_results(&post_id, &next_query),
|
|
search_query: next_query,
|
|
active_tab: next_tab,
|
|
external_url: next_url,
|
|
external_text: next_text,
|
|
});
|
|
}
|
|
|
|
fn refresh_insert_media_modal(&mut self, search_query: String) {
|
|
let Some(modal::ModalState::InsertMedia { post_id, title, link_only, .. }) = self.active_modal.clone() else {
|
|
return;
|
|
};
|
|
|
|
self.active_modal = Some(modal::ModalState::InsertMedia {
|
|
post_id,
|
|
title,
|
|
media_list: self.query_post_insert_media_results(&search_query),
|
|
search_query,
|
|
link_only,
|
|
});
|
|
}
|
|
|
|
fn update_gallery_selection(&mut self, next_index: Option<usize>) {
|
|
let Some(modal::ModalState::PostGallery {
|
|
post_id,
|
|
title,
|
|
media_list,
|
|
..
|
|
}) = self.active_modal.clone() else {
|
|
return;
|
|
};
|
|
|
|
self.active_modal = Some(modal::ModalState::PostGallery {
|
|
post_id,
|
|
title,
|
|
media_list,
|
|
selected_index: next_index,
|
|
});
|
|
}
|
|
|
|
fn step_gallery_selection(&mut self, delta: isize) {
|
|
let Some(modal::ModalState::PostGallery {
|
|
selected_index,
|
|
media_list,
|
|
..
|
|
}) = self.active_modal.clone() else {
|
|
return;
|
|
};
|
|
|
|
let image_count = media_list.iter().filter(|media| media.mime_type.starts_with("image/")).count();
|
|
if image_count == 0 {
|
|
return;
|
|
}
|
|
|
|
let current = selected_index.unwrap_or(0);
|
|
let next = if delta < 0 {
|
|
(current + image_count - 1) % image_count
|
|
} else {
|
|
(current + 1) % image_count
|
|
};
|
|
self.update_gallery_selection(Some(next));
|
|
}
|
|
|
|
fn save_media_editor(&mut self, media_id: &str) -> Task<Message> {
|
|
match self.persist_media_editor_state(media_id) {
|
|
Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")),
|
|
Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn load_media_linked_posts(&self, media_id: &str) -> Vec<LinkedPostItem> {
|
|
let Some(ref db) = self.db else {
|
|
return Vec::new();
|
|
};
|
|
|
|
bds_core::engine::post_media::list_posts_for_media(db.conn(), media_id)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|post| LinkedPostItem {
|
|
post_id: post.id,
|
|
title: if post.title.is_empty() {
|
|
t(self.ui_locale, "editor.untitled")
|
|
} else {
|
|
post.title
|
|
},
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn refresh_media_relationships(&mut self, media_id: &str) {
|
|
let linked_posts = self.load_media_linked_posts(media_id);
|
|
let search = self
|
|
.media_editors
|
|
.get(media_id)
|
|
.map(|state| state.post_picker_search.clone())
|
|
.unwrap_or_default();
|
|
let post_picker_results = self.query_media_post_picker_results(media_id, &search);
|
|
if let Some(state) = self.media_editors.get_mut(media_id) {
|
|
state.linked_posts = linked_posts;
|
|
state.post_picker_results = post_picker_results;
|
|
}
|
|
}
|
|
|
|
fn link_media_to_post(&mut self, media_id: &str, post_id: &str) -> Task<Message> {
|
|
if let (Some(db), Some(data_dir), Some(project)) = (&self.db, &self.data_dir, &self.active_project) {
|
|
let sort_order = bds_core::engine::post_media::list_media_for_post(db.conn(), post_id)
|
|
.map(|items| items.len() as i32)
|
|
.unwrap_or(0);
|
|
match bds_core::engine::post_media::link_media_to_post(
|
|
db.conn(),
|
|
data_dir,
|
|
&project.id,
|
|
post_id,
|
|
media_id,
|
|
sort_order,
|
|
) {
|
|
Ok(_) => {
|
|
self.refresh_media_relationships(media_id);
|
|
self.refresh_post_relationships(post_id);
|
|
}
|
|
Err(error) => {
|
|
self.notify(ToastLevel::Error, &format!("Failed to link post: {error}"));
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn unlink_media_from_post(&mut self, media_id: &str, post_id: &str) -> Task<Message> {
|
|
if let (Some(db), Some(data_dir)) = (&self.db, &self.data_dir) {
|
|
match bds_core::engine::post_media::unlink_media_from_post(db.conn(), data_dir, post_id, media_id) {
|
|
Ok(()) => {
|
|
self.refresh_media_relationships(media_id);
|
|
self.refresh_post_relationships(post_id);
|
|
}
|
|
Err(error) => {
|
|
self.notify(ToastLevel::Error, &format!("Failed to unlink post: {error}"));
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn save_template_editor(&mut self, template_id: &str) -> Task<Message> {
|
|
let Some(state) = self.template_editors.get(template_id) else { return Task::none() };
|
|
let Some(ref db) = self.db else { return Task::none() };
|
|
let Some(ref project) = self.active_project else { return Task::none() };
|
|
|
|
match save_template_editor_state_impl(db, &project.id, state) {
|
|
Ok(tmpl) => {
|
|
let s = self.template_editors.get_mut(template_id).unwrap();
|
|
s.is_dirty = false;
|
|
s.updated_at = tmpl.updated_at;
|
|
s.validation_error = None;
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tmpl.id) {
|
|
tab.is_dirty = false;
|
|
tab.title = tmpl.title.clone();
|
|
}
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(e) => {
|
|
if let Some(s) = self.template_editors.get_mut(template_id) {
|
|
s.validation_error = Some(e.clone());
|
|
}
|
|
self.notify(ToastLevel::Error, &format!("Save failed: {e}"));
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn save_script_editor(&mut self, script_id: &str) -> Task<Message> {
|
|
let Some(state) = self.script_editors.get(script_id) else { return Task::none() };
|
|
let Some(ref db) = self.db else { return Task::none() };
|
|
let Some(ref project) = self.active_project else { return Task::none() };
|
|
|
|
match save_script_editor_state_impl(db, &project.id, state) {
|
|
Ok(script) => {
|
|
let s = self.script_editors.get_mut(script_id).unwrap();
|
|
s.is_dirty = false;
|
|
s.updated_at = script.updated_at;
|
|
s.discovered_entrypoints = engine::script::discover_entrypoints(&s.content);
|
|
s.validation_error = None;
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == script.id) {
|
|
tab.is_dirty = false;
|
|
tab.title = script.title.clone();
|
|
}
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(e) => {
|
|
if let Some(s) = self.script_editors.get_mut(script_id) {
|
|
s.validation_error = Some(e.clone());
|
|
}
|
|
self.notify(ToastLevel::Error, &format!("Save failed: {e}"));
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn apply_persisted_post_state(
|
|
&mut self,
|
|
post_id: &str,
|
|
state: &PostEditorState,
|
|
persisted: PersistedPostState,
|
|
) {
|
|
match persisted {
|
|
PersistedPostState::Translation(translation) => {
|
|
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
|
editor.is_dirty = false;
|
|
editor.last_edit_at_ms = 0;
|
|
if let Some(draft) = editor.translation_drafts.get_mut(&state.active_language) {
|
|
draft.title = translation.title.clone();
|
|
draft.excerpt = translation.excerpt.clone().unwrap_or_default();
|
|
draft.content = translation.content.clone().unwrap_or_default();
|
|
draft.status = translation.status.clone();
|
|
draft.is_dirty = false;
|
|
}
|
|
}
|
|
}
|
|
PersistedPostState::Canonical(post) => {
|
|
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
|
editor.title = post.title.clone();
|
|
editor.slug = post.slug.clone();
|
|
editor.excerpt = post.excerpt.clone().unwrap_or_default();
|
|
editor.author = post.author.clone().unwrap_or_default();
|
|
editor.language = post.language.clone().unwrap_or_default();
|
|
editor.template_slug = post.template_slug.clone().unwrap_or_default();
|
|
editor.do_not_translate = post.do_not_translate;
|
|
editor.tags = post.tags.clone();
|
|
editor.categories = post.categories.clone();
|
|
editor.status = post.status.clone();
|
|
editor.updated_at = post.updated_at;
|
|
editor.published_at = post.published_at;
|
|
editor.is_dirty = false;
|
|
editor.last_edit_at_ms = 0;
|
|
}
|
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == post.id) {
|
|
tab.is_dirty = false;
|
|
if !post.title.is_empty() {
|
|
tab.title = post.title.clone();
|
|
}
|
|
}
|
|
self.refresh_post_relationships(post_id);
|
|
}
|
|
}
|
|
|
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == post_id) {
|
|
tab.is_dirty = false;
|
|
}
|
|
}
|
|
|
|
fn persist_post_editor_state(&mut self, post_id: &str) -> Result<(), String> {
|
|
let state = self
|
|
.post_editors
|
|
.get(post_id)
|
|
.cloned()
|
|
.ok_or_else(|| "missing post editor".to_string())?;
|
|
let db = self.db.as_ref().ok_or_else(|| "database unavailable".to_string())?;
|
|
let data_dir = self
|
|
.data_dir
|
|
.as_ref()
|
|
.ok_or_else(|| "project data directory unavailable".to_string())?;
|
|
|
|
let persisted = persist_post_editor_state_impl(db, data_dir, &state)?;
|
|
self.apply_persisted_post_state(post_id, &state, persisted);
|
|
Ok(())
|
|
}
|
|
|
|
fn persist_post_editor_preview_state(&mut self, post_id: &str) -> Result<(), String> {
|
|
let state = self
|
|
.post_editors
|
|
.get(post_id)
|
|
.cloned()
|
|
.ok_or_else(|| "missing post editor".to_string())?;
|
|
let db = self.db.as_ref().ok_or_else(|| "database unavailable".to_string())?;
|
|
|
|
let persisted = persist_post_editor_preview_state_impl(db, &state)?;
|
|
self.apply_persisted_post_state(post_id, &state, persisted);
|
|
Ok(())
|
|
}
|
|
|
|
fn persist_media_editor_state(&mut self, media_id: &str) -> Result<(), String> {
|
|
let state = self
|
|
.media_editors
|
|
.get(media_id)
|
|
.cloned()
|
|
.ok_or_else(|| "missing media editor".to_string())?;
|
|
let db = self.db.as_ref().ok_or_else(|| "database unavailable".to_string())?;
|
|
let data_dir = self
|
|
.data_dir
|
|
.as_ref()
|
|
.ok_or_else(|| "project data directory unavailable".to_string())?;
|
|
|
|
match persist_media_editor_state_impl(db, data_dir, &state)? {
|
|
PersistedMediaState::Translation => {
|
|
if let Some(editor) = self.media_editors.get_mut(media_id) {
|
|
editor.is_dirty = false;
|
|
}
|
|
}
|
|
PersistedMediaState::Canonical { media, tags } => {
|
|
if let Some(editor) = self.media_editors.get_mut(media_id) {
|
|
editor.title = media.title.clone().unwrap_or_default();
|
|
editor.alt = media.alt.clone().unwrap_or_default();
|
|
editor.caption = media.caption.clone().unwrap_or_default();
|
|
editor.author = media.author.clone().unwrap_or_default();
|
|
editor.language = media.language.clone().unwrap_or_default();
|
|
editor.tags = tags;
|
|
editor.tags_input = editor.tags.join(", ");
|
|
editor.updated_at = media.updated_at;
|
|
editor.is_dirty = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == media_id) {
|
|
tab.is_dirty = false;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn close_entity_tab(&mut self, entity_id: &str) {
|
|
if let Some(next_idx) = tabs::close_tab(&mut self.tabs, entity_id) {
|
|
self.active_tab = self.tabs.get(next_idx).map(|tab| tab.id.clone());
|
|
} else if self.active_tab.as_deref() == Some(entity_id) {
|
|
self.active_tab = None;
|
|
}
|
|
self.enforce_panel_tab_fallback();
|
|
self.sync_menu_state();
|
|
}
|
|
|
|
fn delete_post_editor(&mut self, post_id: &str) -> Task<Message> {
|
|
let Some(db) = &self.db else { return Task::none() };
|
|
let Some(data_dir) = &self.data_dir else { return Task::none() };
|
|
match engine::post::delete_post(db.conn(), data_dir, post_id) {
|
|
Ok(()) => {
|
|
self.post_editors.remove(post_id);
|
|
self.close_entity_tab(post_id);
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted"));
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &format!("Delete failed: {e}")),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn discard_post_editor(&mut self, post_id: &str) -> Task<Message> {
|
|
let Some(db) = &self.db else { return Task::none() };
|
|
let Some(data_dir) = &self.data_dir else { return Task::none() };
|
|
match engine::post::discard_post_draft(db.conn(), data_dir, post_id) {
|
|
Ok(post) => {
|
|
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
|
editor.title = post.title.clone();
|
|
editor.slug = post.slug.clone();
|
|
editor.excerpt = post.excerpt.clone().unwrap_or_default();
|
|
editor.content = post.content.clone().unwrap_or_default();
|
|
editor.author = post.author.clone().unwrap_or_default();
|
|
editor.language = post.language.clone().unwrap_or_default();
|
|
editor.active_language = editor.language.clone();
|
|
editor.canonical_language = editor.language.clone();
|
|
editor.template_slug = post.template_slug.clone().unwrap_or_default();
|
|
editor.tags = post.tags.clone();
|
|
editor.categories = post.categories.clone();
|
|
editor.status = post.status.clone();
|
|
editor.do_not_translate = post.do_not_translate;
|
|
editor.updated_at = post.updated_at;
|
|
editor.published_at = post.published_at;
|
|
editor.is_dirty = false;
|
|
editor.translation_drafts.clear();
|
|
}
|
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == post.id) {
|
|
tab.is_dirty = false;
|
|
tab.title = post.title.clone();
|
|
}
|
|
self.refresh_post_relationships(post_id);
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &format!("Discard failed: {e}")),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn duplicate_post_editor(&mut self, post_id: &str) -> Task<Message> {
|
|
let Some(db) = &self.db else { return Task::none() };
|
|
let Some(data_dir) = &self.data_dir else { return Task::none() };
|
|
match engine::post::duplicate_post(db.conn(), data_dir, post_id) {
|
|
Ok(post) => {
|
|
let tab = Tab {
|
|
id: post.id.clone(),
|
|
title: post.title.clone(),
|
|
tab_type: TabType::Post,
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
};
|
|
let idx = tabs::open_tab(&mut self.tabs, tab);
|
|
self.active_tab = self.tabs.get(idx).map(|tab| tab.id.clone());
|
|
if let Some(tab) = self.tabs.get(idx).cloned() {
|
|
self.load_editor_for_tab(&tab);
|
|
}
|
|
self.enforce_panel_tab_fallback();
|
|
self.sync_menu_state();
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &format!("Duplicate failed: {e}")),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn delete_media_editor(&mut self, media_id: &str) -> Task<Message> {
|
|
let Some(db) = &self.db else { return Task::none() };
|
|
let Some(data_dir) = &self.data_dir else { return Task::none() };
|
|
match engine::media::delete_media(db.conn(), data_dir, media_id) {
|
|
Ok(()) => {
|
|
self.media_editors.remove(media_id);
|
|
self.close_entity_tab(media_id);
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted"));
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &format!("Delete failed: {e}")),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn delete_template_editor(&mut self, template_id: &str, force: bool) -> Task<Message> {
|
|
let Some(db) = &self.db else { return Task::none() };
|
|
let Some(data_dir) = &self.data_dir else { return Task::none() };
|
|
match engine::template::delete_template(db.conn(), data_dir, template_id, force) {
|
|
Ok(()) => {
|
|
self.template_editors.remove(template_id);
|
|
self.close_entity_tab(template_id);
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted"));
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &format!("Delete failed: {e}")),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn show_template_delete_confirmation(&mut self, template_id: &str) -> Task<Message> {
|
|
let Some(db) = &self.db else { return Task::none() };
|
|
let Ok(template) = bds_core::db::queries::template::get_template_by_id(db.conn(), template_id) else {
|
|
return Task::none();
|
|
};
|
|
|
|
let referencing_posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &template.project_id)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter(|post| post.template_slug.as_deref() == Some(template.slug.as_str()))
|
|
.count();
|
|
let referencing_tags = bds_core::db::queries::tag::list_tags_by_project(db.conn(), &template.project_id)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter(|tag| tag.post_template_slug.as_deref() == Some(template.slug.as_str()))
|
|
.count();
|
|
|
|
if referencing_posts > 0 || referencing_tags > 0 {
|
|
let title = t(self.ui_locale, "template.forceDeleteTitle");
|
|
let message = tw(
|
|
self.ui_locale,
|
|
"template.forceDeleteMessage",
|
|
&[
|
|
("posts", &referencing_posts.to_string()),
|
|
("tags", &referencing_tags.to_string()),
|
|
],
|
|
);
|
|
return Task::done(Message::ShowModal(modal::ModalState::Confirm {
|
|
title,
|
|
message,
|
|
on_confirm: modal::ConfirmAction::ForceDeleteTemplate(template_id.to_string()),
|
|
}));
|
|
}
|
|
|
|
Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete {
|
|
entity_name: template.title,
|
|
references: Vec::new(),
|
|
on_confirm: modal::ConfirmAction::DeleteTemplate(template_id.to_string()),
|
|
}))
|
|
}
|
|
|
|
fn delete_script_editor(&mut self, script_id: &str) -> Task<Message> {
|
|
let Some(db) = &self.db else { return Task::none() };
|
|
let Some(data_dir) = &self.data_dir else { return Task::none() };
|
|
match engine::script::delete_script(db.conn(), data_dir, script_id) {
|
|
Ok(()) => {
|
|
self.script_editors.remove(script_id);
|
|
self.close_entity_tab(script_id);
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted"));
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &format!("Delete failed: {e}")),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn reload_tags_state(&mut self) {
|
|
let Some(db) = &self.db else { return };
|
|
let Some(project) = &self.active_project else { return };
|
|
let tags = bds_core::db::queries::tag::list_tags_by_project(db.conn(), &project.id)
|
|
.unwrap_or_default();
|
|
let template_options = bds_core::db::queries::template::list_templates_by_project(db.conn(), &project.id)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|template| template.slug)
|
|
.collect::<Vec<_>>();
|
|
let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id)
|
|
.unwrap_or_default();
|
|
let mut counts = HashMap::new();
|
|
for post in &posts {
|
|
for tag_name in &post.tags {
|
|
*counts.entry(tag_name.to_lowercase()).or_insert(0usize) += 1;
|
|
}
|
|
}
|
|
if let Some(state) = self.tags_view_state.as_mut() {
|
|
state.tags = tags;
|
|
state.tag_post_counts = counts;
|
|
state.template_options = template_options;
|
|
state.selected_tags.retain(|selected_id| state.tags.iter().any(|tag| &tag.id == selected_id));
|
|
if state.selected_tags.len() == 1 {
|
|
if let Some(tag) = state.tags.iter().find(|tag| state.selected_tags[0] == tag.id) {
|
|
state.editing_tag = Some(tags_view::EditingTag {
|
|
id: tag.id.clone(),
|
|
original_name: tag.name.clone(),
|
|
name: tag.name.clone(),
|
|
color: tag.color.clone().unwrap_or_default(),
|
|
template_slug: tag.post_template_slug.clone().unwrap_or_default(),
|
|
});
|
|
}
|
|
} else {
|
|
state.editing_tag = None;
|
|
}
|
|
if let Some(target_id) = state.merge_target.as_ref() {
|
|
if !state.selected_tags.iter().any(|selected_id| selected_id == target_id) {
|
|
state.merge_target = None;
|
|
}
|
|
}
|
|
} else {
|
|
self.tags_view_state = Some(TagsViewState::new(tags, counts, template_options));
|
|
}
|
|
}
|
|
|
|
fn delete_tag(&mut self, tag_id: &str) -> Task<Message> {
|
|
let Some(db) = &self.db else { return Task::none() };
|
|
let Some(data_dir) = &self.data_dir else { return Task::none() };
|
|
let Some(project) = &self.active_project else { return Task::none() };
|
|
match engine::tag::delete_tag(db.conn(), data_dir, &project.id, tag_id) {
|
|
Ok(()) => {
|
|
self.reload_tags_state();
|
|
if let Some(state) = self.tags_view_state.as_mut() {
|
|
if state.editing_tag.as_ref().map(|tag| tag.id.as_str()) == Some(tag_id) {
|
|
state.editing_tag = None;
|
|
}
|
|
}
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted"));
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &format!("Delete failed: {e}")),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn merge_tags(&mut self, sources: &[String], target: &str) -> Task<Message> {
|
|
let Some(db) = &self.db else { return Task::none() };
|
|
let Some(data_dir) = &self.data_dir else { return Task::none() };
|
|
let Some(project) = &self.active_project else { return Task::none() };
|
|
let source_refs = sources.iter().map(String::as_str).collect::<Vec<_>>();
|
|
match engine::tag::merge_tags(db.conn(), data_dir, &project.id, &source_refs, target) {
|
|
Ok(()) => {
|
|
self.reload_tags_state();
|
|
if let Some(state) = self.tags_view_state.as_mut() {
|
|
state.selected_tags.clear();
|
|
state.merge_target = None;
|
|
state.editing_tag = None;
|
|
}
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &format!("Merge failed: {e}")),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn hydrate_settings_state(&self) -> SettingsViewState {
|
|
let mut state = SettingsViewState::default();
|
|
if let Some(project) = &self.active_project {
|
|
state.project_name = project.name.clone();
|
|
state.project_description = iced::widget::text_editor::Content::with_text(
|
|
&project.description.clone().unwrap_or_default(),
|
|
);
|
|
state.data_path = project.data_path.clone().unwrap_or_default();
|
|
}
|
|
if let Some(data_dir) = &self.data_dir {
|
|
if let Ok(meta) = engine::meta::read_project_json(data_dir) {
|
|
state.public_url = meta.public_url.unwrap_or_default();
|
|
state.main_language = meta.main_language.unwrap_or_else(|| self.content_language.clone());
|
|
state.default_author = meta.default_author.unwrap_or_default();
|
|
state.max_posts_per_page = meta.max_posts_per_page.to_string();
|
|
state.blogmark_category = meta.blogmark_category.unwrap_or_default();
|
|
state.blog_languages = if meta.blog_languages.is_empty() {
|
|
vec![state.main_language.clone()]
|
|
} else {
|
|
meta.blog_languages
|
|
};
|
|
if !state.blog_languages.iter().any(|language| language == &state.main_language) {
|
|
state.blog_languages.push(state.main_language.clone());
|
|
}
|
|
state.semantic_similarity_enabled = meta.semantic_similarity_enabled;
|
|
}
|
|
let categories = engine::meta::read_categories_json(data_dir).unwrap_or_else(|_| {
|
|
default_category_rows().into_iter().map(|row| row.name).collect()
|
|
});
|
|
let category_meta = engine::meta::read_category_meta_json(data_dir).unwrap_or_default();
|
|
state.categories = categories
|
|
.into_iter()
|
|
.map(|name| {
|
|
let meta = category_meta.get(&name);
|
|
SettingsCategoryRow {
|
|
title: name.clone(),
|
|
render_in_lists: meta.map(|value| value.render_in_lists).unwrap_or(true),
|
|
show_title: meta.map(|value| value.show_title).unwrap_or(true),
|
|
post_template_slug: meta
|
|
.and_then(|value| value.post_template_slug.clone())
|
|
.unwrap_or_default(),
|
|
list_template_slug: meta
|
|
.and_then(|value| value.list_template_slug.clone())
|
|
.unwrap_or_default(),
|
|
is_protected: ["article", "aside", "page", "picture"].contains(&name.as_str()),
|
|
name,
|
|
}
|
|
})
|
|
.collect();
|
|
if let Ok(pub_prefs) = engine::meta::read_publishing_json(data_dir) {
|
|
state.ssh_host = pub_prefs.ssh_host.unwrap_or_default();
|
|
state.ssh_username = pub_prefs.ssh_user.unwrap_or_default();
|
|
state.ssh_remote_path = pub_prefs.ssh_remote_path.unwrap_or_default();
|
|
state.ssh_mode = match pub_prefs.ssh_mode {
|
|
SshMode::Scp => "scp".to_string(),
|
|
SshMode::Rsync => "rsync".to_string(),
|
|
};
|
|
}
|
|
}
|
|
if let Some(db) = &self.db {
|
|
if let Some(project) = &self.active_project {
|
|
state.template_options = bds_core::db::queries::template::list_templates_by_project(db.conn(), &project.id)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|template| template.slug)
|
|
.collect();
|
|
}
|
|
if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "editor.default_mode") {
|
|
state.default_mode = setting.value;
|
|
}
|
|
if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "editor.diff_view_style") {
|
|
state.diff_view_style = setting.value;
|
|
}
|
|
if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "editor.wrap_long_lines") {
|
|
state.wrap_long_lines = setting.value == "true";
|
|
}
|
|
if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "editor.hide_unchanged_regions") {
|
|
state.hide_unchanged_regions = setting.value == "true";
|
|
}
|
|
if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "ai.system_prompt") {
|
|
state.system_prompt = iced::widget::text_editor::Content::with_text(&setting.value);
|
|
}
|
|
if let Ok(ai_settings) = ai::load_ai_settings(db.conn(), self.offline_mode) {
|
|
state.online_endpoint_url = ai_settings.online_endpoint.url;
|
|
state.online_endpoint_model = ai_settings.online_endpoint.model;
|
|
state.online_api_key_configured = ai_settings.online_endpoint.api_key_configured;
|
|
if !state.online_endpoint_model.is_empty() {
|
|
state.online_model_options = vec![AiModelOption {
|
|
id: state.online_endpoint_model.clone(),
|
|
label: state.online_endpoint_model.clone(),
|
|
supports_vision: false,
|
|
}];
|
|
}
|
|
state.airplane_endpoint_url = ai_settings.airplane_endpoint.url;
|
|
state.airplane_endpoint_model = ai_settings.airplane_endpoint.model;
|
|
if !state.airplane_endpoint_model.is_empty() {
|
|
state.airplane_model_options = vec![AiModelOption {
|
|
id: state.airplane_endpoint_model.clone(),
|
|
label: state.airplane_endpoint_model.clone(),
|
|
supports_vision: false,
|
|
}];
|
|
}
|
|
state.default_model = ai_settings.default_model.unwrap_or_default();
|
|
state.title_model = ai_settings.title_model.unwrap_or_default();
|
|
state.image_model = ai_settings.image_model.unwrap_or_default();
|
|
}
|
|
}
|
|
state.offline_mode = self.offline_mode;
|
|
state
|
|
}
|
|
|
|
fn handle_tags_msg(&mut self, msg: TagsMsg) -> Task<Message> {
|
|
// Ensure tags view state exists
|
|
if self.tags_view_state.is_none() {
|
|
self.reload_tags_state();
|
|
if self.tags_view_state.is_none() {
|
|
self.tags_view_state = Some(TagsViewState::new(Vec::new(), HashMap::new(), Vec::new()));
|
|
}
|
|
}
|
|
let state = self.tags_view_state.as_mut().unwrap();
|
|
match msg {
|
|
TagsMsg::SetSection(s) => { state.section = s; }
|
|
TagsMsg::SearchChanged(q) => { state.search_query = q; }
|
|
TagsMsg::ToggleTagSelection(id) => {
|
|
if let Some(pos) = state.selected_tags.iter().position(|selected_id| selected_id == &id) {
|
|
state.selected_tags.remove(pos);
|
|
} else {
|
|
state.selected_tags.push(id.clone());
|
|
}
|
|
|
|
if state.selected_tags.len() == 1 {
|
|
let selected_id = state.selected_tags[0].clone();
|
|
if let Some(tag) = state.tags.iter().find(|tag| tag.id == selected_id) {
|
|
state.editing_tag = Some(tags_view::EditingTag {
|
|
id: tag.id.clone(),
|
|
original_name: tag.name.clone(),
|
|
name: tag.name.clone(),
|
|
color: tag.color.clone().unwrap_or_default(),
|
|
template_slug: tag.post_template_slug.clone().unwrap_or_default(),
|
|
});
|
|
}
|
|
state.section = TagsSection::Manage;
|
|
} else {
|
|
state.editing_tag = None;
|
|
}
|
|
|
|
if let Some(target_id) = state.merge_target.as_ref() {
|
|
if !state.selected_tags.iter().any(|selected_id| selected_id == target_id) {
|
|
state.merge_target = None;
|
|
}
|
|
}
|
|
}
|
|
TagsMsg::ClearSelection => {
|
|
state.selected_tags.clear();
|
|
state.merge_target = None;
|
|
state.editing_tag = None;
|
|
}
|
|
TagsMsg::CreateNameChanged(name) => { state.create_name = name; }
|
|
TagsMsg::CreateColorChanged(color) => { state.create_color = color; }
|
|
TagsMsg::CreateTag => {
|
|
let mut created_editing = None;
|
|
if let (Some(db), Some(data_dir), Some(project)) = (&self.db, &self.data_dir, &self.active_project) {
|
|
match engine::tag::create_tag(
|
|
db.conn(),
|
|
data_dir,
|
|
&project.id,
|
|
&state.create_name,
|
|
if state.create_color.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(state.create_color.trim())
|
|
},
|
|
) {
|
|
Ok(tag) => {
|
|
created_editing = Some(tags_view::EditingTag {
|
|
id: tag.id,
|
|
original_name: tag.name.clone(),
|
|
name: tag.name,
|
|
color: tag.color.unwrap_or_default(),
|
|
template_slug: tag.post_template_slug.unwrap_or_default(),
|
|
});
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")),
|
|
}
|
|
}
|
|
if let Some(editing_tag) = created_editing {
|
|
self.reload_tags_state();
|
|
if let Some(state) = self.tags_view_state.as_mut() {
|
|
state.selected_tags = vec![editing_tag.id.clone()];
|
|
state.editing_tag = Some(editing_tag);
|
|
state.create_name.clear();
|
|
state.create_color.clear();
|
|
state.section = TagsSection::Manage;
|
|
}
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
}
|
|
TagsMsg::EditTagName(s) => { if let Some(ref mut e) = state.editing_tag { e.name = s; } }
|
|
TagsMsg::EditTagColor(s) => { if let Some(ref mut e) = state.editing_tag { e.color = s; } }
|
|
TagsMsg::EditTagTemplate(option) => {
|
|
if let Some(ref mut e) = state.editing_tag {
|
|
e.template_slug = option.slug;
|
|
}
|
|
}
|
|
TagsMsg::SaveTag => {
|
|
if let Some(editing) = state.editing_tag.clone() {
|
|
if let (Some(db), Some(data_dir)) = (&self.db, &self.data_dir) {
|
|
let rename_result = if editing.name != editing.original_name {
|
|
engine::tag::rename_tag(
|
|
db.conn(),
|
|
data_dir,
|
|
self.active_project.as_ref().map(|project| project.id.as_str()).unwrap_or_default(),
|
|
&editing.id,
|
|
&editing.name,
|
|
)
|
|
} else {
|
|
Ok(())
|
|
};
|
|
match rename_result.and_then(|_| {
|
|
engine::tag::update_tag(
|
|
db.conn(),
|
|
data_dir,
|
|
&editing.id,
|
|
None,
|
|
Some(&editing.color),
|
|
Some(&editing.template_slug),
|
|
)
|
|
}) {
|
|
Ok(()) => {
|
|
self.reload_tags_state();
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
TagsMsg::DeleteTag(id) => {
|
|
let name = state.tags.iter().find(|t| t.id == id).map(|t| t.name.clone()).unwrap_or_default();
|
|
let count = state.tags.iter()
|
|
.find(|t| t.id == id)
|
|
.and_then(|tag| state.tag_post_counts.get(&tag.name.to_lowercase()).copied())
|
|
.unwrap_or(0);
|
|
return Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete {
|
|
entity_name: name,
|
|
references: vec![tw(self.ui_locale, "tags.deleteUsage", &[("count", &count.to_string())])],
|
|
on_confirm: modal::ConfirmAction::DeleteTag(id),
|
|
}));
|
|
}
|
|
TagsMsg::SetMergeTarget(option) => { state.merge_target = Some(option.id); }
|
|
TagsMsg::MergeTags => {
|
|
if let Some(target) = &state.merge_target {
|
|
let target_name = state.tags.iter().find(|tag| &tag.id == target).map(|tag| tag.name.clone()).unwrap_or_default();
|
|
let sources = state.selected_tags.iter().filter(|tag_id| *tag_id != target).cloned().collect::<Vec<_>>();
|
|
return Task::done(Message::ShowModal(modal::ModalState::Confirm {
|
|
title: t(self.ui_locale, "tags.mergeConfirmTitle"),
|
|
message: tw(
|
|
self.ui_locale,
|
|
"tags.mergeConfirmMessage",
|
|
&[
|
|
("count", &sources.len().to_string()),
|
|
("target", &target_name),
|
|
],
|
|
),
|
|
on_confirm: modal::ConfirmAction::MergeTags {
|
|
sources,
|
|
target: target.clone(),
|
|
},
|
|
}));
|
|
}
|
|
}
|
|
TagsMsg::SyncTags => {
|
|
if let (Some(db), Some(data_dir), Some(project)) = (&self.db, &self.data_dir, &self.active_project) {
|
|
match engine::tag::discover_tags(db.conn(), data_dir, &project.id) {
|
|
Ok(_) => {
|
|
self.reload_tags_state();
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &format!("Discover failed: {e}")),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn handle_settings_msg(&mut self, msg: SettingsMsg) -> Task<Message> {
|
|
// Ensure settings state exists
|
|
if self.settings_state.is_none() {
|
|
self.settings_state = Some(self.hydrate_settings_state());
|
|
}
|
|
let state = self.settings_state.as_mut().unwrap();
|
|
match msg {
|
|
SettingsMsg::SearchChanged(q) => { state.search_query = q; }
|
|
SettingsMsg::ToggleSection(section) => {
|
|
if let Some(pos) = state.collapsed.iter().position(|s| *s == section) {
|
|
state.collapsed.remove(pos);
|
|
} else {
|
|
state.collapsed.push(section);
|
|
}
|
|
}
|
|
SettingsMsg::ProjectNameChanged(s) => { state.project_name = s; }
|
|
SettingsMsg::ProjectDescriptionAction(action) => {
|
|
state.project_description.perform(action);
|
|
}
|
|
SettingsMsg::DataPathChanged(s) => { state.data_path = s; }
|
|
SettingsMsg::BrowseDataPath => {
|
|
return crate::platform::dialog::pick_folder(t(self.ui_locale, "dialog.selectFolder"));
|
|
}
|
|
SettingsMsg::ResetDataPath => {
|
|
if let Some(ref project) = self.active_project {
|
|
state.data_path = project.data_path.clone().unwrap_or_default();
|
|
}
|
|
}
|
|
SettingsMsg::PublicUrlChanged(s) => { state.public_url = s; }
|
|
SettingsMsg::MainLanguageChanged(s) => {
|
|
state.main_language = s.clone();
|
|
if !state.blog_languages.iter().any(|language| language == &s) {
|
|
state.blog_languages.push(s);
|
|
}
|
|
}
|
|
SettingsMsg::ToggleBlogLanguage(language) => {
|
|
if language == state.main_language {
|
|
if !state.blog_languages.iter().any(|item| item == &language) {
|
|
state.blog_languages.push(language);
|
|
}
|
|
} else if let Some(index) = state.blog_languages.iter().position(|item| item == &language) {
|
|
state.blog_languages.remove(index);
|
|
} else {
|
|
state.blog_languages.push(language);
|
|
}
|
|
state.blog_languages.sort();
|
|
state.blog_languages.dedup();
|
|
}
|
|
SettingsMsg::DefaultAuthorChanged(s) => { state.default_author = s; }
|
|
SettingsMsg::MaxPostsPerPageChanged(s) => { state.max_posts_per_page = s; }
|
|
SettingsMsg::BlogmarkCategoryChanged(s) => { state.blogmark_category = s; }
|
|
SettingsMsg::SaveProject => {
|
|
if let (Some(db), Some(data_dir), Some(project)) = (&self.db, &self.data_dir, self.active_project.as_mut()) {
|
|
let max_posts = match state.max_posts_per_page.trim().parse::<i32>() {
|
|
Ok(value) => value,
|
|
Err(_) => {
|
|
self.notify(ToastLevel::Error, "Invalid max posts per page");
|
|
return Task::none();
|
|
}
|
|
};
|
|
let mut meta = engine::meta::read_project_json(data_dir).unwrap_or(bds_core::model::metadata::ProjectMetadata {
|
|
name: state.project_name.clone(),
|
|
description: None,
|
|
public_url: None,
|
|
main_language: None,
|
|
default_author: None,
|
|
max_posts_per_page: 50,
|
|
blogmark_category: None,
|
|
pico_theme: None,
|
|
semantic_similarity_enabled: false,
|
|
blog_languages: Vec::new(),
|
|
});
|
|
meta.name = state.project_name.clone();
|
|
meta.description = {
|
|
let value = state.project_description.text();
|
|
if value.trim().is_empty() { None } else { Some(value) }
|
|
};
|
|
meta.public_url = if state.public_url.trim().is_empty() { None } else { Some(state.public_url.clone()) };
|
|
meta.main_language = if state.main_language.trim().is_empty() { None } else { Some(state.main_language.clone()) };
|
|
meta.default_author = if state.default_author.trim().is_empty() { None } else { Some(state.default_author.clone()) };
|
|
meta.max_posts_per_page = max_posts;
|
|
meta.blogmark_category = if state.blogmark_category.trim().is_empty() { None } else { Some(state.blogmark_category.clone()) };
|
|
meta.blog_languages = state.blog_languages.clone();
|
|
meta.semantic_similarity_enabled = state.semantic_similarity_enabled;
|
|
if let Err(e) = meta.validate() {
|
|
self.notify(ToastLevel::Error, &format!("Save failed: {e}"));
|
|
return Task::none();
|
|
}
|
|
project.name = state.project_name.clone();
|
|
project.description = meta.description.clone();
|
|
project.data_path = if state.data_path.trim().is_empty() { None } else { Some(state.data_path.clone()) };
|
|
project.updated_at = bds_core::util::now_unix_ms();
|
|
let db_result = bds_core::db::queries::project::update_project(db.conn(), project);
|
|
let file_result = engine::meta::write_project_json(data_dir, &meta);
|
|
match (db_result, file_result) {
|
|
(Ok(()), Ok(())) => {
|
|
if let Some(listing) = self.projects.iter_mut().find(|p| p.id == project.id) {
|
|
*listing = project.clone();
|
|
}
|
|
self.content_language = state.main_language.clone();
|
|
self.blog_languages = state.blog_languages.clone();
|
|
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
(Err(e), _) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")),
|
|
(_, Err(e)) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")),
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::DefaultModeChanged(s) => { state.default_mode = s; }
|
|
SettingsMsg::DiffViewStyleChanged(s) => { state.diff_view_style = s; }
|
|
SettingsMsg::WrapLongLinesChanged(b) => { state.wrap_long_lines = b; }
|
|
SettingsMsg::HideUnchangedRegionsChanged(b) => { state.hide_unchanged_regions = b; }
|
|
SettingsMsg::SaveEditor => {
|
|
if let Some(db) = &self.db {
|
|
match save_editor_settings_state_impl(db, state) {
|
|
Ok(_) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")),
|
|
Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")),
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::AddCategoryNameChanged(value) => { state.new_category_name = value; }
|
|
SettingsMsg::AddCategory => {
|
|
let category_name = state.new_category_name.trim();
|
|
if category_name.is_empty() {
|
|
self.notify(ToastLevel::Error, "Category name required");
|
|
return Task::none();
|
|
}
|
|
if state.categories.iter().any(|row| row.name.eq_ignore_ascii_case(category_name)) {
|
|
self.notify(ToastLevel::Error, "Category already exists");
|
|
return Task::none();
|
|
}
|
|
if let Some(data_dir) = &self.data_dir {
|
|
match engine::meta::add_category(data_dir, category_name) {
|
|
Ok(()) => {
|
|
self.settings_state = Some(self.hydrate_settings_state());
|
|
if let Some(state) = self.settings_state.as_mut() {
|
|
state.new_category_name.clear();
|
|
}
|
|
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")),
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::CategoryTitleChanged(name, value) => {
|
|
if let Some(row) = state.categories.iter_mut().find(|row| row.name == name) {
|
|
row.title = value;
|
|
}
|
|
}
|
|
SettingsMsg::CategoryRenderInListsChanged(name, value) => {
|
|
if let Some(row) = state.categories.iter_mut().find(|row| row.name == name) {
|
|
row.render_in_lists = value;
|
|
}
|
|
}
|
|
SettingsMsg::CategoryShowTitleChanged(name, value) => {
|
|
if let Some(row) = state.categories.iter_mut().find(|row| row.name == name) {
|
|
row.show_title = value;
|
|
}
|
|
}
|
|
SettingsMsg::CategoryPostTemplateChanged(name, value) => {
|
|
if let Some(row) = state.categories.iter_mut().find(|row| row.name == name) {
|
|
row.post_template_slug = value;
|
|
}
|
|
}
|
|
SettingsMsg::CategoryListTemplateChanged(name, value) => {
|
|
if let Some(row) = state.categories.iter_mut().find(|row| row.name == name) {
|
|
row.list_template_slug = value;
|
|
}
|
|
}
|
|
SettingsMsg::SaveCategory(name) => {
|
|
if let Some(data_dir) = &self.data_dir {
|
|
if let Some(row) = state.categories.iter().find(|row| row.name == name) {
|
|
let mut category_meta = engine::meta::read_category_meta_json(data_dir).unwrap_or_default();
|
|
category_meta.insert(
|
|
row.name.clone(),
|
|
bds_core::model::metadata::CategorySettings {
|
|
render_in_lists: row.render_in_lists,
|
|
show_title: row.show_title,
|
|
post_template_slug: (!row.post_template_slug.is_empty()).then(|| row.post_template_slug.clone()),
|
|
list_template_slug: (!row.list_template_slug.is_empty()).then(|| row.list_template_slug.clone()),
|
|
},
|
|
);
|
|
match engine::meta::write_category_meta_json(data_dir, &category_meta) {
|
|
Ok(()) => {
|
|
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::RemoveCategory(name) => {
|
|
if let Some(data_dir) = &self.data_dir {
|
|
match engine::meta::remove_category(data_dir, &name) {
|
|
Ok(()) => {
|
|
self.settings_state = Some(self.hydrate_settings_state());
|
|
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")),
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::ResetCategoriesToDefaults => {
|
|
if let Some(data_dir) = &self.data_dir {
|
|
let default_names = default_category_rows()
|
|
.into_iter()
|
|
.map(|row| row.name)
|
|
.collect::<Vec<_>>();
|
|
let default_meta = default_category_rows()
|
|
.into_iter()
|
|
.map(|row| {
|
|
(
|
|
row.name,
|
|
bds_core::model::metadata::CategorySettings {
|
|
render_in_lists: row.render_in_lists,
|
|
show_title: row.show_title,
|
|
post_template_slug: None,
|
|
list_template_slug: None,
|
|
},
|
|
)
|
|
})
|
|
.collect::<HashMap<_, _>>();
|
|
match (
|
|
engine::meta::write_categories_json(data_dir, &default_names),
|
|
engine::meta::write_category_meta_json(data_dir, &default_meta),
|
|
) {
|
|
(Ok(()), Ok(())) => {
|
|
self.settings_state = Some(self.hydrate_settings_state());
|
|
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
(Err(e), _) | (_, Err(e)) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")),
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::SshModeChanged(s) => { state.ssh_mode = s; }
|
|
SettingsMsg::SshHostChanged(s) => { state.ssh_host = s; }
|
|
SettingsMsg::SshUsernameChanged(s) => { state.ssh_username = s; }
|
|
SettingsMsg::SshRemotePathChanged(s) => { state.ssh_remote_path = s; }
|
|
SettingsMsg::SavePublishing => {
|
|
if let Some(data_dir) = &self.data_dir {
|
|
let prefs = PublishingPreferences {
|
|
ssh_host: if state.ssh_host.trim().is_empty() { None } else { Some(state.ssh_host.clone()) },
|
|
ssh_user: if state.ssh_username.trim().is_empty() { None } else { Some(state.ssh_username.clone()) },
|
|
ssh_remote_path: if state.ssh_remote_path.trim().is_empty() { None } else { Some(state.ssh_remote_path.clone()) },
|
|
ssh_mode: if state.ssh_mode.eq_ignore_ascii_case("scp") { SshMode::Scp } else { SshMode::Rsync },
|
|
};
|
|
match engine::meta::write_publishing_json(data_dir, &prefs) {
|
|
Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")),
|
|
Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")),
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::ClearPublishing => {
|
|
state.ssh_host.clear();
|
|
state.ssh_username.clear();
|
|
state.ssh_remote_path.clear();
|
|
}
|
|
SettingsMsg::OfflineModeChanged(b) => {
|
|
state.offline_mode = b;
|
|
return Task::done(Message::SetOfflineMode(b));
|
|
}
|
|
SettingsMsg::OnlineEndpointUrlChanged(value) => { state.online_endpoint_url = value; }
|
|
SettingsMsg::OnlineEndpointModelChanged(value) => { state.online_endpoint_model = value; }
|
|
SettingsMsg::OnlineApiKeyChanged(value) => { state.online_api_key_input = value; }
|
|
SettingsMsg::AirplaneEndpointUrlChanged(value) => { state.airplane_endpoint_url = value; }
|
|
SettingsMsg::AirplaneEndpointModelChanged(value) => { state.airplane_endpoint_model = value; }
|
|
SettingsMsg::DefaultModelChanged(value) => { state.default_model = value; }
|
|
SettingsMsg::TitleModelChanged(value) => { state.title_model = value; }
|
|
SettingsMsg::ImageModelChanged(value) => { state.image_model = value; }
|
|
SettingsMsg::RefreshOnlineModels => {
|
|
if let Some(db) = &self.db {
|
|
match Self::refresh_ai_models(
|
|
db,
|
|
state,
|
|
AiEndpointKind::Online,
|
|
) {
|
|
Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")),
|
|
Err(error) => self.notify(ToastLevel::Error, &format!("Save failed: {error}")),
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::RefreshAirplaneModels => {
|
|
if let Some(db) = &self.db {
|
|
match Self::refresh_ai_models(
|
|
db,
|
|
state,
|
|
AiEndpointKind::Airplane,
|
|
) {
|
|
Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")),
|
|
Err(error) => self.notify(ToastLevel::Error, &format!("Save failed: {error}")),
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::SystemPromptAction(action) => {
|
|
state.system_prompt.perform(action);
|
|
}
|
|
SettingsMsg::SaveAi => {
|
|
if let Some(db) = &self.db {
|
|
match Self::save_ai_settings_state(db, state) {
|
|
Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")),
|
|
Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")),
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::ResetSystemPrompt => {
|
|
state.system_prompt = iced::widget::text_editor::Content::new();
|
|
}
|
|
SettingsMsg::SemanticSimilarityChanged(value) => {
|
|
state.semantic_similarity_enabled = value;
|
|
}
|
|
SettingsMsg::RebuildPosts => {
|
|
return self.spawn_engine_task(
|
|
"settings.rebuildPosts",
|
|
|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 => {
|
|
return self.spawn_engine_task(
|
|
"settings.rebuildLinks",
|
|
|db_path, project_id, data_dir, _tm, _tid| {
|
|
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
|
let rebuilt = bds_core::engine::post::rebuild_all_links(
|
|
db.conn(),
|
|
&data_dir,
|
|
&project_id,
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(format!("rebuilt={rebuilt}"))
|
|
},
|
|
);
|
|
}
|
|
SettingsMsg::RegenerateThumbnails => {
|
|
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 => {
|
|
if let Some(ref dir) = self.data_dir {
|
|
let _ = open::that(dir);
|
|
}
|
|
}
|
|
SettingsMsg::FocusSection(section) => {
|
|
state.focus_section(section);
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
/// Load outlinks and backlinks for a post, resolving target post titles.
|
|
fn load_post_links(&self, post_id: &str) -> (Vec<ResolvedPostLink>, Vec<ResolvedPostLink>) {
|
|
let Some(ref db) = self.db else {
|
|
return (Vec::new(), Vec::new());
|
|
};
|
|
let outlinks = bds_core::db::queries::post_link::list_links_by_source(db.conn(), post_id)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter_map(|link| {
|
|
bds_core::db::queries::post::get_post_by_id(db.conn(), &link.target_post_id)
|
|
.ok()
|
|
.map(|p| ResolvedPostLink {
|
|
post_id: p.id,
|
|
title: p.title,
|
|
})
|
|
})
|
|
.collect();
|
|
let backlinks = bds_core::db::queries::post_link::list_links_by_target(db.conn(), post_id)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter_map(|link| {
|
|
bds_core::db::queries::post::get_post_by_id(db.conn(), &link.source_post_id)
|
|
.ok()
|
|
.map(|p| ResolvedPostLink {
|
|
post_id: p.id,
|
|
title: p.title,
|
|
})
|
|
})
|
|
.collect();
|
|
(outlinks, backlinks)
|
|
}
|
|
|
|
fn load_post_media_items(&self, post_id: &str, content: Option<&str>) -> Vec<LinkedMediaItem> {
|
|
let Some(ref db) = self.db else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let mut media_by_id = bds_core::db::queries::post_media::list_post_media_by_post(db.conn(), post_id)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter_map(|link| {
|
|
bds_core::db::queries::media::get_media_by_id(db.conn(), &link.media_id)
|
|
.ok()
|
|
.map(|media| LinkedMediaItem {
|
|
media_id: media.id,
|
|
name: media.title.unwrap_or(media.original_name),
|
|
file_path: media.file_path,
|
|
is_image: media.mime_type.starts_with("image/"),
|
|
sort_order: link.sort_order,
|
|
})
|
|
})
|
|
.map(|media| (media.media_id.clone(), media))
|
|
.collect::<HashMap<_, _>>();
|
|
|
|
for media_id in referenced_media_ids(content.unwrap_or_default()) {
|
|
if media_by_id.contains_key(&media_id) {
|
|
continue;
|
|
}
|
|
|
|
if let Ok(media) = bds_core::db::queries::media::get_media_by_id(db.conn(), &media_id) {
|
|
media_by_id.insert(
|
|
media_id,
|
|
LinkedMediaItem {
|
|
media_id: media.id,
|
|
name: media.title.unwrap_or(media.original_name),
|
|
file_path: media.file_path,
|
|
is_image: media.mime_type.starts_with("image/"),
|
|
sort_order: i32::MAX,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
let mut items = media_by_id.into_values().collect::<Vec<_>>();
|
|
items.sort_by_key(|media| media.sort_order);
|
|
items
|
|
}
|
|
|
|
fn refresh_post_relationships(&mut self, post_id: &str) {
|
|
let (outlinks, backlinks) = self.load_post_links(post_id);
|
|
let current_content = self
|
|
.post_editors
|
|
.get(post_id)
|
|
.map(|state| state.content.clone());
|
|
let linked_media = self.load_post_media_items(post_id, current_content.as_deref());
|
|
if let Some(state) = self.post_editors.get_mut(post_id) {
|
|
state.outlinks = outlinks;
|
|
state.backlinks = backlinks;
|
|
state.linked_media = linked_media;
|
|
}
|
|
}
|
|
|
|
/// Load editor state when a tab is opened for an entity.
|
|
fn load_editor_for_tab(&mut self, tab: &Tab) {
|
|
let Some(ref db) = self.db else { return };
|
|
match tab.tab_type {
|
|
TabType::Post => {
|
|
if !self.post_editors.contains_key(&tab.id) {
|
|
match bds_core::db::queries::post::get_post_by_id(db.conn(), &tab.id) {
|
|
Ok(mut post) => {
|
|
// Published posts don't store body in DB — read from file
|
|
if post.content.is_none() {
|
|
if let Some(ref data_dir) = self.data_dir {
|
|
let rel = bds_core::util::paths::post_file_path(
|
|
post.created_at,
|
|
&post.slug,
|
|
);
|
|
let path = data_dir.join(&rel);
|
|
if let Ok(raw) = std::fs::read_to_string(&path) {
|
|
if let Ok((_fm, body)) =
|
|
bds_core::util::frontmatter::read_post_file(&raw)
|
|
{
|
|
post.content = Some(body);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Load translations for translation flags bar
|
|
let mut translations = bds_core::db::queries::post_translation::list_post_translations_by_post(
|
|
db.conn(), &post.id,
|
|
).unwrap_or_default();
|
|
// Published translations don't store body in DB — read from file
|
|
if let Some(ref data_dir) = self.data_dir {
|
|
for tr in &mut translations {
|
|
if tr.content.is_none() {
|
|
let rel = bds_core::util::paths::translation_file_path(
|
|
post.created_at,
|
|
&post.slug,
|
|
&tr.language,
|
|
);
|
|
let path = data_dir.join(&rel);
|
|
if let Ok(raw) = std::fs::read_to_string(&path) {
|
|
if let Ok((_fm, body)) =
|
|
bds_core::util::frontmatter::read_translation_file(&raw)
|
|
{
|
|
tr.content = Some(body);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let (outlinks, backlinks) = self.load_post_links(&post.id);
|
|
let linked_media = self.load_post_media_items(&post.id, post.content.as_deref());
|
|
self.post_editors.insert(
|
|
post.id.clone(),
|
|
PostEditorState::from_post(
|
|
&post,
|
|
default_post_editor_mode(self.settings_state.as_ref()),
|
|
&self.blog_languages,
|
|
&translations,
|
|
outlinks,
|
|
backlinks,
|
|
linked_media,
|
|
),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
self.notify(ToastLevel::Error, &format!("Failed to load post: {e}"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
TabType::Media => {
|
|
if !self.media_editors.contains_key(&tab.id) {
|
|
match bds_core::db::queries::media::get_media_by_id(db.conn(), &tab.id) {
|
|
Ok(media) => {
|
|
let translations = bds_core::db::queries::media_translation::list_media_translations_by_media(
|
|
db.conn(), &media.id,
|
|
).unwrap_or_default();
|
|
let linked_posts = self.load_media_linked_posts(&media.id);
|
|
self.media_editors.insert(
|
|
media.id.clone(),
|
|
MediaEditorState::from_media(&media, &self.blog_languages, &translations, linked_posts),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
self.notify(ToastLevel::Error, &format!("Failed to load media: {e}"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
TabType::Templates => {
|
|
if !self.template_editors.contains_key(&tab.id) {
|
|
match bds_core::db::queries::template::get_template_by_id(db.conn(), &tab.id) {
|
|
Ok(mut template) => {
|
|
// Published templates: read content from file, strip frontmatter
|
|
if template.content.is_none() {
|
|
if let Some(ref data_dir) = self.data_dir {
|
|
let rel = bds_core::util::paths::template_file_path(&template.slug);
|
|
let path = data_dir.join(&rel);
|
|
if let Ok(raw) = std::fs::read_to_string(&path) {
|
|
if let Ok((_fm, body)) =
|
|
bds_core::util::frontmatter::read_template_file(&raw)
|
|
{
|
|
template.content = Some(body);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.template_editors.insert(template.id.clone(), TemplateEditorState::from_template(&template));
|
|
}
|
|
Err(e) => {
|
|
self.notify(ToastLevel::Error, &format!("Failed to load template: {e}"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
TabType::Scripts => {
|
|
if !self.script_editors.contains_key(&tab.id) {
|
|
match bds_core::db::queries::script::get_script_by_id(db.conn(), &tab.id) {
|
|
Ok(mut script) => {
|
|
// Published scripts: read content from file using actual file_path
|
|
if script.content.is_none() {
|
|
if let Some(ref data_dir) = self.data_dir {
|
|
let path = data_dir.join(&script.file_path);
|
|
if let Ok(raw) = std::fs::read_to_string(&path) {
|
|
if let Ok((_fm, body)) =
|
|
bds_core::util::frontmatter::read_script_file(&raw)
|
|
{
|
|
script.content = Some(body);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.script_editors.insert(script.id.clone(), ScriptEditorState::from_script(&script));
|
|
}
|
|
Err(e) => {
|
|
self.notify(ToastLevel::Error, &format!("Failed to load script: {e}"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
TabType::Tags => {
|
|
if self.tags_view_state.is_none() {
|
|
let project_id = self.active_project.as_ref().map(|p| p.id.as_str()).unwrap_or("");
|
|
// Import tags from file first, then sync from posts (additive only)
|
|
if let Some(ref data_dir) = self.data_dir {
|
|
let _ = bds_core::engine::tag::import_tags_from_file(
|
|
db.conn(), data_dir, project_id,
|
|
);
|
|
let _ = bds_core::engine::tag::sync_tags_from_posts(
|
|
db.conn(), project_id,
|
|
);
|
|
}
|
|
let tags = bds_core::db::queries::tag::list_tags_by_project(
|
|
db.conn(),
|
|
project_id,
|
|
).unwrap_or_default();
|
|
let template_options = bds_core::db::queries::template::list_templates_by_project(
|
|
db.conn(),
|
|
project_id,
|
|
)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|template| template.slug)
|
|
.collect::<Vec<_>>();
|
|
// Compute post counts per tag for cloud sizing
|
|
let posts = bds_core::db::queries::post::list_posts_by_project(
|
|
db.conn(), project_id,
|
|
).unwrap_or_default();
|
|
let mut tag_post_counts = std::collections::HashMap::new();
|
|
for post in &posts {
|
|
for tag_name in &post.tags {
|
|
*tag_post_counts.entry(tag_name.to_lowercase()).or_insert(0usize) += 1;
|
|
}
|
|
}
|
|
self.tags_view_state = Some(TagsViewState::new(tags, tag_post_counts, template_options));
|
|
}
|
|
}
|
|
TabType::Settings => {
|
|
if self.settings_state.is_none() {
|
|
let mut state = SettingsViewState::default();
|
|
if let Some(ref project) = self.active_project {
|
|
state.project_name = project.name.clone();
|
|
state.project_description = iced::widget::text_editor::Content::with_text(
|
|
&project.description.clone().unwrap_or_default(),
|
|
);
|
|
state.data_path = project.data_path.clone().unwrap_or_default();
|
|
}
|
|
if let Some(ref data_dir) = self.data_dir {
|
|
if let Ok(meta) = engine::meta::read_project_json(data_dir) {
|
|
state.public_url = meta.public_url.unwrap_or_default();
|
|
state.default_author = meta.default_author.unwrap_or_default();
|
|
state.max_posts_per_page = meta.max_posts_per_page.to_string();
|
|
}
|
|
if let Ok(pub_prefs) = engine::meta::read_publishing_json(data_dir) {
|
|
state.ssh_host = pub_prefs.ssh_host.unwrap_or_default();
|
|
state.ssh_username = pub_prefs.ssh_user.unwrap_or_default();
|
|
state.ssh_remote_path = pub_prefs.ssh_remote_path.unwrap_or_default();
|
|
state.ssh_mode = format!("{:?}", pub_prefs.ssh_mode).to_lowercase();
|
|
}
|
|
}
|
|
state.offline_mode = self.offline_mode;
|
|
self.settings_state = Some(state);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn refresh_ai_models(
|
|
db: &Database,
|
|
state: &mut SettingsViewState,
|
|
kind: AiEndpointKind,
|
|
) -> Result<(), String> {
|
|
let endpoint = Self::compose_ai_endpoint(state, kind)?;
|
|
let models = ai::refresh_model_catalog(&endpoint).map_err(|error| error.to_string())?;
|
|
let options = models
|
|
.into_iter()
|
|
.map(|model| AiModelOption {
|
|
id: model.id,
|
|
label: model.name,
|
|
supports_vision: model.supports_vision,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
match kind {
|
|
AiEndpointKind::Online => state.online_model_options = options,
|
|
AiEndpointKind::Airplane => state.airplane_model_options = options,
|
|
}
|
|
let _ = db;
|
|
Ok(())
|
|
}
|
|
|
|
fn save_ai_settings_state(
|
|
db: &Database,
|
|
state: &mut SettingsViewState,
|
|
) -> Result<(), String> {
|
|
if Self::endpoint_has_configuration(state, AiEndpointKind::Online) {
|
|
let online_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Online)?;
|
|
ai::test_endpoint(&online_endpoint).map_err(|error| error.to_string())?;
|
|
ai::save_endpoint(db.conn(), &online_endpoint).map_err(|error| error.to_string())?;
|
|
state.online_api_key_input.clear();
|
|
state.online_api_key_configured = true;
|
|
}
|
|
if Self::endpoint_has_configuration(state, AiEndpointKind::Airplane) {
|
|
let airplane_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Airplane)?;
|
|
ai::test_endpoint(&airplane_endpoint).map_err(|error| error.to_string())?;
|
|
ai::save_endpoint(db.conn(), &airplane_endpoint).map_err(|error| error.to_string())?;
|
|
}
|
|
ai::save_model_preferences(
|
|
db.conn(),
|
|
(!state.default_model.trim().is_empty()).then_some(state.default_model.as_str()),
|
|
(!state.title_model.trim().is_empty()).then_some(state.title_model.as_str()),
|
|
(!state.image_model.trim().is_empty()).then_some(state.image_model.as_str()),
|
|
&state.system_prompt.text(),
|
|
)
|
|
.map_err(|error| error.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
fn endpoint_has_configuration(
|
|
state: &SettingsViewState,
|
|
kind: AiEndpointKind,
|
|
) -> bool {
|
|
match kind {
|
|
AiEndpointKind::Online => {
|
|
!state.online_endpoint_url.trim().is_empty()
|
|
|| !state.online_endpoint_model.trim().is_empty()
|
|
|| !state.online_api_key_input.trim().is_empty()
|
|
|| state.online_api_key_configured
|
|
}
|
|
AiEndpointKind::Airplane => {
|
|
!state.airplane_endpoint_url.trim().is_empty()
|
|
|| !state.airplane_endpoint_model.trim().is_empty()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn compose_ai_endpoint(
|
|
state: &SettingsViewState,
|
|
kind: AiEndpointKind,
|
|
) -> Result<AiEndpointConfig, String> {
|
|
let (url, model, configured) = match kind {
|
|
AiEndpointKind::Online => (
|
|
state.online_endpoint_url.trim().to_string(),
|
|
state.online_endpoint_model.trim().to_string(),
|
|
state.online_api_key_configured,
|
|
),
|
|
AiEndpointKind::Airplane => (
|
|
state.airplane_endpoint_url.trim().to_string(),
|
|
state.airplane_endpoint_model.trim().to_string(),
|
|
false,
|
|
),
|
|
};
|
|
let api_key = if kind == AiEndpointKind::Online {
|
|
let input = state.online_api_key_input.trim();
|
|
if !input.is_empty() {
|
|
Some(input.to_string())
|
|
} else if configured {
|
|
ai::load_endpoint_api_key(kind).map_err(|error| error.to_string())?
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
Ok(AiEndpointConfig {
|
|
kind,
|
|
url,
|
|
model,
|
|
api_key,
|
|
})
|
|
}
|
|
|
|
fn ensure_preview_server(&mut self) -> Result<(), String> {
|
|
let Some(project) = self.active_project.as_ref() else {
|
|
return Err(t(self.ui_locale, "engine.generateSiteNoProject"));
|
|
};
|
|
let Some(data_dir) = self.data_dir.clone() else {
|
|
return Err(t(self.ui_locale, "engine.previewDataDirUnavailable"));
|
|
};
|
|
|
|
let should_restart = self
|
|
.preview_session
|
|
.as_ref()
|
|
.map(|session| session.project_id != project.id)
|
|
.unwrap_or(true);
|
|
if should_restart {
|
|
self.preview_session = None;
|
|
match engine::preview::start_preview_server(
|
|
self.db_path.clone(),
|
|
data_dir,
|
|
project.id.clone(),
|
|
) {
|
|
Ok(handle) => {
|
|
self.preview_session = Some(PreviewSession {
|
|
project_id: project.id.clone(),
|
|
handle,
|
|
});
|
|
}
|
|
Err(engine::EngineError::Conflict(_)) if self.preview_session.is_none() => {
|
|
return Err(t(self.ui_locale, "engine.previewPortInUse"));
|
|
}
|
|
Err(error) => {
|
|
return Err(error.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(session) = &self.preview_session {
|
|
let _ = &session.handle;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn preview_url_for_post(&mut self, post_id: &str) -> Result<String, String> {
|
|
if let Err(error) = self.persist_post_editor_preview_state(post_id) {
|
|
return Err(error);
|
|
}
|
|
self.ensure_preview_server()?;
|
|
|
|
let language = self
|
|
.post_editors
|
|
.get(post_id)
|
|
.map(|editor| editor.active_language.clone())
|
|
.filter(|language| !language.is_empty())
|
|
.unwrap_or_else(|| self.content_language.clone());
|
|
|
|
Ok(draft_preview_url(post_id, &language))
|
|
}
|
|
|
|
fn active_post_uses_embedded_preview(&self) -> bool {
|
|
self.active_tab
|
|
.as_ref()
|
|
.and_then(|tab_id| self.post_editors.get(tab_id))
|
|
.map(|editor| editor.editor_mode == "preview")
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn hide_embedded_preview(&self) {
|
|
if let Some(preview) = &self.embedded_preview {
|
|
preview.controller.set_visible(false);
|
|
}
|
|
}
|
|
|
|
fn sync_embedded_preview_for_active_post(&mut self) -> Task<Message> {
|
|
let Some(active_id) = self.active_tab.clone() else {
|
|
self.hide_embedded_preview();
|
|
return Task::none();
|
|
};
|
|
let Some(editor) = self.post_editors.get(&active_id) else {
|
|
self.hide_embedded_preview();
|
|
return Task::none();
|
|
};
|
|
if editor.editor_mode != "preview" {
|
|
self.hide_embedded_preview();
|
|
return Task::none();
|
|
}
|
|
|
|
let url = match self.preview_url_for_post(&active_id) {
|
|
Ok(url) => url,
|
|
Err(error) => {
|
|
self.notify(ToastLevel::Error, &error);
|
|
return Task::none();
|
|
}
|
|
};
|
|
|
|
if let Some(preview) = &mut self.embedded_preview {
|
|
preview.current_url = Some(url.clone());
|
|
if preview.controller.is_active() {
|
|
preview.controller.navigate(&url);
|
|
preview.controller.set_visible(true);
|
|
return Task::none();
|
|
}
|
|
} else {
|
|
self.embedded_preview = Some(EmbeddedPreviewState {
|
|
controller: WebViewController::new(WebViewConfig::default().url(url.clone())),
|
|
current_url: Some(url.clone()),
|
|
});
|
|
}
|
|
|
|
let Some(window_id) = self.main_window_id else {
|
|
return window::get_oldest().map(Message::MainWindowLoaded);
|
|
};
|
|
|
|
if let Some(preview) = &mut self.embedded_preview {
|
|
if !preview.controller.is_active() {
|
|
preview.controller = WebViewController::new(WebViewConfig::default().url(url));
|
|
return preview
|
|
.controller
|
|
.create_task(window_id, Message::EmbeddedPreviewReady);
|
|
}
|
|
}
|
|
|
|
Task::none()
|
|
}
|
|
|
|
fn preview_active_post(&mut self) -> Task<Message> {
|
|
let Some(active_id) = self.active_tab.clone() else {
|
|
return Task::none();
|
|
};
|
|
if !self
|
|
.tabs
|
|
.iter()
|
|
.any(|tab| tab.id == active_id && tab.tab_type == TabType::Post)
|
|
{
|
|
return Task::none();
|
|
}
|
|
|
|
let url = match self.preview_url_for_post(&active_id) {
|
|
Ok(url) => url,
|
|
Err(error) => {
|
|
self.notify(ToastLevel::Error, &error);
|
|
return Task::none();
|
|
}
|
|
};
|
|
|
|
if let Err(error) = open::that(&url) {
|
|
self.notify(ToastLevel::Error, &error.to_string());
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn run_post_ai_analysis(&mut self, post_id: &str) -> Task<Message> {
|
|
let Some(db) = &self.db else {
|
|
self.notify(ToastLevel::Error, "database unavailable");
|
|
return Task::none();
|
|
};
|
|
let Some(state) = self.post_editors.get(post_id).cloned() else {
|
|
return Task::none();
|
|
};
|
|
let request = ai::OneShotRequest {
|
|
operation: ai::OneShotOperation::AnalyzePost,
|
|
content: json!({
|
|
"title": state.title,
|
|
"excerpt": state.excerpt,
|
|
"content": content_sample(&state.content, 2000),
|
|
}),
|
|
};
|
|
match ai::run_one_shot(db.conn(), self.offline_mode, &request) {
|
|
Ok(ai::OneShotResponse::PostAnalysis(result)) => {
|
|
self.active_modal = Some(modal::ModalState::AISuggestions {
|
|
target: modal::AiEntityTarget::Post(post_id.to_string()),
|
|
fields: vec![
|
|
modal::AiSuggestionField {
|
|
key: "title".to_string(),
|
|
label: t(self.ui_locale, "editor.title"),
|
|
current_value: state.title,
|
|
suggested_value: result.title,
|
|
accepted: true,
|
|
locked: false,
|
|
},
|
|
modal::AiSuggestionField {
|
|
key: "excerpt".to_string(),
|
|
label: t(self.ui_locale, "editor.excerpt"),
|
|
current_value: state.excerpt,
|
|
suggested_value: result.excerpt,
|
|
accepted: true,
|
|
locked: false,
|
|
},
|
|
modal::AiSuggestionField {
|
|
key: "slug".to_string(),
|
|
label: t(self.ui_locale, "editor.slug"),
|
|
current_value: state.slug,
|
|
suggested_value: result.slug,
|
|
accepted: state.published_at.is_none(),
|
|
locked: state.published_at.is_some(),
|
|
},
|
|
],
|
|
});
|
|
}
|
|
Ok(_) => {}
|
|
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn run_post_taxonomy_analysis(&mut self, post_id: &str) -> Task<Message> {
|
|
let Some(db) = &self.db else {
|
|
self.notify(ToastLevel::Error, "database unavailable");
|
|
return Task::none();
|
|
};
|
|
let Some(state) = self.post_editors.get(post_id).cloned() else {
|
|
return Task::none();
|
|
};
|
|
let request = ai::OneShotRequest {
|
|
operation: ai::OneShotOperation::AnalyzeTaxonomy,
|
|
content: json!({
|
|
"title": state.title,
|
|
"excerpt": state.excerpt,
|
|
"content": content_sample(&state.content, 2000),
|
|
"tags": state.tags,
|
|
"categories": state.categories,
|
|
}),
|
|
};
|
|
match ai::run_one_shot(db.conn(), self.offline_mode, &request) {
|
|
Ok(ai::OneShotResponse::Taxonomy(result)) => {
|
|
self.active_modal = Some(modal::ModalState::AISuggestions {
|
|
target: modal::AiEntityTarget::Post(post_id.to_string()),
|
|
fields: vec![
|
|
modal::AiSuggestionField {
|
|
key: "tags".to_string(),
|
|
label: t(self.ui_locale, "sidebar.filter.tags"),
|
|
current_value: state.tags.join(", "),
|
|
suggested_value: result.tags.join(", "),
|
|
accepted: true,
|
|
locked: false,
|
|
},
|
|
modal::AiSuggestionField {
|
|
key: "categories".to_string(),
|
|
label: t(self.ui_locale, "sidebar.filter.categories"),
|
|
current_value: state.categories.join(", "),
|
|
suggested_value: result.categories.join(", "),
|
|
accepted: true,
|
|
locked: false,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
Ok(_) => {}
|
|
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn detect_post_language(&mut self, post_id: &str) -> Task<Message> {
|
|
let Some(db) = &self.db else {
|
|
self.notify(ToastLevel::Error, "database unavailable");
|
|
return Task::none();
|
|
};
|
|
let Some(state) = self.post_editors.get(post_id).cloned() else {
|
|
return Task::none();
|
|
};
|
|
let request = ai::OneShotRequest {
|
|
operation: ai::OneShotOperation::DetectLanguage,
|
|
content: json!({
|
|
"text": format!("{}\n\n{}\n\n{}", state.title, state.excerpt, content_sample(&state.content, 2000)),
|
|
}),
|
|
};
|
|
match ai::run_one_shot(db.conn(), self.offline_mode, &request) {
|
|
Ok(ai::OneShotResponse::LanguageDetection(result)) => {
|
|
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
|
editor.language = result.language_code;
|
|
editor.mark_dirty();
|
|
}
|
|
if let Err(error) = self.persist_post_editor_state(post_id) {
|
|
self.notify(ToastLevel::Error, &error);
|
|
} else {
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
}
|
|
Ok(_) => {}
|
|
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn open_post_translation_modal(&mut self, post_id: &str) -> Task<Message> {
|
|
let Some(state) = self.post_editors.get(post_id) else {
|
|
return Task::none();
|
|
};
|
|
let targets = self
|
|
.blog_languages
|
|
.iter()
|
|
.filter(|language| **language != state.active_language)
|
|
.map(|language| modal::LanguageTarget {
|
|
code: language.clone(),
|
|
name: language_label(self.ui_locale, language),
|
|
flag_emoji: bds_core::i18n::normalize_language(language).flag_emoji().to_string(),
|
|
has_existing_translation: state.translation_drafts.contains_key(language),
|
|
existing_status: state.translation_drafts.get(language).map(|draft| match draft.status {
|
|
PostStatus::Draft => "draft".to_string(),
|
|
PostStatus::Published => "published".to_string(),
|
|
PostStatus::Archived => "archived".to_string(),
|
|
}),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
self.active_modal = Some(modal::ModalState::LanguagePicker {
|
|
target: modal::AiEntityTarget::Post(post_id.to_string()),
|
|
source_language: state.active_language.clone(),
|
|
available_targets: targets,
|
|
});
|
|
Task::none()
|
|
}
|
|
|
|
fn translate_post_to(&mut self, post_id: &str, target_language: &str) -> Task<Message> {
|
|
self.active_modal = None;
|
|
let Some(db) = &self.db else {
|
|
self.notify(ToastLevel::Error, "database unavailable");
|
|
return Task::none();
|
|
};
|
|
let Some(data_dir) = &self.data_dir else {
|
|
self.notify(ToastLevel::Error, "project data directory unavailable");
|
|
return Task::none();
|
|
};
|
|
let Some(state) = self.post_editors.get(post_id).cloned() else {
|
|
return Task::none();
|
|
};
|
|
|
|
if state.active_language == state.canonical_language && state.status == PostStatus::Published {
|
|
match engine::post::update_post(
|
|
db.conn(),
|
|
data_dir,
|
|
post_id,
|
|
Some(&state.title),
|
|
None,
|
|
Some(Some(state.excerpt.as_str())),
|
|
Some(&state.content),
|
|
Some(state.tags.clone()),
|
|
Some(state.categories.clone()),
|
|
Some(Some(state.author.as_str())),
|
|
Some(Some(state.language.as_str())),
|
|
Some(Some(state.template_slug.as_str())),
|
|
Some(state.do_not_translate),
|
|
) {
|
|
Ok(updated) => {
|
|
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
|
editor.status = updated.status;
|
|
}
|
|
}
|
|
Err(error) => {
|
|
self.notify(ToastLevel::Error, &error.to_string());
|
|
return Task::none();
|
|
}
|
|
}
|
|
}
|
|
|
|
let request = ai::OneShotRequest {
|
|
operation: ai::OneShotOperation::TranslatePost { target_language: target_language.to_string() },
|
|
content: json!({
|
|
"title": state.title,
|
|
"excerpt": state.excerpt,
|
|
"content": state.content,
|
|
}),
|
|
};
|
|
match ai::run_one_shot(db.conn(), self.offline_mode, &request) {
|
|
Ok(ai::OneShotResponse::Translation(result)) => {
|
|
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
|
editor.switch_language(target_language);
|
|
editor.title = result.title.clone();
|
|
editor.excerpt = result.excerpt.clone();
|
|
editor.content = result.content.clone();
|
|
editor.editor_buffer = std::cell::RefCell::new(bds_editor::EditorBuffer::new(&result.content));
|
|
editor.mark_dirty();
|
|
}
|
|
if let Err(error) = self.persist_post_editor_state(post_id) {
|
|
self.notify(ToastLevel::Error, &error);
|
|
} else {
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
}
|
|
Ok(_) => {}
|
|
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn run_media_ai_analysis(&mut self, media_id: &str) -> Task<Message> {
|
|
let Some(db) = &self.db else {
|
|
self.notify(ToastLevel::Error, "database unavailable");
|
|
return Task::none();
|
|
};
|
|
let Some(data_dir) = &self.data_dir else {
|
|
self.notify(ToastLevel::Error, "project data directory unavailable");
|
|
return Task::none();
|
|
};
|
|
let Some(state) = self.media_editors.get(media_id).cloned() else {
|
|
return Task::none();
|
|
};
|
|
let image_data_url = match build_ai_image_data_url(data_dir, &state.media_id, &state.file_path, &state.mime_type) {
|
|
Ok(value) => value,
|
|
Err(error) => {
|
|
self.notify(ToastLevel::Error, &error);
|
|
return Task::none();
|
|
}
|
|
};
|
|
let request = ai::OneShotRequest {
|
|
operation: ai::OneShotOperation::AnalyzeImage,
|
|
content: json!({
|
|
"title": state.title,
|
|
"alt": state.alt,
|
|
"caption": state.caption,
|
|
"filename": state.original_name,
|
|
"mime_type": state.mime_type,
|
|
"image_data_url": image_data_url,
|
|
}),
|
|
};
|
|
match ai::run_one_shot(db.conn(), self.offline_mode, &request) {
|
|
Ok(ai::OneShotResponse::ImageAnalysis(result)) => {
|
|
self.active_modal = Some(modal::ModalState::AISuggestions {
|
|
target: modal::AiEntityTarget::Media(media_id.to_string()),
|
|
fields: vec![
|
|
modal::AiSuggestionField {
|
|
key: "title".to_string(),
|
|
label: t(self.ui_locale, "editor.title"),
|
|
current_value: state.title,
|
|
suggested_value: result.title,
|
|
accepted: true,
|
|
locked: false,
|
|
},
|
|
modal::AiSuggestionField {
|
|
key: "alt".to_string(),
|
|
label: t(self.ui_locale, "editor.alt"),
|
|
current_value: state.alt,
|
|
suggested_value: result.alt,
|
|
accepted: true,
|
|
locked: false,
|
|
},
|
|
modal::AiSuggestionField {
|
|
key: "caption".to_string(),
|
|
label: t(self.ui_locale, "editor.caption"),
|
|
current_value: state.caption,
|
|
suggested_value: result.caption,
|
|
accepted: true,
|
|
locked: false,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
Ok(_) => {}
|
|
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn detect_media_language(&mut self, media_id: &str) -> Task<Message> {
|
|
let Some(db) = &self.db else {
|
|
self.notify(ToastLevel::Error, "database unavailable");
|
|
return Task::none();
|
|
};
|
|
let Some(state) = self.media_editors.get(media_id).cloned() else {
|
|
return Task::none();
|
|
};
|
|
let request = ai::OneShotRequest {
|
|
operation: ai::OneShotOperation::DetectLanguage,
|
|
content: json!({
|
|
"text": format!("{}\n{}\n{}", state.title, state.alt, state.caption),
|
|
}),
|
|
};
|
|
match ai::run_one_shot(db.conn(), self.offline_mode, &request) {
|
|
Ok(ai::OneShotResponse::LanguageDetection(result)) => {
|
|
if let Some(editor) = self.media_editors.get_mut(media_id) {
|
|
editor.language = result.language_code;
|
|
editor.is_dirty = true;
|
|
}
|
|
if let Err(error) = self.persist_media_editor_state(media_id) {
|
|
self.notify(ToastLevel::Error, &error);
|
|
} else {
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
}
|
|
Ok(_) => {}
|
|
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn open_media_translation_modal(&mut self, media_id: &str) -> Task<Message> {
|
|
let Some(state) = self.media_editors.get(media_id) else {
|
|
return Task::none();
|
|
};
|
|
let targets = self
|
|
.blog_languages
|
|
.iter()
|
|
.filter(|language| **language != state.active_language)
|
|
.map(|language| modal::LanguageTarget {
|
|
code: language.clone(),
|
|
name: language_label(self.ui_locale, language),
|
|
flag_emoji: bds_core::i18n::normalize_language(language).flag_emoji().to_string(),
|
|
has_existing_translation: state.translation_drafts.contains_key(language),
|
|
existing_status: state.translation_drafts.get(language).map(|_| "draft".to_string()),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
self.active_modal = Some(modal::ModalState::LanguagePicker {
|
|
target: modal::AiEntityTarget::Media(media_id.to_string()),
|
|
source_language: state.active_language.clone(),
|
|
available_targets: targets,
|
|
});
|
|
Task::none()
|
|
}
|
|
|
|
fn translate_media_to(&mut self, media_id: &str, target_language: &str) -> Task<Message> {
|
|
self.active_modal = None;
|
|
let Some(db) = &self.db else {
|
|
self.notify(ToastLevel::Error, "database unavailable");
|
|
return Task::none();
|
|
};
|
|
let Some(state) = self.media_editors.get(media_id).cloned() else {
|
|
return Task::none();
|
|
};
|
|
let source_language = if state.language.is_empty() {
|
|
match ai::run_one_shot(
|
|
db.conn(),
|
|
self.offline_mode,
|
|
&ai::OneShotRequest {
|
|
operation: ai::OneShotOperation::DetectLanguage,
|
|
content: json!({ "text": format!("{}\n{}\n{}", state.title, state.alt, state.caption) }),
|
|
},
|
|
) {
|
|
Ok(ai::OneShotResponse::LanguageDetection(result)) => result.language_code,
|
|
Ok(_) => String::new(),
|
|
Err(error) => {
|
|
self.notify(ToastLevel::Error, &error.to_string());
|
|
return Task::none();
|
|
}
|
|
}
|
|
} else {
|
|
state.language.clone()
|
|
};
|
|
if let Some(editor) = self.media_editors.get_mut(media_id) {
|
|
if editor.language.is_empty() {
|
|
editor.language = source_language;
|
|
}
|
|
}
|
|
let request = ai::OneShotRequest {
|
|
operation: ai::OneShotOperation::TranslateMedia { target_language: target_language.to_string() },
|
|
content: json!({
|
|
"title": state.title,
|
|
"alt": state.alt,
|
|
"caption": state.caption,
|
|
}),
|
|
};
|
|
match ai::run_one_shot(db.conn(), self.offline_mode, &request) {
|
|
Ok(ai::OneShotResponse::MediaTranslation(result)) => {
|
|
if let Some(editor) = self.media_editors.get_mut(media_id) {
|
|
editor.switch_language(target_language);
|
|
editor.title = result.title.clone();
|
|
editor.alt = result.alt.clone();
|
|
editor.caption = result.caption.clone();
|
|
editor.is_dirty = true;
|
|
}
|
|
if let Err(error) = self.persist_media_editor_state(media_id) {
|
|
self.notify(ToastLevel::Error, &error);
|
|
} else {
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
}
|
|
Ok(_) => {}
|
|
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn apply_ai_suggestions(
|
|
&mut self,
|
|
target: modal::AiEntityTarget,
|
|
fields: &[modal::AiSuggestionField],
|
|
) -> Task<Message> {
|
|
match target {
|
|
modal::AiEntityTarget::Post(post_id) => {
|
|
if let Some(editor) = self.post_editors.get_mut(&post_id) {
|
|
for field in fields.iter().filter(|field| field.accepted && !field.locked) {
|
|
match field.key.as_str() {
|
|
"title" => editor.title = field.suggested_value.clone(),
|
|
"excerpt" => editor.excerpt = field.suggested_value.clone(),
|
|
"slug" => editor.slug = field.suggested_value.clone(),
|
|
"tags" => {
|
|
editor.tags = split_csv_values(&field.suggested_value);
|
|
editor.tags_input.clear();
|
|
}
|
|
"categories" => {
|
|
editor.categories = split_csv_values(&field.suggested_value);
|
|
editor.categories_input.clear();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
editor.mark_dirty();
|
|
}
|
|
if let Err(error) = self.persist_post_editor_state(&post_id) {
|
|
self.notify(ToastLevel::Error, &error);
|
|
} else {
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
}
|
|
modal::AiEntityTarget::Media(media_id) => {
|
|
if let Some(editor) = self.media_editors.get_mut(&media_id) {
|
|
for field in fields.iter().filter(|field| field.accepted && !field.locked) {
|
|
match field.key.as_str() {
|
|
"title" => editor.title = field.suggested_value.clone(),
|
|
"alt" => editor.alt = field.suggested_value.clone(),
|
|
"caption" => editor.caption = field.suggested_value.clone(),
|
|
_ => {}
|
|
}
|
|
}
|
|
editor.is_dirty = true;
|
|
}
|
|
if let Err(error) = self.persist_media_editor_state(&media_id) {
|
|
self.notify(ToastLevel::Error, &error);
|
|
} else {
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
}
|
|
|
|
fn content_sample(content: &str, max_len: usize) -> String {
|
|
content.chars().take(max_len).collect()
|
|
}
|
|
|
|
fn build_ai_image_data_url(
|
|
data_dir: &Path,
|
|
media_id: &str,
|
|
file_path: &str,
|
|
mime_type: &str,
|
|
) -> Result<String, String> {
|
|
if !mime_type.starts_with("image/") {
|
|
return Err("AI image analysis requires an image".to_string());
|
|
}
|
|
|
|
let source_path = data_dir.join(file_path.trim_start_matches('/'));
|
|
let thumbnail_relative = bds_core::util::thumbnail_path(media_id, "ai", "jpg");
|
|
let thumbnail_path = data_dir.join(&thumbnail_relative);
|
|
|
|
if !thumbnail_path.exists() {
|
|
bds_core::util::thumbnail::generate_all_thumbnails(
|
|
&source_path,
|
|
&data_dir.join("thumbnails"),
|
|
media_id,
|
|
)
|
|
.map_err(|error| format!("failed to generate AI thumbnail: {error}"))?;
|
|
}
|
|
|
|
let bytes = std::fs::read(&thumbnail_path)
|
|
.map_err(|error| format!("failed to read AI thumbnail: {error}"))?;
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
|
Ok(format!("data:image/jpeg;base64,{encoded}"))
|
|
}
|
|
|
|
fn split_csv_values(value: &str) -> Vec<String> {
|
|
value
|
|
.split(',')
|
|
.map(str::trim)
|
|
.filter(|item| !item.is_empty())
|
|
.map(|item| item.to_string())
|
|
.collect()
|
|
}
|
|
|
|
fn language_label(locale: UiLocale, code: &str) -> String {
|
|
let key = format!("language.{code}");
|
|
let value = t(locale, &key);
|
|
if value == key { code.to_string() } else { value }
|
|
}
|