fix: more gaps closed
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use iced::{Element, Subscription, Task};
|
use iced::{Element, Subscription, Task};
|
||||||
@@ -154,6 +154,350 @@ pub enum Message {
|
|||||||
InitMenuBar,
|
InitMenuBar,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PersistedPostState {
|
||||||
|
Canonical(Post),
|
||||||
|
Translation(bds_core::model::PostTranslation),
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PersistedMediaState {
|
||||||
|
Canonical { media: Media, tags: Vec<String> },
|
||||||
|
Translation,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 save_template_editor_state_impl(
|
||||||
|
db: &Database,
|
||||||
|
project_id: &str,
|
||||||
|
state: &TemplateEditorState,
|
||||||
|
) -> Result<Template, String> {
|
||||||
|
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::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())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{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, PersistedMediaState, PersistedPostState};
|
||||||
|
use crate::views::media_editor::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 bds_core::db::fts::ensure_fts_tables;
|
||||||
|
use bds_core::db::queries::project::insert_project;
|
||||||
|
use bds_core::db::Database;
|
||||||
|
use bds_core::engine::{media, post, script, template};
|
||||||
|
use bds_core::model::{Project, ScriptKind, TemplateKind};
|
||||||
|
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"), "{}\n").unwrap();
|
||||||
|
std::fs::write(tempdir.path().join("meta/publishing.json"), "{}\n").unwrap();
|
||||||
|
(db, project, tempdir)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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, &["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 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()], &[]);
|
||||||
|
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 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 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ───────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────
|
||||||
// App State
|
// App State
|
||||||
// ───────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────
|
||||||
@@ -2469,16 +2813,7 @@ impl BdsApp {
|
|||||||
let Some(ref db) = self.db 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() };
|
let Some(ref project) = self.active_project else { return Task::none() };
|
||||||
|
|
||||||
match engine::template::update_template(
|
match save_template_editor_state_impl(db, &project.id, state) {
|
||||||
db.conn(),
|
|
||||||
&state.template_id,
|
|
||||||
&project.id,
|
|
||||||
Some(&state.title),
|
|
||||||
Some(&state.slug),
|
|
||||||
Some(state.kind.clone()),
|
|
||||||
Some(state.enabled),
|
|
||||||
Some(&state.content),
|
|
||||||
) {
|
|
||||||
Ok(tmpl) => {
|
Ok(tmpl) => {
|
||||||
let s = self.template_editors.get_mut(template_id).unwrap();
|
let s = self.template_editors.get_mut(template_id).unwrap();
|
||||||
s.is_dirty = false;
|
s.is_dirty = false;
|
||||||
@@ -2501,17 +2836,7 @@ impl BdsApp {
|
|||||||
let Some(ref db) = self.db 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() };
|
let Some(ref project) = self.active_project else { return Task::none() };
|
||||||
|
|
||||||
match engine::script::update_script(
|
match save_script_editor_state_impl(db, &project.id, state) {
|
||||||
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),
|
|
||||||
) {
|
|
||||||
Ok(script) => {
|
Ok(script) => {
|
||||||
let s = self.script_editors.get_mut(script_id).unwrap();
|
let s = self.script_editors.get_mut(script_id).unwrap();
|
||||||
s.is_dirty = false;
|
s.is_dirty = false;
|
||||||
@@ -2541,22 +2866,8 @@ impl BdsApp {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| "project data directory unavailable".to_string())?;
|
.ok_or_else(|| "project data directory unavailable".to_string())?;
|
||||||
|
|
||||||
if state.active_language != state.canonical_language {
|
match persist_post_editor_state_impl(db, data_dir, &state)? {
|
||||||
let translation = engine::post::upsert_translation(
|
PersistedPostState::Translation(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())?;
|
|
||||||
|
|
||||||
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;
|
||||||
if let Some(draft) = editor.translation_drafts.get_mut(&state.active_language) {
|
if let Some(draft) = editor.translation_drafts.get_mut(&state.active_language) {
|
||||||
@@ -2567,40 +2878,8 @@ impl BdsApp {
|
|||||||
draft.is_dirty = false;
|
draft.is_dirty = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
let post = engine::post::update_post(
|
PersistedPostState::Canonical(post) => {
|
||||||
db.conn(),
|
|
||||||
data_dir,
|
|
||||||
&state.post_id,
|
|
||||||
Some(&state.title),
|
|
||||||
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())?;
|
|
||||||
|
|
||||||
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
||||||
editor.title = post.title.clone();
|
editor.title = post.title.clone();
|
||||||
editor.slug = post.slug.clone();
|
editor.slug = post.slug.clone();
|
||||||
@@ -2624,6 +2903,7 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
self.refresh_post_relationships(post_id);
|
self.refresh_post_relationships(post_id);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -2643,73 +2923,13 @@ impl BdsApp {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| "project data directory unavailable".to_string())?;
|
.ok_or_else(|| "project data directory unavailable".to_string())?;
|
||||||
|
|
||||||
if state.active_language != state.canonical_language {
|
match persist_media_editor_state_impl(db, data_dir, &state)? {
|
||||||
engine::media::upsert_media_translation(
|
PersistedMediaState::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())?;
|
|
||||||
if let Some(editor) = self.media_editors.get_mut(media_id) {
|
if let Some(editor) = self.media_editors.get_mut(media_id) {
|
||||||
editor.is_dirty = false;
|
editor.is_dirty = false;
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
let tags = state
|
PersistedMediaState::Canonical { media, tags } => {
|
||||||
.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())?;
|
|
||||||
|
|
||||||
if let Some(editor) = self.media_editors.get_mut(media_id) {
|
if let Some(editor) = self.media_editors.get_mut(media_id) {
|
||||||
editor.title = media.title.clone().unwrap_or_default();
|
editor.title = media.title.clone().unwrap_or_default();
|
||||||
editor.alt = media.alt.clone().unwrap_or_default();
|
editor.alt = media.alt.clone().unwrap_or_default();
|
||||||
@@ -2722,6 +2942,7 @@ impl BdsApp {
|
|||||||
editor.is_dirty = false;
|
editor.is_dirty = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == media_id) {
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == media_id) {
|
||||||
tab.is_dirty = false;
|
tab.is_dirty = false;
|
||||||
@@ -3155,16 +3376,7 @@ impl BdsApp {
|
|||||||
SettingsMsg::HideUnchangedRegionsChanged(b) => { state.hide_unchanged_regions = b; }
|
SettingsMsg::HideUnchangedRegionsChanged(b) => { state.hide_unchanged_regions = b; }
|
||||||
SettingsMsg::SaveEditor => {
|
SettingsMsg::SaveEditor => {
|
||||||
if let Some(db) = &self.db {
|
if let Some(db) = &self.db {
|
||||||
let now = bds_core::util::now_unix_ms();
|
match save_editor_settings_state_impl(db, state) {
|
||||||
let result = [
|
|
||||||
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<_>, _>>();
|
|
||||||
match result {
|
|
||||||
Ok(_) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")),
|
Ok(_) => 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}")),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,3 +35,35 @@ pub fn view(locale: UiLocale) -> Element<'static, Message> {
|
|||||||
})
|
})
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn tab_placeholder(
|
||||||
|
title: impl Into<String>,
|
||||||
|
subtitle: Option<String>,
|
||||||
|
) -> Element<'static, Message> {
|
||||||
|
let title = title.into();
|
||||||
|
let subtitle = subtitle.unwrap_or_else(|| "This tab is routed correctly, but its content surface is not implemented yet.".to_string());
|
||||||
|
|
||||||
|
container(
|
||||||
|
column![
|
||||||
|
text(title)
|
||||||
|
.size(24)
|
||||||
|
.shaping(Shaping::Advanced)
|
||||||
|
.color(Color::from_rgb(0.85, 0.85, 0.90)),
|
||||||
|
text(subtitle)
|
||||||
|
.size(14)
|
||||||
|
.shaping(Shaping::Advanced)
|
||||||
|
.color(Color::from_rgb(0.55, 0.55, 0.60)),
|
||||||
|
]
|
||||||
|
.spacing(12)
|
||||||
|
.align_x(iced::Alignment::Center),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.center_x(Length::Fill)
|
||||||
|
.center_y(Length::Fill)
|
||||||
|
.style(|_theme: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb(0.11, 0.11, 0.14))),
|
||||||
|
..container::Style::default()
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|||||||
@@ -343,17 +343,23 @@ fn route_content_area<'a>(
|
|||||||
script_editors: &'a HashMap<String, ScriptEditorState>,
|
script_editors: &'a HashMap<String, ScriptEditorState>,
|
||||||
tags_view_state: Option<&'a TagsViewState>,
|
tags_view_state: Option<&'a TagsViewState>,
|
||||||
settings_state: Option<&'a SettingsViewState>,
|
settings_state: Option<&'a SettingsViewState>,
|
||||||
_dashboard_state: Option<&'a DashboardState>,
|
dashboard_state: Option<&'a DashboardState>,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let Some(tab_id) = active_tab else {
|
match route_kind(
|
||||||
return welcome::view(locale);
|
tabs,
|
||||||
};
|
active_tab,
|
||||||
let Some(tab) = tabs.iter().find(|t| t.id == tab_id) else {
|
post_editors,
|
||||||
return welcome::view(locale);
|
media_editors,
|
||||||
};
|
template_editors,
|
||||||
|
script_editors,
|
||||||
match tab.tab_type {
|
tags_view_state,
|
||||||
TabType::Post => {
|
settings_state,
|
||||||
|
dashboard_state,
|
||||||
|
) {
|
||||||
|
ContentRoute::Dashboard(state) => crate::views::dashboard::view(state, locale),
|
||||||
|
ContentRoute::Welcome => welcome::view(locale),
|
||||||
|
ContentRoute::Loading => loading_view(locale),
|
||||||
|
ContentRoute::Post(tab_id) => {
|
||||||
if let Some(state) = post_editors.get(tab_id) {
|
if let Some(state) = post_editors.get(tab_id) {
|
||||||
let wrap = settings_state.map(|s| s.wrap_long_lines).unwrap_or(true);
|
let wrap = settings_state.map(|s| s.wrap_long_lines).unwrap_or(true);
|
||||||
post_editor::view(state, locale, wrap)
|
post_editor::view(state, locale, wrap)
|
||||||
@@ -361,42 +367,187 @@ fn route_content_area<'a>(
|
|||||||
loading_view(locale)
|
loading_view(locale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TabType::Media => {
|
ContentRoute::Media(tab_id) => {
|
||||||
if let Some(state) = media_editors.get(tab_id) {
|
if let Some(state) = media_editors.get(tab_id) {
|
||||||
media_editor::view(state, locale, data_dir)
|
media_editor::view(state, locale, data_dir)
|
||||||
} else {
|
} else {
|
||||||
loading_view(locale)
|
loading_view(locale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TabType::Templates => {
|
ContentRoute::Templates(tab_id) => {
|
||||||
if let Some(state) = template_editors.get(tab_id) {
|
if let Some(state) = template_editors.get(tab_id) {
|
||||||
template_editor::view(state, locale)
|
template_editor::view(state, locale)
|
||||||
} else {
|
} else {
|
||||||
loading_view(locale)
|
loading_view(locale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TabType::Scripts => {
|
ContentRoute::Scripts(tab_id) => {
|
||||||
if let Some(state) = script_editors.get(tab_id) {
|
if let Some(state) = script_editors.get(tab_id) {
|
||||||
script_editor::view(state, locale)
|
script_editor::view(state, locale)
|
||||||
} else {
|
} else {
|
||||||
loading_view(locale)
|
loading_view(locale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TabType::Tags => {
|
ContentRoute::Tags => {
|
||||||
if let Some(state) = tags_view_state {
|
if let Some(state) = tags_view_state {
|
||||||
tags_view::view(state, locale)
|
tags_view::view(state, locale)
|
||||||
} else {
|
} else {
|
||||||
loading_view(locale)
|
loading_view(locale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TabType::Settings => {
|
ContentRoute::Settings => {
|
||||||
if let Some(state) = settings_state {
|
if let Some(state) = settings_state {
|
||||||
settings_view::view(state, locale)
|
settings_view::view(state, locale)
|
||||||
} else {
|
} else {
|
||||||
loading_view(locale)
|
loading_view(locale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => welcome::view(locale),
|
ContentRoute::Placeholder(title) => welcome::tab_placeholder(title, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum ContentRoute<'a> {
|
||||||
|
Dashboard(&'a DashboardState),
|
||||||
|
Welcome,
|
||||||
|
Loading,
|
||||||
|
Post(&'a str),
|
||||||
|
Media(&'a str),
|
||||||
|
Templates(&'a str),
|
||||||
|
Scripts(&'a str),
|
||||||
|
Tags,
|
||||||
|
Settings,
|
||||||
|
Placeholder(&'a str),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn route_kind<'a>(
|
||||||
|
tabs: &'a [Tab],
|
||||||
|
active_tab: Option<&'a str>,
|
||||||
|
post_editors: &'a HashMap<String, PostEditorState>,
|
||||||
|
media_editors: &'a HashMap<String, MediaEditorState>,
|
||||||
|
template_editors: &'a HashMap<String, TemplateEditorState>,
|
||||||
|
script_editors: &'a HashMap<String, ScriptEditorState>,
|
||||||
|
tags_view_state: Option<&'a TagsViewState>,
|
||||||
|
settings_state: Option<&'a SettingsViewState>,
|
||||||
|
dashboard_state: Option<&'a DashboardState>,
|
||||||
|
) -> ContentRoute<'a> {
|
||||||
|
let Some(tab_id) = active_tab else {
|
||||||
|
return dashboard_state.map(ContentRoute::Dashboard).unwrap_or(ContentRoute::Welcome);
|
||||||
|
};
|
||||||
|
let Some(tab) = tabs.iter().find(|t| t.id == tab_id) else {
|
||||||
|
return dashboard_state.map(ContentRoute::Dashboard).unwrap_or(ContentRoute::Welcome);
|
||||||
|
};
|
||||||
|
|
||||||
|
match tab.tab_type {
|
||||||
|
TabType::Post => {
|
||||||
|
if post_editors.contains_key(tab_id) { ContentRoute::Post(tab_id) } else { ContentRoute::Loading }
|
||||||
|
}
|
||||||
|
TabType::Media => {
|
||||||
|
if media_editors.contains_key(tab_id) { ContentRoute::Media(tab_id) } else { ContentRoute::Loading }
|
||||||
|
}
|
||||||
|
TabType::Templates => {
|
||||||
|
if template_editors.contains_key(tab_id) { ContentRoute::Templates(tab_id) } else { ContentRoute::Loading }
|
||||||
|
}
|
||||||
|
TabType::Scripts => {
|
||||||
|
if script_editors.contains_key(tab_id) { ContentRoute::Scripts(tab_id) } else { ContentRoute::Loading }
|
||||||
|
}
|
||||||
|
TabType::Tags => {
|
||||||
|
if tags_view_state.is_some() { ContentRoute::Tags } else { ContentRoute::Loading }
|
||||||
|
}
|
||||||
|
TabType::Settings => {
|
||||||
|
if settings_state.is_some() { ContentRoute::Settings } else { ContentRoute::Loading }
|
||||||
|
}
|
||||||
|
TabType::Style
|
||||||
|
| TabType::Chat
|
||||||
|
| TabType::Import
|
||||||
|
| TabType::MenuEditor
|
||||||
|
| TabType::MetadataDiff
|
||||||
|
| TabType::GitDiff
|
||||||
|
| TabType::Documentation
|
||||||
|
| TabType::ApiDocumentation
|
||||||
|
| TabType::SiteValidation
|
||||||
|
| TabType::TranslationValidation
|
||||||
|
| TabType::FindDuplicates => ContentRoute::Placeholder(&tab.title),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::state::tabs::{Tab, TabType};
|
||||||
|
|
||||||
|
fn tab(id: &str, tab_type: TabType, title: &str) -> Tab {
|
||||||
|
Tab {
|
||||||
|
id: id.to_string(),
|
||||||
|
tab_type,
|
||||||
|
title: title.to_string(),
|
||||||
|
is_transient: false,
|
||||||
|
is_dirty: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_tool_tabs_do_not_fall_back_to_welcome_route() {
|
||||||
|
let empty_posts = HashMap::new();
|
||||||
|
let empty_media = HashMap::new();
|
||||||
|
let empty_templates = HashMap::new();
|
||||||
|
let empty_scripts = HashMap::new();
|
||||||
|
let unsupported = [
|
||||||
|
TabType::Style,
|
||||||
|
TabType::Chat,
|
||||||
|
TabType::Import,
|
||||||
|
TabType::MenuEditor,
|
||||||
|
TabType::MetadataDiff,
|
||||||
|
TabType::GitDiff,
|
||||||
|
TabType::Documentation,
|
||||||
|
TabType::ApiDocumentation,
|
||||||
|
TabType::SiteValidation,
|
||||||
|
TabType::TranslationValidation,
|
||||||
|
TabType::FindDuplicates,
|
||||||
|
];
|
||||||
|
|
||||||
|
for tab_type in unsupported {
|
||||||
|
let tabs = vec![tab("tool", tab_type.clone(), "Tool")];
|
||||||
|
let route = route_kind(
|
||||||
|
&tabs,
|
||||||
|
Some("tool"),
|
||||||
|
&empty_posts,
|
||||||
|
&empty_media,
|
||||||
|
&empty_templates,
|
||||||
|
&empty_scripts,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
match route {
|
||||||
|
ContentRoute::Placeholder(title) => assert_eq!(title, "Tool"),
|
||||||
|
_ => panic!("expected placeholder route for {tab_type:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dashboard_is_used_when_available_and_no_tab_is_active() {
|
||||||
|
let dashboard = DashboardState::new("Test Project".to_string());
|
||||||
|
let empty_posts = HashMap::new();
|
||||||
|
let empty_media = HashMap::new();
|
||||||
|
let empty_templates = HashMap::new();
|
||||||
|
let empty_scripts = HashMap::new();
|
||||||
|
let route = route_kind(
|
||||||
|
&[],
|
||||||
|
None,
|
||||||
|
&empty_posts,
|
||||||
|
&empty_media,
|
||||||
|
&empty_templates,
|
||||||
|
&empty_scripts,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(&dashboard),
|
||||||
|
);
|
||||||
|
match route {
|
||||||
|
ContentRoute::Dashboard(state) => assert_eq!(state.project_name, "Test Project"),
|
||||||
|
_ => panic!("expected dashboard route"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ use "./i18n.allium" as i18n
|
|||||||
value SettingsView {
|
value SettingsView {
|
||||||
search_query: String? -- filters sections by keyword match
|
search_query: String? -- filters sections by keyword match
|
||||||
active_sections: List<String> -- visible sections after search filter
|
active_sections: List<String> -- visible sections after search filter
|
||||||
|
project_section: SettingsProjectSection?
|
||||||
|
editor_section: SettingsEditorSection?
|
||||||
|
categories: List<SettingsCategoryRow>
|
||||||
|
ai_section: SettingsAISection?
|
||||||
|
publishing_section: SettingsPublishingSection?
|
||||||
|
mcp_section: SettingsMCPSection?
|
||||||
}
|
}
|
||||||
|
|
||||||
value SettingsProjectSection {
|
value SettingsProjectSection {
|
||||||
|
|||||||
Reference in New Issue
Block a user