diff --git a/crates/bds-core/src/db/connection.rs b/crates/bds-core/src/db/connection.rs index 83f50ac..96ed856 100644 --- a/crates/bds-core/src/db/connection.rs +++ b/crates/bds-core/src/db/connection.rs @@ -11,7 +11,7 @@ impl Database { /// Open an existing bDS project database. pub fn open(path: &Path) -> Result { let conn = Connection::open(path)?; - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;")?; Ok(Self { conn }) } diff --git a/crates/bds-core/src/db/migrations.rs b/crates/bds-core/src/db/migrations.rs index 7fcf7ee..0f8b511 100644 --- a/crates/bds-core/src/db/migrations.rs +++ b/crates/bds-core/src/db/migrations.rs @@ -5,16 +5,19 @@ use rusqlite::Connection; /// For M0, this is a stub. Once we have the full schema from the TypeScript /// app's migrations, we will embed them via refinery. pub fn run_migrations(conn: &Connection) -> Result<(), Box> { - // TODO(M0): Embed real migrations from the TypeScript app's schema. + // TODO(M1): Embed real migrations from the TypeScript app's schema. // For now, create the minimal tables needed for read-access verification. conn.execute_batch( " CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL, - path TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL + slug TEXT NOT NULL, + description TEXT, + data_path TEXT, + is_active INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL ); ", )?; diff --git a/crates/bds-core/src/lib.rs b/crates/bds-core/src/lib.rs index 3361b55..c2f686e 100644 --- a/crates/bds-core/src/lib.rs +++ b/crates/bds-core/src/lib.rs @@ -3,4 +3,5 @@ pub mod engine; pub mod model; pub mod render; pub mod i18n; +pub mod scripting; pub mod util; diff --git a/crates/bds-core/src/model/generation.rs b/crates/bds-core/src/model/generation.rs index 5c2d7f2..037e1ce 100644 --- a/crates/bds-core/src/model/generation.rs +++ b/crates/bds-core/src/model/generation.rs @@ -10,20 +10,44 @@ pub struct GeneratedFileHash { pub updated_at: i64, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum NotificationEntity { + Post, + Media, + Script, + Template, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum NotificationAction { + Created, + Updated, + Deleted, +} + /// Notification for CLI-to-app synchronization. /// Matches the `db_notifications` table. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DbNotification { pub id: i64, - pub entity: String, + pub entity_type: NotificationEntity, pub entity_id: String, - pub action: String, + pub action: NotificationAction, pub from_cli: bool, #[serde(skip_serializing_if = "Option::is_none")] pub seen_at: Option, pub created_at: i64, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SshMode { + Scp, + Rsync, +} + /// Publishing preferences stored in meta/publishing.json. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PublishingPreferences { @@ -33,6 +57,5 @@ pub struct PublishingPreferences { pub ssh_user: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ssh_remote_path: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ssh_mode: Option, + pub ssh_mode: SshMode, } diff --git a/crates/bds-core/src/model/media.rs b/crates/bds-core/src/model/media.rs index 63c9f8d..0e6d1f6 100644 --- a/crates/bds-core/src/model/media.rs +++ b/crates/bds-core/src/model/media.rs @@ -29,8 +29,8 @@ pub struct Media { #[serde(skip_serializing_if = "Option::is_none")] pub checksum: Option, /// JSON-serialized string array in DB. - #[serde(skip_serializing_if = "Option::is_none")] - pub tags: Option, + #[serde(default)] + pub tags: Vec, pub created_at: i64, pub updated_at: i64, } diff --git a/crates/bds-core/src/model/mod.rs b/crates/bds-core/src/model/mod.rs index 405671a..5222118 100644 --- a/crates/bds-core/src/model/mod.rs +++ b/crates/bds-core/src/model/mod.rs @@ -10,6 +10,9 @@ pub use post::{Post, PostLink, PostMedia, PostStatus, PostTranslation}; pub use media::{Media, MediaTranslation}; pub use tag::Tag; pub use project::{Project, Setting}; -pub use template::Template; -pub use script::Script; -pub use generation::{DbNotification, GeneratedFileHash, PublishingPreferences}; +pub use template::{Template, TemplateKind, TemplateStatus}; +pub use script::{Script, ScriptKind, ScriptStatus}; +pub use generation::{ + DbNotification, GeneratedFileHash, NotificationAction, NotificationEntity, + PublishingPreferences, SshMode, +}; diff --git a/crates/bds-core/src/model/script.rs b/crates/bds-core/src/model/script.rs index 4a9c4d3..96065fe 100644 --- a/crates/bds-core/src/model/script.rs +++ b/crates/bds-core/src/model/script.rs @@ -1,5 +1,20 @@ use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ScriptKind { + Macro, + Utility, + Transform, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ScriptStatus { + Draft, + Published, +} + /// A user-authored script. Matches the `scripts` table. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Script { @@ -7,13 +22,12 @@ pub struct Script { pub project_id: String, pub slug: String, pub title: String, - /// "macro", "transform", or "utility" - pub kind: String, + pub kind: ScriptKind, pub entrypoint: String, pub enabled: bool, pub version: i32, pub file_path: String, - pub status: String, + pub status: ScriptStatus, #[serde(skip_serializing_if = "Option::is_none")] pub content: Option, pub created_at: i64, diff --git a/crates/bds-core/src/model/template.rs b/crates/bds-core/src/model/template.rs index 642ae70..8b2ed2b 100644 --- a/crates/bds-core/src/model/template.rs +++ b/crates/bds-core/src/model/template.rs @@ -1,5 +1,21 @@ use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TemplateKind { + Post, + List, + NotFound, + Partial, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TemplateStatus { + Draft, + Published, +} + /// A Liquid template. Matches the `templates` table. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Template { @@ -7,11 +23,11 @@ pub struct Template { pub project_id: String, pub slug: String, pub title: String, - pub kind: String, + pub kind: TemplateKind, pub enabled: bool, pub version: i32, pub file_path: String, - pub status: String, + pub status: TemplateStatus, #[serde(skip_serializing_if = "Option::is_none")] pub content: Option, pub created_at: i64, diff --git a/crates/bds-core/src/util/mod.rs b/crates/bds-core/src/util/mod.rs index 70772c1..bb84da3 100644 --- a/crates/bds-core/src/util/mod.rs +++ b/crates/bds-core/src/util/mod.rs @@ -1,5 +1,5 @@ mod slug; mod checksum; -pub use slug::slugify; +pub use slug::{slugify, ensure_unique}; pub use checksum::content_hash; diff --git a/crates/bds-core/src/util/slug.rs b/crates/bds-core/src/util/slug.rs index e601728..5c5d553 100644 --- a/crates/bds-core/src/util/slug.rs +++ b/crates/bds-core/src/util/slug.rs @@ -1,4 +1,5 @@ use deunicode::deunicode; +use std::time::{SystemTime, UNIX_EPOCH}; /// Generate a URL-safe slug from a title string. /// @@ -25,6 +26,30 @@ pub fn slugify(input: &str) -> String { slug } +/// Ensure a slug is unique within a project, using the spec's algorithm: +/// tries base, then {slug}-2 .. {slug}-999, then {slug}-{timestamp}. +/// +/// `exists` is a predicate that returns true if the candidate slug is already taken. +pub fn ensure_unique(base: &str, exists: F) -> String +where + F: Fn(&str) -> bool, +{ + if !exists(base) { + return base.to_string(); + } + for n in 2..=999 { + let candidate = format!("{base}-{n}"); + if !exists(&candidate) { + return candidate; + } + } + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + format!("{base}-{ts}") +} + #[cfg(test)] mod tests { use super::*; @@ -58,4 +83,40 @@ mod tests { fn consecutive_special_chars() { assert_eq!(slugify("a --- b"), "a-b"); } + + #[test] + fn ensure_unique_base_available() { + let slug = ensure_unique("hello", |_| false); + assert_eq!(slug, "hello"); + } + + #[test] + fn ensure_unique_base_taken() { + let slug = ensure_unique("hello", |s| s == "hello"); + assert_eq!(slug, "hello-2"); + } + + #[test] + fn ensure_unique_sequential_taken() { + let slug = ensure_unique("hello", |s| s == "hello" || s == "hello-2" || s == "hello-3"); + assert_eq!(slug, "hello-4"); + } + + #[test] + fn ensure_unique_all_999_taken() { + let slug = ensure_unique("x", |s| { + if s == "x" { return true; } + if let Some(suffix) = s.strip_prefix("x-") { + if let Ok(n) = suffix.parse::() { + return n <= 999; + } + } + false + }); + // Should fall back to timestamp-based slug + assert!(slug.starts_with("x-")); + let suffix = slug.strip_prefix("x-").unwrap(); + let ts: u64 = suffix.parse().expect("should be a timestamp"); + assert!(ts > 1_000_000_000); + } }