Implement the shared automation CLI.

This commit is contained in:
2026-07-19 14:25:15 +02:00
parent a2abb901cd
commit fdfae200a0
33 changed files with 2411 additions and 58 deletions

View File

@@ -37,6 +37,7 @@ libsqlite3-sys = { workspace = true }
mlua = { workspace = true }
url = { workspace = true }
base64 = { workspace = true }
dirs = { workspace = true }
[dev-dependencies]
tempfile = "3"

View File

@@ -53,7 +53,9 @@ impl DbConnection {
.batch_execute("ROLLBACK TO bds_operation; RELEASE bds_operation")
}
pub(crate) fn database_path(&self) -> diesel::QueryResult<std::path::PathBuf> {
/// Filesystem database path for sibling surfaces that must open their own
/// short-lived connection (gallery workers, preview servers, Lua hosts).
pub fn database_path(&self) -> diesel::QueryResult<std::path::PathBuf> {
self.with(|conn| {
diesel::sql_query("SELECT file FROM pragma_database_list WHERE name = 'main'")
.get_result::<DatabasePathRow>(conn)

View File

@@ -0,0 +1,73 @@
use std::path::{Path, PathBuf};
use crate::engine::{EngineError, EngineResult};
/// Install a recoverable launcher pointing at a packaged `bds-cli` binary.
/// Existing unrelated files are never overwritten.
pub fn install_launcher(executable: &Path, home_dir: &Path) -> EngineResult<PathBuf> {
if !executable.is_file() {
return Err(EngineError::Validation(format!(
"installing the CLI requires the packaged bds-cli executable (not found at {})",
executable.display()
)));
}
let bin_dir = home_dir.join(".local/bin");
std::fs::create_dir_all(&bin_dir)?;
let target = bin_dir.join(if cfg!(windows) {
"bds-cli.exe"
} else {
"bds-cli"
});
if target.exists() {
let existing = target.canonicalize().ok();
let source = executable.canonicalize()?;
if existing.as_ref() != Some(&source) {
return Err(EngineError::Conflict(format!(
"refusing to overwrite existing launcher at {}",
target.display()
)));
}
return Ok(target);
}
#[cfg(unix)]
std::os::unix::fs::symlink(executable.canonicalize()?, &target)?;
#[cfg(windows)]
std::fs::copy(executable, &target)?;
Ok(target)
}
/// Resolve the CLI shipped beside the desktop executable and install it.
pub fn install_packaged_launcher(home_dir: &Path) -> EngineResult<PathBuf> {
let app = std::env::current_exe()?;
let cli = app.with_file_name(if cfg!(windows) {
"bds-cli.exe"
} else {
"bds-cli"
});
install_launcher(&cli, home_dir)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn install_is_idempotent_and_never_overwrites_an_unrelated_file() {
let root = tempfile::tempdir().unwrap();
let executable = root.path().join("packaged-bds-cli");
std::fs::write(&executable, b"binary").unwrap();
let home = root.path().join("home");
let target = install_launcher(&executable, &home).unwrap();
assert_eq!(
target.canonicalize().unwrap(),
executable.canonicalize().unwrap()
);
assert_eq!(install_launcher(&executable, &home).unwrap(), target);
std::fs::remove_file(&target).unwrap();
std::fs::write(&target, b"mine").unwrap();
assert!(install_launcher(&executable, &home).is_err());
assert_eq!(std::fs::read(&target).unwrap(), b"mine");
}
}

View File

@@ -21,12 +21,74 @@ pub fn run_cli_mutation<T>(
) -> EngineResult<T> {
let (result, events) = domain_events::capture_current_thread(operation)
.map_err(|message| EngineError::Validation(message.to_string()))?;
for event in &events {
let mut unique = Vec::<DomainEvent>::new();
for event in events {
if let Some(seen) = unique
.iter_mut()
.find(|seen| same_notification(seen, &event))
{
merge_notification_action(seen, &event);
} else {
unique.push(event);
}
}
for event in &unique {
record_cli_event(conn, event)?;
}
result
}
fn merge_notification_action(existing: &mut DomainEvent, incoming: &DomainEvent) {
let (
DomainEvent::EntityChanged {
action: existing_action,
..
},
DomainEvent::EntityChanged {
action: incoming_action,
..
},
) = (existing, incoming)
else {
return;
};
if *incoming_action == NotificationAction::Deleted
|| *existing_action != NotificationAction::Created
{
*existing_action = incoming_action.clone();
}
}
fn same_notification(left: &DomainEvent, right: &DomainEvent) -> bool {
match (left, right) {
(
DomainEvent::EntityChanged {
project_id: left_project,
entity: left_entity,
entity_id: left_id,
..
},
DomainEvent::EntityChanged {
project_id: right_project,
entity: right_entity,
entity_id: right_id,
..
},
) => left_project == right_project && left_entity == right_entity && left_id == right_id,
(
DomainEvent::SettingsChanged {
project_id: left_project,
key: left_key,
},
DomainEvent::SettingsChanged {
project_id: right_project,
key: right_key,
},
) => left_project == right_project && left_key == right_key,
_ => false,
}
}
pub fn record_cli_event(conn: &Connection, event: &DomainEvent) -> EngineResult<()> {
record_cli_event_at(conn, event, now_unix_ms())
}

View File

@@ -183,7 +183,7 @@ fn import_gallery_image(
.map_err(|error| error.to_string())?;
let title = if ai_available {
enrich_image(
enrich_imported_image(
db.conn(),
data_dir,
&imported,
@@ -201,7 +201,9 @@ fn import_gallery_image(
})
}
fn enrich_image(
/// Apply the shared gallery AI enrichment and translation pipeline to one
/// already-imported image. Returns the generated title when AI was available.
pub fn enrich_imported_image(
conn: &crate::db::DbConnection,
data_dir: &Path,
imported: &crate::model::Media,

View File

@@ -103,6 +103,20 @@ pub fn generate_starter_site(
)
}
/// Forget stored generated-file hashes so the next render writes every
/// artifact while repopulating the cache with its current content hash.
pub fn clear_generation_cache(conn: &Connection, project_id: &str) -> EngineResult<usize> {
use crate::db::schema::generated_file_hashes;
use diesel::prelude::*;
Ok(conn.with(|connection| {
diesel::delete(
generated_file_hashes::table.filter(generated_file_hashes::project_id.eq(project_id)),
)
.execute(connection)
})?)
}
pub fn generate_starter_site_with_progress(
conn: &Connection,
output_dir: &Path,

View File

@@ -34,6 +34,116 @@ pub struct MediaRebuildReport {
pub errors: Vec<String>,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct MediaLinkRebuildReport {
pub links: usize,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct ThumbnailRepairReport {
pub media_repaired: usize,
pub thumbnails_generated: usize,
}
/// Rebuild the exact post/media relationship set stored in canonical media
/// sidecars. Stale database links are removed as well as missing links added.
pub fn rebuild_media_links(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<MediaLinkRebuildReport> {
conn.begin_savepoint()?;
match rebuild_media_links_inner(conn, data_dir, project_id) {
Ok(report) => {
conn.release_savepoint()?;
Ok(report)
}
Err(error) => {
let _ = conn.rollback_savepoint();
Err(error)
}
}
}
fn rebuild_media_links_inner(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<MediaLinkRebuildReport> {
let mut report = MediaLinkRebuildReport::default();
for item in qm::list_media_by_project(conn, project_id)? {
let sidecar = read_sidecar(&fs::read_to_string(data_dir.join(&item.sidecar_path))?)
.map_err(EngineError::Parse)?;
for link in qpm::list_post_media_by_media(conn, &item.id)? {
qpm::unlink_media(conn, &link.post_id, &item.id)?;
}
for (sort_order, post_id) in sidecar.linked_post_ids.into_iter().enumerate() {
let post = qp::get_post_by_id(conn, &post_id)?;
if post.project_id != project_id {
return Err(EngineError::Validation(format!(
"media {} sidecar links to a post outside the active project",
item.id
)));
}
qpm::link_media(
conn,
&PostMedia {
id: Uuid::new_v4().to_string(),
project_id: project_id.to_string(),
post_id,
media_id: item.id.clone(),
sort_order: sort_order as i32,
created_at: now_unix_ms(),
},
)?;
report.links += 1;
}
}
Ok(report)
}
/// Regenerate all standard thumbnail variants for items missing at least one
/// variant. Existing complete sets are left untouched.
pub fn regenerate_missing_thumbnails(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<ThumbnailRepairReport> {
let mut report = ThumbnailRepairReport::default();
for item in qm::list_media_by_project(conn, project_id)? {
if !item.mime_type.starts_with("image/") {
continue;
}
let prefix = &item.id[..2.min(item.id.len())];
let missing = THUMBNAIL_SIZES
.iter()
.filter(|size| {
let extension = match size.format {
ThumbnailFormat::Webp => "webp",
ThumbnailFormat::Jpeg => "jpg",
};
!data_dir
.join("thumbnails")
.join(prefix)
.join(format!("{}-{}.{}", item.id, size.name, extension))
.is_file()
})
.count();
if missing == 0 {
continue;
}
generate_all_thumbnails(
&data_dir.join(&item.file_path),
&data_dir.join("thumbnails"),
&item.id,
)
.map_err(EngineError::Parse)?;
report.media_repaired += 1;
report.thumbnails_generated += missing;
}
Ok(report)
}
/// Supported image MIME types for import (per media_processing.allium).
const SUPPORTED_IMAGE_TYPES: &[&str] = &[
"image/jpeg",
@@ -959,6 +1069,95 @@ mod tests {
assert_eq!(sc.tags, vec!["updated-tag"]);
}
#[test]
fn rebuild_media_links_replaces_stale_links_and_rolls_back_invalid_sidecars() {
let (db, dir) = setup();
let source = create_test_png(dir.path());
let media = import_media(
db.conn(),
dir.path(),
"p1",
&source,
"photo.png",
None,
None,
None,
None,
None,
vec![],
)
.unwrap();
let first = crate::engine::post::create_post(
db.conn(),
dir.path(),
"p1",
"First",
None,
vec![],
vec![],
None,
None,
None,
)
.unwrap();
let stale = crate::engine::post::create_post(
db.conn(),
dir.path(),
"p1",
"Stale",
None,
vec![],
vec![],
None,
None,
None,
)
.unwrap();
crate::engine::post_media::link_media_to_post(
db.conn(),
dir.path(),
"p1",
&first.id,
&media.id,
0,
)
.unwrap();
crate::engine::post_media::link_media_to_post(
db.conn(),
dir.path(),
"p1",
&stale.id,
&media.id,
1,
)
.unwrap();
atomic_write_str(
&dir.path().join(&media.sidecar_path),
&MediaSidecar::from_media(&media, std::slice::from_ref(&first.id)).to_string(),
)
.unwrap();
let report = rebuild_media_links(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.links, 1);
let links = qpm::list_post_media_by_media(db.conn(), &media.id).unwrap();
assert_eq!(links.len(), 1);
assert_eq!(links[0].post_id, first.id);
atomic_write_str(
&dir.path().join(&media.sidecar_path),
&MediaSidecar::from_media(&media, &["missing-post".into()]).to_string(),
)
.unwrap();
assert!(rebuild_media_links(db.conn(), dir.path(), "p1").is_err());
let links = qpm::list_post_media_by_media(db.conn(), &media.id).unwrap();
assert_eq!(
links.len(),
1,
"the failed repair must roll back link deletion"
);
assert_eq!(links[0].post_id, first.id);
}
#[test]
fn replace_media_file_preserves_identity_and_regenerates_artifacts() {
let (db, dir) = setup();

View File

@@ -228,6 +228,62 @@ pub fn repair_metadata_diff_item(
Ok(())
}
/// Import one content file reported as a filesystem orphan by
/// [`compute_metadata_diff`]. The normal per-entity rebuild paths remain the
/// sole parsers and writers for these formats.
pub fn import_orphan_file(
conn: &Connection,
data_dir: &Path,
project_id: &str,
orphan: &OrphanFile,
) -> EngineResult<()> {
if orphan.reason != "file_without_db_entry" {
return Err(EngineError::Validation(format!(
"cannot import an orphan that is absent from the filesystem: {}",
orphan.file_path
)));
}
let path = data_dir.join(&orphan.file_path);
let canonical_data_dir = data_dir.canonicalize()?;
let canonical_path = path.canonicalize()?;
if !canonical_path.starts_with(&canonical_data_dir) {
return Err(EngineError::Validation(
"orphan path is outside the active project".into(),
));
}
if orphan.file_path.starts_with("posts/") && orphan.file_path.ends_with(".md") {
let stem = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("");
if crate::engine::post::is_translation_filename(stem) {
crate::engine::post::rebuild_translation(conn, data_dir, project_id, &path)?;
} else {
crate::engine::post::rebuild_canonical_post(conn, data_dir, project_id, &path)?;
}
} else if orphan.file_path.starts_with("media/") && orphan.file_path.ends_with(".meta") {
let raw = fs::read_to_string(&path)?;
if read_translation_sidecar(&raw).is_ok() {
crate::engine::media::rebuild_translation_sidecar(conn, data_dir, project_id, &path)?;
} else {
crate::engine::media::rebuild_canonical_media(conn, data_dir, project_id, &path)?;
}
} else if orphan.file_path.starts_with("scripts/") && orphan.file_path.ends_with(".lua") {
crate::engine::script_rebuild::rebuild_single_script(conn, data_dir, project_id, &path)?;
} else if orphan.file_path.starts_with("templates/") && orphan.file_path.ends_with(".liquid") {
crate::engine::template_rebuild::rebuild_single_template(
conn, data_dir, project_id, &path,
)?;
} else {
return Err(EngineError::Validation(format!(
"unsupported orphan file: {}",
orphan.file_path
)));
}
Ok(())
}
fn diff_project(
data_dir: &Path,
project: &crate::model::Project,

View File

@@ -2,6 +2,7 @@ pub mod ai;
pub mod auto_translation;
pub mod blogmark;
pub mod calendar;
pub mod cli_launcher;
pub mod cli_sync;
pub mod domain_events;
pub mod error;

View File

@@ -916,7 +916,7 @@ fn fts_index_post(conn: &Connection, post: &Post) -> EngineResult<()> {
/// Check if a file stem looks like a translation filename: `{slug}.{lang}`
/// where lang is a 2-letter code. We look for a dot followed by exactly 2 lowercase letters.
fn is_translation_filename(stem: &str) -> bool {
pub(crate) fn is_translation_filename(stem: &str) -> bool {
if let Some(dot_pos) = stem.rfind('.') {
let suffix = &stem[dot_pos + 1..];
suffix.len() == 2 && suffix.chars().all(|c| c.is_ascii_lowercase())

View File

@@ -14,6 +14,50 @@ use crate::util::now_unix_ms;
const REBUILD_REQUIRED_SETTING: &str = "app.search-index-rebuild-required";
/// Deterministic offline language fallback used when no permitted AI endpoint
/// is configured. This intentionally mirrors the legacy application's small
/// heuristic; it is a notice-worthy fallback, not a language model.
pub fn detect_language(text: &str) -> &'static str {
let normalized = text.to_lowercase();
if normalized.trim().is_empty() {
"en"
} else if normalized.contains(['ä', 'ö', 'ü', 'ß']) {
"de"
} else if normalized.contains([
'à', 'â', 'ç', 'é', 'è', 'ê', 'ë', 'î', 'ï', 'ô', 'ù', 'û', 'ÿ', 'œ',
]) {
"fr"
} else if normalized.contains(['ñ', '¡', '¿']) {
"es"
} else {
detect_language_from_hints(&normalized)
}
}
fn detect_language_from_hints(text: &str) -> &'static str {
let padded = format!(" {text} ");
let scores = [
(
"de",
[" der ", " die ", " das ", " und ", " ist ", " nicht "],
),
("fr", [" le ", " la ", " les ", " et ", " est ", " pas "]),
("es", [" el ", " la ", " los ", " y ", " es ", " no "]),
];
scores
.into_iter()
.map(|(language, hints)| {
let score = hints
.into_iter()
.filter(|hint| padded.contains(hint))
.count();
(language, score)
})
.max_by_key(|(_, score)| *score)
.filter(|(_, score)| *score >= 2)
.map_or("en", |(language, _)| language)
}
/// Result of a full reindex operation.
pub struct ReindexReport {
pub posts_indexed: usize,

View File

@@ -10,7 +10,12 @@ use crate::model::Media;
use crate::util::{media_sidecar_path, thumbnail_path};
/// Thumbnail sizes per media_processing.allium.
const THUMBNAIL_SIZES: &[&str] = &["small", "medium", "large", "ai"];
const THUMBNAIL_VARIANTS: &[(&str, &str)] = &[
("small", "webp"),
("medium", "webp"),
("large", "webp"),
("ai", "jpg"),
];
/// Types of media validation issues.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -125,8 +130,7 @@ fn check_media_item(
// 3. Missing thumbnails — only for image types
if is_image_mime(&media.mime_type) {
let ext = thumbnail_extension(&media.mime_type);
for size in THUMBNAIL_SIZES {
for (size, ext) in THUMBNAIL_VARIANTS {
let thumb_rel = thumbnail_path(&media.id, size, ext);
let thumb_path = data_dir.join(&thumb_rel);
if !thumb_path.exists() {
@@ -160,15 +164,6 @@ fn is_image_mime(mime: &str) -> bool {
mime.starts_with("image/")
}
fn thumbnail_extension(mime: &str) -> &str {
match mime {
"image/png" => "png",
"image/gif" => "gif",
"image/webp" => "webp",
_ => "jpg",
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -182,10 +177,15 @@ mod tests {
}
#[test]
fn thumbnail_ext_defaults_to_jpg() {
assert_eq!(thumbnail_extension("image/jpeg"), "jpg");
assert_eq!(thumbnail_extension("image/png"), "png");
assert_eq!(thumbnail_extension("image/webp"), "webp");
assert_eq!(thumbnail_extension("image/tiff"), "jpg"); // fallback
fn thumbnail_variants_match_the_generator_formats() {
assert_eq!(
THUMBNAIL_VARIANTS,
&[
("small", "webp"),
("medium", "webp"),
("large", "webp"),
("ai", "jpg")
]
);
}
}

View File

@@ -0,0 +1,34 @@
use std::path::PathBuf;
/// Machine-local application data directory shared by every RuDS surface.
pub fn application_data_dir() -> PathBuf {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("bds")
}
/// SQLite cache/registry used by desktop, CLI, TUI, and remote surfaces.
pub fn application_database_path() -> PathBuf {
application_data_dir().join("bds.db")
}
/// Default portable project folder used on first launch.
pub fn default_project_data_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("bds")
.join("my-blog")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn database_is_inside_application_data_directory() {
assert_eq!(
application_database_path(),
application_data_dir().join("bds.db")
);
}
}

View File

@@ -1,3 +1,4 @@
pub mod app_paths;
pub mod atomic_write;
mod checksum;
pub mod frontmatter;
@@ -7,6 +8,7 @@ mod slug;
pub mod thumbnail;
pub mod timestamp;
pub use app_paths::{application_data_dir, application_database_path, default_project_data_dir};
pub use atomic_write::{atomic_write, atomic_write_str};
pub use checksum::{content_hash, file_hash};
pub use paths::*;

View File

@@ -470,3 +470,48 @@ fn cli_mutation_persists_the_shared_event_for_the_desktop_process() {
);
assert!(notifications[0].from_cli);
}
#[test]
fn cli_mutation_deduplicates_composite_events_and_keeps_the_final_state() {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
let result: bds_core::engine::EngineResult<()> = cli_sync::run_cli_mutation(db.conn(), || {
bds_core::engine::domain_events::entity_changed(
"project",
DomainEntity::Post,
"created-then-updated",
NotificationAction::Created,
);
bds_core::engine::domain_events::entity_changed(
"project",
DomainEntity::Post,
"created-then-updated",
NotificationAction::Updated,
);
bds_core::engine::domain_events::entity_changed(
"project",
DomainEntity::Media,
"updated-then-deleted",
NotificationAction::Updated,
);
bds_core::engine::domain_events::entity_changed(
"project",
DomainEntity::Media,
"updated-then-deleted",
NotificationAction::Deleted,
);
Err(bds_core::engine::EngineError::Validation(
"later composite step failed".into(),
))
});
assert!(result.is_err());
let notifications =
bds_core::db::queries::db_notification::list_notifications(db.conn()).unwrap();
assert_eq!(notifications.len(), 2);
assert_eq!(notifications[0].entity_id, "created-then-updated");
assert_eq!(notifications[0].action, NotificationAction::Created);
assert_eq!(notifications[1].entity_id, "updated-then-deleted");
assert_eq!(notifications[1].action, NotificationAction::Deleted);
}