fix: text preview now works, kinda
This commit is contained in:
20
Cargo.lock
generated
20
Cargo.lock
generated
@@ -520,7 +520,7 @@ dependencies = [
|
|||||||
"keyring",
|
"keyring",
|
||||||
"liquid",
|
"liquid",
|
||||||
"liquid-core",
|
"liquid-core",
|
||||||
"pulldown-cmark 0.13.3",
|
"pulldown-cmark",
|
||||||
"refinery",
|
"refinery",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -557,16 +557,17 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"iced",
|
"iced",
|
||||||
"iced_widget",
|
|
||||||
"muda",
|
"muda",
|
||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit 0.3.2",
|
"objc2-app-kit 0.3.2",
|
||||||
"objc2-foundation 0.3.2",
|
"objc2-foundation 0.3.2",
|
||||||
"open",
|
"open",
|
||||||
"rfd",
|
"rfd",
|
||||||
|
"rusqlite",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"uuid",
|
||||||
"wry",
|
"wry",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2713,11 +2714,9 @@ dependencies = [
|
|||||||
"iced_runtime",
|
"iced_runtime",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"pulldown-cmark 0.11.3",
|
|
||||||
"rustc-hash 2.1.2",
|
"rustc-hash 2.1.2",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"url",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4741,19 +4740,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "pulldown-cmark"
|
name = "pulldown-cmark"
|
||||||
version = "0.13.3"
|
version = "0.13.3"
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ license.workspace = true
|
|||||||
bds-core = { workspace = true }
|
bds-core = { workspace = true }
|
||||||
bds-editor = { workspace = true }
|
bds-editor = { workspace = true }
|
||||||
iced = { workspace = true }
|
iced = { workspace = true }
|
||||||
iced_widget = { version = "0.13.4", features = ["markdown"] }
|
|
||||||
muda = { workspace = true }
|
muda = { workspace = true }
|
||||||
rfd = { workspace = true }
|
rfd = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
@@ -17,6 +16,8 @@ chrono = { workspace = true }
|
|||||||
open = { workspace = true }
|
open = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
rusqlite = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
wry = "0.55"
|
wry = "0.55"
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use chrono::Datelike;
|
use chrono::Datelike;
|
||||||
use iced::{Element, Subscription, Task, window};
|
use iced::{Element, Subscription, Task, window};
|
||||||
|
use rusqlite::Error as SqlError;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use bds_core::db::Database;
|
use bds_core::db::Database;
|
||||||
use bds_core::engine::task::{TaskId, TaskManager, TaskStatus};
|
use bds_core::engine::task::{TaskId, TaskManager, TaskStatus};
|
||||||
use bds_core::engine;
|
use bds_core::engine;
|
||||||
use bds_core::engine::ai::{self, AiEndpointConfig, AiEndpointKind};
|
use bds_core::engine::ai::{self, AiEndpointConfig, AiEndpointKind};
|
||||||
use bds_core::i18n::{detect_os_locale, UiLocale};
|
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::components::webview::{self, WebViewConfig, WebViewController};
|
||||||
use crate::i18n::{t, tw};
|
use crate::i18n::{t, tw};
|
||||||
@@ -203,7 +205,11 @@ fn persist_post_editor_state_impl(
|
|||||||
data_dir,
|
data_dir,
|
||||||
&state.post_id,
|
&state.post_id,
|
||||||
Some(&state.title),
|
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(if state.excerpt.is_empty() { None } else { Some(state.excerpt.as_str()) }),
|
||||||
Some(&state.content),
|
Some(&state.content),
|
||||||
Some(state.tags.clone()),
|
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<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 {
|
enum PersistedMediaState {
|
||||||
Canonical { media: Media, tags: Vec<String> },
|
Canonical { media: Media, tags: Vec<String> },
|
||||||
Translation,
|
Translation,
|
||||||
@@ -417,7 +544,7 @@ fn format_bytes(size: i64) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{month_abbreviation, persist_media_editor_state_impl, persist_post_editor_state_impl, save_editor_settings_state_impl, save_script_editor_state_impl, save_template_editor_state_impl, BdsApp, Message, PersistedMediaState, PersistedPostState, PostStatus, SettingsMsg, POST_AUTO_SAVE_DELAY_MS};
|
use super::{month_abbreviation, persist_media_editor_state_impl, persist_post_editor_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::state::sidebar_filter::PostFilter;
|
||||||
use crate::views::media_editor::{MediaEditorMsg, MediaEditorState};
|
use crate::views::media_editor::{MediaEditorMsg, MediaEditorState};
|
||||||
use crate::views::post_editor::PostEditorState;
|
use crate::views::post_editor::PostEditorState;
|
||||||
@@ -520,6 +647,160 @@ mod tests {
|
|||||||
assert_eq!(saved.tags, vec!["rust".to_string(), "lua".to_string()]);
|
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]
|
#[test]
|
||||||
fn media_editor_save_flow_persists_changes() {
|
fn media_editor_save_flow_persists_changes() {
|
||||||
let (db, project, tmp) = setup();
|
let (db, project, tmp) = setup();
|
||||||
@@ -717,6 +998,65 @@ mod tests {
|
|||||||
assert!(linked_media[0].is_image);
|
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]
|
#[test]
|
||||||
fn task_tick_autosaves_dirty_post_editor() {
|
fn task_tick_autosaves_dirty_post_editor() {
|
||||||
let (db, project, tmp) = setup();
|
let (db, project, tmp) = setup();
|
||||||
@@ -4378,19 +4718,13 @@ impl BdsApp {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persist_post_editor_state(&mut self, post_id: &str) -> Result<(), String> {
|
fn apply_persisted_post_state(
|
||||||
let state = self
|
&mut self,
|
||||||
.post_editors
|
post_id: &str,
|
||||||
.get(post_id)
|
state: &PostEditorState,
|
||||||
.cloned()
|
persisted: PersistedPostState,
|
||||||
.ok_or_else(|| "missing post editor".to_string())?;
|
) {
|
||||||
let db = self.db.as_ref().ok_or_else(|| "database unavailable".to_string())?;
|
match persisted {
|
||||||
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)? {
|
|
||||||
PersistedPostState::Translation(translation) => {
|
PersistedPostState::Translation(translation) => {
|
||||||
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
||||||
editor.is_dirty = false;
|
editor.is_dirty = false;
|
||||||
@@ -4434,6 +4768,35 @@ impl BdsApp {
|
|||||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == post_id) {
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == post_id) {
|
||||||
tab.is_dirty = false;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5889,7 +6252,7 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn preview_url_for_post(&mut self, post_id: &str) -> Result<String, String> {
|
fn preview_url_for_post(&mut self, post_id: &str) -> Result<String, String> {
|
||||||
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);
|
return Err(error);
|
||||||
}
|
}
|
||||||
self.ensure_preview_server()?;
|
self.ensure_preview_server()?;
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use iced::widget::text::{Shaping, Wrapping};
|
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::{Color, Element, Length, Theme};
|
||||||
use iced_widget::markdown;
|
|
||||||
|
|
||||||
use bds_core::i18n::{self, UiLocale};
|
use bds_core::i18n::{self, UiLocale};
|
||||||
use bds_core::model::{Post, PostStatus, PostTranslation};
|
use bds_core::model::{Post, PostStatus, PostTranslation};
|
||||||
@@ -52,12 +50,6 @@ pub struct LinkedMediaItem {
|
|||||||
pub sort_order: i32,
|
pub sort_order: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
enum PreviewBlock {
|
|
||||||
Markdown(Vec<markdown::Item>),
|
|
||||||
Image { alt: String, target: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// State for an open post editor.
|
/// State for an open post editor.
|
||||||
pub struct PostEditorState {
|
pub struct PostEditorState {
|
||||||
pub post_id: String,
|
pub post_id: String,
|
||||||
@@ -66,8 +58,6 @@ pub struct PostEditorState {
|
|||||||
pub excerpt: String,
|
pub excerpt: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub editor_buffer: RefCell<EditorBuffer>,
|
pub editor_buffer: RefCell<EditorBuffer>,
|
||||||
pub preview_items: Vec<markdown::Item>,
|
|
||||||
preview_blocks: Vec<PreviewBlock>,
|
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
pub categories: Vec<String>,
|
pub categories: Vec<String>,
|
||||||
pub author: String,
|
pub author: String,
|
||||||
@@ -115,8 +105,6 @@ impl Clone for PostEditorState {
|
|||||||
excerpt: self.excerpt.clone(),
|
excerpt: self.excerpt.clone(),
|
||||||
content: self.content.clone(),
|
content: self.content.clone(),
|
||||||
editor_buffer: RefCell::new(EditorBuffer::new(&self.content)),
|
editor_buffer: RefCell::new(EditorBuffer::new(&self.content)),
|
||||||
preview_items: self.preview_items.clone(),
|
|
||||||
preview_blocks: self.preview_blocks.clone(),
|
|
||||||
tags: self.tags.clone(),
|
tags: self.tags.clone(),
|
||||||
categories: self.categories.clone(),
|
categories: self.categories.clone(),
|
||||||
author: self.author.clone(),
|
author: self.author.clone(),
|
||||||
@@ -161,9 +149,6 @@ impl PostEditorState {
|
|||||||
let excerpt = post.excerpt.clone().unwrap_or_default();
|
let excerpt = post.excerpt.clone().unwrap_or_default();
|
||||||
let content = post.content.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 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::<Vec<_>>();
|
|
||||||
let preview_blocks = build_preview_blocks_from_document(&preview_document, &preview_items);
|
|
||||||
|
|
||||||
let mut translation_drafts = HashMap::new();
|
let mut translation_drafts = HashMap::new();
|
||||||
for tr in translations {
|
for tr in translations {
|
||||||
@@ -185,8 +170,6 @@ impl PostEditorState {
|
|||||||
excerpt,
|
excerpt,
|
||||||
content: content.clone(),
|
content: content.clone(),
|
||||||
editor_buffer: RefCell::new(EditorBuffer::new(&content)),
|
editor_buffer: RefCell::new(EditorBuffer::new(&content)),
|
||||||
preview_items,
|
|
||||||
preview_blocks,
|
|
||||||
tags: post.tags.clone(),
|
tags: post.tags.clone(),
|
||||||
categories: post.categories.clone(),
|
categories: post.categories.clone(),
|
||||||
author: post.author.clone().unwrap_or_default(),
|
author: post.author.clone().unwrap_or_default(),
|
||||||
@@ -229,7 +212,6 @@ impl PostEditorState {
|
|||||||
buffer.text()
|
buffer.text()
|
||||||
};
|
};
|
||||||
self.content = new_content;
|
self.content = new_content;
|
||||||
self.refresh_preview_items();
|
|
||||||
self.mark_dirty();
|
self.mark_dirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,12 +219,6 @@ impl PostEditorState {
|
|||||||
self.editor_mode = normalize_editor_mode(mode);
|
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) {
|
pub fn switch_language(&mut self, target_lang: &str) {
|
||||||
if target_lang == self.active_language {
|
if target_lang == self.active_language {
|
||||||
return;
|
return;
|
||||||
@@ -293,7 +269,6 @@ impl PostEditorState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.active_language = target_lang.to_string();
|
self.active_language = target_lang.to_string();
|
||||||
self.refresh_preview_items();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn translation_flags(&self) -> Vec<TranslationFlag> {
|
pub fn translation_flags(&self) -> Vec<TranslationFlag> {
|
||||||
@@ -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<PreviewBlock> {
|
|
||||||
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<PreviewBlock>, markdown_buffer: &mut Vec<String>) {
|
|
||||||
let markdown_text = markdown_buffer.join("\n");
|
|
||||||
if !markdown_text.trim().is_empty() {
|
|
||||||
blocks.push(PreviewBlock::Markdown(
|
|
||||||
markdown::parse(&markdown_text).collect::<Vec<_>>(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
markdown_buffer.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_standalone_markdown_image(line: &str) -> Option<(String, String)> {
|
|
||||||
let trimmed = line.trim();
|
|
||||||
if !trimmed.starts_with("?;
|
|
||||||
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!(""))
|
|
||||||
.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<String> {
|
|
||||||
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 {
|
fn normalize_editor_mode(mode: &str) -> String {
|
||||||
match mode {
|
match mode {
|
||||||
"preview" => "preview".to_string(),
|
"preview" => "preview".to_string(),
|
||||||
|
|||||||
Reference in New Issue
Block a user