Compare commits

...

4 Commits

28 changed files with 1397 additions and 250 deletions

View File

@@ -12,6 +12,15 @@ Invariants and behaviours in the allium spec should be covered by unit tests of
- Before implementing or changing UI, read and follow `docs/UI_STYLE_GUIDE.md`.
- Reuse the shared styling primitives described there so new sidebars and editor areas remain consistent with the post editor.
## Computer Use for the macOS app
- Build the real app bundle with `cargo bundle-macos`.
- Use Computer Use against the exact app path `target/release/Blogging Desktop Server.app` (resolved to its absolute path), then raise that window before interacting with it.
- Do not use `cargo run` for Computer Use: it starts RuDS as a child of the command runner and Computer Use cannot attach to it as a macOS app.
- Do not run `open` on `target/debug/bds-ui`; macOS hands the executable to Ghostty instead of launching RuDS as an app.
- Do not target only the `de.rfc1437.ruds` bundle identifier because both the installed app and the repository build may share it; always use the exact repository bundle path.
- Do not improvise with AppleScript, Dock automation, or raw screen-capture workarounds. Use the Computer Use skill and its `node_repl` runtime.
## Plan Mode
- Make the plan extremely concise. Sacrifice grammar for the sake of concision.

8
Cargo.lock generated
View File

