Compare commits
4 Commits
e6fa506b28
...
ac993d8743
| Author | SHA1 | Date | |
|---|---|---|---|
| ac993d8743 | |||
| b641f6492b | |||
| d1829f0e9b | |||
| b713f7fa07 |
@@ -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
8
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -592,7 +592,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rendered, "ueber-die-bruecke");
|
||||
assert_eq!(rendered, "uber-die-brucke");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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/"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod inputs;
|
||||
pub mod native_edit;
|
||||
pub mod popover;
|
||||
pub mod webview;
|
||||
|
||||
365
crates/bds-ui/src/components/popover.rs
Normal file
365
crates/bds-ui/src/components/popover.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,6 +29,8 @@ menu-item-rebuildEmbeddingIndex = Reconstruire l’index sémantique
|
||||
menu-item-findDuplicates = Rechercher les articles en double
|
||||
common-refresh = Actualiser
|
||||
editor-semanticTagSuggestions = Suggestions d’articles 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user