Implement the shared automation CLI.
This commit is contained in:
@@ -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)
|
||||
|
||||
73
crates/bds-core/src/engine/cli_launcher.rs
Normal file
73
crates/bds-core/src/engine/cli_launcher.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
34
crates/bds-core/src/util/app_paths.rs
Normal file
34
crates/bds-core/src/util/app_paths.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user