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`.
|
- 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.
|
- 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
|
## Plan Mode
|
||||||
|
|
||||||
- Make the plan extremely concise. Sacrifice grammar for the sake of concision.
|
- 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",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"deunicode",
|
|
||||||
"diesel",
|
"diesel",
|
||||||
"diesel_migrations",
|
"diesel_migrations",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
@@ -969,6 +968,7 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"unicode-normalization",
|
||||||
"url",
|
"url",
|
||||||
"usearch",
|
"usearch",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -2489,12 +2489,6 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "21d8ad60dd5b13a4ee6bd8fa2d5d88965c597c67bce32b5fc49c94f55cb50810"
|
checksum = "21d8ad60dd5b13a4ee6bd8fa2d5d88965c597c67bce32b5fc49c94f55cb50810"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "deunicode"
|
|
||||||
version = "1.6.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "diesel"
|
name = "diesel"
|
||||||
version = "2.3.11"
|
version = "2.3.11"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ serde_json = "1"
|
|||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
deunicode = "1"
|
unicode-normalization = "0.1"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ The project is under active development. Core blogging workflows are broadly ava
|
|||||||
|
|
||||||
## Available Features
|
## 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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 tag suggestions backed by a persistent USearch index, with duplicate-post review and dismissal in the desktop workspace.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
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 }
|
serde_yaml = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
deunicode = { workspace = true }
|
unicode-normalization = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
walkdir = { workspace = true }
|
walkdir = { workspace = true }
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::{LazyLock, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::db::DbConnection as Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
@@ -13,6 +14,8 @@ use crate::util::now_unix_ms;
|
|||||||
|
|
||||||
const KEYRING_SERVICE: &str = "RuDS";
|
const KEYRING_SERVICE: &str = "RuDS";
|
||||||
const KEYRING_SETTING_PREFIX: &str = "ai.endpoint";
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -246,15 +249,11 @@ fn save_endpoint_api_key_at(
|
|||||||
api_key: &str,
|
api_key: &str,
|
||||||
updated_at: i64,
|
updated_at: i64,
|
||||||
) -> EngineResult<()> {
|
) -> EngineResult<()> {
|
||||||
let entry = endpoint_keyring_entry(kind)?;
|
|
||||||
let configured = !api_key.trim().is_empty();
|
let configured = !api_key.trim().is_empty();
|
||||||
if configured {
|
if configured {
|
||||||
entry.set_password(api_key.trim()).map_err(keyring_error)?;
|
store_endpoint_api_key(kind, api_key.trim())?;
|
||||||
} else {
|
} else {
|
||||||
match entry.delete_credential() {
|
delete_endpoint_api_key(kind)?;
|
||||||
Ok(()) | Err(keyring::Error::NoEntry) => {}
|
|
||||||
Err(error) => return Err(keyring_error(error)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
set_setting(
|
set_setting(
|
||||||
conn,
|
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 api_key = if stored.api_key_configured {
|
||||||
let entry = endpoint_keyring_entry(kind)?;
|
let password = read_endpoint_api_key(kind)?.ok_or_else(|| {
|
||||||
let password = entry.get_password().map_err(keyring_error)?;
|
EngineError::Validation(format!(
|
||||||
|
"AI unavailable - configure {} endpoint in Settings",
|
||||||
|
kind.as_str()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
if password.trim().is_empty() {
|
if password.trim().is_empty() {
|
||||||
return Err(EngineError::Validation(format!(
|
return Err(EngineError::Validation(format!(
|
||||||
"AI unavailable - configure {} endpoint in Settings",
|
"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>> {
|
pub fn load_endpoint_api_key(kind: AiEndpointKind) -> EngineResult<Option<String>> {
|
||||||
let entry = endpoint_keyring_entry(kind)?;
|
read_endpoint_api_key(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)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<AiModelInfo>> {
|
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)
|
.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 {
|
fn keyring_error(error: keyring::Error) -> EngineError {
|
||||||
EngineError::Validation(error.to_string())
|
EngineError::Validation(error.to_string())
|
||||||
}
|
}
|
||||||
@@ -1024,8 +1079,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn clear_keyring(kind: AiEndpointKind) {
|
fn clear_keyring(kind: AiEndpointKind) {
|
||||||
let entry = endpoint_keyring_entry(kind).unwrap();
|
delete_endpoint_api_key(kind).unwrap();
|
||||||
entry.delete_credential().ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1039,7 +1093,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore = "touches system keychain; run explicitly when validating keychain integration"]
|
|
||||||
fn saves_online_endpoint_with_keychain_secret() {
|
fn saves_online_endpoint_with_keychain_secret() {
|
||||||
clear_keyring(AiEndpointKind::Online);
|
clear_keyring(AiEndpointKind::Online);
|
||||||
let db = setup();
|
let db = setup();
|
||||||
@@ -1245,7 +1298,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore = "touches system keychain; run explicitly when validating keychain integration"]
|
|
||||||
fn run_one_shot_uses_active_endpoint_and_parses_response() {
|
fn run_one_shot_uses_active_endpoint_and_parses_response() {
|
||||||
clear_keyring(AiEndpointKind::Online);
|
clear_keyring(AiEndpointKind::Online);
|
||||||
let server = spawn_test_server(
|
let server = spawn_test_server(
|
||||||
|
|||||||
@@ -178,7 +178,11 @@ pub fn translate_missing_for_post(
|
|||||||
if is_cancelled() {
|
if is_cancelled() {
|
||||||
return Err(EngineError::Validation("cancelled".to_string()));
|
return Err(EngineError::Validation("cancelled".to_string()));
|
||||||
}
|
}
|
||||||
let result = translate_one_post(
|
merge_reactive_translation_result(
|
||||||
|
&mut report,
|
||||||
|
&post,
|
||||||
|
&language,
|
||||||
|
translate_one_post(
|
||||||
conn,
|
conn,
|
||||||
data_dir,
|
data_dir,
|
||||||
&post,
|
&post,
|
||||||
@@ -186,7 +190,59 @@ pub fn translate_missing_for_post(
|
|||||||
false,
|
false,
|
||||||
&mut |post, language| translate_post_ai(conn, offline_mode, post, language),
|
&mut |post, language| translate_post_ai(conn, offline_mode, post, language),
|
||||||
&mut |media, language| translate_media_ai(conn, offline_mode, media, 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,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_reactive_translation_result(
|
||||||
|
report: &mut FillMissingTranslationsReport,
|
||||||
|
post: &Post,
|
||||||
|
language: &str,
|
||||||
|
result: EngineResult<usize>,
|
||||||
|
) {
|
||||||
match result {
|
match result {
|
||||||
Ok(media_count) => {
|
Ok(media_count) => {
|
||||||
report.translated_posts += 1;
|
report.translated_posts += 1;
|
||||||
@@ -200,8 +256,6 @@ pub fn translate_missing_for_post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(report)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn translate_one_post(
|
fn translate_one_post(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
@@ -342,7 +396,7 @@ mod tests {
|
|||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
use crate::db::fts::ensure_fts_tables;
|
use crate::db::fts::ensure_fts_tables;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
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;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
#[test]
|
#[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]
|
#[test]
|
||||||
fn skips_do_not_translate_posts() {
|
fn skips_do_not_translate_posts() {
|
||||||
let db = Database::open_in_memory().unwrap();
|
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>> {
|
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 source = qp::get_post_by_id(self.conn, post_id)?;
|
||||||
let current = source
|
let current = source
|
||||||
.tags
|
.tags
|
||||||
.iter()
|
.iter()
|
||||||
.map(|tag| tag.to_lowercase())
|
.map(|tag| tag.to_lowercase())
|
||||||
.collect::<HashSet<_>>();
|
.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();
|
let mut scores = HashMap::<String, (String, f32)>::new();
|
||||||
for neighbor in similar {
|
for neighbor in similar {
|
||||||
if let Ok(post) = qp::get_post_by_id(self.conn, &neighbor.post_id) {
|
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]
|
#[test]
|
||||||
fn duplicate_search_paginates_and_batch_dismisses_in_chunks() {
|
fn duplicate_search_paginates_and_batch_dismisses_in_chunks() {
|
||||||
let (db, data, cache, project_id, backend) = setup_service(true);
|
let (db, data, cache, project_id, backend) = setup_service(true);
|
||||||
|
|||||||
@@ -1605,7 +1605,7 @@ mod tests {
|
|||||||
oldest.post.id = "post-1".into();
|
oldest.post.id = "post-1".into();
|
||||||
oldest.post.slug = "alpha".into();
|
oldest.post.slug = "alpha".into();
|
||||||
oldest.post.title = "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.published_at = Some(1_709_568_000_000);
|
||||||
oldest.post.created_at = 1_709_568_000_000;
|
oldest.post.created_at = 1_709_568_000_000;
|
||||||
oldest.body_markdown = "Alpha body".into();
|
oldest.body_markdown = "Alpha body".into();
|
||||||
@@ -1614,7 +1614,7 @@ mod tests {
|
|||||||
newest.post.id = "post-2".into();
|
newest.post.id = "post-2".into();
|
||||||
newest.post.slug = "beta".into();
|
newest.post.slug = "beta".into();
|
||||||
newest.post.title = "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.published_at = Some(1_710_086_400_000);
|
||||||
newest.post.created_at = 1_710_086_400_000;
|
newest.post.created_at = 1_710_086_400_000;
|
||||||
newest.body_markdown = "Beta body".into();
|
newest.body_markdown = "Beta body".into();
|
||||||
@@ -1640,7 +1640,7 @@ mod tests {
|
|||||||
"project-1",
|
"project-1",
|
||||||
&metadata,
|
&metadata,
|
||||||
&posts,
|
&posts,
|
||||||
"/tag/rust",
|
"/tag/uber-ol",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(tag_first.status_code, 200);
|
assert_eq!(tag_first.status_code, 200);
|
||||||
@@ -1653,7 +1653,7 @@ mod tests {
|
|||||||
"project-1",
|
"project-1",
|
||||||
&metadata,
|
&metadata,
|
||||||
&posts,
|
&posts,
|
||||||
"/tag/rust/page/2",
|
"/tag/uber-ol/page/2",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(tag_second.status_code, 200);
|
assert_eq!(tag_second.status_code, 200);
|
||||||
|
|||||||
@@ -592,7 +592,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(rendered, "ueber-die-bruecke");
|
assert_eq!(rendered, "uber-die-brucke");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1826,8 +1826,8 @@ mod menu_tests {
|
|||||||
slug: None,
|
slug: None,
|
||||||
children: vec![MenuItem {
|
children: vec![MenuItem {
|
||||||
kind: MenuItemKind::CategoryArchive,
|
kind: MenuItemKind::CategoryArchive,
|
||||||
label: "Long Form".into(),
|
label: "Über Öl".into(),
|
||||||
slug: Some("Long Form".into()),
|
slug: Some("Über Öl".into()),
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
@@ -1838,12 +1838,12 @@ mod menu_tests {
|
|||||||
let rendered = build_menu_items(dir.path(), "en", "en").unwrap();
|
let rendered = build_menu_items(dir.path(), "en", "en").unwrap();
|
||||||
assert_eq!(rendered[0]["href"], "/");
|
assert_eq!(rendered[0]["href"], "/");
|
||||||
assert_eq!(rendered[1]["href"], "/about/");
|
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();
|
let translated = build_menu_items(dir.path(), "de", "en").unwrap();
|
||||||
assert_eq!(translated[1]["href"], "/de/about/");
|
assert_eq!(translated[1]["href"], "/de/about/");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
translated[2]["children"][0]["href"],
|
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.
|
/// Apply the one German replacement that canonical decomposition cannot provide.
|
||||||
/// deunicode maps ä→a, ö→o, ü→u but TypeScript produces ä→ae, ö→oe, ü→ue.
|
|
||||||
/// We replace these before deunicode so the slug output is compatible.
|
|
||||||
fn german_transliterate(input: &str) -> String {
|
fn german_transliterate(input: &str) -> String {
|
||||||
let mut result = String::with_capacity(input.len() + 16);
|
input.replace('ß', "ss")
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a URL-safe slug from a title string.
|
/// Generate a URL-safe slug from a title string.
|
||||||
///
|
///
|
||||||
/// Transliterates Unicode to ASCII, lowercases, replaces non-alphanumeric
|
/// Matches bDS2 exactly: replace ß with `ss`, canonically decompose Unicode,
|
||||||
/// chars with hyphens, and collapses/trims hyphens.
|
/// discard non-ASCII code points, lowercase, replace non-alphanumeric runs
|
||||||
///
|
/// with hyphens, and trim leading/trailing hyphens.
|
||||||
/// German umlauts (ä/ö/ü/Ä/Ö/Ü) are pre-processed to ae/oe/ue/Ae/Oe/Ue
|
|
||||||
/// to match TypeScript `transliteration` npm output. ß→ss is handled by deunicode.
|
|
||||||
pub fn slugify(input: &str) -> String {
|
pub fn slugify(input: &str) -> String {
|
||||||
let preprocessed = german_transliterate(input);
|
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 lowered = ascii.to_lowercase();
|
||||||
let mut slug = String::with_capacity(lowered.len());
|
let mut slug = String::with_capacity(lowered.len());
|
||||||
let mut prev_hyphen = true; // avoid leading hyphen
|
let mut prev_hyphen = true; // avoid leading hyphen
|
||||||
@@ -79,7 +66,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unicode_slug() {
|
fn unicode_slug() {
|
||||||
assert_eq!(slugify("Über die Brücke"), "ueber-die-bruecke");
|
assert_eq!(slugify("Über die Brücke"), "uber-die-brucke");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -122,24 +109,34 @@ mod tests {
|
|||||||
assert_eq!(slug, "hello-4");
|
assert_eq!(slug, "hello-4");
|
||||||
}
|
}
|
||||||
|
|
||||||
// German umlaut tests — spec: "only German and English letters are used.
|
// German corpus copied from the bDS2 golden-master tests.
|
||||||
// Verify deunicode handles ä/ö/ü/ß/ÄÖÜ correctly against transliteration npm."
|
#[test]
|
||||||
// Pre-processing maps ä→ae, ö→oe, ü→ue to match TypeScript transliteration npm.
|
fn bds2_german_transliteration_corpus() {
|
||||||
// ß→ss is handled correctly by deunicode without pre-processing.
|
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]
|
#[test]
|
||||||
fn german_umlaut_ae() {
|
fn german_umlaut_ae() {
|
||||||
assert_eq!(slugify("Ärger"), "aerger");
|
assert_eq!(slugify("Ärger"), "arger");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn german_umlaut_oe() {
|
fn german_umlaut_oe() {
|
||||||
assert_eq!(slugify("Öffnung"), "oeffnung");
|
assert_eq!(slugify("Öffnung"), "offnung");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn german_umlaut_ue() {
|
fn german_umlaut_ue() {
|
||||||
assert_eq!(slugify("Über"), "ueber");
|
assert_eq!(slugify("Über"), "uber");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -149,12 +146,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn german_mixed_umlauts() {
|
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]
|
#[test]
|
||||||
fn german_uppercase_umlauts() {
|
fn german_uppercase_umlauts() {
|
||||||
assert_eq!(slugify("ÄÖÜ Test"), "aeoeue-test");
|
assert_eq!(slugify("ÄÖÜ Test"), "aou-test");
|
||||||
}
|
}
|
||||||
|
|
||||||
// spec: CreatePost uses Slug.generate(title ?? "untitled")
|
// 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("a --- b"), "a-b");
|
||||||
assert_eq!(slugify("---hello---"), "hello");
|
assert_eq!(slugify("---hello---"), "hello");
|
||||||
assert_eq!(slugify("café"), "cafe");
|
assert_eq!(slugify("café"), "cafe");
|
||||||
assert_eq!(slugify("über"), "ueber");
|
assert_eq!(slugify("über"), "uber");
|
||||||
assert_eq!(slugify("Straße"), "strasse");
|
assert_eq!(slugify("Straße"), "strasse");
|
||||||
assert_eq!(ensure_unique("test", |value| value == "test"), "test-2");
|
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()) {
|
if self.active_tab.as_deref() != Some(id.as_str()) {
|
||||||
self.flush_active_post_editor();
|
self.flush_active_post_editor();
|
||||||
}
|
}
|
||||||
self.active_tab = Some(id);
|
self.active_tab = Some(id.clone());
|
||||||
}
|
}
|
||||||
self.enforce_panel_tab_fallback();
|
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 ──
|
// ── Project management ──
|
||||||
Message::ProjectsLoaded(projects) => {
|
Message::ProjectsLoaded(projects) => {
|
||||||
@@ -4013,6 +4020,7 @@ impl BdsApp {
|
|||||||
|
|
||||||
if entity == DomainEntity::Tag {
|
if entity == DomainEntity::Tag {
|
||||||
self.tags_view_state = None;
|
self.tags_view_state = None;
|
||||||
|
self.refresh_post_editor_tag_options();
|
||||||
if let Some(tab) = self
|
if let Some(tab) = self
|
||||||
.tabs
|
.tabs
|
||||||
.iter()
|
.iter()
|
||||||
@@ -4026,6 +4034,9 @@ impl BdsApp {
|
|||||||
|
|
||||||
let tab = self.tabs.iter().find(|tab| tab.id == entity_id).cloned();
|
let tab = self.tabs.iter().find(|tab| tab.id == entity_id).cloned();
|
||||||
let Some(tab) = tab else { return };
|
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 {
|
let dirty = match entity {
|
||||||
DomainEntity::Post => self
|
DomainEntity::Post => self
|
||||||
.post_editors
|
.post_editors
|
||||||
@@ -4064,6 +4075,11 @@ impl BdsApp {
|
|||||||
DomainEntity::Tag | DomainEntity::Project | DomainEntity::Setting => {}
|
DomainEntity::Tag | DomainEntity::Project | DomainEntity::Setting => {}
|
||||||
}
|
}
|
||||||
self.load_editor_for_tab(&tab);
|
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) {
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == entity_id) {
|
||||||
tab.title = match entity {
|
tab.title = match entity {
|
||||||
DomainEntity::Post => self
|
DomainEntity::Post => self
|
||||||
@@ -5638,12 +5654,22 @@ impl BdsApp {
|
|||||||
|
|
||||||
// ── Editor save/publish helpers ──
|
// ── 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) {
|
match self.persist_post_editor_state(post_id) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
||||||
|
if schedule_auto_translation && saves_canonical_post {
|
||||||
return self.schedule_post_auto_translation(post_id);
|
return self.schedule_post_auto_translation(post_id);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Err(e) => self.notify_operation_failed("common.save", e),
|
Err(e) => self.notify_operation_failed("common.save", e),
|
||||||
}
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
@@ -5954,28 +5980,46 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn schedule_post_auto_translation(&mut self, post_id: &str) -> Task<Message> {
|
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();
|
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() {
|
if engine::ai::active_endpoint(db.conn(), self.offline_mode).is_err() {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
let post_id = post_id.to_string();
|
let post_id = post_id.to_string();
|
||||||
let offline_mode = self.offline_mode;
|
let offline_mode = self.offline_mode;
|
||||||
let locale = self.ui_locale;
|
let locale = self.ui_locale;
|
||||||
|
Task::batch(targets.into_iter().map(|language| {
|
||||||
|
let post_id = post_id.clone();
|
||||||
|
let configured = configured.clone();
|
||||||
self.spawn_grouped_engine_task(
|
self.spawn_grouped_engine_task(
|
||||||
"engine.autoTranslationStarted",
|
"engine.autoTranslationStarted",
|
||||||
"AI",
|
"AI",
|
||||||
move |db_path, _project_id, data_dir, task_manager, task_id| {
|
move |db_path, _project_id, data_dir, task_manager, task_id| {
|
||||||
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
||||||
let meta = engine::meta::read_project_json(&data_dir)
|
let report = engine::auto_translation::translate_missing_language_for_post(
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
let report = engine::auto_translation::translate_missing_for_post(
|
|
||||||
db.conn(),
|
db.conn(),
|
||||||
&data_dir,
|
&data_dir,
|
||||||
&post_id,
|
&post_id,
|
||||||
meta.main_language.as_deref().unwrap_or("en"),
|
&configured,
|
||||||
&meta.blog_languages,
|
&language,
|
||||||
offline_mode,
|
offline_mode,
|
||||||
move || task_manager.is_cancelled(task_id),
|
move || task_manager.is_cancelled(task_id),
|
||||||
)
|
)
|
||||||
@@ -5987,6 +6031,30 @@ impl BdsApp {
|
|||||||
))
|
))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
fn insert_link_modal(&mut self, post_id: &str) -> Task<Message> {
|
||||||
@@ -6177,7 +6245,7 @@ impl BdsApp {
|
|||||||
tab.is_dirty = true;
|
tab.is_dirty = true;
|
||||||
}
|
}
|
||||||
self.active_modal = None;
|
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> {
|
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.
|
/// Load editor state when a tab is opened for an entity.
|
||||||
fn load_editor_for_tab(&mut self, tab: &Tab) {
|
fn load_editor_for_tab(&mut self, tab: &Tab) {
|
||||||
let Some(ref db) = self.db else { return };
|
let Some(ref db) = self.db else { return };
|
||||||
@@ -8268,9 +8375,7 @@ impl BdsApp {
|
|||||||
let (outlinks, backlinks) = self.load_post_links(&post.id);
|
let (outlinks, backlinks) = self.load_post_links(&post.id);
|
||||||
let linked_media =
|
let linked_media =
|
||||||
self.load_post_media_items(&post.id, post.content.as_deref());
|
self.load_post_media_items(&post.id, post.content.as_deref());
|
||||||
self.post_editors.insert(
|
let mut editor = PostEditorState::from_post(
|
||||||
post.id.clone(),
|
|
||||||
PostEditorState::from_post(
|
|
||||||
&post,
|
&post,
|
||||||
default_post_editor_mode(self.settings_state.as_ref()),
|
default_post_editor_mode(self.settings_state.as_ref()),
|
||||||
&self.blog_languages,
|
&self.blog_languages,
|
||||||
@@ -8278,8 +8383,9 @@ impl BdsApp {
|
|||||||
outlinks,
|
outlinks,
|
||||||
backlinks,
|
backlinks,
|
||||||
linked_media,
|
linked_media,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
editor.available_tags = self.project_tag_names(&post.project_id);
|
||||||
|
self.post_editors.insert(post.id.clone(), editor);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.notify_operation_failed("activity.posts", e);
|
self.notify_operation_failed("activity.posts", e);
|
||||||
@@ -9490,10 +9596,12 @@ mod tests {
|
|||||||
use bds_core::engine::generation::GenerationReport;
|
use bds_core::engine::generation::GenerationReport;
|
||||||
use bds_core::engine::task::{TaskStatus, TaskStatus::*};
|
use bds_core::engine::task::{TaskStatus, TaskStatus::*};
|
||||||
use bds_core::engine::{
|
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::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 chrono::{Datelike, TimeZone};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
@@ -9617,8 +9725,10 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf());
|
let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf());
|
||||||
let mut settings = SettingsViewState::default();
|
let settings = SettingsViewState {
|
||||||
settings.default_mode = "preview".to_string();
|
default_mode: "preview".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
app.settings_state = Some(settings);
|
app.settings_state = Some(settings);
|
||||||
app.offline_mode = true;
|
app.offline_mode = true;
|
||||||
app.sidebar_view = SidebarView::Settings;
|
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]
|
#[test]
|
||||||
fn switching_tabs_flushes_active_post_editor() {
|
fn switching_tabs_flushes_active_post_editor() {
|
||||||
let (db, project, tmp) = setup();
|
let (db, project, tmp) = setup();
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ impl BdsApp {
|
|||||||
SyncEmbeddedPreview,
|
SyncEmbeddedPreview,
|
||||||
Analyze(String),
|
Analyze(String),
|
||||||
AnalyzeTaxonomy(String),
|
AnalyzeTaxonomy(String),
|
||||||
|
EnsureTag {
|
||||||
|
post_id: String,
|
||||||
|
tag: String,
|
||||||
|
},
|
||||||
|
LoadSemanticTags(String),
|
||||||
AddGalleryImages(String),
|
AddGalleryImages(String),
|
||||||
DetectLanguage(String),
|
DetectLanguage(String),
|
||||||
OpenTranslate(String),
|
OpenTranslate(String),
|
||||||
@@ -127,6 +132,9 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
PostEditorMsg::ToggleMetadata => {
|
PostEditorMsg::ToggleMetadata => {
|
||||||
state.metadata_expanded = !state.metadata_expanded;
|
state.metadata_expanded = !state.metadata_expanded;
|
||||||
|
if state.metadata_expanded {
|
||||||
|
deferred = DeferredPostAction::LoadSemanticTags(state.post_id.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
PostEditorMsg::ToggleExcerpt => {
|
PostEditorMsg::ToggleExcerpt => {
|
||||||
state.excerpt_expanded = !state.excerpt_expanded;
|
state.excerpt_expanded = !state.excerpt_expanded;
|
||||||
@@ -139,12 +147,24 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
PostEditorMsg::TagsInputChanged(s) => {
|
PostEditorMsg::TagsInputChanged(s) => {
|
||||||
state.tags_input = s;
|
state.tags_input = s;
|
||||||
|
if state.tags_input.trim().is_empty() {
|
||||||
|
deferred = DeferredPostAction::LoadSemanticTags(state.post_id.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
PostEditorMsg::TagsInputSubmit => {
|
PostEditorMsg::TagsInputSubmit => {
|
||||||
let tag = state.tags_input.trim().to_string();
|
let tag = state.tags_input.trim().to_string();
|
||||||
if !tag.is_empty() && !state.tags.contains(&tag) {
|
if !tag.is_empty()
|
||||||
state.tags.push(tag);
|
&& !state
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.any(|current| current.eq_ignore_ascii_case(&tag))
|
||||||
|
{
|
||||||
|
state.tags.push(tag.clone());
|
||||||
state.mark_dirty();
|
state.mark_dirty();
|
||||||
|
deferred = DeferredPostAction::EnsureTag {
|
||||||
|
post_id: state.post_id.clone(),
|
||||||
|
tag,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
state.tags_input.clear();
|
state.tags_input.clear();
|
||||||
}
|
}
|
||||||
@@ -156,10 +176,12 @@ impl BdsApp {
|
|||||||
{
|
{
|
||||||
state.tags.push(tag.clone());
|
state.tags.push(tag.clone());
|
||||||
state.mark_dirty();
|
state.mark_dirty();
|
||||||
|
deferred = DeferredPostAction::EnsureTag {
|
||||||
|
post_id: state.post_id.clone(),
|
||||||
|
tag: tag.clone(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
state
|
state.tags_input.clear();
|
||||||
.semantic_tag_suggestions
|
|
||||||
.retain(|candidate| !candidate.eq_ignore_ascii_case(&tag));
|
|
||||||
}
|
}
|
||||||
PostEditorMsg::RemoveTag(tag) => {
|
PostEditorMsg::RemoveTag(tag) => {
|
||||||
state.tags.retain(|t| t != &tag);
|
state.tags.retain(|t| t != &tag);
|
||||||
@@ -286,6 +308,12 @@ impl BdsApp {
|
|||||||
DeferredPostAction::SyncEmbeddedPreview => self.sync_embedded_preview_for_active_post(),
|
DeferredPostAction::SyncEmbeddedPreview => self.sync_embedded_preview_for_active_post(),
|
||||||
DeferredPostAction::Analyze(tab_id) => self.run_post_ai_analysis(&tab_id),
|
DeferredPostAction::Analyze(tab_id) => self.run_post_ai_analysis(&tab_id),
|
||||||
DeferredPostAction::AnalyzeTaxonomy(tab_id) => self.run_post_taxonomy_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::AddGalleryImages(post_id) => self.add_gallery_images(&post_id),
|
||||||
DeferredPostAction::DetectLanguage(tab_id) => self.detect_post_language(&tab_id),
|
DeferredPostAction::DetectLanguage(tab_id) => self.detect_post_language(&tab_id),
|
||||||
DeferredPostAction::OpenTranslate(tab_id) => self.open_post_translation_modal(&tab_id),
|
DeferredPostAction::OpenTranslate(tab_id) => self.open_post_translation_modal(&tab_id),
|
||||||
@@ -293,7 +321,7 @@ impl BdsApp {
|
|||||||
post_id,
|
post_id,
|
||||||
target_language,
|
target_language,
|
||||||
} => self.translate_post_to(&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::Publish(tab_id) => self.publish_post_editor(&tab_id),
|
||||||
DeferredPostAction::Discard(tab_id) => self.discard_post_editor(&tab_id),
|
DeferredPostAction::Discard(tab_id) => self.discard_post_editor(&tab_id),
|
||||||
DeferredPostAction::ShowDelete { tab_id, name } => {
|
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 search_rebuild_finished = self.search_index_rebuild_task_id == Some(task_id);
|
||||||
let cancelled = self.task_manager.status(task_id) == Some(TaskStatus::Cancelled);
|
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 {
|
match &result {
|
||||||
_ if cancelled => {}
|
_ if cancelled => {}
|
||||||
Ok(detail) => {
|
Ok(detail) => {
|
||||||
@@ -265,8 +271,22 @@ impl BdsApp {
|
|||||||
self.sync_menu_state();
|
self.sync_menu_state();
|
||||||
}
|
}
|
||||||
let sidebar_task = self.refresh_counts();
|
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();
|
self.refresh_task_snapshots();
|
||||||
sidebar_task
|
Task::batch([sidebar_task, semantic_task])
|
||||||
}
|
}
|
||||||
Message::SiteGenerationSectionDone {
|
Message::SiteGenerationSectionDone {
|
||||||
group_id,
|
group_id,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
pub mod inputs;
|
pub mod inputs;
|
||||||
pub mod native_edit;
|
pub mod native_edit;
|
||||||
|
pub mod popover;
|
||||||
pub mod webview;
|
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 bds_core::model::{Media, MediaTranslation};
|
||||||
|
|
||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
use crate::components::inputs;
|
use crate::components::{inputs, popover};
|
||||||
use crate::i18n::t;
|
use crate::i18n::t;
|
||||||
use crate::views::post_editor::TranslationFlag;
|
use crate::views::post_editor::TranslationFlag;
|
||||||
use crate::views::status_bar;
|
use crate::views::status_bar;
|
||||||
@@ -235,16 +235,48 @@ pub fn view<'a>(
|
|||||||
data_dir: Option<&Path>,
|
data_dir: Option<&Path>,
|
||||||
ai_enabled: bool,
|
ai_enabled: bool,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let header_row = inputs::toolbar(
|
let mut quick_action_items: Vec<Element<'a, Message>> = Vec::new();
|
||||||
vec![text(state.original_name.clone()).size(18).into()],
|
if state.mime_type.starts_with("image/") {
|
||||||
vec![
|
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))
|
button(text(t(locale, "editor.quickActions")).size(13))
|
||||||
.on_press_maybe(
|
.on_press_maybe(
|
||||||
ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::ToggleQuickActions)),
|
ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::ToggleQuickActions)),
|
||||||
)
|
)
|
||||||
.style(inputs::secondary_button)
|
.style(inputs::secondary_button)
|
||||||
.padding([6, 16])
|
.padding([6, 16])
|
||||||
.into(),
|
.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![
|
||||||
|
quick_actions,
|
||||||
button(text(t(locale, "editor.replaceFile")).size(13))
|
button(text(t(locale, "editor.replaceFile")).size(13))
|
||||||
.on_press(Message::MediaEditor(MediaEditorMsg::ReplaceFile))
|
.on_press(Message::MediaEditor(MediaEditorMsg::ReplaceFile))
|
||||||
.style(inputs::secondary_button)
|
.style(inputs::secondary_button)
|
||||||
@@ -263,38 +295,7 @@ pub fn view<'a>(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
let header_menu: Element<'a, Message> = if state.quick_actions_open {
|
let header = header_row;
|
||||||
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);
|
|
||||||
|
|
||||||
// Translation flags bar
|
// Translation flags bar
|
||||||
let flags = state.translation_flags();
|
let flags = state.translation_flags();
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ use bds_core::model::{Post, PostStatus, PostTranslation};
|
|||||||
use bds_editor::{CodeEditor, EditorBuffer, EditorMessage, highlighter};
|
use bds_editor::{CodeEditor, EditorBuffer, EditorMessage, highlighter};
|
||||||
|
|
||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
use crate::components::inputs;
|
use crate::components::{inputs, popover};
|
||||||
use crate::i18n::t;
|
use crate::i18n::{t, tw};
|
||||||
use crate::views::status_bar;
|
use crate::views::status_bar;
|
||||||
|
|
||||||
/// Per editor_post.allium TranslationFlag value.
|
/// Per editor_post.allium TranslationFlag value.
|
||||||
@@ -76,6 +76,7 @@ pub struct PostEditorState {
|
|||||||
pub quick_actions_open: bool,
|
pub quick_actions_open: bool,
|
||||||
pub tags_input: String,
|
pub tags_input: String,
|
||||||
pub categories_input: String,
|
pub categories_input: String,
|
||||||
|
pub available_tags: Vec<String>,
|
||||||
pub semantic_tag_suggestions: Vec<String>,
|
pub semantic_tag_suggestions: Vec<String>,
|
||||||
pub active_language: String,
|
pub active_language: String,
|
||||||
pub canonical_language: String,
|
pub canonical_language: String,
|
||||||
@@ -124,6 +125,7 @@ impl Clone for PostEditorState {
|
|||||||
quick_actions_open: self.quick_actions_open,
|
quick_actions_open: self.quick_actions_open,
|
||||||
tags_input: self.tags_input.clone(),
|
tags_input: self.tags_input.clone(),
|
||||||
categories_input: self.categories_input.clone(),
|
categories_input: self.categories_input.clone(),
|
||||||
|
available_tags: self.available_tags.clone(),
|
||||||
semantic_tag_suggestions: self.semantic_tag_suggestions.clone(),
|
semantic_tag_suggestions: self.semantic_tag_suggestions.clone(),
|
||||||
active_language: self.active_language.clone(),
|
active_language: self.active_language.clone(),
|
||||||
canonical_language: self.canonical_language.clone(),
|
canonical_language: self.canonical_language.clone(),
|
||||||
@@ -190,6 +192,7 @@ impl PostEditorState {
|
|||||||
quick_actions_open: false,
|
quick_actions_open: false,
|
||||||
tags_input: String::new(),
|
tags_input: String::new(),
|
||||||
categories_input: String::new(),
|
categories_input: String::new(),
|
||||||
|
available_tags: Vec::new(),
|
||||||
semantic_tag_suggestions: Vec::new(),
|
semantic_tag_suggestions: Vec::new(),
|
||||||
active_language: canonical_lang.clone(),
|
active_language: canonical_lang.clone(),
|
||||||
canonical_language: canonical_lang,
|
canonical_language: canonical_lang,
|
||||||
@@ -208,6 +211,18 @@ impl PostEditorState {
|
|||||||
self.last_edit_at_ms = bds_core::util::now_unix_ms();
|
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) {
|
pub fn insert_markdown_at_cursor(&mut self, markdown: &str) {
|
||||||
let new_content = {
|
let new_content = {
|
||||||
let mut buffer = self.editor_buffer.borrow_mut();
|
let mut buffer = self.editor_buffer.borrow_mut();
|
||||||
@@ -409,9 +424,55 @@ pub fn view<'a>(
|
|||||||
.style(inputs::secondary_button)
|
.style(inputs::secondary_button)
|
||||||
.into();
|
.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![
|
let mut header_action_items: Vec<Element<'a, Message>> = vec![
|
||||||
status_badge(&state.status),
|
status_badge(&state.status),
|
||||||
quick_actions_button,
|
quick_actions,
|
||||||
button(
|
button(
|
||||||
text(t(locale, "common.save"))
|
text(t(locale, "common.save"))
|
||||||
.size(13)
|
.size(13)
|
||||||
@@ -478,54 +539,7 @@ pub fn view<'a>(
|
|||||||
.align_y(iced::Alignment::Center)
|
.align_y(iced::Alignment::Center)
|
||||||
.width(Length::Fill);
|
.width(Length::Fill);
|
||||||
|
|
||||||
let header_menu: Element<'a, Message> = if state.quick_actions_open {
|
let header = inputs::card(header_row).padding(10);
|
||||||
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);
|
|
||||||
|
|
||||||
// ── Collapsible Metadata Section ──
|
// ── Collapsible Metadata Section ──
|
||||||
let meta_toggle_label = if state.metadata_expanded {
|
let meta_toggle_label = if state.metadata_expanded {
|
||||||
@@ -629,7 +643,12 @@ pub fn view<'a>(
|
|||||||
Message::PostEditor(PostEditorMsg::TagsInputSubmit),
|
Message::PostEditor(PostEditorMsg::TagsInputSubmit),
|
||||||
|tag| Message::PostEditor(PostEditorMsg::RemoveTag(tag)),
|
|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()
|
Space::new(0, 0).into()
|
||||||
} else {
|
} else {
|
||||||
let mut chips = row![
|
let mut chips = row![
|
||||||
@@ -639,11 +658,11 @@ pub fn view<'a>(
|
|||||||
]
|
]
|
||||||
.spacing(6)
|
.spacing(6)
|
||||||
.align_y(iced::Alignment::Center);
|
.align_y(iced::Alignment::Center);
|
||||||
for tag in &state.semantic_tag_suggestions {
|
for tag in semantic_suggestions {
|
||||||
chips = chips.push(
|
chips = chips.push(
|
||||||
button(text(format!("+ {tag}")).size(11))
|
button(text(format!("+ {tag}")).size(11))
|
||||||
.on_press(Message::PostEditor(PostEditorMsg::AddSuggestedTag(
|
.on_press(Message::PostEditor(PostEditorMsg::AddSuggestedTag(
|
||||||
tag.clone(),
|
tag.to_string(),
|
||||||
)))
|
)))
|
||||||
.padding([4, 8])
|
.padding([4, 8])
|
||||||
.style(inputs::secondary_button),
|
.style(inputs::secondary_button),
|
||||||
@@ -651,6 +670,43 @@ pub fn view<'a>(
|
|||||||
}
|
}
|
||||||
chips.into()
|
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
|
// Categories chip input
|
||||||
let categories_section = chip_input_field(
|
let categories_section = chip_input_field(
|
||||||
@@ -809,6 +865,7 @@ pub fn view<'a>(
|
|||||||
meta_row2,
|
meta_row2,
|
||||||
tags_section,
|
tags_section,
|
||||||
semantic_tags,
|
semantic_tags,
|
||||||
|
matching_tags,
|
||||||
categories_section,
|
categories_section,
|
||||||
outlinks_section,
|
outlinks_section,
|
||||||
backlinks_section,
|
backlinks_section,
|
||||||
@@ -993,6 +1050,54 @@ pub fn view<'a>(
|
|||||||
.into()
|
.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.
|
/// Chip input: shows existing chips as removable buttons + a text input to add new ones.
|
||||||
fn chip_input_field<'a>(
|
fn chip_input_field<'a>(
|
||||||
label: &str,
|
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]
|
#[test]
|
||||||
fn insert_markdown_at_cursor_uses_buffer_cursor() {
|
fn insert_markdown_at_cursor_uses_buffer_cursor() {
|
||||||
let mut state = sample_state();
|
let mut state = sample_state();
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ menu-item-rebuildEmbeddingIndex = Embedding-Index neu erstellen
|
|||||||
menu-item-findDuplicates = Doppelte Beiträge finden
|
menu-item-findDuplicates = Doppelte Beiträge finden
|
||||||
common-refresh = Aktualisieren
|
common-refresh = Aktualisieren
|
||||||
editor-semanticTagSuggestions = Vorschläge aus ähnlichen Beiträgen
|
editor-semanticTagSuggestions = Vorschläge aus ähnlichen Beiträgen
|
||||||
|
editor-matchingTags = Passende Tags
|
||||||
|
editor-createTag = Tag erstellen: { $name }
|
||||||
duplicates-title = Doppelte Beiträge finden
|
duplicates-title = Doppelte Beiträge finden
|
||||||
duplicates-searching = Duplikate werden gesucht…
|
duplicates-searching = Duplikate werden gesucht…
|
||||||
duplicates-count = { $count } mögliche Paare
|
duplicates-count = { $count } mögliche Paare
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ menu-item-rebuildEmbeddingIndex = Rebuild Embedding Index
|
|||||||
menu-item-findDuplicates = Find Duplicate Posts
|
menu-item-findDuplicates = Find Duplicate Posts
|
||||||
common-refresh = Refresh
|
common-refresh = Refresh
|
||||||
editor-semanticTagSuggestions = Suggested from similar posts
|
editor-semanticTagSuggestions = Suggested from similar posts
|
||||||
|
editor-matchingTags = Matching tags
|
||||||
|
editor-createTag = Create tag: { $name }
|
||||||
duplicates-title = Find Duplicate Posts
|
duplicates-title = Find Duplicate Posts
|
||||||
duplicates-searching = Searching for duplicates…
|
duplicates-searching = Searching for duplicates…
|
||||||
duplicates-count = { $count } potential pairs
|
duplicates-count = { $count } potential pairs
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ menu-item-rebuildEmbeddingIndex = Reconstruir índice semántico
|
|||||||
menu-item-findDuplicates = Buscar publicaciones duplicadas
|
menu-item-findDuplicates = Buscar publicaciones duplicadas
|
||||||
common-refresh = Actualizar
|
common-refresh = Actualizar
|
||||||
editor-semanticTagSuggestions = Sugerencias de publicaciones similares
|
editor-semanticTagSuggestions = Sugerencias de publicaciones similares
|
||||||
|
editor-matchingTags = Etiquetas coincidentes
|
||||||
|
editor-createTag = Crear etiqueta: { $name }
|
||||||
duplicates-title = Buscar publicaciones duplicadas
|
duplicates-title = Buscar publicaciones duplicadas
|
||||||
duplicates-searching = Buscando duplicados…
|
duplicates-searching = Buscando duplicados…
|
||||||
duplicates-count = { $count } pares potenciales
|
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
|
menu-item-findDuplicates = Rechercher les articles en double
|
||||||
common-refresh = Actualiser
|
common-refresh = Actualiser
|
||||||
editor-semanticTagSuggestions = Suggestions d’articles similaires
|
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-title = Rechercher les articles en double
|
||||||
duplicates-searching = Recherche des doublons…
|
duplicates-searching = Recherche des doublons…
|
||||||
duplicates-count = { $count } paires potentielles
|
duplicates-count = { $count } paires potentielles
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ menu-item-rebuildEmbeddingIndex = Ricostruisci indice semantico
|
|||||||
menu-item-findDuplicates = Trova post duplicati
|
menu-item-findDuplicates = Trova post duplicati
|
||||||
common-refresh = Aggiorna
|
common-refresh = Aggiorna
|
||||||
editor-semanticTagSuggestions = Suggerimenti da post simili
|
editor-semanticTagSuggestions = Suggerimenti da post simili
|
||||||
|
editor-matchingTags = Tag corrispondenti
|
||||||
|
editor-createTag = Crea tag: { $name }
|
||||||
duplicates-title = Trova post duplicati
|
duplicates-title = Trova post duplicati
|
||||||
duplicates-searching = Ricerca duplicati…
|
duplicates-searching = Ricerca duplicati…
|
||||||
duplicates-count = { $count } coppie potenziali
|
duplicates-count = { $count } coppie potenziali
|
||||||
|
|||||||
@@ -59,14 +59,15 @@ rule AISuggestionFlow {
|
|||||||
rule AutoTranslationChain {
|
rule AutoTranslationChain {
|
||||||
when: PostSaved(post_id)
|
when: PostSaved(post_id)
|
||||||
-- Gate: AIOperationGating + post.doNotTranslate must be false
|
-- 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:
|
-- For each configured blogLanguage missing a translation for this post:
|
||||||
-- 1. Enqueue background task: translate metadata (title, excerpt)
|
-- 1. Enqueue one background task for that language
|
||||||
-- via title model
|
-- 2. Translate metadata (title, excerpt) via title model
|
||||||
-- 2. Enqueue background task: translate content (full markdown)
|
-- 3. Translate content (full markdown) via title model
|
||||||
-- via title model
|
-- 4. Create/update translation record in DB
|
||||||
-- 3. Create/update translation record in DB
|
-- A later manual save with no missing languages enqueues no task.
|
||||||
-- Tasks: sequential per language, parallel across languages
|
-- Work is sequential within a language and parallel across languages.
|
||||||
-- Progress visible in Tasks panel
|
-- Progress visible in Tasks panel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,9 +74,9 @@ use "./metadata_diff.allium" as metadata_diff -- DB/filesystem diff and rebuil
|
|||||||
|
|
||||||
-- Resolved questions:
|
-- Resolved questions:
|
||||||
--
|
--
|
||||||
-- 1. Slug generation scope: only German and English letters are used.
|
-- 1. Slug generation is identical to bDS2: replace ß with "ss", normalize to
|
||||||
-- Verify transliteration preserves the established bDS behaviour for
|
-- 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.
|
-- 2. Liquid subset: see template.allium for the exact subset.
|
||||||
-- Only 5 tags, 4 standard filters, 2 custom filters, 5 operators.
|
-- Only 5 tags, 4 standard filters, 2 custom filters, 5 operators.
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ enum PostStatus {
|
|||||||
value Slug {
|
value Slug {
|
||||||
value: String
|
value: String
|
||||||
|
|
||||||
-- Generated by: transliterate unicode to ASCII, lowercase,
|
-- Generated exactly as bDS2: replace ß with "ss", normalize to Unicode NFD,
|
||||||
-- replace [^a-z0-9]+ with hyphens, strip leading/trailing hyphens
|
-- discard every non-ASCII code point, lowercase, replace [^a-z0-9]+ with
|
||||||
-- Transliteration scope: only German (ä/ö/ü/ß/ÄÖÜ) and English letters used.
|
-- hyphens, then strip leading/trailing hyphens.
|
||||||
-- Verify transliteration matches the established bDS behaviour for this set.
|
-- 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)
|
-- Uniqueness: tries base, then {slug}-2, {slug}-3, … (unbounded numeric suffix)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user