fix: align code with spec

This commit is contained in:
2026-04-03 15:53:48 +02:00
parent 95c97bce33
commit 897577a369
10 changed files with 141 additions and 20 deletions

View File

@@ -11,7 +11,7 @@ impl Database {
/// Open an existing bDS project database.
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
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 })
}

View File

@@ -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<dyn std::error::Error>> {
// 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
);
",
)?;

View File

@@ -3,4 +3,5 @@ pub mod engine;
pub mod model;
pub mod render;
pub mod i18n;
pub mod scripting;
pub mod util;

View File

@@ -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<i64>,
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ssh_remote_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ssh_mode: Option<String>,
pub ssh_mode: SshMode,
}

View File

@@ -29,8 +29,8 @@ pub struct Media {
#[serde(skip_serializing_if = "Option::is_none")]
pub checksum: Option<String>,
/// JSON-serialized string array in DB.
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
pub created_at: i64,
pub updated_at: i64,
}

View File

@@ -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,
};

View File

@@ -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<String>,
pub created_at: i64,

View File

@@ -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<String>,
pub created_at: i64,

View File

@@ -1,5 +1,5 @@
mod slug;
mod checksum;
pub use slug::slugify;
pub use slug::{slugify, ensure_unique};
pub use checksum::content_hash;

View File

@@ -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<F>(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::<u32>() {
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);
}
}