fix: align code with spec
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
",
|
||||
)?;
|
||||
|
||||
@@ -3,4 +3,5 @@ pub mod engine;
|
||||
pub mod model;
|
||||
pub mod render;
|
||||
pub mod i18n;
|
||||
pub mod scripting;
|
||||
pub mod util;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
mod slug;
|
||||
mod checksum;
|
||||
|
||||
pub use slug::slugify;
|
||||
pub use slug::{slugify, ensure_unique};
|
||||
pub use checksum::content_hash;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user