feat: first cut on M0 - base structure of project
This commit is contained in:
21
crates/bds-core/Cargo.toml
Normal file
21
crates/bds-core/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "bds-core"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
rusqlite = { workspace = true }
|
||||
refinery = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
deunicode = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
walkdir = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
49
crates/bds-core/src/db/connection.rs
Normal file
49
crates/bds-core/src/db/connection.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use rusqlite::Connection;
|
||||
use std::path::Path;
|
||||
use crate::db::migrations;
|
||||
|
||||
/// Database wrapper managing a SQLite connection.
|
||||
pub struct Database {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
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;")?;
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
/// Open an in-memory database (for tests).
|
||||
pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON;")?;
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
/// Get a reference to the underlying connection.
|
||||
pub fn conn(&self) -> &Connection {
|
||||
&self.conn
|
||||
}
|
||||
|
||||
/// Run all pending migrations.
|
||||
pub fn migrate(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
migrations::run_migrations(&self.conn)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn open_in_memory() {
|
||||
let db = Database::open_in_memory().expect("should open in-memory db");
|
||||
let result: i64 = db
|
||||
.conn()
|
||||
.query_row("SELECT 1", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(result, 1);
|
||||
}
|
||||
}
|
||||
22
crates/bds-core/src/db/migrations.rs
Normal file
22
crates/bds-core/src/db/migrations.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Run all embedded migrations against the given 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.
|
||||
// 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
|
||||
);
|
||||
",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
5
crates/bds-core/src/db/mod.rs
Normal file
5
crates/bds-core/src/db/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod connection;
|
||||
mod migrations;
|
||||
|
||||
pub use connection::Database;
|
||||
pub use migrations::run_migrations;
|
||||
1
crates/bds-core/src/engine/mod.rs
Normal file
1
crates/bds-core/src/engine/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
// Engine modules — stubs for M0, implemented in M1.
|
||||
3
crates/bds-core/src/i18n/mod.rs
Normal file
3
crates/bds-core/src/i18n/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// Internationalization — split localization support.
|
||||
// ui_locale: follows OS locale (menus, dialogs, toasts).
|
||||
// content_language: follows project settings (rendering, preview, feeds).
|
||||
6
crates/bds-core/src/lib.rs
Normal file
6
crates/bds-core/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod db;
|
||||
pub mod engine;
|
||||
pub mod model;
|
||||
pub mod render;
|
||||
pub mod i18n;
|
||||
pub mod util;
|
||||
38
crates/bds-core/src/model/generation.rs
Normal file
38
crates/bds-core/src/model/generation.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Tracks content hashes of generated files to skip unchanged writes.
|
||||
/// Matches the `generated_file_hashes` table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GeneratedFileHash {
|
||||
pub project_id: String,
|
||||
pub relative_path: String,
|
||||
pub content_hash: String,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// 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_id: String,
|
||||
pub action: String,
|
||||
pub from_cli: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub seen_at: Option<i64>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// Publishing preferences stored in meta/publishing.json.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PublishingPreferences {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ssh_host: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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>,
|
||||
}
|
||||
54
crates/bds-core/src/model/media.rs
Normal file
54
crates/bds-core/src/model/media.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A media item (image, video, etc.).
|
||||
/// Matches the `media` table schema.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Media {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub filename: String,
|
||||
pub original_name: String,
|
||||
pub mime_type: String,
|
||||
pub size: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub width: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub height: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub alt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub caption: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<String>,
|
||||
pub file_path: String,
|
||||
pub sidecar_path: String,
|
||||
#[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>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// A translation of media metadata into another language.
|
||||
/// Matches the `media_translations` table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MediaTranslation {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub translation_for: String,
|
||||
pub language: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub alt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub caption: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
15
crates/bds-core/src/model/mod.rs
Normal file
15
crates/bds-core/src/model/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
mod post;
|
||||
mod media;
|
||||
mod tag;
|
||||
mod project;
|
||||
mod template;
|
||||
mod script;
|
||||
mod generation;
|
||||
|
||||
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};
|
||||
107
crates/bds-core/src/model/post.rs
Normal file
107
crates/bds-core/src/model/post.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PostStatus {
|
||||
Draft,
|
||||
Published,
|
||||
Archived,
|
||||
}
|
||||
|
||||
/// A blog post. Matches the `posts` table schema.
|
||||
///
|
||||
/// NOTE: `content` is null for published posts — body lives in the filesystem
|
||||
/// `.md` file only. Draft content is stored in DB.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Post {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excerpt: Option<String>,
|
||||
/// Draft body text. Null/empty when published (content is in the file).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
pub status: PostStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<String>,
|
||||
#[serde(default)]
|
||||
pub do_not_translate: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub template_slug: Option<String>,
|
||||
pub file_path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub checksum: Option<String>,
|
||||
/// JSON-serialized string array in DB.
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// JSON-serialized string array in DB.
|
||||
#[serde(default)]
|
||||
pub categories: Vec<String>,
|
||||
// Published snapshot fields (used for diff detection)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub published_title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub published_content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub published_tags: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub published_categories: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub published_excerpt: Option<String>,
|
||||
/// Unix timestamp (integer in DB).
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub published_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// A translation of a post into another language.
|
||||
/// Matches the `post_translations` table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PostTranslation {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub translation_for: String,
|
||||
pub language: String,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excerpt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
pub status: PostStatus,
|
||||
pub file_path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub checksum: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub published_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// A link between two posts (tracked for backlinks).
|
||||
/// Matches the `post_links` table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PostLink {
|
||||
pub id: String,
|
||||
pub source_post_id: String,
|
||||
pub target_post_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub link_text: Option<String>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// Association between a post and media item.
|
||||
/// Matches the `post_media` table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PostMedia {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub post_id: String,
|
||||
pub media_id: String,
|
||||
pub sort_order: i32,
|
||||
pub created_at: i64,
|
||||
}
|
||||
24
crates/bds-core/src/model/project.rs
Normal file
24
crates/bds-core/src/model/project.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A bDS project. Matches the `projects` table schema.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data_path: Option<String>,
|
||||
pub is_active: bool,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// Key-value settings. Matches the `settings` table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Setting {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
21
crates/bds-core/src/model/script.rs
Normal file
21
crates/bds-core/src/model/script.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A user-authored script. Matches the `scripts` table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Script {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
/// "macro", "transform", or "utility"
|
||||
pub kind: String,
|
||||
pub entrypoint: String,
|
||||
pub enabled: bool,
|
||||
pub version: i32,
|
||||
pub file_path: String,
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
14
crates/bds-core/src/model/tag.rs
Normal file
14
crates/bds-core/src/model/tag.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Tag {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub post_template_slug: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
19
crates/bds-core/src/model/template.rs
Normal file
19
crates/bds-core/src/model/template.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A Liquid template. Matches the `templates` table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Template {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub kind: String,
|
||||
pub enabled: bool,
|
||||
pub version: i32,
|
||||
pub file_path: String,
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
1
crates/bds-core/src/render/mod.rs
Normal file
1
crates/bds-core/src/render/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
// Rendering pipeline — stubs for M0, implemented in M4.
|
||||
1
crates/bds-core/src/scripting/mod.rs
Normal file
1
crates/bds-core/src/scripting/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
// Scripting (Lua) — stubs for M0, implemented in M5/Wave 6.
|
||||
48
crates/bds-core/src/util/checksum.rs
Normal file
48
crates/bds-core/src/util/checksum.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Compute a hex-encoded SHA-256 hash of the given content.
|
||||
pub fn content_hash(content: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(content);
|
||||
let result = hasher.finalize();
|
||||
hex::encode(result)
|
||||
}
|
||||
|
||||
// sha2 doesn't include hex encoding, so we use a tiny inline helper.
|
||||
mod hex {
|
||||
const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
|
||||
|
||||
pub fn encode(bytes: impl AsRef<[u8]>) -> String {
|
||||
let bytes = bytes.as_ref();
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for &b in bytes {
|
||||
s.push(HEX_CHARS[(b >> 4) as usize] as char);
|
||||
s.push(HEX_CHARS[(b & 0xf) as usize] as char);
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_hash() {
|
||||
// SHA-256 of "hello"
|
||||
let hash = content_hash(b"hello");
|
||||
assert_eq!(
|
||||
hash,
|
||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_hash() {
|
||||
let hash = content_hash(b"");
|
||||
assert_eq!(
|
||||
hash,
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
);
|
||||
}
|
||||
}
|
||||
5
crates/bds-core/src/util/mod.rs
Normal file
5
crates/bds-core/src/util/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod slug;
|
||||
mod checksum;
|
||||
|
||||
pub use slug::slugify;
|
||||
pub use checksum::content_hash;
|
||||
61
crates/bds-core/src/util/slug.rs
Normal file
61
crates/bds-core/src/util/slug.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use deunicode::deunicode;
|
||||
|
||||
/// Generate a URL-safe slug from a title string.
|
||||
///
|
||||
/// Transliterates Unicode to ASCII, lowercases, replaces non-alphanumeric
|
||||
/// chars with hyphens, and collapses/trims hyphens.
|
||||
pub fn slugify(input: &str) -> String {
|
||||
let ascii = deunicode(input);
|
||||
let lowered = ascii.to_lowercase();
|
||||
let mut slug = String::with_capacity(lowered.len());
|
||||
let mut prev_hyphen = true; // avoid leading hyphen
|
||||
for c in lowered.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
slug.push(c);
|
||||
prev_hyphen = false;
|
||||
} else if !prev_hyphen {
|
||||
slug.push('-');
|
||||
prev_hyphen = true;
|
||||
}
|
||||
}
|
||||
// Trim trailing hyphen
|
||||
if slug.ends_with('-') {
|
||||
slug.pop();
|
||||
}
|
||||
slug
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn basic_slug() {
|
||||
assert_eq!(slugify("Hello World"), "hello-world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_slug() {
|
||||
assert_eq!(slugify("Über die Brücke"), "uber-die-brucke");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_chars() {
|
||||
assert_eq!(slugify("What's up? (2024)"), "what-s-up-2024");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_clean() {
|
||||
assert_eq!(slugify("already-clean"), "already-clean");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input() {
|
||||
assert_eq!(slugify(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_special_chars() {
|
||||
assert_eq!(slugify("a --- b"), "a-b");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user