fix: align code with spec
This commit is contained in:
@@ -11,7 +11,7 @@ impl Database {
|
|||||||
/// Open an existing bDS project database.
|
/// Open an existing bDS project database.
|
||||||
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
|
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
|
||||||
let conn = Connection::open(path)?;
|
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 })
|
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
|
/// For M0, this is a stub. Once we have the full schema from the TypeScript
|
||||||
/// app's migrations, we will embed them via refinery.
|
/// app's migrations, we will embed them via refinery.
|
||||||
pub fn run_migrations(conn: &Connection) -> Result<(), Box<dyn std::error::Error>> {
|
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.
|
// For now, create the minimal tables needed for read-access verification.
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"
|
"
|
||||||
CREATE TABLE IF NOT EXISTS projects (
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
path TEXT NOT NULL,
|
slug TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL,
|
description TEXT,
|
||||||
updated_at TEXT NOT NULL
|
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 model;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
|
pub mod scripting;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
|||||||
@@ -10,20 +10,44 @@ pub struct GeneratedFileHash {
|
|||||||
pub updated_at: i64,
|
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.
|
/// Notification for CLI-to-app synchronization.
|
||||||
/// Matches the `db_notifications` table.
|
/// Matches the `db_notifications` table.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct DbNotification {
|
pub struct DbNotification {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub entity: String,
|
pub entity_type: NotificationEntity,
|
||||||
pub entity_id: String,
|
pub entity_id: String,
|
||||||
pub action: String,
|
pub action: NotificationAction,
|
||||||
pub from_cli: bool,
|
pub from_cli: bool,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub seen_at: Option<i64>,
|
pub seen_at: Option<i64>,
|
||||||
pub created_at: 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.
|
/// Publishing preferences stored in meta/publishing.json.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PublishingPreferences {
|
pub struct PublishingPreferences {
|
||||||
@@ -33,6 +57,5 @@ pub struct PublishingPreferences {
|
|||||||
pub ssh_user: Option<String>,
|
pub ssh_user: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub ssh_remote_path: Option<String>,
|
pub ssh_remote_path: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
pub ssh_mode: SshMode,
|
||||||
pub ssh_mode: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ pub struct Media {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub checksum: Option<String>,
|
pub checksum: Option<String>,
|
||||||
/// JSON-serialized string array in DB.
|
/// JSON-serialized string array in DB.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(default)]
|
||||||
pub tags: Option<String>,
|
pub tags: Vec<String>,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
pub updated_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 media::{Media, MediaTranslation};
|
||||||
pub use tag::Tag;
|
pub use tag::Tag;
|
||||||
pub use project::{Project, Setting};
|
pub use project::{Project, Setting};
|
||||||
pub use template::Template;
|
pub use template::{Template, TemplateKind, TemplateStatus};
|
||||||
pub use script::Script;
|
pub use script::{Script, ScriptKind, ScriptStatus};
|
||||||
pub use generation::{DbNotification, GeneratedFileHash, PublishingPreferences};
|
pub use generation::{
|
||||||
|
DbNotification, GeneratedFileHash, NotificationAction, NotificationEntity,
|
||||||
|
PublishingPreferences, SshMode,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
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.
|
/// A user-authored script. Matches the `scripts` table.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Script {
|
pub struct Script {
|
||||||
@@ -7,13 +22,12 @@ pub struct Script {
|
|||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
/// "macro", "transform", or "utility"
|
pub kind: ScriptKind,
|
||||||
pub kind: String,
|
|
||||||
pub entrypoint: String,
|
pub entrypoint: String,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub version: i32,
|
pub version: i32,
|
||||||
pub file_path: String,
|
pub file_path: String,
|
||||||
pub status: String,
|
pub status: ScriptStatus,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub content: Option<String>,
|
pub content: Option<String>,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
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.
|
/// A Liquid template. Matches the `templates` table.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Template {
|
pub struct Template {
|
||||||
@@ -7,11 +23,11 @@ pub struct Template {
|
|||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub kind: String,
|
pub kind: TemplateKind,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub version: i32,
|
pub version: i32,
|
||||||
pub file_path: String,
|
pub file_path: String,
|
||||||
pub status: String,
|
pub status: TemplateStatus,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub content: Option<String>,
|
pub content: Option<String>,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
mod slug;
|
mod slug;
|
||||||
mod checksum;
|
mod checksum;
|
||||||
|
|
||||||
pub use slug::slugify;
|
pub use slug::{slugify, ensure_unique};
|
||||||
pub use checksum::content_hash;
|
pub use checksum::content_hash;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use deunicode::deunicode;
|
use deunicode::deunicode;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
/// Generate a URL-safe slug from a title string.
|
/// Generate a URL-safe slug from a title string.
|
||||||
///
|
///
|
||||||
@@ -25,6 +26,30 @@ pub fn slugify(input: &str) -> String {
|
|||||||
slug
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -58,4 +83,40 @@ mod tests {
|
|||||||
fn consecutive_special_chars() {
|
fn consecutive_special_chars() {
|
||||||
assert_eq!(slugify("a --- b"), "a-b");
|
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