diff --git a/Cargo.lock b/Cargo.lock index 7e7c01f..5a1f808 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,7 +520,7 @@ dependencies = [ "keyring", "liquid", "liquid-core", - "pulldown-cmark 0.13.3", + "pulldown-cmark", "refinery", "reqwest", "rusqlite", @@ -557,16 +557,17 @@ dependencies = [ "chrono", "dirs 5.0.1", "iced", - "iced_widget", "muda", "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "open", "rfd", + "rusqlite", "serde_json", "tempfile", "tokio", + "uuid", "wry", ] @@ -2713,11 +2714,9 @@ dependencies = [ "iced_runtime", "num-traits", "once_cell", - "pulldown-cmark 0.11.3", "rustc-hash 2.1.2", "thiserror 1.0.69", "unicode-segmentation", - "url", ] [[package]] @@ -4741,19 +4740,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "pulldown-cmark" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "679341d22c78c6c649893cbd6c3278dcbe9fc4faa62fea3a9296ae2b50c14625" -dependencies = [ - "bitflags 2.11.0", - "getopts", - "memchr", - "pulldown-cmark-escape", - "unicase", -] - [[package]] name = "pulldown-cmark" version = "0.13.3" diff --git a/crates/bds-ui/Cargo.toml b/crates/bds-ui/Cargo.toml index 080ee09..b15a25d 100644 --- a/crates/bds-ui/Cargo.toml +++ b/crates/bds-ui/Cargo.toml @@ -8,7 +8,6 @@ license.workspace = true bds-core = { workspace = true } bds-editor = { workspace = true } iced = { workspace = true } -iced_widget = { version = "0.13.4", features = ["markdown"] } muda = { workspace = true } rfd = { workspace = true } serde_json = { workspace = true } @@ -17,6 +16,8 @@ chrono = { workspace = true } open = { workspace = true } anyhow = { workspace = true } tokio = { workspace = true } +rusqlite = { workspace = true } +uuid = { workspace = true } wry = "0.55" [target.'cfg(target_os = "macos")'.dependencies] diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 02b30e0..d6e88d3 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -4,14 +4,16 @@ use std::sync::Arc; 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, Project, PublishingPreferences, Script, SshMode, Template}; +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}; @@ -203,7 +205,11 @@ fn persist_post_editor_state_impl( data_dir, &state.post_id, Some(&state.title), - Some(&state.slug), + 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()), @@ -218,6 +224,127 @@ fn persist_post_editor_state_impl( } } +fn persist_post_editor_preview_state_impl( + db: &Database, + state: &PostEditorState, +) -> Result { + 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 }, Translation, @@ -417,7 +544,7 @@ fn format_bytes(size: i64) -> String { #[cfg(test)] mod tests { - use super::{month_abbreviation, persist_media_editor_state_impl, persist_post_editor_state_impl, save_editor_settings_state_impl, save_script_editor_state_impl, save_template_editor_state_impl, BdsApp, Message, PersistedMediaState, PersistedPostState, PostStatus, SettingsMsg, POST_AUTO_SAVE_DELAY_MS}; + use super::{month_abbreviation, persist_media_editor_state_impl, persist_post_editor_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; @@ -520,6 +647,160 @@ mod tests { 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 media_editor_save_flow_persists_changes() { let (db, project, tmp) = setup(); @@ -717,6 +998,65 @@ mod tests { 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(); @@ -4378,19 +4718,13 @@ impl BdsApp { Task::none() } - 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())?; - - match persist_post_editor_state_impl(db, data_dir, &state)? { + 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; @@ -4434,6 +4768,35 @@ impl BdsApp { 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(()) } @@ -5889,7 +6252,7 @@ impl BdsApp { } fn preview_url_for_post(&mut self, post_id: &str) -> Result { - if let Err(error) = self.persist_post_editor_state(post_id) { + if let Err(error) = self.persist_post_editor_preview_state(post_id) { return Err(error); } self.ensure_preview_server()?; diff --git a/crates/bds-ui/src/views/post_editor.rs b/crates/bds-ui/src/views/post_editor.rs index af3f8cd..c0ab6e9 100644 --- a/crates/bds-ui/src/views/post_editor.rs +++ b/crates/bds-ui/src/views/post_editor.rs @@ -1,11 +1,9 @@ use std::cell::RefCell; use std::collections::HashMap; -use std::path::Path; use iced::widget::text::{Shaping, Wrapping}; -use iced::widget::{button, column, container, image, row, scrollable, text, text_input, Column, Space}; +use iced::widget::{button, column, container, row, scrollable, text, text_input, Column, Space}; use iced::{Color, Element, Length, Theme}; -use iced_widget::markdown; use bds_core::i18n::{self, UiLocale}; use bds_core::model::{Post, PostStatus, PostTranslation}; @@ -52,12 +50,6 @@ pub struct LinkedMediaItem { pub sort_order: i32, } -#[derive(Debug, Clone)] -enum PreviewBlock { - Markdown(Vec), - Image { alt: String, target: String }, -} - /// State for an open post editor. pub struct PostEditorState { pub post_id: String, @@ -66,8 +58,6 @@ pub struct PostEditorState { pub excerpt: String, pub content: String, pub editor_buffer: RefCell, - pub preview_items: Vec, - preview_blocks: Vec, pub tags: Vec, pub categories: Vec, pub author: String, @@ -115,8 +105,6 @@ impl Clone for PostEditorState { excerpt: self.excerpt.clone(), content: self.content.clone(), editor_buffer: RefCell::new(EditorBuffer::new(&self.content)), - preview_items: self.preview_items.clone(), - preview_blocks: self.preview_blocks.clone(), tags: self.tags.clone(), categories: self.categories.clone(), author: self.author.clone(), @@ -161,9 +149,6 @@ impl PostEditorState { let excerpt = post.excerpt.clone().unwrap_or_default(); let content = post.content.clone().unwrap_or_default(); let canonical_lang = post.language.clone().unwrap_or_else(|| "en".to_string()); - let preview_document = preview_markdown_document_parts(&title, &excerpt, &content); - let preview_items = markdown::parse(&preview_document).collect::>(); - let preview_blocks = build_preview_blocks_from_document(&preview_document, &preview_items); let mut translation_drafts = HashMap::new(); for tr in translations { @@ -185,8 +170,6 @@ impl PostEditorState { excerpt, content: content.clone(), editor_buffer: RefCell::new(EditorBuffer::new(&content)), - preview_items, - preview_blocks, tags: post.tags.clone(), categories: post.categories.clone(), author: post.author.clone().unwrap_or_default(), @@ -229,7 +212,6 @@ impl PostEditorState { buffer.text() }; self.content = new_content; - self.refresh_preview_items(); self.mark_dirty(); } @@ -237,12 +219,6 @@ impl PostEditorState { self.editor_mode = normalize_editor_mode(mode); } - pub fn refresh_preview_items(&mut self) { - let preview_document = preview_markdown_document(self); - self.preview_items = markdown::parse(&preview_document).collect(); - self.preview_blocks = build_preview_blocks_from_document(&preview_document, &self.preview_items); - } - pub fn switch_language(&mut self, target_lang: &str) { if target_lang == self.active_language { return; @@ -293,7 +269,6 @@ impl PostEditorState { } self.active_language = target_lang.to_string(); - self.refresh_preview_items(); } pub fn translation_flags(&self) -> Vec { @@ -1027,151 +1002,6 @@ fn truncate_header_title(title: &str) -> String { } } -fn preview_markdown_document(state: &PostEditorState) -> String { - preview_markdown_document_parts(&state.title, &state.excerpt, &state.content) -} - -fn preview_markdown_document_parts(title: &str, excerpt: &str, content: &str) -> String { - let mut sections = Vec::new(); - if !title.trim().is_empty() { - sections.push(format!("# {}", title.trim())); - } - if !excerpt.trim().is_empty() { - sections.push(format!("> {}", excerpt.trim())); - } - if !content.trim().is_empty() { - sections.push(content.to_string()); - } - sections.join("\n\n") -} - -fn build_preview_blocks_from_document( - document: &str, - fallback_items: &[markdown::Item], -) -> Vec { - let mut blocks = Vec::new(); - let mut markdown_buffer = Vec::new(); - - for line in document.lines() { - if let Some((alt, target)) = parse_standalone_markdown_image(line) { - flush_markdown_buffer(&mut blocks, &mut markdown_buffer); - blocks.push(PreviewBlock::Image { alt, target }); - } else { - markdown_buffer.push(line.to_string()); - } - } - flush_markdown_buffer(&mut blocks, &mut markdown_buffer); - - if blocks.is_empty() { - blocks.push(PreviewBlock::Markdown(fallback_items.to_vec())); - } - - blocks -} - -fn flush_markdown_buffer(blocks: &mut Vec, markdown_buffer: &mut Vec) { - let markdown_text = markdown_buffer.join("\n"); - if !markdown_text.trim().is_empty() { - blocks.push(PreviewBlock::Markdown( - markdown::parse(&markdown_text).collect::>(), - )); - } - markdown_buffer.clear(); -} - -fn parse_standalone_markdown_image(line: &str) -> Option<(String, String)> { - let trimmed = line.trim(); - if !trimmed.starts_with("![") { - return None; - } - let alt_end = trimmed.find("](")?; - if !trimmed.ends_with(')') { - return None; - } - let alt = trimmed[2..alt_end].to_string(); - let target = trimmed[alt_end + 2..trimmed.len() - 1].trim().to_string(); - if target.is_empty() { - None - } else { - Some((alt, target)) - } -} - -fn preview_block_view<'a>( - block: &'a PreviewBlock, - state: &'a PostEditorState, - data_dir: Option<&'a Path>, -) -> Element<'a, Message> { - match block { - PreviewBlock::Markdown(items) => markdown::view( - items, - markdown::Settings::with_text_size(15), - markdown::Style::from_palette(Theme::TokyoNightStorm.palette()), - ) - .map(|url: markdown::Url| Message::UrlOpenRequested(url.to_string())) - .into(), - PreviewBlock::Image { alt, target } => preview_image_block(state, data_dir, alt, target), - } -} - -fn preview_image_block<'a>( - state: &'a PostEditorState, - data_dir: Option<&'a Path>, - alt: &str, - target: &str, -) -> Element<'a, Message> { - let Some(path) = resolve_preview_image_path(state, data_dir, target) else { - return text(format!("![{alt}]({target})")) - .size(12) - .color(Color::from_rgb(0.70, 0.50, 0.50)) - .shaping(Shaping::Advanced) - .into(); - }; - - let mut content = column![ - container( - image(path) - .width(Length::Fill) - .height(Length::Shrink) - ) - .width(Length::Fill) - ] - .spacing(6); - if !alt.is_empty() { - content = content.push( - text(alt.to_string()) - .size(12) - .color(Color::from_rgb(0.62, 0.66, 0.74)) - .shaping(Shaping::Advanced), - ); - } - - content.into() -} - -fn resolve_preview_image_path( - state: &PostEditorState, - data_dir: Option<&Path>, - target: &str, -) -> Option { - let data_dir = data_dir?; - if let Some(media_id) = target.strip_prefix("bds-media://") { - return state - .linked_media - .iter() - .find(|media| media.media_id == media_id && media.is_image) - .map(|media| data_dir.join(&media.file_path).to_string_lossy().to_string()); - } - - let relative = target.trim_start_matches('/'); - let candidate = data_dir.join(relative); - if candidate.exists() { - Some(candidate.to_string_lossy().to_string()) - } else { - None - } -} - fn normalize_editor_mode(mode: &str) -> String { match mode { "preview" => "preview".to_string(),