@@ -939,7 +939,6 @@ dependencies = [
"axum",
"base64 0.22.1",
"chrono",
"deunicode",
"diesel",
"diesel_migrations",
"dirs 5.0.1",
@@ -969,6 +968,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.19",
"tokio",
"unicode-normalization",
"url",
"usearch",
"uuid",
@@ -2489,12 +2489,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21d8ad60dd5b13a4ee6bd8fa2d5d88965c597c67bce32b5fc49c94f55cb50810"
[[package]]
name = "deunicode"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
[[package]]
name = "diesel"
version = "2.3.11"

View File

@@ -24,7 +24,7 @@ serde_json = "1"
serde_yaml = "0.9"
chrono = { version = "0.4", features = ["serde"] }
sha2 = "0.10"
deunicode = "1"
unicode-normalization = "0.1"
thiserror = "2"
anyhow = "1"
clap = { version = "4", features = ["derive"] }

View File

@@ -6,13 +6,13 @@ The project is under active development. Core blogging workflows are broadly ava
## Available Features
- Native Iced desktop workspace with localized menus, tabs, automatically paged post/media sidebars, dialogs, tasks, embedded Wry previews, and a live Pico CSS theme editor.
- Native Iced desktop workspace with localized menus, tabs, anchored editor popovers, automatically paged post/media sidebars, dialogs, tasks, embedded Wry previews, and a live Pico CSS theme editor.
- Post and translation authoring with draft/published lifecycle, metadata, tags, categories, links, media, and batch gallery-image import.
- Media import, thumbnails, metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors.
- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
- Post, Liquid template, and Lua script editing with dedicated syntax highlighting and explicit syntax-check feedback, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync.
- SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, and FTS5 search.
- Optional on-device multilingual semantic search and tag suggestions backed by a persistent USearch index, with duplicate-post review and dismissal in the desktop workspace.
- SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, bDS2-compatible NFD slug generation, and FTS5 search.
- Optional on-device multilingual semantic search and similar-post tag suggestions backed by a persistent USearch index, plus always-available partial-name tag autocomplete and duplicate-post review in the desktop workspace.
- Read-only in-app browsers in the Help menu for the bundled global `DOCUMENTATION.md`, the generated Lua API reference with public types and runnable examples, the [CLI/server/TUI documentation](CLI.md), and the [MCP server documentation](MCP.md), with safe GFM rendering and confirmed external links.
- A localized OPML menu editor manages pages, submenus, and category archives with protected Home ordering, keyboard-accessible tree controls, drag-and-drop, and bDS2-compatible persistence.
- Project-scoped typed domain events synchronize desktop views with shared-engine and future CLI mutations; persisted CLI notifications are consumed once, and the selected UI language is shared through settings.
@@ -26,7 +26,7 @@ The project is under active development. Core blogging workflows are broadly ava
- Persistent conversational AI with safe Markdown, streamed and cancellable responses, model/session/token tracking, bounded project-aware blog tools, and localized conversation management in the Chat workspace. Allowlisted render tools add persistent native cards, charts, forms, lists, metrics, mind maps, tables, and tabs without executing assistant-provided HTML or JavaScript.
- SSH-agent-based SCP or rsync publishing.
- Integrated Git workflow with repository initialization, Git LFS image tracking, status and diffs, branch/file history, commits, remotes, cancellable fetch/pull/push, and post-pull filesystem reconciliation; network actions respect airplane mode.
- Site, media, and translation validation plus `ruds://new-post` Blogmark capture and Lua transforms; captures open directly in the post editor and defer automatic translation until an explicit save or publish. bDS2 keeps its separate `bds2://` bookmarklet protocol.
- Site, media, and translation validation plus `ruds://new-post` Blogmark capture and Lua transforms; captures open directly in the post editor and defer automatic translation until an explicit save or publish, which queues one task per still-missing language. bDS2 keeps its separate `bds2://` bookmarklet protocol.
RuDS uses no JavaScript application runtime and loads no CSS or JavaScript from CDNs. The preview is served by the Rust application and displayed by the operating-system webview.

View File

@@ -14,7 +14,7 @@ serde_json = { workspace = true }
serde_yaml = { workspace = true }
chrono = { workspace = true }
sha2 = { workspace = true }
deunicode = { workspace = true }
unicode-normalization = { workspace = true }
thiserror = { workspace = true }
walkdir = { workspace = true }
image = { workspace = true }

View File

@@ -1,4 +1,5 @@
use std::collections::BTreeMap;
use std::sync::{LazyLock, Mutex};
use std::time::Duration;
use crate::db::DbConnection as Connection;
@@ -13,6 +14,8 @@ use crate::util::now_unix_ms;
const KEYRING_SERVICE: &str = "RuDS";
const KEYRING_SETTING_PREFIX: &str = "ai.endpoint";
static TEST_API_KEYS: LazyLock<Mutex<BTreeMap<String, String>>> =
LazyLock::new(|| Mutex::new(BTreeMap::new()));
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
@@ -246,15 +249,11 @@ fn save_endpoint_api_key_at(
api_key: &str,
updated_at: i64,
) -> EngineResult<()> {
let entry = endpoint_keyring_entry(kind)?;
let configured = !api_key.trim().is_empty();
if configured {
entry.set_password(api_key.trim()).map_err(keyring_error)?;
store_endpoint_api_key(kind, api_key.trim())?;
} else {
match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => {}
Err(error) => return Err(keyring_error(error)),
}
delete_endpoint_api_key(kind)?;
}
set_setting(
conn,
@@ -397,8 +396,12 @@ pub fn active_endpoint(conn: &Connection, offline_mode: bool) -> EngineResult<Ai
));
}
let api_key = if stored.api_key_configured {
let entry = endpoint_keyring_entry(kind)?;
let password = entry.get_password().map_err(keyring_error)?;
let password = read_endpoint_api_key(kind)?.ok_or_else(|| {
EngineError::Validation(format!(
"AI unavailable - configure {} endpoint in Settings",
kind.as_str()
))
})?;
if password.trim().is_empty() {
return Err(EngineError::Validation(format!(
"AI unavailable - configure {} endpoint in Settings",
@@ -419,13 +422,7 @@ pub fn active_endpoint(conn: &Connection, offline_mode: bool) -> EngineResult<Ai
}
pub fn load_endpoint_api_key(kind: AiEndpointKind) -> EngineResult<Option<String>> {
let entry = endpoint_keyring_entry(kind)?;
match entry.get_password() {
Ok(password) if password.trim().is_empty() => Ok(None),
Ok(password) => Ok(Some(password)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(error) => Err(keyring_error(error)),
}
read_endpoint_api_key(kind)
}
pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<AiModelInfo>> {
@@ -934,6 +931,64 @@ fn endpoint_keyring_entry(kind: AiEndpointKind) -> EngineResult<Entry> {
.map_err(keyring_error)
}
fn store_endpoint_api_key(kind: AiEndpointKind, api_key: &str) -> EngineResult<()> {
if cargo_test_process() {
TEST_API_KEYS
.lock()
.map_err(|error| EngineError::Validation(error.to_string()))?
.insert(kind.as_str().to_string(), api_key.to_string());
return Ok(());
}
endpoint_keyring_entry(kind)?
.set_password(api_key)
.map_err(keyring_error)
}
fn read_endpoint_api_key(kind: AiEndpointKind) -> EngineResult<Option<String>> {
if cargo_test_process() {
return TEST_API_KEYS
.lock()
.map_err(|error| EngineError::Validation(error.to_string()))
.map(|keys| {
keys.get(kind.as_str())
.cloned()
.filter(|key| !key.trim().is_empty())
});
}
match endpoint_keyring_entry(kind)?.get_password() {
Ok(password) if password.trim().is_empty() => Ok(None),
Ok(password) => Ok(Some(password)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(error) => Err(keyring_error(error)),
}
}
fn delete_endpoint_api_key(kind: AiEndpointKind) -> EngineResult<()> {
if cargo_test_process() {
TEST_API_KEYS
.lock()
.map_err(|error| EngineError::Validation(error.to_string()))?
.remove(kind.as_str());
return Ok(());
}
match endpoint_keyring_entry(kind)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(error) => Err(keyring_error(error)),
}
}
/// Cargo places every unit and integration test executable in a `deps`
/// directory. Keep those processes on a process-local credential store so a
/// test can never open an operating-system password prompt.
fn cargo_test_process() -> bool {
cfg!(test)
|| std::env::current_exe().is_ok_and(|path| {
path.parent()
.and_then(|parent| parent.file_name())
.is_some_and(|name| name == "deps")
})
}
fn keyring_error(error: keyring::Error) -> EngineError {
EngineError::Validation(error.to_string())
}
@@ -1024,8 +1079,7 @@ mod tests {
}
fn clear_keyring(kind: AiEndpointKind) {
let entry = endpoint_keyring_entry(kind).unwrap();
entry.delete_credential().ok();
delete_endpoint_api_key(kind).unwrap();
}
#[test]
@@ -1039,7 +1093,6 @@ mod tests {
}
#[test]
#[ignore = "touches system keychain; run explicitly when validating keychain integration"]
fn saves_online_endpoint_with_keychain_secret() {
clear_keyring(AiEndpointKind::Online);
let db = setup();
@@ -1245,7 +1298,6 @@ mod tests {
}
#[test]
#[ignore = "touches system keychain; run explicitly when validating keychain integration"]
fn run_one_shot_uses_active_endpoint_and_parses_response() {
clear_keyring(AiEndpointKind::Online);
let server = spawn_test_server(

View File

@@ -178,29 +178,83 @@ pub fn translate_missing_for_post(
if is_cancelled() {
return Err(EngineError::Validation("cancelled".to_string()));
}
let result = translate_one_post(
merge_reactive_translation_result(
&mut report,
&post,
&language,
translate_one_post(
conn,
data_dir,
&post,
&language,
false,
&mut |post, language| translate_post_ai(conn, offline_mode, post, language),
&mut |media, language| translate_media_ai(conn, offline_mode, media, language),
),
);
}
Ok(report)
}
/// Translate one language from the current missing-language set. This is the
/// reactive editor path: already-created or unconfigured translations are a
/// silent no-op, and generated translations remain drafts.
pub fn translate_missing_language_for_post(
conn: &Connection,
data_dir: &Path,
post_id: &str,
configured_languages: &[String],
language: &str,
offline_mode: bool,
is_cancelled: impl Fn() -> bool,
) -> EngineResult<FillMissingTranslationsReport> {
let post = qp::get_post_by_id(conn, post_id)?;
let targets = missing_languages(conn, &post, configured_languages)?;
if !targets.iter().any(|target| target == language) {
return Ok(FillMissingTranslationsReport {
nothing_to_do: true,
..Default::default()
});
}
if is_cancelled() {
return Err(EngineError::Validation("cancelled".to_string()));
}
let mut report = FillMissingTranslationsReport::default();
merge_reactive_translation_result(
&mut report,
&post,
language,
translate_one_post(
conn,
data_dir,
&post,
&language,
language,
false,
&mut |post, language| translate_post_ai(conn, offline_mode, post, language),
&mut |media, language| translate_media_ai(conn, offline_mode, media, language),
);
match result {
Ok(media_count) => {
report.translated_posts += 1;
report.translated_media += media_count;
}
Err(error) => {
report.failed_count += 1;
report
.errors
.push(format!("{} ({language}): {error}", post.title));
}
),
);
Ok(report)
}
fn merge_reactive_translation_result(
report: &mut FillMissingTranslationsReport,
post: &Post,
language: &str,
result: EngineResult<usize>,
) {
match result {
Ok(media_count) => {
report.translated_posts += 1;
report.translated_media += media_count;
}
Err(error) => {
report.failed_count += 1;
report
.errors
.push(format!("{} ({language}): {error}", post.title));
}
}
Ok(report)
}
fn translate_one_post(
@@ -342,7 +396,7 @@ mod tests {
use crate::db::Database;
use crate::db::fts::ensure_fts_tables;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::engine::post::{create_post, publish_post};
use crate::engine::post::{create_post, publish_post, upsert_translation};
use tempfile::TempDir;
#[test]
@@ -401,6 +455,52 @@ mod tests {
}
}
#[test]
fn reactive_language_translation_is_a_no_op_when_translation_exists() {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
ensure_fts_tables(db.conn()).unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
let dir = TempDir::new().unwrap();
let post = create_post(
db.conn(),
dir.path(),
"p1",
"Hello",
Some("Body"),
vec![],
vec![],
None,
Some("en"),
None,
)
.unwrap();
upsert_translation(
db.conn(),
dir.path(),
&post.id,
"de",
"Hallo",
None,
Some("Inhalt"),
)
.unwrap();
let report = translate_missing_language_for_post(
db.conn(),
dir.path(),
&post.id,
&["en".to_string(), "de".to_string()],
"de",
true,
|| false,
)
.unwrap();
assert!(report.nothing_to_do);
assert_eq!(report.translated_posts, 0);
}
#[test]
fn skips_do_not_translate_posts() {
let db = Database::open_in_memory().unwrap();

View File

@@ -419,13 +419,24 @@ impl<'a> EmbeddingService<'a> {
}
pub fn suggest_tags(&self, post_id: &str) -> EngineResult<Vec<String>> {
if !self.enabled() {
return Ok(Vec::new());
}
let source = qp::get_post_by_id(self.conn, post_id)?;
let current = source
.tags
.iter()
.map(|tag| tag.to_lowercase())
.collect::<HashSet<_>>();
let similar = self.find_similar(post_id, 10)?;
let Some(key) = qe::get_key_for_post(self.conn, &source.project_id, post_id)? else {
return Ok(Vec::new());
};
let similar = self.search_index(
&source.project_id,
&decode_vector(&key.vector)?,
10,
Some(key.label as u64),
)?;
let mut scores = HashMap::<String, (String, f32)>::new();
for neighbor in similar {
if let Ok(post) = qp::get_post_by_id(self.conn, &neighbor.post_id) {
@@ -1280,6 +1291,41 @@ mod tests {
);
}
#[test]
fn tag_suggestions_read_the_existing_index_without_running_inference() {
let (db, data, cache, project_id, backend) = setup_service(true);
insert_post(
&db,
&project_id,
"source",
"Space",
"rocket launch",
&["space"],
);
insert_post(
&db,
&project_id,
"neighbor",
"Science",
"rocket mission",
&["science"],
);
let service = EmbeddingService::with_backend(
db.conn(),
data.path(),
cache.path().into(),
backend.clone(),
);
service.index_unindexed(&project_id).unwrap();
let inference_count = backend.embedded();
let mut changed = qp::get_post_by_id(db.conn(), "source").unwrap();
changed.content = Some("content changed after indexing".into());
qp::update_post(db.conn(), &changed).unwrap();
assert_eq!(service.suggest_tags("source").unwrap(), vec!["science"]);
assert_eq!(backend.embedded(), inference_count);
}
#[test]
fn duplicate_search_paginates_and_batch_dismisses_in_chunks() {
let (db, data, cache, project_id, backend) = setup_service(true);

View File

@@ -1605,7 +1605,7 @@ mod tests {
oldest.post.id = "post-1".into();
oldest.post.slug = "alpha".into();
oldest.post.title = "Alpha".into();
oldest.post.tags = vec!["Rust".into()];
oldest.post.tags = vec!["Über Öl".into()];
oldest.post.published_at = Some(1_709_568_000_000);
oldest.post.created_at = 1_709_568_000_000;
oldest.body_markdown = "Alpha body".into();
@@ -1614,7 +1614,7 @@ mod tests {
newest.post.id = "post-2".into();
newest.post.slug = "beta".into();
newest.post.title = "Beta".into();
newest.post.tags = vec!["Rust".into()];
newest.post.tags = vec!["Über Öl".into()];
newest.post.published_at = Some(1_710_086_400_000);
newest.post.created_at = 1_710_086_400_000;
newest.body_markdown = "Beta body".into();
@@ -1640,7 +1640,7 @@ mod tests {
"project-1",
&metadata,
&posts,
"/tag/rust",
"/tag/uber-ol",
)
.unwrap();
assert_eq!(tag_first.status_code, 200);
@@ -1653,7 +1653,7 @@ mod tests {
"project-1",
&metadata,
&posts,
"/tag/rust/page/2",
"/tag/uber-ol/page/2",
)
.unwrap();
assert_eq!(tag_second.status_code, 200);

View File

@@ -592,7 +592,7 @@ mod tests {
)
.unwrap();
assert_eq!(rendered, "ueber-die-bruecke");
assert_eq!(rendered, "uber-die-brucke");
}
#[test]

View File

@@ -1826,8 +1826,8 @@ mod menu_tests {
slug: None,
children: vec![MenuItem {
kind: MenuItemKind::CategoryArchive,
label: "Long Form".into(),
slug: Some("Long Form".into()),
label: "Über Öl".into(),
slug: Some("Über Öl".into()),
children: Vec::new(),
}],
},
@@ -1838,12 +1838,12 @@ mod menu_tests {
let rendered = build_menu_items(dir.path(), "en", "en").unwrap();
assert_eq!(rendered[0]["href"], "/");
assert_eq!(rendered[1]["href"], "/about/");
assert_eq!(rendered[2]["children"][0]["href"], "/category/long-form/");
assert_eq!(rendered[2]["children"][0]["href"], "/category/uber-ol/");
let translated = build_menu_items(dir.path(), "de", "en").unwrap();
assert_eq!(translated[1]["href"], "/de/about/");
assert_eq!(
translated[2]["children"][0]["href"],
"/de/category/long-form/"
"/de/category/uber-ol/"
);
}
}

View File

@@ -1,34 +1,21 @@
use deunicode::deunicode;
use unicode_normalization::UnicodeNormalization;
/// Pre-process German characters to match TypeScript `transliteration` npm output.
/// deunicode maps ä→a, ö→o, ü→u but TypeScript produces ä→ae, ö→oe, ü→ue.
/// We replace these before deunicode so the slug output is compatible.
/// Apply the one German replacement that canonical decomposition cannot provide.
fn german_transliterate(input: &str) -> String {
let mut result = String::with_capacity(input.len() + 16);
for c in input.chars() {
match c {
'ä' => result.push_str("ae"),
'ö' => result.push_str("oe"),
'ü' => result.push_str("ue"),
'Ä' => result.push_str("Ae"),
'Ö' => result.push_str("Oe"),
'Ü' => result.push_str("Ue"),
_ => result.push(c),
}
}
result
input.replace('ß', "ss")
}
/// Generate a URL-safe slug from a title string.
///
/// Transliterates Unicode to ASCII, lowercases, replaces non-alphanumeric
/// chars with hyphens, and collapses/trims hyphens.
///
/// German umlauts (ä/ö/ü/Ä/Ö/Ü) are pre-processed to ae/oe/ue/Ae/Oe/Ue
/// to match TypeScript `transliteration` npm output. ß→ss is handled by deunicode.
/// Matches bDS2 exactly: replace ß with `ss`, canonically decompose Unicode,
/// discard non-ASCII code points, lowercase, replace non-alphanumeric runs
/// with hyphens, and trim leading/trailing hyphens.
pub fn slugify(input: &str) -> String {
let preprocessed = german_transliterate(input);
let ascii = deunicode(&preprocessed);
let ascii = preprocessed
.nfd()
.filter(char::is_ascii)
.collect::<String>();
let lowered = ascii.to_lowercase();
let mut slug = String::with_capacity(lowered.len());
let mut prev_hyphen = true; // avoid leading hyphen
@@ -79,7 +66,7 @@ mod tests {
#[test]
fn unicode_slug() {
assert_eq!(slugify("Über die Brücke"), "ueber-die-bruecke");
assert_eq!(slugify("Über die Brücke"), "uber-die-brucke");
}
#[test]
@@ -122,24 +109,34 @@ mod tests {
assert_eq!(slug, "hello-4");
}
// German umlaut tests — spec: "only German and English letters are used.
// Verify deunicode handles ä/ö/ü/ß/ÄÖÜ correctly against transliteration npm."
// Pre-processing maps ä→ae, ö→oe, ü→ue to match TypeScript transliteration npm.
// ß→ss is handled correctly by deunicode without pre-processing.
// German corpus copied from the bDS2 golden-master tests.
#[test]
fn bds2_german_transliteration_corpus() {
assert_eq!(slugify("Straße"), "strasse");
assert_eq!(slugify("Öl"), "ol");
assert_eq!(slugify("Äpfel"), "apfel");
assert_eq!(slugify("Über"), "uber");
assert_eq!(slugify("ÄÖÜäöüß"), "aouaouss");
}
#[test]
fn bds2_nfd_discards_non_ascii_instead_of_transliterating_it() {
assert_eq!(slugify("Crème 東京 œ"), "creme");
}
#[test]
fn german_umlaut_ae() {
assert_eq!(slugify("Ärger"), "aerger");
assert_eq!(slugify("Ärger"), "arger");
}
#[test]
fn german_umlaut_oe() {
assert_eq!(slugify("Öffnung"), "oeffnung");
assert_eq!(slugify("Öffnung"), "offnung");
}
#[test]
fn german_umlaut_ue() {
assert_eq!(slugify("Über"), "ueber");
assert_eq!(slugify("Über"), "uber");
}
#[test]
@@ -149,12 +146,12 @@ mod tests {
#[test]
fn german_mixed_umlauts() {
assert_eq!(slugify("Größe über Maße"), "groesse-ueber-masse");
assert_eq!(slugify("Größe über Maße"), "grosse-uber-masse");
}
#[test]
fn german_uppercase_umlauts() {
assert_eq!(slugify("ÄÖÜ Test"), "aeoeue-test");
assert_eq!(slugify("ÄÖÜ Test"), "aou-test");
}
// spec: CreatePost uses Slug.generate(title ?? "untitled")

View File

@@ -284,7 +284,7 @@ fn slug_generation_matches_spec() {
assert_eq!(slugify("a --- b"), "a-b");
assert_eq!(slugify("---hello---"), "hello");
assert_eq!(slugify("café"), "cafe");
assert_eq!(slugify("über"), "ueber");
assert_eq!(slugify("über"), "uber");
assert_eq!(slugify("Straße"), "strasse");
assert_eq!(ensure_unique("test", |value| value == "test"), "test-2");
}

View File

@@ -1647,10 +1647,17 @@ impl BdsApp {
if self.active_tab.as_deref() != Some(id.as_str()) {
self.flush_active_post_editor();
}
self.active_tab = Some(id);
self.active_tab = Some(id.clone());
}
self.enforce_panel_tab_fallback();
self.sync_embedded_previews()
let semantic_task = self
.tabs
.iter()
.find(|tab| tab.id == id && tab.tab_type == TabType::Post)
.map_or_else(Task::none, |_| {
Task::done(Message::LoadSemanticTagSuggestions(id))
});
Task::batch([self.sync_embedded_previews(), semantic_task])
}
// ── Project management ──
Message::ProjectsLoaded(projects) => {
@@ -4013,6 +4020,7 @@ impl BdsApp {
if entity == DomainEntity::Tag {
self.tags_view_state = None;
self.refresh_post_editor_tag_options();
if let Some(tab) = self
.tabs
.iter()
@@ -4026,6 +4034,9 @@ impl BdsApp {
let tab = self.tabs.iter().find(|tab| tab.id == entity_id).cloned();
let Some(tab) = tab else { return };
let previous_post_state = (entity == DomainEntity::Post)
.then(|| self.post_editors.get(entity_id).cloned())
.flatten();
let dirty = match entity {
DomainEntity::Post => self
.post_editors
@@ -4064,6 +4075,11 @@ impl BdsApp {
DomainEntity::Tag | DomainEntity::Project | DomainEntity::Setting => {}
}
self.load_editor_for_tab(&tab);
if let (Some(previous), Some(current)) =
(previous_post_state, self.post_editors.get_mut(entity_id))
{
current.restore_view_state(&previous);
}
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == entity_id) {
tab.title = match entity {
DomainEntity::Post => self
@@ -5638,11 +5654,21 @@ impl BdsApp {
// ── Editor save/publish helpers ──
fn save_post_editor(&mut self, post_id: &str) -> Task<Message> {
fn save_post_editor(
&mut self,
post_id: &str,
schedule_auto_translation: bool,
) -> Task<Message> {
let saves_canonical_post = self
.post_editors
.get(post_id)
.is_some_and(|editor| editor.active_language == editor.canonical_language);
match self.persist_post_editor_state(post_id) {
Ok(()) => {
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
return self.schedule_post_auto_translation(post_id);
if schedule_auto_translation && saves_canonical_post {
return self.schedule_post_auto_translation(post_id);
}
}
Err(e) => self.notify_operation_failed("common.save", e),
}
@@ -5954,39 +5980,81 @@ impl BdsApp {
}
fn schedule_post_auto_translation(&mut self, post_id: &str) -> Task<Message> {
let Some(db) = self.db.as_ref() else {
let (Some(db), Some(data_dir)) = (self.db.as_ref(), self.data_dir.as_ref()) else {
return Task::none();
};
let Ok(meta) = engine::meta::read_project_json(data_dir) else {
return Task::none();
};
let Ok(post) = bds_core::db::queries::post::get_post_by_id(db.conn(), post_id) else {
return Task::none();
};
let main_language = meta.main_language.unwrap_or_else(|| "en".to_string());
let configured =
engine::auto_translation::configured_languages(&main_language, &meta.blog_languages);
let Ok(targets) =
engine::auto_translation::missing_languages(db.conn(), &post, &configured)
else {
return Task::none();
};
if targets.is_empty() {
return Task::none();
}
if engine::ai::active_endpoint(db.conn(), self.offline_mode).is_err() {
return Task::none();
}
let post_id = post_id.to_string();
let offline_mode = self.offline_mode;
let locale = self.ui_locale;
self.spawn_grouped_engine_task(
"engine.autoTranslationStarted",
"AI",
move |db_path, _project_id, data_dir, task_manager, task_id| {
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
let meta = engine::meta::read_project_json(&data_dir)
Task::batch(targets.into_iter().map(|language| {
let post_id = post_id.clone();
let configured = configured.clone();
self.spawn_grouped_engine_task(
"engine.autoTranslationStarted",
"AI",
move |db_path, _project_id, data_dir, task_manager, task_id| {
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
let report = engine::auto_translation::translate_missing_language_for_post(
db.conn(),
&data_dir,
&post_id,
&configured,
&language,
offline_mode,
move || task_manager.is_cancelled(task_id),
)
.map_err(|error| error.to_string())?;
let report = engine::auto_translation::translate_missing_for_post(
db.conn(),
&data_dir,
&post_id,
meta.main_language.as_deref().unwrap_or("en"),
&meta.blog_languages,
offline_mode,
move || task_manager.is_cancelled(task_id),
)
.map_err(|error| error.to_string())?;
Ok(tw(
locale,
"engine.autoTranslationComplete",
&[("count", &report.translated_posts.to_string())],
))
},
Ok(tw(
locale,
"engine.autoTranslationComplete",
&[("count", &report.translated_posts.to_string())],
))
},
)
}))
}
fn ensure_post_editor_tag(&mut self, post_id: &str, name: &str) -> Task<Message> {
let (Some(db), Some(data_dir)) = (self.db.as_ref(), self.data_dir.as_ref()) else {
return Task::none();
};
let Ok(post) = bds_core::db::queries::post::get_post_by_id(db.conn(), post_id) else {
return Task::none();
};
if bds_core::db::queries::tag::get_tag_by_project_and_name(
db.conn(),
&post.project_id,
name,
)
.is_err()
&& let Err(error) =
engine::tag::create_tag(db.conn(), data_dir, &post.project_id, name, None)
{
self.notify_operation_failed("editor.tags", error);
return Task::none();
}
self.refresh_post_editor_tag_options();
Task::none()
}
fn insert_link_modal(&mut self, post_id: &str) -> Task<Message> {
@@ -6177,7 +6245,7 @@ impl BdsApp {
tab.is_dirty = true;
}
self.active_modal = None;
self.save_post_editor(post_id)
self.save_post_editor(post_id, false)
}
fn insert_selected_post_link(&mut self, post_id: &str, linked_post_id: &str) -> Task<Message> {
@@ -8216,6 +8284,45 @@ impl BdsApp {
}
}
fn project_tag_names(&self, project_id: &str) -> Vec<String> {
let Some(db) = self.db.as_ref() else {
return Vec::new();
};
let mut names = bds_core::db::queries::tag::list_tags_by_project(db.conn(), project_id)
.unwrap_or_default()
.into_iter()
.map(|tag| tag.name)
.collect::<Vec<_>>();
for tag in bds_core::db::queries::post::list_posts_by_project(db.conn(), project_id)
.unwrap_or_default()
.into_iter()
.flat_map(|post| post.tags)
{
if !names
.iter()
.any(|existing| existing.eq_ignore_ascii_case(&tag))
{
names.push(tag);
}
}
names.sort_by_key(|name| name.to_lowercase());
names
}
fn refresh_post_editor_tag_options(&mut self) {
let Some(project_id) = self
.active_project
.as_ref()
.map(|project| project.id.clone())
else {
return;
};
let names = self.project_tag_names(&project_id);
for editor in self.post_editors.values_mut() {
editor.available_tags.clone_from(&names);
}
}
/// Load editor state when a tab is opened for an entity.
fn load_editor_for_tab(&mut self, tab: &Tab) {
let Some(ref db) = self.db else { return };
@@ -8268,18 +8375,17 @@ impl BdsApp {
let (outlinks, backlinks) = self.load_post_links(&post.id);
let linked_media =
self.load_post_media_items(&post.id, post.content.as_deref());
self.post_editors.insert(
post.id.clone(),
PostEditorState::from_post(
&post,
default_post_editor_mode(self.settings_state.as_ref()),
&self.blog_languages,
&translations,
outlinks,
backlinks,
linked_media,
),
let mut editor = PostEditorState::from_post(
&post,
default_post_editor_mode(self.settings_state.as_ref()),
&self.blog_languages,
&translations,
outlinks,
backlinks,
linked_media,
);
editor.available_tags = self.project_tag_names(&post.project_id);
self.post_editors.insert(post.id.clone(), editor);
}
Err(e) => {
self.notify_operation_failed("activity.posts", e);
@@ -9490,10 +9596,12 @@ mod tests {
use bds_core::engine::generation::GenerationReport;
use bds_core::engine::task::{TaskStatus, TaskStatus::*};
use bds_core::engine::{
ai, blogmark, chat, media, menu, meta, post, script, template, wordpress_import,
ai, blogmark, chat, media, menu, meta, post, script, tag, template, wordpress_import,
};
use bds_core::i18n::UiLocale;
use bds_core::model::{ChatRole, DomainEvent, Project, ScriptKind, TemplateKind};
use bds_core::model::{
ChatRole, DomainEntity, DomainEvent, NotificationAction, Project, ScriptKind, TemplateKind,
};
use chrono::{Datelike, TimeZone};
use std::io::{Read, Write};
use std::net::TcpListener;
@@ -9617,8 +9725,10 @@ mod tests {
)
.unwrap();
let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf());
let mut settings = SettingsViewState::default();
settings.default_mode = "preview".to_string();
let settings = SettingsViewState {
default_mode: "preview".to_string(),
..Default::default()
};
app.settings_state = Some(settings);
app.offline_mode = true;
app.sidebar_view = SidebarView::Settings;
@@ -11164,6 +11274,271 @@ mod tests {
);
}
fn auto_translation_test_app(translated_languages: &[&str]) -> (BdsApp, String, TempDir) {
let (db, project, tmp) = setup();
let mut metadata = bds_core::engine::meta::read_project_json(tmp.path()).unwrap();
metadata.main_language = Some("en".to_string());
metadata.blog_languages = vec!["en".to_string(), "de".to_string(), "fr".to_string()];
bds_core::engine::meta::write_project_json(tmp.path(), &metadata).unwrap();
ai::save_endpoint(
db.conn(),
&ai::AiEndpointConfig {
kind: ai::AiEndpointKind::Airplane,
url: "http://127.0.0.1:9".to_string(),
model: "test".to_string(),
api_key: None,
},
)
.unwrap();
let created = post::create_post(
db.conn(),
tmp.path(),
&project.id,
"Original",
Some("Body"),
vec![],
vec![],
None,
Some("en"),
None,
)
.unwrap();
for language in translated_languages {
post::upsert_translation(
db.conn(),
tmp.path(),
&created.id,
language,
"Translated",
None,
Some("Body"),
)
.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 editor = PostEditorState::from_post(
&editor_post,
"markdown",
&["en".to_string(), "de".to_string(), "fr".to_string()],
&translations,
Vec::new(),
Vec::new(),
Vec::new(),
);
let mut app = make_app(db, project, &tmp);
app.offline_mode = true;
app.post_editors.insert(created.id.clone(), editor);
app.tabs.push(crate::state::tabs::Tab {
id: created.id.clone(),
tab_type: crate::state::tabs::TabType::Post,
title: created.title,
is_transient: false,
is_dirty: false,
});
(app, created.id, tmp)
}
#[test]
fn first_manual_save_enqueues_one_task_per_missing_translation() {
let (mut app, post_id, _tmp) = auto_translation_test_app(&[]);
let _ = app.save_post_editor(&post_id, true);
assert_eq!(
app.task_manager
.snapshots()
.iter()
.filter(|task| task.group_name.as_deref() == Some("AI"))
.count(),
2
);
}
#[test]
fn later_manual_save_enqueues_no_translation_task_when_languages_exist() {
let (mut app, post_id, _tmp) = auto_translation_test_app(&["de", "fr"]);
let _ = app.save_post_editor(&post_id, true);
assert!(
app.task_manager
.snapshots()
.iter()
.all(|task| task.group_name.as_deref() != Some("AI"))
);
}
#[test]
fn saving_a_translation_draft_does_not_enqueue_other_languages() {
let (mut app, post_id, _tmp) = auto_translation_test_app(&["de"]);
app.post_editors
.get_mut(&post_id)
.unwrap()
.switch_language("de");
let _ = app.save_post_editor(&post_id, true);
assert!(
app.task_manager
.snapshots()
.iter()
.all(|task| task.group_name.as_deref() != Some("AI"))
);
}
#[test]
fn inserting_editor_markdown_persists_without_enqueuing_translations() {
let (mut app, post_id, _tmp) = auto_translation_test_app(&[]);
let _ = app.insert_markdown_into_post(&post_id, "[Link](/target)");
assert!(
app.task_manager
.snapshots()
.iter()
.all(|task| task.group_name.as_deref() != Some("AI"))
);
}
#[test]
fn post_editor_loads_existing_project_tags_for_partial_suggestions() {
let (db, project, tmp) = setup();
tag::create_tag(db.conn(), tmp.path(), &project.id, "Photography", None).unwrap();
let tagged = post::create_post(
db.conn(),
tmp.path(),
&project.id,
"Existing",
Some("Body"),
vec!["Photo Essay".to_string()],
vec![],
None,
Some("en"),
None,
)
.unwrap();
let edited = post::create_post(
db.conn(),
tmp.path(),
&project.id,
"Edited",
Some("Body"),
vec![],
vec![],
None,
Some("en"),
None,
)
.unwrap();
let mut app = make_app(db, project, &tmp);
let _ = app.open_tab(Tab {
id: edited.id.clone(),
tab_type: TabType::Post,
title: edited.title,
is_transient: false,
is_dirty: false,
});
assert_eq!(
app.post_editors[&edited.id].available_tags,
vec!["Photo Essay", "Photography"]
);
assert_eq!(tagged.tags, vec!["Photo Essay"]);
}
#[test]
fn adding_new_tag_from_post_editor_creates_portable_tag_metadata() {
let (db, project, tmp) = setup();
let edited = post::create_post(
db.conn(),
tmp.path(),
&project.id,
"Edited",
Some("Body"),
vec![],
vec![],
None,
Some("en"),
None,
)
.unwrap();
let mut app = make_app(db, project.clone(), &tmp);
let _ = app.open_tab(Tab {
id: edited.id.clone(),
tab_type: TabType::Post,
title: edited.title,
is_transient: false,
is_dirty: false,
});
let _ = app.handle_post_editor_msg(PostEditorMsg::AddSuggestedTag("New Tag".into()));
assert!(
bds_core::db::queries::tag::get_tag_by_project_and_name(
app.db.as_ref().unwrap().conn(),
&project.id,
"new tag",
)
.is_ok()
);
assert!(
app.post_editors[&edited.id]
.tags
.contains(&"New Tag".to_string())
);
let portable = std::fs::read_to_string(tmp.path().join("meta/tags.json")).unwrap();
assert!(portable.contains("New Tag"));
}
#[test]
fn post_refresh_does_not_drop_loaded_semantic_tag_suggestions() {
let (db, project, tmp) = setup();
let edited = post::create_post(
db.conn(),
tmp.path(),
&project.id,
"Edited",
Some("Body"),
vec![],
vec![],
None,
Some("en"),
None,
)
.unwrap();
let mut app = make_app(db, project.clone(), &tmp);
let _ = app.open_tab(Tab {
id: edited.id.clone(),
tab_type: TabType::Post,
title: edited.title,
is_transient: false,
is_dirty: false,
});
app.post_editors
.get_mut(&edited.id)
.unwrap()
.semantic_tag_suggestions = vec!["science".to_string()];
app.handle_domain_event(DomainEvent::EntityChanged {
project_id: project.id,
entity: DomainEntity::Post,
entity_id: edited.id.clone(),
action: NotificationAction::Updated,
});
assert_eq!(
app.post_editors[&edited.id].semantic_tag_suggestions,
vec!["science"]
);
}
#[test]
fn switching_tabs_flushes_active_post_editor() {
let (db, project, tmp) = setup();

View File

@@ -7,6 +7,11 @@ impl BdsApp {
SyncEmbeddedPreview,
Analyze(String),
AnalyzeTaxonomy(String),
EnsureTag {
post_id: String,
tag: String,
},
LoadSemanticTags(String),
AddGalleryImages(String),
DetectLanguage(String),
OpenTranslate(String),
@@ -127,6 +132,9 @@ impl BdsApp {
}
PostEditorMsg::ToggleMetadata => {
state.metadata_expanded = !state.metadata_expanded;
if state.metadata_expanded {
deferred = DeferredPostAction::LoadSemanticTags(state.post_id.clone());
}
}
PostEditorMsg::ToggleExcerpt => {
state.excerpt_expanded = !state.excerpt_expanded;
@@ -139,12 +147,24 @@ impl BdsApp {
}
PostEditorMsg::TagsInputChanged(s) => {
state.tags_input = s;
if state.tags_input.trim().is_empty() {
deferred = DeferredPostAction::LoadSemanticTags(state.post_id.clone());
}
}
PostEditorMsg::TagsInputSubmit => {
let tag = state.tags_input.trim().to_string();
if !tag.is_empty() && !state.tags.contains(&tag) {
state.tags.push(tag);
if !tag.is_empty()
&& !state
.tags
.iter()
.any(|current| current.eq_ignore_ascii_case(&tag))
{
state.tags.push(tag.clone());
state.mark_dirty();
deferred = DeferredPostAction::EnsureTag {
post_id: state.post_id.clone(),
tag,
};
}
state.tags_input.clear();
}
@@ -156,10 +176,12 @@ impl BdsApp {
{
state.tags.push(tag.clone());
state.mark_dirty();
deferred = DeferredPostAction::EnsureTag {
post_id: state.post_id.clone(),
tag: tag.clone(),
};
}
state
.semantic_tag_suggestions
.retain(|candidate| !candidate.eq_ignore_ascii_case(&tag));
state.tags_input.clear();
}
PostEditorMsg::RemoveTag(tag) => {
state.tags.retain(|t| t != &tag);
@@ -286,6 +308,12 @@ impl BdsApp {
DeferredPostAction::SyncEmbeddedPreview => self.sync_embedded_preview_for_active_post(),
DeferredPostAction::Analyze(tab_id) => self.run_post_ai_analysis(&tab_id),
DeferredPostAction::AnalyzeTaxonomy(tab_id) => self.run_post_taxonomy_analysis(&tab_id),
DeferredPostAction::EnsureTag { post_id, tag } => {
self.ensure_post_editor_tag(&post_id, &tag)
}
DeferredPostAction::LoadSemanticTags(post_id) => {
Task::done(Message::LoadSemanticTagSuggestions(post_id))
}
DeferredPostAction::AddGalleryImages(post_id) => self.add_gallery_images(&post_id),
DeferredPostAction::DetectLanguage(tab_id) => self.detect_post_language(&tab_id),
DeferredPostAction::OpenTranslate(tab_id) => self.open_post_translation_modal(&tab_id),
@@ -293,7 +321,7 @@ impl BdsApp {
post_id,
target_language,
} => self.translate_post_to(&post_id, &target_language),
DeferredPostAction::Save(tab_id) => self.save_post_editor(&tab_id),
DeferredPostAction::Save(tab_id) => self.save_post_editor(&tab_id, true),
DeferredPostAction::Publish(tab_id) => self.publish_post_editor(&tab_id),
DeferredPostAction::Discard(tab_id) => self.discard_post_editor(&tab_id),
DeferredPostAction::ShowDelete { tab_id, name } => {

View File

@@ -219,6 +219,12 @@ impl BdsApp {
} => {
let search_rebuild_finished = self.search_index_rebuild_task_id == Some(task_id);
let cancelled = self.task_manager.status(task_id) == Some(TaskStatus::Cancelled);
let refresh_semantic_tags = !cancelled
&& result.is_ok()
&& matches!(
operation,
"embeddings.indexing" | "menu.item.rebuildEmbeddingIndex"
);
match &result {
_ if cancelled => {}
Ok(detail) => {
@@ -265,8 +271,22 @@ impl BdsApp {
self.sync_menu_state();
}
let sidebar_task = self.refresh_counts();
let semantic_task = if refresh_semantic_tags {
self.active_tab
.as_ref()
.filter(|id| {
self.tabs
.iter()
.any(|tab| tab.id == id.as_str() && tab.tab_type == TabType::Post)
})
.map_or_else(Task::none, |id| {
Task::done(Message::LoadSemanticTagSuggestions(id.clone()))
})
} else {
Task::none()
};
self.refresh_task_snapshots();
sidebar_task
Task::batch([sidebar_task, semantic_task])
}
Message::SiteGenerationSectionDone {
group_id,

View File

@@ -1,3 +1,4 @@
pub mod inputs;
pub mod native_edit;
pub mod popover;
pub mod webview;

View File

@@ -0,0 +1,365 @@
use iced::advanced::layout;
use iced::advanced::overlay;
use iced::advanced::renderer;
use iced::advanced::widget::{Operation, Tree};
use iced::advanced::{Clipboard, Layout, Shell, Widget};
use iced::event;
use iced::keyboard::{self, Key, key};
use iced::mouse;
use iced::{Element, Event, Length, Point, Rectangle, Size, Vector};
/// An interactive popup anchored below the right edge of its trigger.
pub struct Popover<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer> {
trigger: Element<'a, Message, Theme, Renderer>,
popup: Element<'a, Message, Theme, Renderer>,
is_open: bool,
on_dismiss: Message,
gap: f32,
}
impl<'a, Message, Theme, Renderer> Popover<'a, Message, Theme, Renderer> {
pub fn new(
trigger: impl Into<Element<'a, Message, Theme, Renderer>>,
popup: impl Into<Element<'a, Message, Theme, Renderer>>,
is_open: bool,
on_dismiss: Message,
) -> Self {
Self {
trigger: trigger.into(),
popup: popup.into(),
is_open,
on_dismiss,
gap: 8.0,
}
}
}
fn popup_position(anchor: Rectangle, popup: Size, viewport: Size, gap: f32) -> Point {
let max_x = (viewport.width - popup.width).max(0.0);
let x = (anchor.x + anchor.width - popup.width).clamp(0.0, max_x);
let below = anchor.y + anchor.height + gap;
let y = if below + popup.height <= viewport.height {
below
} else {
(anchor.y - gap - popup.height).max(0.0)
};
Point::new(x, y)
}
impl<'a, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for Popover<'a, Message, Theme, Renderer>
where
Message: Clone,
Renderer: renderer::Renderer,
{
fn children(&self) -> Vec<Tree> {
vec![Tree::new(&self.trigger), Tree::new(&self.popup)]
}
fn diff(&self, tree: &mut Tree) {
tree.diff_children(&[self.trigger.as_widget(), self.popup.as_widget()]);
}
fn size(&self) -> Size<Length> {
self.trigger.as_widget().size()
}
fn layout(
&self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.trigger
.as_widget()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn Operation,
) {
self.trigger
.as_widget()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn on_event(
&mut self,
tree: &mut Tree,
event: Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) -> event::Status {
self.trigger.as_widget_mut().on_event(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
)
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.trigger.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.trigger.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
let (trigger_tree, popup_tree) = tree.children.split_at_mut(1);
let trigger_overlay = self.trigger.as_widget_mut().overlay(
&mut trigger_tree[0],
layout,
renderer,
translation,
);
let popup_overlay = self.is_open.then(|| {
overlay::Element::new(Box::new(PopupOverlay {
anchor: layout.bounds() + translation,
popup: &mut self.popup,
state: &mut popup_tree[0],
on_dismiss: self.on_dismiss.clone(),
gap: self.gap,
}))
});
if trigger_overlay.is_some() || popup_overlay.is_some() {
Some(
overlay::Group::with_children(
trigger_overlay.into_iter().chain(popup_overlay).collect(),
)
.overlay(),
)
} else {
None
}
}
}
impl<'a, Message, Theme, Renderer> From<Popover<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: Clone + 'a,
Theme: 'a,
Renderer: renderer::Renderer + 'a,
{
fn from(popover: Popover<'a, Message, Theme, Renderer>) -> Self {
Element::new(popover)
}
}
struct PopupOverlay<'a, 'b, Message, Theme, Renderer> {
anchor: Rectangle,
popup: &'b mut Element<'a, Message, Theme, Renderer>,
state: &'b mut Tree,
on_dismiss: Message,
gap: f32,
}
impl<Message, Theme, Renderer> overlay::Overlay<Message, Theme, Renderer>
for PopupOverlay<'_, '_, Message, Theme, Renderer>
where
Message: Clone,
Renderer: renderer::Renderer,
{
fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node {
let popup = self.popup.as_widget().layout(
self.state,
renderer,
&layout::Limits::new(Size::ZERO, bounds),
);
let position = popup_position(self.anchor, popup.size(), bounds, self.gap);
layout::Node::with_children(popup.size(), vec![popup])
.translate(Vector::new(position.x, position.y))
}
fn draw(
&self,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
) {
self.popup.as_widget().draw(
self.state,
renderer,
theme,
style,
layout.children().next().expect("popover child layout"),
cursor,
&Rectangle::with_size(Size::INFINITY),
);
}
fn operate(&mut self, layout: Layout<'_>, renderer: &Renderer, operation: &mut dyn Operation) {
self.popup.as_widget().operate(
self.state,
layout.children().next().expect("popover child layout"),
renderer,
operation,
);
}
fn on_event(
&mut self,
event: Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
) -> event::Status {
let popup_layout = layout.children().next().expect("popover child layout");
let status = self.popup.as_widget_mut().on_event(
self.state,
event.clone(),
popup_layout,
cursor,
renderer,
clipboard,
shell,
&Rectangle::with_size(Size::INFINITY),
);
if status == event::Status::Captured {
return status;
}
let escape = matches!(
event,
Event::Keyboard(keyboard::Event::KeyPressed {
key: Key::Named(key::Named::Escape),
..
})
);
let outside_press = matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
| Event::Touch(iced::touch::Event::FingerPressed { .. })
) && !cursor.is_over(layout.bounds());
if escape || outside_press {
shell.publish(self.on_dismiss.clone());
event::Status::Captured
} else {
status
}
}
fn mouse_interaction(
&self,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.popup.as_widget().mouse_interaction(
self.state,
layout.children().next().expect("popover child layout"),
cursor,
viewport,
renderer,
)
}
fn overlay<'b>(
&'b mut self,
layout: Layout<'_>,
renderer: &Renderer,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
self.popup.as_widget_mut().overlay(
self.state,
layout.children().next().expect("popover child layout"),
renderer,
Vector::ZERO,
)
}
}
pub fn popover<'a, Message: Clone + 'a>(
trigger: impl Into<Element<'a, Message>>,
popup: impl Into<Element<'a, Message>>,
is_open: bool,
on_dismiss: Message,
) -> Popover<'a, Message> {
Popover::new(trigger, popup, is_open, on_dismiss)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn popup_is_right_aligned_below_its_trigger() {
assert_eq!(
popup_position(
Rectangle::new(Point::new(400.0, 20.0), Size::new(100.0, 30.0)),
Size::new(220.0, 180.0),
Size::new(800.0, 600.0),
8.0,
),
Point::new(280.0, 58.0)
);
}
#[test]
fn popup_flips_above_and_stays_inside_the_viewport() {
assert_eq!(
popup_position(
Rectangle::new(Point::new(10.0, 570.0), Size::new(40.0, 24.0)),
Size::new(220.0, 180.0),
Size::new(800.0, 600.0),
8.0,
),
Point::new(0.0, 382.0)
);
}
}

View File

@@ -9,7 +9,7 @@ use bds_core::i18n::{self, UiLocale};
use bds_core::model::{Media, MediaTranslation};
use crate::app::Message;
use crate::components::inputs;
use crate::components::{inputs, popover};
use crate::i18n::t;
use crate::views::post_editor::TranslationFlag;
use crate::views::status_bar;
@@ -235,16 +235,48 @@ pub fn view<'a>(
data_dir: Option<&Path>,
ai_enabled: bool,
) -> Element<'a, Message> {
let mut quick_action_items: Vec<Element<'a, Message>> = Vec::new();
if state.mime_type.starts_with("image/") {
quick_action_items.push(quick_action_item(
t(locale, "editor.aiAnalyze"),
MediaEditorMsg::AnalyzeWithAi,
ai_enabled,
));
}
quick_action_items.push(quick_action_item(
t(locale, "editor.detectLanguage"),
MediaEditorMsg::DetectLanguage,
ai_enabled,
));
quick_action_items.push(quick_action_item(
t(locale, "editor.translate"),
MediaEditorMsg::TranslateMetadata,
ai_enabled,
));
let quick_actions_menu: Element<'a, Message> = container(column(quick_action_items).spacing(4))
.padding(8)
.style(status_bar::dropdown_bg)
.into();
let quick_actions_button: Element<'a, Message> =
button(text(t(locale, "editor.quickActions")).size(13))
.on_press_maybe(
ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::ToggleQuickActions)),
)
.style(inputs::secondary_button)
.padding([6, 16])
.into();
let quick_actions: Element<'a, Message> = popover::popover(
quick_actions_button,
quick_actions_menu,
state.quick_actions_open,
Message::MediaEditor(MediaEditorMsg::ToggleQuickActions),
)
.into();
let header_row = inputs::toolbar(
vec![text(state.original_name.clone()).size(18).into()],
vec![
button(text(t(locale, "editor.quickActions")).size(13))
.on_press_maybe(
ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::ToggleQuickActions)),
)
.style(inputs::secondary_button)
.padding([6, 16])
.into(),
quick_actions,
button(text(t(locale, "editor.replaceFile")).size(13))
.on_press(Message::MediaEditor(MediaEditorMsg::ReplaceFile))
.style(inputs::secondary_button)
@@ -263,38 +295,7 @@ pub fn view<'a>(
],
);
let header_menu: Element<'a, Message> = if state.quick_actions_open {
let mut items: Vec<Element<'a, Message>> = Vec::new();
if state.mime_type.starts_with("image/") {
items.push(quick_action_item(
t(locale, "editor.aiAnalyze"),
MediaEditorMsg::AnalyzeWithAi,
ai_enabled,
));
}
items.push(quick_action_item(
t(locale, "editor.detectLanguage"),
MediaEditorMsg::DetectLanguage,
ai_enabled,
));
items.push(quick_action_item(
t(locale, "editor.translate"),
MediaEditorMsg::TranslateMetadata,
ai_enabled,
));
row![
Space::with_width(Length::Fill),
container(column(items).spacing(4))
.padding(8)
.style(status_bar::dropdown_bg),
]
.align_y(iced::Alignment::Start)
.into()
} else {
Space::new(0, 0).into()
};
let header = column![header_row, header_menu].spacing(6);
let header = header_row;
// Translation flags bar
let flags = state.translation_flags();

View File

@@ -10,8 +10,8 @@ use bds_core::model::{Post, PostStatus, PostTranslation};
use bds_editor::{CodeEditor, EditorBuffer, EditorMessage, highlighter};
use crate::app::Message;
use crate::components::inputs;
use crate::i18n::t;
use crate::components::{inputs, popover};
use crate::i18n::{t, tw};
use crate::views::status_bar;
/// Per editor_post.allium TranslationFlag value.
@@ -76,6 +76,7 @@ pub struct PostEditorState {
pub quick_actions_open: bool,
pub tags_input: String,
pub categories_input: String,
pub available_tags: Vec<String>,
pub semantic_tag_suggestions: Vec<String>,
pub active_language: String,
pub canonical_language: String,
@@ -124,6 +125,7 @@ impl Clone for PostEditorState {
quick_actions_open: self.quick_actions_open,
tags_input: self.tags_input.clone(),
categories_input: self.categories_input.clone(),
available_tags: self.available_tags.clone(),
semantic_tag_suggestions: self.semantic_tag_suggestions.clone(),
active_language: self.active_language.clone(),
canonical_language: self.canonical_language.clone(),
@@ -190,6 +192,7 @@ impl PostEditorState {
quick_actions_open: false,
tags_input: String::new(),
categories_input: String::new(),
available_tags: Vec::new(),
semantic_tag_suggestions: Vec::new(),
active_language: canonical_lang.clone(),
canonical_language: canonical_lang,
@@ -208,6 +211,18 @@ impl PostEditorState {
self.last_edit_at_ms = bds_core::util::now_unix_ms();
}
pub fn restore_view_state(&mut self, previous: &Self) {
self.metadata_expanded = previous.metadata_expanded;
self.excerpt_expanded = previous.excerpt_expanded;
self.editor_mode.clone_from(&previous.editor_mode);
self.quick_actions_open = previous.quick_actions_open;
self.tags_input.clone_from(&previous.tags_input);
self.categories_input.clone_from(&previous.categories_input);
self.semantic_tag_suggestions
.clone_from(&previous.semantic_tag_suggestions);
self.switch_language(&previous.active_language);
}
pub fn insert_markdown_at_cursor(&mut self, markdown: &str) {
let new_content = {
let mut buffer = self.editor_buffer.borrow_mut();
@@ -409,9 +424,55 @@ pub fn view<'a>(
.style(inputs::secondary_button)
.into();
let quick_actions_menu: Element<'a, Message> = container(
column![
quick_action_item(
locale,
t(locale, "editor.aiAnalyze"),
PostEditorMsg::AnalyzeWithAi,
ai_enabled
),
quick_action_item(
locale,
t(locale, "editor.suggestTaxonomy"),
PostEditorMsg::AnalyzeTaxonomy,
ai_enabled
),
quick_action_item(
locale,
t(locale, "editor.translate"),
PostEditorMsg::Translate,
ai_enabled
),
quick_action_item(
locale,
t(locale, "editor.detectLanguage"),
PostEditorMsg::DetectLanguage,
ai_enabled
),
quick_action_item(
locale,
t(locale, "editor.addGalleryImages"),
PostEditorMsg::AddGalleryImages,
true
),
]
.spacing(4),
)
.padding(8)
.style(status_bar::dropdown_bg)
.into();
let quick_actions: Element<'a, Message> = popover::popover(
quick_actions_button,
quick_actions_menu,
state.quick_actions_open,
Message::PostEditor(PostEditorMsg::ToggleQuickActions),
)
.into();
let mut header_action_items: Vec<Element<'a, Message>> = vec![
status_badge(&state.status),
quick_actions_button,
quick_actions,
button(
text(t(locale, "common.save"))
.size(13)
@@ -478,54 +539,7 @@ pub fn view<'a>(
.align_y(iced::Alignment::Center)
.width(Length::Fill);
let header_menu: Element<'a, Message> = if state.quick_actions_open {
row![
Space::with_width(Length::Fill),
container(
column![
quick_action_item(
locale,
t(locale, "editor.aiAnalyze"),
PostEditorMsg::AnalyzeWithAi,
ai_enabled
),
quick_action_item(
locale,
t(locale, "editor.suggestTaxonomy"),
PostEditorMsg::AnalyzeTaxonomy,
ai_enabled
),
quick_action_item(
locale,
t(locale, "editor.translate"),
PostEditorMsg::Translate,
ai_enabled
),
quick_action_item(
locale,
t(locale, "editor.detectLanguage"),
PostEditorMsg::DetectLanguage,
ai_enabled
),
quick_action_item(
locale,
t(locale, "editor.addGalleryImages"),
PostEditorMsg::AddGalleryImages,
true
),
]
.spacing(4)
)
.padding(8)
.style(status_bar::dropdown_bg),
]
.align_y(iced::Alignment::Start)
.into()
} else {
Space::new(0, 0).into()
};
let header = inputs::card(column![header_row, header_menu].spacing(6)).padding(10);
let header = inputs::card(header_row).padding(10);
// ── Collapsible Metadata Section ──
let meta_toggle_label = if state.metadata_expanded {
@@ -629,7 +643,12 @@ pub fn view<'a>(
Message::PostEditor(PostEditorMsg::TagsInputSubmit),
|tag| Message::PostEditor(PostEditorMsg::RemoveTag(tag)),
);
let semantic_tags: Element<'a, Message> = if state.semantic_tag_suggestions.is_empty() {
let semantic_suggestions = visible_semantic_tag_suggestions(
&state.semantic_tag_suggestions,
&state.tags,
&state.tags_input,
);
let semantic_tags: Element<'a, Message> = if semantic_suggestions.is_empty() {
Space::new(0, 0).into()
} else {
let mut chips = row![
@@ -639,11 +658,11 @@ pub fn view<'a>(
]
.spacing(6)
.align_y(iced::Alignment::Center);
for tag in &state.semantic_tag_suggestions {
for tag in semantic_suggestions {
chips = chips.push(
button(text(format!("+ {tag}")).size(11))
.on_press(Message::PostEditor(PostEditorMsg::AddSuggestedTag(
tag.clone(),
tag.to_string(),
)))
.padding([4, 8])
.style(inputs::secondary_button),
@@ -651,6 +670,43 @@ pub fn view<'a>(
}
chips.into()
};
let matching_suggestions =
matching_tag_suggestions(&state.available_tags, &state.tags, &state.tags_input);
let query_addable =
tag_query_addable(&state.available_tags, &state.tags, &state.tags_input);
let matching_tags: Element<'a, Message> = if state.tags_input.trim().is_empty()
|| (matching_suggestions.is_empty() && !query_addable)
{
Space::new(0, 0).into()
} else {
let mut chips = row![
text(t(locale, "editor.matchingTags"))
.size(11)
.color(inputs::LABEL_COLOR)
]
.spacing(6)
.align_y(iced::Alignment::Center);
for tag in matching_suggestions {
chips = chips.push(
button(text(tag).size(11))
.on_press(Message::PostEditor(PostEditorMsg::AddSuggestedTag(
tag.to_string(),
)))
.padding([4, 8])
.style(inputs::secondary_button),
);
}
if query_addable {
let query = state.tags_input.trim().to_string();
chips = chips.push(
button(text(tw(locale, "editor.createTag", &[("name", &query)])).size(11))
.on_press(Message::PostEditor(PostEditorMsg::AddSuggestedTag(query)))
.padding([4, 8])
.style(inputs::secondary_button),
);
}
chips.wrap().into()
};
// Categories chip input
let categories_section = chip_input_field(
@@ -809,6 +865,7 @@ pub fn view<'a>(
meta_row2,
tags_section,
semantic_tags,
matching_tags,
categories_section,
outlinks_section,
backlinks_section,
@@ -993,6 +1050,54 @@ pub fn view<'a>(
.into()
}
fn matching_tag_suggestions<'a>(
available: &'a [String],
selected: &[String],
query: &str,
) -> Vec<&'a str> {
let query = query.trim().to_lowercase();
if query.is_empty() {
return Vec::new();
}
available
.iter()
.filter(|tag| {
!selected
.iter()
.any(|current| current.eq_ignore_ascii_case(tag))
})
.filter(|tag| tag.to_lowercase().contains(&query))
.take(8)
.map(String::as_str)
.collect()
}
fn tag_query_addable(available: &[String], selected: &[String], query: &str) -> bool {
let query = query.trim();
!query.is_empty()
&& !available.iter().any(|tag| tag.eq_ignore_ascii_case(query))
&& !selected.iter().any(|tag| tag.eq_ignore_ascii_case(query))
}
fn visible_semantic_tag_suggestions<'a>(
semantic: &'a [String],
selected: &[String],
query: &str,
) -> Vec<&'a str> {
if !query.trim().is_empty() {
return Vec::new();
}
semantic
.iter()
.filter(|tag| {
!selected
.iter()
.any(|current| current.eq_ignore_ascii_case(tag))
})
.map(String::as_str)
.collect()
}
/// Chip input: shows existing chips as removable buttons + a text input to add new ones.
fn chip_input_field<'a>(
label: &str,
@@ -1211,6 +1316,48 @@ mod tests {
)
}
#[test]
fn partial_tag_query_matches_existing_tags_case_insensitively() {
let available = vec![
"Photography".to_string(),
"Photo Essay".to_string(),
"Rust".to_string(),
];
let selected = vec!["Photography".to_string()];
assert_eq!(
matching_tag_suggestions(&available, &selected, "PHO"),
vec!["Photo Essay"]
);
}
#[test]
fn partial_tag_query_limits_matches_and_exact_names_are_not_addable() {
let available = (0..10)
.map(|index| format!("tag-{index}"))
.collect::<Vec<_>>();
assert_eq!(matching_tag_suggestions(&available, &[], "tag").len(), 8);
assert!(!tag_query_addable(&available, &[], " TAG-3 "));
assert!(tag_query_addable(&available, &[], "new tag"));
}
#[test]
fn semantic_tag_suggestions_are_hidden_while_filtering_and_exclude_selected_tags() {
let selected = vec!["science".to_string()];
let semantic = vec![
"science".to_string(),
"space".to_string(),
"history".to_string(),
];
assert_eq!(
visible_semantic_tag_suggestions(&semantic, &selected, ""),
vec!["space", "history"]
);
assert!(visible_semantic_tag_suggestions(&semantic, &selected, "spa").is_empty());
}
#[test]
fn insert_markdown_at_cursor_uses_buffer_cursor() {
let mut state = sample_state();

View File

@@ -29,6 +29,8 @@ menu-item-rebuildEmbeddingIndex = Embedding-Index neu erstellen
menu-item-findDuplicates = Doppelte Beiträge finden
common-refresh = Aktualisieren
editor-semanticTagSuggestions = Vorschläge aus ähnlichen Beiträgen
editor-matchingTags = Passende Tags
editor-createTag = Tag erstellen: { $name }
duplicates-title = Doppelte Beiträge finden
duplicates-searching = Duplikate werden gesucht…
duplicates-count = { $count } mögliche Paare

View File

@@ -29,6 +29,8 @@ menu-item-rebuildEmbeddingIndex = Rebuild Embedding Index
menu-item-findDuplicates = Find Duplicate Posts
common-refresh = Refresh
editor-semanticTagSuggestions = Suggested from similar posts
editor-matchingTags = Matching tags
editor-createTag = Create tag: { $name }
duplicates-title = Find Duplicate Posts
duplicates-searching = Searching for duplicates…
duplicates-count = { $count } potential pairs

View File

@@ -29,6 +29,8 @@ menu-item-rebuildEmbeddingIndex = Reconstruir índice semántico
menu-item-findDuplicates = Buscar publicaciones duplicadas
common-refresh = Actualizar
editor-semanticTagSuggestions = Sugerencias de publicaciones similares
editor-matchingTags = Etiquetas coincidentes
editor-createTag = Crear etiqueta: { $name }
duplicates-title = Buscar publicaciones duplicadas
duplicates-searching = Buscando duplicados…
duplicates-count = { $count } pares potenciales

View File

@@ -29,6 +29,8 @@ menu-item-rebuildEmbeddingIndex = Reconstruire lindex sémantique
menu-item-findDuplicates = Rechercher les articles en double
common-refresh = Actualiser
editor-semanticTagSuggestions = Suggestions darticles similaires
editor-matchingTags = Étiquettes correspondantes
editor-createTag = Créer létiquette : { $name }
duplicates-title = Rechercher les articles en double
duplicates-searching = Recherche des doublons…
duplicates-count = { $count } paires potentielles

View File

@@ -29,6 +29,8 @@ menu-item-rebuildEmbeddingIndex = Ricostruisci indice semantico
menu-item-findDuplicates = Trova post duplicati
common-refresh = Aggiorna
editor-semanticTagSuggestions = Suggerimenti da post simili
editor-matchingTags = Tag corrispondenti
editor-createTag = Crea tag: { $name }
duplicates-title = Trova post duplicati
duplicates-searching = Ricerca duplicati…
duplicates-count = { $count } coppie potenziali

View File

@@ -59,14 +59,15 @@ rule AISuggestionFlow {
rule AutoTranslationChain {
when: PostSaved(post_id)
-- Gate: AIOperationGating + post.doNotTranslate must be false
-- Triggered after any post save (auto-save, manual Ctrl+S, or unmount)
-- Triggered only by explicit manual save or publish, never by auto-save,
-- unmount/tab-switch persistence, post creation, import, or scripting.
-- For each configured blogLanguage missing a translation for this post:
-- 1. Enqueue background task: translate metadata (title, excerpt)
-- via title model
-- 2. Enqueue background task: translate content (full markdown)
-- via title model
-- 3. Create/update translation record in DB
-- Tasks: sequential per language, parallel across languages
-- 1. Enqueue one background task for that language
-- 2. Translate metadata (title, excerpt) via title model
-- 3. Translate content (full markdown) via title model
-- 4. Create/update translation record in DB
-- A later manual save with no missing languages enqueues no task.
-- Work is sequential within a language and parallel across languages.
-- Progress visible in Tasks panel
}

View File

@@ -74,9 +74,9 @@ use "./metadata_diff.allium" as metadata_diff -- DB/filesystem diff and rebuil
-- Resolved questions:
--
-- 1. Slug generation scope: only German and English letters are used.
-- Verify transliteration preserves the established bDS behaviour for
-- ä/ö/ü/ß/ÄÖÜ.
-- 1. Slug generation is identical to bDS2: replace ß with "ss", normalize to
-- Unicode NFD, discard non-ASCII code points, lowercase, hyphenate
-- non-alphanumeric runs, and trim hyphens. Thus ä/ö/ü become a/o/u.
--
-- 2. Liquid subset: see template.allium for the exact subset.
-- Only 5 tags, 4 standard filters, 2 custom filters, 5 operators.

View File

@@ -14,10 +14,11 @@ enum PostStatus {
value Slug {
value: String
-- Generated by: transliterate unicode to ASCII, lowercase,
-- replace [^a-z0-9]+ with hyphens, strip leading/trailing hyphens
-- Transliteration scope: only German (ä/ö/ü/ß/ÄÖÜ) and English letters used.
-- Verify transliteration matches the established bDS behaviour for this set.
-- Generated exactly as bDS2: replace ß with "ss", normalize to Unicode NFD,
-- discard every non-ASCII code point, lowercase, replace [^a-z0-9]+ with
-- hyphens, then strip leading/trailing hyphens.
-- Therefore ä/ö/ü/Ä/Ö/Ü become a/o/u/A/O/U through NFD decomposition;
-- examples: "Über" -> "uber", "ÄÖÜäöüß" -> "aouaouss".
-- Uniqueness: tries base, then {slug}-2, {slug}-3, … (unbounded numeric suffix)
}