chore: cleanups and refactorings for cleaner code

This commit is contained in:
2026-07-18 21:39:16 +02:00
parent b438a99999
commit e5080b7282
63 changed files with 3454 additions and 4093 deletions

View File

@@ -1,759 +0,0 @@
use diesel::prelude::*;
use diesel::sqlite::Sqlite;
use crate::db::schema;
use crate::model::{
DbNotification, GeneratedFileHash, Media, MediaTranslation, NotificationAction,
NotificationEntity, Post, PostLink, PostMedia, PostStatus, PostTranslation, Project, Script,
ScriptKind, ScriptStatus, Setting, Tag, Template, TemplateKind, TemplateStatus,
};
type ConversionError = Box<dyn std::error::Error + Send + Sync>;
fn invalid_value(kind: &str, value: &str) -> ConversionError {
format!("invalid {kind}: {value}").into()
}
fn json_strings(value: &str) -> Result<Vec<String>, ConversionError> {
Ok(serde_json::from_str(value)?)
}
pub fn post_status_to_str(value: &PostStatus) -> &'static str {
match value {
PostStatus::Draft => "draft",
PostStatus::Published => "published",
PostStatus::Archived => "archived",
}
}
fn post_status(value: &str) -> Result<PostStatus, ConversionError> {
match value {
"draft" => Ok(PostStatus::Draft),
"published" => Ok(PostStatus::Published),
"archived" => Ok(PostStatus::Archived),
_ => Err(invalid_value("PostStatus", value)),
}
}
pub fn template_kind_to_str(value: &TemplateKind) -> &'static str {
match value {
TemplateKind::Post => "post",
TemplateKind::List => "list",
TemplateKind::NotFound => "not_found",
TemplateKind::Partial => "partial",
}
}
fn template_kind(value: &str) -> Result<TemplateKind, ConversionError> {
match value {
"post" => Ok(TemplateKind::Post),
"list" => Ok(TemplateKind::List),
"not_found" => Ok(TemplateKind::NotFound),
"partial" => Ok(TemplateKind::Partial),
_ => Err(invalid_value("TemplateKind", value)),
}
}
pub fn template_status_to_str(value: &TemplateStatus) -> &'static str {
match value {
TemplateStatus::Draft => "draft",
TemplateStatus::Published => "published",
}
}
fn template_status(value: &str) -> Result<TemplateStatus, ConversionError> {
match value {
"draft" => Ok(TemplateStatus::Draft),
"published" => Ok(TemplateStatus::Published),
_ => Err(invalid_value("TemplateStatus", value)),
}
}
pub fn script_kind_to_str(value: &ScriptKind) -> &'static str {
match value {
ScriptKind::Macro => "macro",
ScriptKind::Utility => "utility",
ScriptKind::Transform => "transform",
}
}
fn script_kind(value: &str) -> Result<ScriptKind, ConversionError> {
match value {
"macro" => Ok(ScriptKind::Macro),
"utility" => Ok(ScriptKind::Utility),
"transform" => Ok(ScriptKind::Transform),
_ => Err(invalid_value("ScriptKind", value)),
}
}
pub fn script_status_to_str(value: &ScriptStatus) -> &'static str {
match value {
ScriptStatus::Draft => "draft",
ScriptStatus::Published => "published",
}
}
fn script_status(value: &str) -> Result<ScriptStatus, ConversionError> {
match value {
"draft" => Ok(ScriptStatus::Draft),
"published" => Ok(ScriptStatus::Published),
_ => Err(invalid_value("ScriptStatus", value)),
}
}
pub fn notification_entity_to_str(value: &NotificationEntity) -> &'static str {
match value {
NotificationEntity::Post => "post",
NotificationEntity::Media => "media",
NotificationEntity::Script => "script",
NotificationEntity::Template => "template",
}
}
fn notification_entity(value: &str) -> Result<NotificationEntity, ConversionError> {
match value {
"post" => Ok(NotificationEntity::Post),
"media" => Ok(NotificationEntity::Media),
"script" => Ok(NotificationEntity::Script),
"template" => Ok(NotificationEntity::Template),
_ => Err(invalid_value("NotificationEntity", value)),
}
}
pub fn notification_action_to_str(value: &NotificationAction) -> &'static str {
match value {
NotificationAction::Created => "created",
NotificationAction::Updated => "updated",
NotificationAction::Deleted => "deleted",
}
}
fn notification_action(value: &str) -> Result<NotificationAction, ConversionError> {
match value {
"created" => Ok(NotificationAction::Created),
"updated" => Ok(NotificationAction::Updated),
"deleted" => Ok(NotificationAction::Deleted),
_ => Err(invalid_value("NotificationAction", value)),
}
}
#[derive(Queryable, Selectable, Insertable, AsChangeset)]
#[diesel(table_name = schema::projects, check_for_backend(Sqlite))]
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
pub struct ProjectRecord {
pub id: String,
pub name: String,
pub slug: String,
pub description: Option<String>,
pub data_path: Option<String>,
pub is_active: i32,
pub created_at: i64,
pub updated_at: i64,
}
impl From<&Project> for ProjectRecord {
fn from(value: &Project) -> Self {
Self {
id: value.id.clone(),
name: value.name.clone(),
slug: value.slug.clone(),
description: value.description.clone(),
data_path: value.data_path.clone(),
is_active: value.is_active as i32,
created_at: value.created_at,
updated_at: value.updated_at,
}
}
}
impl From<ProjectRecord> for Project {
fn from(value: ProjectRecord) -> Self {
Self {
id: value.id,
name: value.name,
slug: value.slug,
description: value.description,
data_path: value.data_path,
is_active: value.is_active != 0,
created_at: value.created_at,
updated_at: value.updated_at,
}
}
}
#[derive(Queryable, Selectable, Insertable, AsChangeset)]
#[diesel(table_name = schema::posts, check_for_backend(Sqlite))]
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
pub struct PostRecord {
pub id: String,
pub project_id: String,
pub title: String,
pub slug: String,
pub excerpt: Option<String>,
pub content: Option<String>,
pub status: String,
pub author: Option<String>,
pub created_at: i64,
pub updated_at: i64,
pub published_at: Option<i64>,
pub file_path: String,
pub checksum: Option<String>,
pub tags: String,
pub categories: String,
pub template_slug: Option<String>,
pub language: Option<String>,
pub do_not_translate: i32,
pub published_title: Option<String>,
pub published_content: Option<String>,
pub published_tags: Option<String>,
pub published_categories: Option<String>,
pub published_excerpt: Option<String>,
}
impl From<&Post> for PostRecord {
fn from(value: &Post) -> Self {
Self {
id: value.id.clone(),
project_id: value.project_id.clone(),
title: value.title.clone(),
slug: value.slug.clone(),
excerpt: value.excerpt.clone(),
content: value.content.clone(),
status: post_status_to_str(&value.status).into(),
author: value.author.clone(),
created_at: value.created_at,
updated_at: value.updated_at,
published_at: value.published_at,
file_path: value.file_path.clone(),
checksum: value.checksum.clone(),
tags: serde_json::to_string(&value.tags).unwrap_or_else(|_| "[]".into()),
categories: serde_json::to_string(&value.categories).unwrap_or_else(|_| "[]".into()),
template_slug: value.template_slug.clone(),
language: value.language.clone(),
do_not_translate: value.do_not_translate as i32,
published_title: value.published_title.clone(),
published_content: value.published_content.clone(),
published_tags: value.published_tags.clone(),
published_categories: value.published_categories.clone(),
published_excerpt: value.published_excerpt.clone(),
}
}
}
impl TryFrom<PostRecord> for Post {
type Error = ConversionError;
fn try_from(value: PostRecord) -> Result<Self, Self::Error> {
Ok(Self {
id: value.id,
project_id: value.project_id,
title: value.title,
slug: value.slug,
excerpt: value.excerpt,
content: value.content,
status: post_status(&value.status)?,
author: value.author,
language: value.language,
do_not_translate: value.do_not_translate != 0,
template_slug: value.template_slug,
file_path: value.file_path,
checksum: value.checksum,
tags: json_strings(&value.tags)?,
categories: json_strings(&value.categories)?,
published_title: value.published_title,
published_content: value.published_content,
published_tags: value.published_tags,
published_categories: value.published_categories,
published_excerpt: value.published_excerpt,
created_at: value.created_at,
updated_at: value.updated_at,
published_at: value.published_at,
})
}
}
#[derive(Queryable, Selectable, Insertable, AsChangeset)]
#[diesel(table_name = schema::post_translations, check_for_backend(Sqlite))]
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
pub struct PostTranslationRecord {
pub id: String,
pub project_id: String,
pub translation_for: String,
pub language: String,
pub title: String,
pub excerpt: Option<String>,
pub content: Option<String>,
pub status: String,
pub created_at: i64,
pub updated_at: i64,
pub published_at: Option<i64>,
pub file_path: String,
pub checksum: Option<String>,
}
impl From<&PostTranslation> for PostTranslationRecord {
fn from(v: &PostTranslation) -> Self {
Self {
id: v.id.clone(),
project_id: v.project_id.clone(),
translation_for: v.translation_for.clone(),
language: v.language.clone(),
title: v.title.clone(),
excerpt: v.excerpt.clone(),
content: v.content.clone(),
status: post_status_to_str(&v.status).into(),
created_at: v.created_at,
updated_at: v.updated_at,
published_at: v.published_at,
file_path: v.file_path.clone(),
checksum: v.checksum.clone(),
}
}
}
impl TryFrom<PostTranslationRecord> for PostTranslation {
type Error = ConversionError;
fn try_from(v: PostTranslationRecord) -> Result<Self, Self::Error> {
Ok(Self {
id: v.id,
project_id: v.project_id,
translation_for: v.translation_for,
language: v.language,
title: v.title,
excerpt: v.excerpt,
content: v.content,
status: post_status(&v.status)?,
file_path: v.file_path,
checksum: v.checksum,
created_at: v.created_at,
updated_at: v.updated_at,
published_at: v.published_at,
})
}
}
#[derive(Queryable, Selectable, Insertable)]
#[diesel(table_name = schema::post_links, check_for_backend(Sqlite))]
pub struct PostLinkRecord {
pub id: String,
pub source_post_id: String,
pub target_post_id: String,
pub link_text: Option<String>,
pub created_at: i64,
}
impl From<&PostLink> for PostLinkRecord {
fn from(v: &PostLink) -> Self {
Self {
id: v.id.clone(),
source_post_id: v.source_post_id.clone(),
target_post_id: v.target_post_id.clone(),
link_text: v.link_text.clone(),
created_at: v.created_at,
}
}
}
impl From<PostLinkRecord> for PostLink {
fn from(v: PostLinkRecord) -> Self {
Self {
id: v.id,
source_post_id: v.source_post_id,
target_post_id: v.target_post_id,
link_text: v.link_text,
created_at: v.created_at,
}
}
}
#[derive(Queryable, Selectable, Insertable)]
#[diesel(table_name = schema::post_media, check_for_backend(Sqlite))]
pub struct PostMediaRecord {
pub id: String,
pub project_id: String,
pub post_id: String,
pub media_id: String,
pub sort_order: i32,
pub created_at: i64,
}
impl From<&PostMedia> for PostMediaRecord {
fn from(v: &PostMedia) -> Self {
Self {
id: v.id.clone(),
project_id: v.project_id.clone(),
post_id: v.post_id.clone(),
media_id: v.media_id.clone(),
sort_order: v.sort_order,
created_at: v.created_at,
}
}
}
impl From<PostMediaRecord> for PostMedia {
fn from(v: PostMediaRecord) -> Self {
Self {
id: v.id,
project_id: v.project_id,
post_id: v.post_id,
media_id: v.media_id,
sort_order: v.sort_order,
created_at: v.created_at,
}
}
}
#[derive(Queryable, Selectable, Insertable, AsChangeset)]
#[diesel(table_name = schema::media, check_for_backend(Sqlite))]
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
pub struct MediaRecord {
pub id: String,
pub project_id: String,
pub filename: String,
pub original_name: String,
pub mime_type: String,
pub size: i64,
pub width: Option<i32>,
pub height: Option<i32>,
pub title: Option<String>,
pub alt: Option<String>,
pub caption: Option<String>,
pub author: Option<String>,
pub file_path: String,
pub sidecar_path: String,
pub created_at: i64,
pub updated_at: i64,
pub checksum: Option<String>,
pub tags: String,
pub language: Option<String>,
}
impl From<&Media> for MediaRecord {
fn from(v: &Media) -> Self {
Self {
id: v.id.clone(),
project_id: v.project_id.clone(),
filename: v.filename.clone(),
original_name: v.original_name.clone(),
mime_type: v.mime_type.clone(),
size: v.size,
width: v.width,
height: v.height,
title: v.title.clone(),
alt: v.alt.clone(),
caption: v.caption.clone(),
author: v.author.clone(),
file_path: v.file_path.clone(),
sidecar_path: v.sidecar_path.clone(),
created_at: v.created_at,
updated_at: v.updated_at,
checksum: v.checksum.clone(),
tags: serde_json::to_string(&v.tags).unwrap_or_else(|_| "[]".into()),
language: v.language.clone(),
}
}
}
impl TryFrom<MediaRecord> for Media {
type Error = ConversionError;
fn try_from(v: MediaRecord) -> Result<Self, Self::Error> {
Ok(Self {
id: v.id,
project_id: v.project_id,
filename: v.filename,
original_name: v.original_name,
mime_type: v.mime_type,
size: v.size,
width: v.width,
height: v.height,
title: v.title,
alt: v.alt,
caption: v.caption,
author: v.author,
language: v.language,
file_path: v.file_path,
sidecar_path: v.sidecar_path,
checksum: v.checksum,
tags: json_strings(&v.tags)?,
created_at: v.created_at,
updated_at: v.updated_at,
})
}
}
#[derive(Queryable, Selectable, Insertable, AsChangeset)]
#[diesel(table_name = schema::media_translations, check_for_backend(Sqlite))]
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
pub struct MediaTranslationRecord {
pub id: String,
pub project_id: String,
pub translation_for: String,
pub language: String,
pub title: Option<String>,
pub alt: Option<String>,
pub caption: Option<String>,
pub created_at: i64,
pub updated_at: i64,
}
impl From<&MediaTranslation> for MediaTranslationRecord {
fn from(v: &MediaTranslation) -> Self {
Self {
id: v.id.clone(),
project_id: v.project_id.clone(),
translation_for: v.translation_for.clone(),
language: v.language.clone(),
title: v.title.clone(),
alt: v.alt.clone(),
caption: v.caption.clone(),
created_at: v.created_at,
updated_at: v.updated_at,
}
}
}
impl From<MediaTranslationRecord> for MediaTranslation {
fn from(v: MediaTranslationRecord) -> Self {
Self {
id: v.id,
project_id: v.project_id,
translation_for: v.translation_for,
language: v.language,
title: v.title,
alt: v.alt,
caption: v.caption,
created_at: v.created_at,
updated_at: v.updated_at,
}
}
}
#[derive(Queryable, Selectable, Insertable, AsChangeset)]
#[diesel(table_name = schema::tags, check_for_backend(Sqlite))]
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
pub struct TagRecord {
pub id: String,
pub project_id: String,
pub name: String,
pub color: Option<String>,
pub post_template_slug: Option<String>,
pub created_at: i64,
pub updated_at: i64,
}
impl From<&Tag> for TagRecord {
fn from(v: &Tag) -> Self {
Self {
id: v.id.clone(),
project_id: v.project_id.clone(),
name: v.name.clone(),
color: v.color.clone(),
post_template_slug: v.post_template_slug.clone(),
created_at: v.created_at,
updated_at: v.updated_at,
}
}
}
impl From<TagRecord> for Tag {
fn from(v: TagRecord) -> Self {
Self {
id: v.id,
project_id: v.project_id,
name: v.name,
color: v.color,
post_template_slug: v.post_template_slug,
created_at: v.created_at,
updated_at: v.updated_at,
}
}
}
#[derive(Queryable, Selectable, Insertable, AsChangeset)]
#[diesel(table_name = schema::templates, check_for_backend(Sqlite))]
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
pub struct TemplateRecord {
pub id: String,
pub project_id: String,
pub slug: String,
pub title: String,
pub kind: String,
pub enabled: i32,
pub version: i32,
pub file_path: String,
pub status: String,
pub content: Option<String>,
pub created_at: i64,
pub updated_at: i64,
}
impl From<&Template> for TemplateRecord {
fn from(v: &Template) -> Self {
Self {
id: v.id.clone(),
project_id: v.project_id.clone(),
slug: v.slug.clone(),
title: v.title.clone(),
kind: template_kind_to_str(&v.kind).into(),
enabled: v.enabled as i32,
version: v.version,
file_path: v.file_path.clone(),
status: template_status_to_str(&v.status).into(),
content: v.content.clone(),
created_at: v.created_at,
updated_at: v.updated_at,
}
}
}
impl TryFrom<TemplateRecord> for Template {
type Error = ConversionError;
fn try_from(v: TemplateRecord) -> Result<Self, Self::Error> {
Ok(Self {
id: v.id,
project_id: v.project_id,
slug: v.slug,
title: v.title,
kind: template_kind(&v.kind)?,
enabled: v.enabled != 0,
version: v.version,
file_path: v.file_path,
status: template_status(&v.status)?,
content: v.content,
created_at: v.created_at,
updated_at: v.updated_at,
})
}
}
#[derive(Queryable, Selectable, Insertable, AsChangeset)]
#[diesel(table_name = schema::scripts, check_for_backend(Sqlite))]
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
pub struct ScriptRecord {
pub id: String,
pub project_id: String,
pub slug: String,
pub title: String,
pub kind: String,
pub entrypoint: String,
pub enabled: i32,
pub version: i32,
pub file_path: String,
pub status: String,
pub content: Option<String>,
pub created_at: i64,
pub updated_at: i64,
}
impl From<&Script> for ScriptRecord {
fn from(v: &Script) -> Self {
Self {
id: v.id.clone(),
project_id: v.project_id.clone(),
slug: v.slug.clone(),
title: v.title.clone(),
kind: script_kind_to_str(&v.kind).into(),
entrypoint: v.entrypoint.clone(),
enabled: v.enabled as i32,
version: v.version,
file_path: v.file_path.clone(),
status: script_status_to_str(&v.status).into(),
content: v.content.clone(),
created_at: v.created_at,
updated_at: v.updated_at,
}
}
}
impl TryFrom<ScriptRecord> for Script {
type Error = ConversionError;
fn try_from(v: ScriptRecord) -> Result<Self, Self::Error> {
Ok(Self {
id: v.id,
project_id: v.project_id,
slug: v.slug,
title: v.title,
kind: script_kind(&v.kind)?,
entrypoint: v.entrypoint,
enabled: v.enabled != 0,
version: v.version,
file_path: v.file_path,
status: script_status(&v.status)?,
content: v.content,
created_at: v.created_at,
updated_at: v.updated_at,
})
}
}
#[derive(Queryable, Selectable, Insertable, AsChangeset)]
#[diesel(table_name = schema::settings, check_for_backend(Sqlite))]
pub struct SettingRecord {
pub key: String,
pub value: String,
pub updated_at: i64,
}
impl From<SettingRecord> for Setting {
fn from(v: SettingRecord) -> Self {
Self {
key: v.key,
value: v.value,
updated_at: v.updated_at,
}
}
}
#[derive(Queryable, Selectable, Insertable, AsChangeset)]
#[diesel(table_name = schema::generated_file_hashes, check_for_backend(Sqlite))]
pub struct GeneratedFileHashRecord {
pub project_id: String,
pub relative_path: String,
pub content_hash: String,
pub updated_at: i64,
}
impl From<&GeneratedFileHash> for GeneratedFileHashRecord {
fn from(v: &GeneratedFileHash) -> Self {
Self {
project_id: v.project_id.clone(),
relative_path: v.relative_path.clone(),
content_hash: v.content_hash.clone(),
updated_at: v.updated_at,
}
}
}
impl From<GeneratedFileHashRecord> for GeneratedFileHash {
fn from(v: GeneratedFileHashRecord) -> Self {
Self {
project_id: v.project_id,
relative_path: v.relative_path,
content_hash: v.content_hash,
updated_at: v.updated_at,
}
}
}
#[derive(Queryable, Selectable)]
#[diesel(table_name = schema::db_notifications, check_for_backend(Sqlite))]
pub struct DbNotificationRecord {
pub id: i32,
pub entity_type: String,
pub entity_id: String,
pub action: String,
pub from_cli: i32,
pub seen_at: Option<i64>,
pub created_at: i64,
}
impl TryFrom<DbNotificationRecord> for DbNotification {
type Error = ConversionError;
fn try_from(v: DbNotificationRecord) -> Result<Self, Self::Error> {
Ok(Self {
id: i64::from(v.id),
entity_type: notification_entity(&v.entity_type)?,
entity_id: v.entity_id,
action: notification_action(&v.action)?,
from_cli: v.from_cli != 0,
seen_at: v.seen_at,
created_at: v.created_at,
})
}
}
pub(crate) fn convert<T, U>(value: T) -> diesel::QueryResult<U>
where
U: TryFrom<T, Error = ConversionError>,
{
value
.try_into()
.map_err(diesel::result::Error::DeserializationError)
}
pub(crate) fn convert_all<T, U>(values: Vec<T>) -> diesel::QueryResult<Vec<U>>
where
U: TryFrom<T, Error = ConversionError>,
{
values.into_iter().map(convert).collect()
}

View File

@@ -5,7 +5,7 @@ use diesel::sqlite::Sqlite;
use rust_stemmers::{Algorithm, Stemmer};
use crate::db::DbConnection as Connection;
use crate::db::schema::{post_translations, posts};
use crate::db::schema::{media, post_translations, posts};
use crate::util::calendar_range_unix_ms;
diesel::define_sql_function!(fn instr(haystack: Text, needle: Text) -> diesel::sql_types::Integer);
@@ -457,11 +457,92 @@ pub fn search_media(conn: &Connection, query: &str, language: &str) -> QueryResu
})
}
#[derive(Default)]
pub struct MediaSearchFilters<'a> {
pub project_id: Option<&'a str>,
pub tags: Option<&'a [String]>,
pub year: Option<i32>,
pub month: Option<u32>,
pub limit: Option<usize>,
pub offset: Option<usize>,
}
#[derive(Debug)]
pub struct MediaSearchResults {
pub media_ids: Vec<String>,
pub total: usize,
pub offset: usize,
pub limit: usize,
}
pub fn search_media_filtered(
conn: &Connection,
query: &str,
language: &str,
filters: &MediaSearchFilters<'_>,
) -> QueryResult<MediaSearchResults> {
let ranked_ids = search_media(conn, query, language)?;
let offset = filters.offset.unwrap_or(0);
if ranked_ids.is_empty() {
return Ok(MediaSearchResults {
media_ids: Vec::new(),
total: 0,
offset,
limit: filters.limit.unwrap_or(0),
});
}
let matching_ids = conn.with(|connection| {
let mut filtered = media::table
.filter(media::id.eq_any(&ranked_ids))
.into_boxed();
if let Some(project_id) = filters.project_id {
filtered = filtered.filter(media::project_id.eq(project_id));
}
if let Some(year) = filters.year {
let (start, end) = calendar_range_unix_ms(year, filters.month).ok_or_else(|| {
diesel::result::Error::SerializationError("invalid calendar range".into())
})?;
filtered = filtered.filter(media::created_at.ge(start).and(media::created_at.lt(end)));
}
if let Some(tags) = filters.tags {
for tag in tags {
filtered = filtered.filter(
instr(
lower(media::tags),
serde_json::to_string(&tag.to_lowercase()).unwrap(),
)
.gt(0),
);
}
}
filtered.select(media::id).load::<String>(connection)
})?;
let matching_ids = matching_ids
.into_iter()
.collect::<std::collections::HashSet<_>>();
let mut media_ids = ranked_ids
.into_iter()
.filter(|id| matching_ids.contains(id))
.collect::<Vec<_>>();
let total = media_ids.len();
let limit = filters.limit.unwrap_or(total);
media_ids = media_ids.into_iter().skip(offset).take(limit).collect();
Ok(MediaSearchResults {
media_ids,
total,
offset,
limit,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use crate::db::queries::media::{insert_media, make_test_media};
use crate::db::queries::post::{insert_post, make_test_post};
use crate::db::queries::project::{insert_project, make_test_project};
use crate::model::PostStatus;
fn setup() -> Database {
@@ -652,6 +733,50 @@ mod tests {
assert_eq!(results, vec!["media-1"]);
}
#[test]
fn filtered_media_search_honors_project_tags_and_pagination() {
let db = setup();
insert_project(db.conn(), &make_test_project("p1", "one")).unwrap();
insert_project(db.conn(), &make_test_project("p2", "two")).unwrap();
for (id, project, tags) in [
("m1", "p1", vec!["nature".to_string()]),
("m2", "p1", vec!["city".to_string()]),
("m3", "p2", vec!["nature".to_string()]),
] {
let mut media = make_test_media(id, project);
media.tags = tags.clone();
insert_media(db.conn(), &media).unwrap();
index_media(
db.conn(),
id,
Some("Shared sunset"),
None,
None,
&media.original_name,
&tags,
&[],
"en",
)
.unwrap();
}
let tags = vec!["nature".to_string()];
let results = search_media_filtered(
db.conn(),
"sunset",
"en",
&MediaSearchFilters {
project_id: Some("p1"),
tags: Some(&tags),
limit: Some(1),
..Default::default()
},
)
.unwrap();
assert_eq!(results.total, 1);
assert_eq!(results.media_ids, ["m1"]);
}
#[test]
fn search_no_results() {
let db = setup();

View File

@@ -1,9 +1,10 @@
mod connection;
pub mod from_row;
pub mod fts;
mod migrations;
pub mod queries;
pub mod schema;
#[doc(hidden)]
pub mod types;
pub use connection::{Database, DatabaseError, DbConnection};
pub use diesel::result::Error as DbQueryError;

View File

@@ -1,7 +1,6 @@
use diesel::prelude::*;
use crate::db::DbConnection;
use crate::db::from_row::GeneratedFileHashRecord;
use crate::db::schema::generated_file_hashes;
use crate::model::GeneratedFileHash;
@@ -14,9 +13,8 @@ pub fn get_generated_file_hash(
generated_file_hashes::table
.filter(generated_file_hashes::project_id.eq(project_id))
.filter(generated_file_hashes::relative_path.eq(relative_path))
.select(GeneratedFileHashRecord::as_select())
.select(GeneratedFileHash::as_select())
.first(c)
.map(Into::into)
})
}
@@ -26,7 +24,7 @@ pub fn upsert_generated_file_hash(
) -> QueryResult<()> {
conn.with(|c| {
diesel::insert_into(generated_file_hashes::table)
.values(GeneratedFileHashRecord::from(hash))
.values(hash.clone())
.on_conflict((
generated_file_hashes::project_id,
generated_file_hashes::relative_path,
@@ -41,36 +39,6 @@ pub fn upsert_generated_file_hash(
})
}
pub fn delete_generated_file_hash(
conn: &DbConnection,
project_id: &str,
relative_path: &str,
) -> QueryResult<()> {
conn.with(|c| {
diesel::delete(
generated_file_hashes::table
.filter(generated_file_hashes::project_id.eq(project_id))
.filter(generated_file_hashes::relative_path.eq(relative_path)),
)
.execute(c)
.map(|_| ())
})
}
pub fn list_generated_file_hashes_by_project(
conn: &DbConnection,
project_id: &str,
) -> QueryResult<Vec<GeneratedFileHash>> {
conn.with(|c| {
generated_file_hashes::table
.filter(generated_file_hashes::project_id.eq(project_id))
.order(generated_file_hashes::relative_path)
.select(GeneratedFileHashRecord::as_select())
.load(c)
.map(|rows: Vec<GeneratedFileHashRecord>| rows.into_iter().map(Into::into).collect())
})
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -3,7 +3,6 @@ use diesel::prelude::*;
use diesel::sql_types::Text;
use crate::db::DbConnection;
use crate::db::from_row::{MediaRecord, convert, convert_all};
use crate::db::schema::media;
use crate::model::Media;
use crate::util::calendar_range_unix_ms;
@@ -13,7 +12,7 @@ diesel::define_sql_function!(fn instr(haystack: Text, needle: Text) -> Integer);
pub fn insert_media(conn: &DbConnection, m: &Media) -> QueryResult<()> {
conn.with(|c| {
diesel::insert_into(media::table)
.values(MediaRecord::from(m))
.values(m.clone())
.execute(c)
.map(|_| ())
})
@@ -23,9 +22,8 @@ pub fn get_media_by_id(conn: &DbConnection, id: &str) -> QueryResult<Media> {
conn.with(|c| {
media::table
.filter(media::id.eq(id))
.select(MediaRecord::as_select())
.select(Media::as_select())
.first(c)
.and_then(convert)
})
}
@@ -34,27 +32,8 @@ pub fn list_media_by_project(conn: &DbConnection, project_id: &str) -> QueryResu
media::table
.filter(media::project_id.eq(project_id))
.order(media::created_at.desc())
.select(MediaRecord::as_select())
.select(Media::as_select())
.load(c)
.and_then(convert_all)
})
}
pub fn list_media_by_project_limited(
conn: &DbConnection,
project_id: &str,
limit: i64,
offset: i64,
) -> QueryResult<Vec<Media>> {
conn.with(|c| {
media::table
.filter(media::project_id.eq(project_id))
.order(media::created_at.desc())
.limit(limit)
.offset(offset)
.select(MediaRecord::as_select())
.load(c)
.and_then(convert_all)
})
}
@@ -70,7 +49,7 @@ pub fn count_media_by_project(conn: &DbConnection, project_id: &str) -> QueryRes
pub fn update_media(conn: &DbConnection, m: &Media) -> QueryResult<()> {
conn.with(|c| {
diesel::update(media::table.filter(media::id.eq(&m.id)))
.set(MediaRecord::from(m))
.set(m.clone())
.execute(c)
.map(|_| ())
})
@@ -140,9 +119,8 @@ pub fn list_media_filtered(
.order(media::created_at.desc())
.limit(limit)
.offset(offset)
.select(MediaRecord::as_select())
.select(Media::as_select())
.load(c)
.and_then(convert_all)
})
}

View File

@@ -1,14 +1,13 @@
use diesel::prelude::*;
use crate::db::DbConnection;
use crate::db::from_row::MediaTranslationRecord;
use crate::db::schema::media_translations;
use crate::model::MediaTranslation;
pub fn insert_media_translation(conn: &DbConnection, t: &MediaTranslation) -> QueryResult<()> {
conn.with(|c| {
diesel::insert_into(media_translations::table)
.values(MediaTranslationRecord::from(t))
.values(t.clone())
.execute(c)
.map(|_| ())
})
@@ -23,9 +22,8 @@ pub fn get_media_translation_by_media_and_language(
media_translations::table
.filter(media_translations::translation_for.eq(translation_for))
.filter(media_translations::language.eq(language))
.select(MediaTranslationRecord::as_select())
.select(MediaTranslation::as_select())
.first(c)
.map(Into::into)
})
}
@@ -37,16 +35,15 @@ pub fn list_media_translations_by_media(
media_translations::table
.filter(media_translations::translation_for.eq(translation_for))
.order(media_translations::language)
.select(MediaTranslationRecord::as_select())
.select(MediaTranslation::as_select())
.load(c)
.map(|rows: Vec<MediaTranslationRecord>| rows.into_iter().map(Into::into).collect())
})
}
pub fn upsert_media_translation(conn: &DbConnection, t: &MediaTranslation) -> QueryResult<()> {
conn.with(|c| {
diesel::insert_into(media_translations::table)
.values(MediaTranslationRecord::from(t))
.values(t.clone())
.on_conflict((
media_translations::translation_for,
media_translations::language,

View File

@@ -4,7 +4,6 @@ use diesel::sql_types::Text;
use diesel::sqlite::Sqlite;
use crate::db::DbConnection;
use crate::db::from_row::{PostRecord, convert, convert_all, post_status_to_str};
use crate::db::schema::posts;
use crate::model::{Post, PostStatus};
use crate::util::calendar_range_unix_ms;
@@ -15,7 +14,7 @@ diesel::define_sql_function!(fn lower(value: Text) -> Text);
pub fn insert_post(conn: &DbConnection, post: &Post) -> QueryResult<()> {
conn.with(|c| {
diesel::insert_into(posts::table)
.values(PostRecord::from(post))
.values(post.clone())
.execute(c)
.map(|_| ())
})
@@ -25,9 +24,8 @@ pub fn get_post_by_id(conn: &DbConnection, id: &str) -> QueryResult<Post> {
conn.with(|c| {
posts::table
.filter(posts::id.eq(id))
.select(PostRecord::as_select())
.select(Post::as_select())
.first(c)
.and_then(convert)
})
}
@@ -40,9 +38,8 @@ pub fn get_post_by_project_and_slug(
posts::table
.filter(posts::project_id.eq(project_id))
.filter(posts::slug.eq(slug))
.select(PostRecord::as_select())
.select(Post::as_select())
.first(c)
.and_then(convert)
})
}
@@ -51,34 +48,15 @@ pub fn list_posts_by_project(conn: &DbConnection, project_id: &str) -> QueryResu
posts::table
.filter(posts::project_id.eq(project_id))
.order(posts::created_at.desc())
.select(PostRecord::as_select())
.select(Post::as_select())
.load(c)
.and_then(convert_all)
})
}
pub fn list_posts_by_project_limited(
conn: &DbConnection,
project_id: &str,
limit: i64,
offset: i64,
) -> QueryResult<Vec<Post>> {
conn.with(|c| {
posts::table
.filter(posts::project_id.eq(project_id))
.order(posts::created_at.desc())
.limit(limit)
.offset(offset)
.select(PostRecord::as_select())
.load(c)
.and_then(convert_all)
})
}
pub fn update_post(conn: &DbConnection, post: &Post) -> QueryResult<()> {
conn.with(|c| {
diesel::update(posts::table.filter(posts::id.eq(&post.id)))
.set(PostRecord::from(post))
.set(post.clone())
.execute(c)
.map(|_| ())
})
@@ -93,7 +71,7 @@ pub fn update_post_status(
conn.with(|c| {
diesel::update(posts::table.filter(posts::id.eq(id)))
.set((
posts::status.eq(post_status_to_str(status)),
posts::status.eq(status.as_str()),
posts::updated_at.eq(updated_at),
))
.execute(c)
@@ -307,17 +285,17 @@ pub fn list_posts_filtered(
.order(posts::created_at.desc())
.limit(limit)
.offset(offset)
.select(PostRecord::as_select())
.select(Post::as_select())
.load(c)?
} else if content_filters {
let mut records: Vec<PostRecord> = post_query(project_id, filters, false, false)
let mut records: Vec<Post> = post_query(project_id, filters, false, false)
.filter(posts::status.eq("draft"))
.select(PostRecord::as_select())
.select(Post::as_select())
.load(c)?;
records.extend(
post_query(project_id, filters, true, true)
.select(PostRecord::as_select())
.load::<PostRecord>(c)?,
.select(Post::as_select())
.load::<Post>(c)?,
);
records.sort_unstable_by_key(|record| std::cmp::Reverse(record.created_at));
records
@@ -330,10 +308,10 @@ pub fn list_posts_filtered(
.order(posts::created_at.desc())
.limit(limit)
.offset(offset)
.select(PostRecord::as_select())
.select(Post::as_select())
.load(c)?
};
convert_all(records)
Ok(records)
})
}
@@ -544,6 +522,29 @@ mod tests {
assert_eq!(fetched.updated_at, 5000);
}
#[test]
fn malformed_persisted_types_fail_deserialization() {
let db = setup();
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
db.conn()
.with(|connection| {
diesel::update(posts::table.filter(posts::id.eq("x1")))
.set(posts::status.eq("unknown"))
.execute(connection)
})
.unwrap();
assert!(get_post_by_id(db.conn(), "x1").is_err());
db.conn()
.with(|connection| {
diesel::update(posts::table.filter(posts::id.eq("x1")))
.set((posts::status.eq("draft"), posts::tags.eq("not-json")))
.execute(connection)
})
.unwrap();
assert!(get_post_by_id(db.conn(), "x1").is_err());
}
#[test]
fn clear_content_sets_null() {
let db = setup();

View File

@@ -1,14 +1,13 @@
use diesel::prelude::*;
use crate::db::DbConnection;
use crate::db::from_row::PostLinkRecord;
use crate::db::schema::post_links;
use crate::model::PostLink;
pub fn insert_post_link(conn: &DbConnection, link: &PostLink) -> QueryResult<()> {
conn.with(|c| {
diesel::insert_into(post_links::table)
.values(PostLinkRecord::from(link))
.values(link.clone())
.execute(c)
.map(|_| ())
})
@@ -38,9 +37,8 @@ pub fn list_links_by_source(
post_links::table
.filter(post_links::source_post_id.eq(source_post_id))
.order(post_links::created_at)
.select(PostLinkRecord::as_select())
.select(PostLink::as_select())
.load(c)
.map(|rows: Vec<PostLinkRecord>| rows.into_iter().map(Into::into).collect())
})
}
@@ -52,9 +50,8 @@ pub fn list_links_by_target(
post_links::table
.filter(post_links::target_post_id.eq(target_post_id))
.order(post_links::created_at)
.select(PostLinkRecord::as_select())
.select(PostLink::as_select())
.load(c)
.map(|rows: Vec<PostLinkRecord>| rows.into_iter().map(Into::into).collect())
})
}

View File

@@ -1,14 +1,13 @@
use diesel::prelude::*;
use crate::db::DbConnection;
use crate::db::from_row::PostMediaRecord;
use crate::db::schema::post_media;
use crate::model::PostMedia;
pub fn link_media(conn: &DbConnection, pm: &PostMedia) -> QueryResult<()> {
conn.with(|c| {
diesel::insert_into(post_media::table)
.values(PostMediaRecord::from(pm))
.values(pm.clone())
.execute(c)
.map(|_| ())
})
@@ -39,9 +38,8 @@ pub fn list_post_media_by_post(conn: &DbConnection, post_id: &str) -> QueryResul
post_media::table
.filter(post_media::post_id.eq(post_id))
.order(post_media::sort_order)
.select(PostMediaRecord::as_select())
.select(PostMedia::as_select())
.load(c)
.map(|rows: Vec<PostMediaRecord>| rows.into_iter().map(Into::into).collect())
})
}
@@ -53,9 +51,8 @@ pub fn list_post_media_by_media(
post_media::table
.filter(post_media::media_id.eq(media_id))
.order(post_media::created_at)
.select(PostMediaRecord::as_select())
.select(PostMedia::as_select())
.load(c)
.map(|rows: Vec<PostMediaRecord>| rows.into_iter().map(Into::into).collect())
})
}

View File

@@ -1,7 +1,6 @@
use diesel::prelude::*;
use crate::db::DbConnection;
use crate::db::from_row::{PostTranslationRecord, convert, convert_all};
use crate::db::schema::post_translations;
use crate::model::PostTranslation;
@@ -13,7 +12,7 @@ pub fn insert_post_translation(conn: &DbConnection, t: &PostTranslation) -> Quer
}
conn.with(|c| {
diesel::insert_into(post_translations::table)
.values(PostTranslationRecord::from(t))
.values(t.clone())
.execute(c)
.map(|_| ())
})
@@ -23,9 +22,8 @@ pub fn get_post_translation_by_id(conn: &DbConnection, id: &str) -> QueryResult<
conn.with(|c| {
post_translations::table
.filter(post_translations::id.eq(id))
.select(PostTranslationRecord::as_select())
.select(PostTranslation::as_select())
.first(c)
.and_then(convert)
})
}
@@ -38,9 +36,8 @@ pub fn get_post_translation_by_post_and_language(
post_translations::table
.filter(post_translations::translation_for.eq(translation_for))
.filter(post_translations::language.eq(language))
.select(PostTranslationRecord::as_select())
.select(PostTranslation::as_select())
.first(c)
.and_then(convert)
})
}
@@ -52,9 +49,8 @@ pub fn list_post_translations_by_post(
post_translations::table
.filter(post_translations::translation_for.eq(translation_for))
.order(post_translations::language)
.select(PostTranslationRecord::as_select())
.select(PostTranslation::as_select())
.load(c)
.and_then(convert_all)
})
}
@@ -66,7 +62,7 @@ pub fn update_post_translation(conn: &DbConnection, t: &PostTranslation) -> Quer
}
conn.with(|c| {
diesel::update(post_translations::table.filter(post_translations::id.eq(&t.id)))
.set(PostTranslationRecord::from(t))
.set(t.clone())
.execute(c)
.map(|_| ())
})

View File

@@ -1,14 +1,13 @@
use diesel::prelude::*;
use crate::db::DbConnection;
use crate::db::from_row::ProjectRecord;
use crate::db::schema::projects;
use crate::model::Project;
pub fn insert_project(conn: &DbConnection, project: &Project) -> QueryResult<()> {
conn.with(|c| {
diesel::insert_into(projects::table)
.values(ProjectRecord::from(project))
.values(project.clone())
.execute(c)
.map(|_| ())
})
@@ -18,9 +17,8 @@ pub fn get_project_by_id(conn: &DbConnection, id: &str) -> QueryResult<Project>
conn.with(|c| {
projects::table
.filter(projects::id.eq(id))
.select(ProjectRecord::as_select())
.select(Project::as_select())
.first(c)
.map(Into::into)
})
}
@@ -28,9 +26,8 @@ pub fn get_project_by_slug(conn: &DbConnection, slug: &str) -> QueryResult<Proje
conn.with(|c| {
projects::table
.filter(projects::slug.eq(slug))
.select(ProjectRecord::as_select())
.select(Project::as_select())
.first(c)
.map(Into::into)
})
}
@@ -38,9 +35,8 @@ pub fn get_active_project(conn: &DbConnection) -> QueryResult<Project> {
conn.with(|c| {
projects::table
.filter(projects::is_active.eq(1))
.select(ProjectRecord::as_select())
.select(Project::as_select())
.first(c)
.map(Into::into)
})
}
@@ -62,16 +58,15 @@ pub fn list_projects(conn: &DbConnection) -> QueryResult<Vec<Project>> {
conn.with(|c| {
projects::table
.order(projects::name)
.select(ProjectRecord::as_select())
.select(Project::as_select())
.load(c)
.map(|rows: Vec<ProjectRecord>| rows.into_iter().map(Into::into).collect())
})
}
pub fn update_project(conn: &DbConnection, project: &Project) -> QueryResult<()> {
conn.with(|c| {
diesel::update(projects::table.filter(projects::id.eq(&project.id)))
.set(ProjectRecord::from(project))
.set(project.clone())
.execute(c)
.map(|_| ())
})

View File

@@ -1,14 +1,13 @@
use diesel::prelude::*;
use crate::db::DbConnection;
use crate::db::from_row::{ScriptRecord, convert, convert_all};
use crate::db::schema::scripts;
use crate::model::Script;
pub fn insert_script(conn: &DbConnection, s: &Script) -> QueryResult<()> {
conn.with(|c| {
diesel::insert_into(scripts::table)
.values(ScriptRecord::from(s))
.values(s.clone())
.execute(c)
.map(|_| ())
})
@@ -18,9 +17,8 @@ pub fn get_script_by_id(conn: &DbConnection, id: &str) -> QueryResult<Script> {
conn.with(|c| {
scripts::table
.filter(scripts::id.eq(id))
.select(ScriptRecord::as_select())
.select(Script::as_select())
.first(c)
.and_then(convert)
})
}
@@ -33,9 +31,8 @@ pub fn get_script_by_slug(
scripts::table
.filter(scripts::project_id.eq(project_id))
.filter(scripts::slug.eq(slug))
.select(ScriptRecord::as_select())
.select(Script::as_select())
.first(c)
.and_then(convert)
})
}
@@ -44,16 +41,15 @@ pub fn list_scripts_by_project(conn: &DbConnection, project_id: &str) -> QueryRe
scripts::table
.filter(scripts::project_id.eq(project_id))
.order(scripts::title)
.select(ScriptRecord::as_select())
.select(Script::as_select())
.load(c)
.and_then(convert_all)
})
}
pub fn update_script(conn: &DbConnection, s: &Script) -> QueryResult<()> {
conn.with(|c| {
diesel::update(scripts::table.filter(scripts::id.eq(&s.id)))
.set(ScriptRecord::from(s))
.set(s.clone())
.execute(c)
.map(|_| ())
})

View File

@@ -1,7 +1,6 @@
use diesel::prelude::*;
use crate::db::DbConnection;
use crate::db::from_row::SettingRecord;
use crate::db::schema::settings;
use crate::model::Setting;
@@ -9,9 +8,8 @@ pub fn get_setting_by_key(conn: &DbConnection, key: &str) -> QueryResult<Setting
conn.with(|c| {
settings::table
.filter(settings::key.eq(key))
.select(SettingRecord::as_select())
.select(Setting::as_select())
.first(c)
.map(Into::into)
})
}
@@ -43,9 +41,8 @@ pub fn list_all_settings(conn: &DbConnection) -> QueryResult<Vec<Setting>> {
conn.with(|c| {
settings::table
.order(settings::key)
.select(SettingRecord::as_select())
.select(Setting::as_select())
.load(c)
.map(|rows: Vec<SettingRecord>| rows.into_iter().map(Into::into).collect())
})
}

View File

@@ -2,7 +2,6 @@ use diesel::prelude::*;
use diesel::sql_types::Text;
use crate::db::DbConnection;
use crate::db::from_row::TagRecord;
use crate::db::schema::tags;
use crate::model::Tag;
@@ -11,7 +10,7 @@ diesel::define_sql_function!(fn lower(value: Text) -> Text);
pub fn insert_tag(conn: &DbConnection, tag: &Tag) -> QueryResult<()> {
conn.with(|c| {
diesel::insert_into(tags::table)
.values(TagRecord::from(tag))
.values(tag.clone())
.execute(c)
.map(|_| ())
})
@@ -21,9 +20,8 @@ pub fn get_tag_by_id(conn: &DbConnection, id: &str) -> QueryResult<Tag> {
conn.with(|c| {
tags::table
.filter(tags::id.eq(id))
.select(TagRecord::as_select())
.select(Tag::as_select())
.first(c)
.map(Into::into)
})
}
@@ -36,9 +34,8 @@ pub fn get_tag_by_project_and_name(
tags::table
.filter(tags::project_id.eq(project_id))
.filter(lower(tags::name).eq(name.to_lowercase()))
.select(TagRecord::as_select())
.select(Tag::as_select())
.first(c)
.map(Into::into)
})
}
@@ -47,16 +44,15 @@ pub fn list_tags_by_project(conn: &DbConnection, project_id: &str) -> QueryResul
tags::table
.filter(tags::project_id.eq(project_id))
.order(tags::name)
.select(TagRecord::as_select())
.select(Tag::as_select())
.load(c)
.map(|rows: Vec<TagRecord>| rows.into_iter().map(Into::into).collect())
})
}
pub fn update_tag(conn: &DbConnection, tag: &Tag) -> QueryResult<()> {
conn.with(|c| {
diesel::update(tags::table.filter(tags::id.eq(&tag.id)))
.set(TagRecord::from(tag))
.set(tag.clone())
.execute(c)
.map(|_| ())
})

View File

@@ -1,14 +1,13 @@
use diesel::prelude::*;
use crate::db::DbConnection;
use crate::db::from_row::{TemplateRecord, convert, convert_all};
use crate::db::schema::templates;
use crate::model::Template;
pub fn insert_template(conn: &DbConnection, t: &Template) -> QueryResult<()> {
conn.with(|c| {
diesel::insert_into(templates::table)
.values(TemplateRecord::from(t))
.values(t.clone())
.execute(c)
.map(|_| ())
})
@@ -18,9 +17,8 @@ pub fn get_template_by_id(conn: &DbConnection, id: &str) -> QueryResult<Template
conn.with(|c| {
templates::table
.filter(templates::id.eq(id))
.select(TemplateRecord::as_select())
.select(Template::as_select())
.first(c)
.and_then(convert)
})
}
@@ -33,9 +31,8 @@ pub fn get_template_by_slug(
templates::table
.filter(templates::project_id.eq(project_id))
.filter(templates::slug.eq(slug))
.select(TemplateRecord::as_select())
.select(Template::as_select())
.first(c)
.and_then(convert)
})
}
@@ -47,16 +44,15 @@ pub fn list_templates_by_project(
templates::table
.filter(templates::project_id.eq(project_id))
.order(templates::title)
.select(TemplateRecord::as_select())
.select(Template::as_select())
.load(c)
.and_then(convert_all)
})
}
pub fn update_template(conn: &DbConnection, t: &Template) -> QueryResult<()> {
conn.with(|c| {
diesel::update(templates::table.filter(templates::id.eq(&t.id)))
.set(TemplateRecord::from(t))
.set(t.clone())
.execute(c)
.map(|_| ())
})

View File

@@ -0,0 +1,95 @@
use diesel::deserialize::{self, FromSql, FromSqlRow};
use diesel::expression::AsExpression;
use diesel::serialize::{self, IsNull, Output, ToSql};
use diesel::sql_types::{Integer, Text};
use diesel::sqlite::{Sqlite, SqliteValue};
use crate::model::{
NotificationAction, NotificationEntity, PostStatus, ScriptKind, ScriptStatus, TemplateKind,
TemplateStatus,
};
#[derive(Debug, AsExpression, FromSqlRow)]
#[diesel(sql_type = Integer)]
pub struct DbBool(bool);
impl From<bool> for DbBool {
fn from(value: bool) -> Self {
Self(value)
}
}
impl From<DbBool> for bool {
fn from(value: DbBool) -> Self {
value.0
}
}
impl ToSql<Integer, Sqlite> for DbBool {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Sqlite>) -> serialize::Result {
out.set_value(i32::from(self.0));
Ok(IsNull::No)
}
}
impl FromSql<Integer, Sqlite> for DbBool {
fn from_sql(value: SqliteValue<'_, '_, '_>) -> deserialize::Result<Self> {
Ok(Self(i32::from_sql(value)? != 0))
}
}
#[derive(Debug, AsExpression, FromSqlRow)]
#[diesel(sql_type = Text)]
pub struct DbStringList(Vec<String>);
impl From<Vec<String>> for DbStringList {
fn from(value: Vec<String>) -> Self {
Self(value)
}
}
impl From<DbStringList> for Vec<String> {
fn from(value: DbStringList) -> Self {
value.0
}
}
impl ToSql<Text, Sqlite> for DbStringList {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Sqlite>) -> serialize::Result {
out.set_value(serde_json::to_string(&self.0)?);
Ok(IsNull::No)
}
}
impl FromSql<Text, Sqlite> for DbStringList {
fn from_sql(value: SqliteValue<'_, '_, '_>) -> deserialize::Result<Self> {
let value = <String as FromSql<Text, Sqlite>>::from_sql(value)?;
Ok(Self(serde_json::from_str(&value)?))
}
}
macro_rules! text_enum_sql {
($type:ty) => {
impl ToSql<Text, Sqlite> for $type {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Sqlite>) -> serialize::Result {
out.set_value(self.as_str().to_owned());
Ok(IsNull::No)
}
}
impl FromSql<Text, Sqlite> for $type {
fn from_sql(value: SqliteValue<'_, '_, '_>) -> deserialize::Result<Self> {
let value = <String as FromSql<Text, Sqlite>>::from_sql(value)?;
value.parse().map_err(Into::into)
}
}
};
}
text_enum_sql!(PostStatus);
text_enum_sql!(TemplateKind);
text_enum_sql!(TemplateStatus);
text_enum_sql!(ScriptKind);
text_enum_sql!(ScriptStatus);
text_enum_sql!(NotificationEntity);
text_enum_sql!(NotificationAction);

View File

@@ -64,7 +64,7 @@ pub fn fill_missing_translations(
main_language: &str,
blog_languages: &[String],
offline_mode: bool,
mut on_progress: impl FnMut(f32, &str),
mut on_progress: impl FnMut(f32, &str) -> bool,
) -> EngineResult<FillMissingTranslationsReport> {
fill_missing_translations_with(
conn,
@@ -90,7 +90,7 @@ fn fill_missing_translations_with(
blog_languages: &[String],
post_translator: &mut dyn FnMut(&Post, &str) -> EngineResult<TranslationResult>,
media_translator: &mut dyn FnMut(&Media, &str) -> EngineResult<MediaTranslationResult>,
on_progress: &mut dyn FnMut(f32, &str),
on_progress: &mut dyn FnMut(f32, &str) -> bool,
) -> EngineResult<FillMissingTranslationsReport> {
let configured = configured_languages(main_language, blog_languages);
if configured.len() <= 1 {
@@ -100,12 +100,17 @@ fn fill_missing_translations_with(
});
}
let posts = qp::list_posts_by_project(conn, project_id)?;
on_progress(0.0, "Scanning published posts");
if !on_progress(0.0, "Scanning published posts") {
return Err(EngineError::Validation("cancelled".to_string()));
}
let mut work = Vec::new();
for post in posts
.into_iter()
.filter(|post| post.status == PostStatus::Published && !post.do_not_translate)
{
if !on_progress(0.0, "Scanning published posts") {
return Err(EngineError::Validation("cancelled".to_string()));
}
for language in missing_languages(conn, &post, &configured)? {
work.push((post.clone(), language));
}
@@ -119,10 +124,12 @@ fn fill_missing_translations_with(
let mut report = FillMissingTranslationsReport::default();
for (index, (post, language)) in work.iter().enumerate() {
on_progress(
if !on_progress(
0.15 + (index as f32 / work.len() as f32) * 0.85,
&format!("{}{language}", post.title),
);
) {
return Err(EngineError::Validation("cancelled".to_string()));
}
match translate_one_post(
conn,
data_dir,
@@ -144,7 +151,9 @@ fn fill_missing_translations_with(
}
}
}
on_progress(1.0, "Translation batch complete");
if !on_progress(1.0, "Translation batch complete") {
return Err(EngineError::Validation("cancelled".to_string()));
}
Ok(report)
}
@@ -156,6 +165,7 @@ pub fn translate_missing_for_post(
main_language: &str,
blog_languages: &[String],
offline_mode: bool,
is_cancelled: impl Fn() -> bool,
) -> EngineResult<FillMissingTranslationsReport> {
let post = qp::get_post_by_id(conn, post_id)?;
let configured = configured_languages(main_language, blog_languages);
@@ -165,6 +175,9 @@ pub fn translate_missing_for_post(
..Default::default()
};
for language in targets {
if is_cancelled() {
return Err(EngineError::Validation("cancelled".to_string()));
}
let result = translate_one_post(
conn,
data_dir,
@@ -369,7 +382,7 @@ mod tests {
})
},
&mut |_media, _language| unreachable!(),
&mut |_, _| {},
&mut |_, _| true,
)
.unwrap();
@@ -433,9 +446,30 @@ mod tests {
&["de".into()],
&mut |_, _| panic!("translator must not run"),
&mut |_, _| panic!("translator must not run"),
&mut |_, _| {},
&mut |_, _| true,
)
.unwrap();
assert!(report.nothing_to_do);
}
#[test]
fn batch_stops_when_progress_callback_cancels() {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
let dir = TempDir::new().unwrap();
let result = fill_missing_translations_with(
db.conn(),
dir.path(),
"p1",
"en",
&["de".into()],
&mut |_, _| panic!("translator must not run"),
&mut |_, _| panic!("translator must not run"),
&mut |_, _| false,
);
assert!(matches!(result, Err(EngineError::Validation(message)) if message == "cancelled"));
}
}

View File

@@ -1,28 +0,0 @@
use std::path::PathBuf;
/// Shared context passed to engine operations.
pub struct EngineContext<'a> {
pub conn: &'a crate::db::DbConnection,
pub project_id: String,
pub data_dir: PathBuf,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use tempfile::TempDir;
#[test]
fn context_holds_references() {
let db = Database::open_in_memory().unwrap();
let dir = TempDir::new().unwrap();
let ctx = EngineContext {
conn: db.conn(),
project_id: "p1".into(),
data_dir: dir.path().to_path_buf(),
};
assert_eq!(ctx.project_id, "p1");
assert!(ctx.data_dir.exists());
}
}

View File

@@ -1,54 +1,22 @@
use std::fmt;
/// Errors produced by engine operations.
#[derive(Debug)]
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
Db(diesel::result::Error),
DbConnection(diesel::ConnectionError),
Io(std::io::Error),
#[error("database error: {0}")]
Db(#[from] diesel::result::Error),
#[error("database connection error: {0}")]
DbConnection(#[from] diesel::ConnectionError),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("parse error: {0}")]
Parse(String),
#[error("not found: {0}")]
NotFound(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("validation error: {0}")]
Validation(String),
}
impl fmt::Display for EngineError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Db(e) => write!(f, "database error: {e}"),
Self::DbConnection(e) => write!(f, "database connection error: {e}"),
Self::Io(e) => write!(f, "I/O error: {e}"),
Self::Parse(msg) => write!(f, "parse error: {msg}"),
Self::NotFound(msg) => write!(f, "not found: {msg}"),
Self::Conflict(msg) => write!(f, "conflict: {msg}"),
Self::Validation(msg) => write!(f, "validation error: {msg}"),
}
}
}
impl std::error::Error for EngineError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Db(e) => Some(e),
Self::DbConnection(e) => Some(e),
Self::Io(e) => Some(e),
_ => None,
}
}
}
impl From<diesel::result::Error> for EngineError {
fn from(e: diesel::result::Error) -> Self {
Self::Db(e)
}
}
impl From<diesel::ConnectionError> for EngineError {
fn from(e: diesel::ConnectionError) -> Self {
Self::DbConnection(e)
}
}
impl From<crate::db::DatabaseError> for EngineError {
fn from(e: crate::db::DatabaseError) -> Self {
match e {
@@ -58,12 +26,6 @@ impl From<crate::db::DatabaseError> for EngineError {
}
}
impl From<std::io::Error> for EngineError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl From<reqwest::Error> for EngineError {
fn from(e: reqwest::Error) -> Self {
Self::Parse(e.to_string())
@@ -83,39 +45,3 @@ impl From<serde_yaml::Error> for EngineError {
}
pub type EngineResult<T> = Result<T, EngineError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_variants() {
assert!(
EngineError::Parse("bad yaml".into())
.to_string()
.contains("parse error")
);
assert!(
EngineError::NotFound("post 123".into())
.to_string()
.contains("not found")
);
assert!(
EngineError::Conflict("slug taken".into())
.to_string()
.contains("conflict")
);
assert!(
EngineError::Validation("title empty".into())
.to_string()
.contains("validation")
);
}
#[test]
fn from_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
let engine_err = EngineError::from(io_err);
assert!(matches!(engine_err, EngineError::Io(_)));
}
}

View File

@@ -164,75 +164,6 @@ pub fn remove_category(data_dir: &Path, category: &str) -> EngineResult<()> {
Ok(())
}
// ── update helpers ──────────────────────────────────────────────────
/// Update the blog_languages list in project.json.
/// Per metadata.allium UpdateProjectMetadata: writes ProjectJsonWritten.
pub fn update_blog_languages(data_dir: &Path, languages: Vec<String>) -> EngineResult<()> {
let mut meta = read_project_json(data_dir)?;
meta.blog_languages = languages;
write_project_json(data_dir, &meta)?;
Ok(())
}
/// Update arbitrary fields of project metadata.
/// Per metadata.allium UpdateProjectMetadata rule.
pub fn update_project_metadata(data_dir: &Path, changes: &serde_json::Value) -> EngineResult<()> {
let mut meta = read_project_json(data_dir)?;
if let Some(name) = changes.get("name").and_then(|v| v.as_str()) {
meta.name = name.to_string();
}
if let Some(desc) = changes.get("description") {
meta.description = desc.as_str().map(|s| s.to_string());
}
if let Some(url) = changes.get("publicUrl") {
meta.public_url = url.as_str().map(|s| s.to_string());
}
if let Some(lang) = changes.get("mainLanguage") {
meta.main_language = lang.as_str().map(|s| s.to_string());
}
if let Some(author) = changes.get("defaultAuthor") {
meta.default_author = author.as_str().map(|s| s.to_string());
}
if let Some(max) = changes.get("maxPostsPerPage").and_then(|v| v.as_i64()) {
meta.max_posts_per_page = max as i32;
}
if let Some(concurrency) = changes.get("imageImportConcurrency") {
meta.image_import_concurrency = concurrency
.as_i64()
.map(|value| value as i32)
.or_else(|| {
concurrency
.as_str()
.and_then(|value| value.parse::<i32>().ok())
})
.unwrap_or(4)
.clamp(1, 8);
}
if let Some(cat) = changes.get("blogmarkCategory") {
meta.blogmark_category = cat.as_str().map(|s| s.to_string());
}
if let Some(theme) = changes.get("picoTheme") {
meta.pico_theme = theme.as_str().map(|s| s.to_string());
}
if let Some(enabled) = changes
.get("semanticSimilarityEnabled")
.and_then(|v| v.as_bool())
{
meta.semantic_similarity_enabled = enabled;
}
if let Some(langs) = changes.get("blogLanguages").and_then(|v| v.as_array()) {
meta.blog_languages = langs
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
}
meta.validate()
.map_err(crate::engine::EngineError::Validation)?;
write_project_json(data_dir, &meta)?;
Ok(())
}
// ── startup sync ────────────────────────────────────────────────────
/// Per metadata.allium StartupSync: loads metadata from filesystem, creating

View File

@@ -5,7 +5,6 @@ use std::path::Path;
use crate::db::DbConnection as Connection;
use walkdir::WalkDir;
use crate::db::from_row::{script_kind_to_str, template_kind_to_str};
use crate::db::queries::media as qm;
use crate::db::queries::media_translation as qmt;
use crate::db::queries::post as qp;
@@ -14,7 +13,7 @@ use crate::db::queries::project as qproject;
use crate::db::queries::script as qs;
use crate::db::queries::template as qtpl;
use crate::engine::{EngineError, EngineResult};
use crate::model::{Media, MediaTranslation, Post, PostStatus, PostTranslation, Script, Template};
use crate::model::{Media, MediaTranslation, Post, PostTranslation, Script, Template};
use crate::util::frontmatter::{
ScriptFrontmatter, TemplateFrontmatter, read_post_file, read_script_file, read_template_file,
read_translation_file, write_post_file, write_script_file, write_template_file,
@@ -371,7 +370,7 @@ fn rewrite_script_from_database(
project_id: Some(script.project_id),
slug: script.slug,
title: script.title,
kind: script_kind_to_str(&script.kind).to_owned(),
kind: script.kind.as_str().to_owned(),
entrypoint: script.entrypoint,
enabled: script.enabled,
version: script.version,
@@ -399,7 +398,7 @@ fn rewrite_template_from_database(
project_id: Some(template.project_id),
slug: template.slug,
title: template.title,
kind: template_kind_to_str(&template.kind).to_owned(),
kind: template.kind.as_str().to_owned(),
enabled: template.enabled,
version: template.version,
created_at: template.created_at,
@@ -427,14 +426,6 @@ fn tags_to_json(tags: &[String]) -> String {
serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string())
}
fn status_to_str(s: &PostStatus) -> &'static str {
match s {
PostStatus::Draft => "draft",
PostStatus::Published => "published",
PostStatus::Archived => "archived",
}
}
fn compare_field(fields: &mut Vec<DiffField>, name: &str, db_val: &str, file_val: &str) {
if db_val != file_val {
fields.push(DiffField {
@@ -459,12 +450,7 @@ fn diff_post(data_dir: &Path, post: &Post) -> EngineResult<Option<EntityDiff>> {
compare_field(&mut fields, "title", &post.title, &fm.title);
compare_field(&mut fields, "slug", &post.slug, &fm.slug);
compare_field(
&mut fields,
"status",
status_to_str(&post.status),
&fm.status,
);
compare_field(&mut fields, "status", post.status.as_str(), &fm.status);
compare_field(
&mut fields,
"tags",
@@ -700,12 +686,7 @@ fn diff_template(data_dir: &Path, tpl: &Template) -> EngineResult<Option<EntityD
let mut fields = Vec::new();
compare_field(&mut fields, "title", &tpl.title, &fm.title);
compare_field(
&mut fields,
"kind",
template_kind_to_str(&tpl.kind),
&fm.kind,
);
compare_field(&mut fields, "kind", tpl.kind.as_str(), &fm.kind);
compare_field(
&mut fields,
"enabled",
@@ -743,12 +724,7 @@ fn diff_script(data_dir: &Path, script: &Script) -> EngineResult<Option<EntityDi
let mut fields = Vec::new();
compare_field(&mut fields, "title", &script.title, &fm.title);
compare_field(
&mut fields,
"kind",
script_kind_to_str(&script.kind),
&fm.kind,
);
compare_field(&mut fields, "kind", script.kind.as_str(), &fm.kind);
compare_field(
&mut fields,
"entrypoint",

View File

@@ -2,7 +2,6 @@ pub mod ai;
pub mod auto_translation;
pub mod blogmark;
pub mod calendar;
pub mod context;
pub mod error;
pub mod generation;
pub mod media;
@@ -27,5 +26,4 @@ pub mod validate_media;
pub mod validate_site;
pub mod validate_translations;
pub use context::EngineContext;
pub use error::{EngineError, EngineResult};

View File

@@ -1163,18 +1163,6 @@ pub fn post_insert_media(media_id: &str, is_image: bool, original_name: &str) ->
}
}
/// Get posts linked from a given post (outlinks).
pub fn list_post_outlinks(conn: &Connection, post_id: &str) -> EngineResult<Vec<String>> {
let links = ql::list_links_by_source(conn, post_id)?;
Ok(links.into_iter().map(|l| l.target_post_id).collect())
}
/// Get posts linking to a given post (backlinks).
pub fn list_post_backlinks(conn: &Connection, post_id: &str) -> EngineResult<Vec<String>> {
let links = ql::list_links_by_target(conn, post_id)?;
Ok(links.into_iter().map(|l| l.source_post_id).collect())
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -6,7 +6,7 @@ use walkdir::WalkDir;
use crate::db::queries::script as qs;
use crate::engine::{EngineError, EngineResult};
use crate::model::{Script, ScriptKind, ScriptStatus};
use crate::model::{Script, ScriptStatus};
use crate::util::frontmatter::read_script_file;
use crate::util::now_unix_ms;
@@ -18,16 +18,6 @@ pub struct ScriptRebuildReport {
pub errors: Vec<String>,
}
/// Parse a script kind string from frontmatter into a `ScriptKind`.
fn parse_script_kind(s: &str) -> Result<ScriptKind, String> {
match s {
"macro" => Ok(ScriptKind::Macro),
"utility" => Ok(ScriptKind::Utility),
"transform" => Ok(ScriptKind::Transform),
other => Err(format!("unknown script kind: '{other}'")),
}
}
/// Rebuild scripts from the filesystem into the database.
///
/// Walks the `scripts/` directory for `*.lua` files, parses each
@@ -92,7 +82,7 @@ pub(crate) fn rebuild_single_script(
.to_string_lossy()
.to_string();
let kind = parse_script_kind(&fm.kind).map_err(EngineError::Parse)?;
let kind = fm.kind.parse().map_err(EngineError::Parse)?;
let now = now_unix_ms();
// File exists on disk -> Published; content is None in DB
@@ -142,6 +132,7 @@ mod tests {
use super::*;
use crate::db::Database;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::model::ScriptKind;
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {

View File

@@ -1,5 +1,5 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
/// Unique task identifier.
@@ -15,14 +15,6 @@ pub enum TaskStatus {
Cancelled,
}
/// Progress update for a task.
#[derive(Debug, Clone)]
pub struct TaskProgress {
pub task_id: TaskId,
pub message: String,
pub percent: Option<f32>,
}
/// Immutable task state exposed to UI consumers.
#[derive(Debug, Clone)]
pub struct TaskSnapshot {
@@ -57,6 +49,7 @@ pub struct TaskManager {
max_concurrent: usize,
next_id: Mutex<TaskId>,
tasks: Mutex<Vec<TaskEntry>>,
state_changed: Condvar,
}
impl TaskManager {
@@ -66,6 +59,7 @@ impl TaskManager {
max_concurrent,
next_id: Mutex::new(1),
tasks: Mutex::new(Vec::new()),
state_changed: Condvar::new(),
}
}
@@ -117,24 +111,20 @@ impl TaskManager {
id
}
/// Try to start a queued task. Returns true if the task was moved to
/// Running, false if concurrency is at capacity or the task is not Queued.
pub fn try_start(&self, task_id: TaskId) -> bool {
/// Block a worker until its task may run. Returns false if cancelled.
pub fn wait_until_runnable(&self, task_id: TaskId) -> bool {
let mut tasks = self.tasks.lock().unwrap();
let running = tasks
.iter()
.filter(|t| t.status == TaskStatus::Running)
.count();
if running >= self.max_concurrent {
return false;
loop {
match tasks
.iter()
.find(|task| task.id == task_id)
.map(|task| &task.status)
{
Some(TaskStatus::Running) => return true,
Some(TaskStatus::Pending) => tasks = self.state_changed.wait(tasks).unwrap(),
_ => return false,
}
}
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
&& entry.status == TaskStatus::Pending
{
entry.status = TaskStatus::Running;
return true;
}
false
}
/// Mark a task as completed.
@@ -148,6 +138,7 @@ impl TaskManager {
entry.finished_at = Some(Instant::now());
}
Self::promote_next(&mut tasks, self.max_concurrent);
self.state_changed.notify_all();
}
/// Mark a task as failed with an error message.
@@ -161,6 +152,7 @@ impl TaskManager {
entry.finished_at = Some(Instant::now());
}
Self::promote_next(&mut tasks, self.max_concurrent);
self.state_changed.notify_all();
}
/// Cancel a task by setting its cancel flag and status.
@@ -174,6 +166,7 @@ impl TaskManager {
entry.finished_at = Some(Instant::now());
}
Self::promote_next(&mut tasks, self.max_concurrent);
self.state_changed.notify_all();
}
/// Check whether a task has been cancelled.
@@ -223,24 +216,6 @@ impl TaskManager {
});
}
/// Return the label of a task.
pub fn label(&self, task_id: TaskId) -> Option<String> {
let tasks = self.tasks.lock().unwrap();
tasks
.iter()
.find(|t| t.id == task_id)
.map(|t| t.label.clone())
}
/// Return the id of the first queued task (FIFO order).
pub fn next_queued(&self) -> Option<TaskId> {
let tasks = self.tasks.lock().unwrap();
tasks
.iter()
.find(|t| t.status == TaskStatus::Pending)
.map(|t| t.id)
}
/// Update progress for a running task. Throttled to at most once per 250ms.
pub fn report_progress(&self, task_id: TaskId, progress: Option<f32>, message: Option<String>) {
let mut tasks = self.tasks.lock().unwrap();
@@ -269,15 +244,6 @@ impl TaskManager {
.and_then(|t| t.progress)
}
/// Return the current message of a task.
pub fn message(&self, task_id: TaskId) -> Option<String> {
let tasks = self.tasks.lock().unwrap();
tasks
.iter()
.find(|t| t.id == task_id)
.and_then(|t| t.message.clone())
}
/// Return a snapshot of all tasks for UI display.
pub fn snapshots(&self) -> Vec<TaskSnapshot> {
let tasks = self.tasks.lock().unwrap();
@@ -331,35 +297,6 @@ pub const PROGRESS_THROTTLE_MS: u64 = 250;
pub const RECENT_FINISHED_LIMIT: usize = 10;
pub const FINISHED_TASK_TTL: Duration = Duration::from_secs(60 * 60);
/// Throttles progress reporting to avoid flooding.
pub struct ProgressThrottle {
interval_ms: u64,
last_report: Mutex<Option<Instant>>,
}
impl ProgressThrottle {
/// Create a throttle with the given interval in milliseconds.
pub fn new(interval_ms: u64) -> Self {
Self {
interval_ms,
last_report: Mutex::new(None),
}
}
/// Returns true if enough time has elapsed since the last report.
pub fn should_report(&self) -> bool {
let mut last = self.last_report.lock().unwrap();
let now = Instant::now();
match *last {
Some(prev) if now.duration_since(prev).as_millis() < self.interval_ms as u128 => false,
_ => {
*last = Some(now);
true
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -390,11 +327,9 @@ mod tests {
let c = mgr.submit("third"); // queued
assert_eq!(mgr.status(a), Some(TaskStatus::Running));
assert_eq!(mgr.next_queued(), Some(b));
mgr.complete(a); // should auto-promote b
assert_eq!(mgr.status(b), Some(TaskStatus::Running));
assert_eq!(mgr.next_queued(), Some(c));
assert_eq!(mgr.status(c), Some(TaskStatus::Pending));
}
#[test]
@@ -481,6 +416,35 @@ mod tests {
assert_eq!(mgr.status(b), Some(TaskStatus::Running));
}
#[test]
fn queued_task_waits_for_a_slot() {
let mgr = std::sync::Arc::new(TaskManager::new(1));
let running = mgr.submit("running");
let queued = mgr.submit("queued");
let waiter = {
let mgr = mgr.clone();
std::thread::spawn(move || mgr.wait_until_runnable(queued))
};
assert!(!waiter.is_finished());
mgr.complete(running);
assert!(waiter.join().unwrap());
}
#[test]
fn cancelling_queued_task_stops_its_waiter() {
let mgr = std::sync::Arc::new(TaskManager::new(1));
let _running = mgr.submit("running");
let queued = mgr.submit("queued");
let waiter = {
let mgr = mgr.clone();
std::thread::spawn(move || mgr.wait_until_runnable(queued))
};
mgr.cancel(queued);
assert!(!waiter.join().unwrap());
}
#[test]
fn cancel_precondition_ignores_completed() {
let mgr = TaskManager::default();
@@ -496,19 +460,6 @@ mod tests {
let id = mgr.submit("upload");
mgr.report_progress(id, Some(0.5), Some("halfway".into()));
assert_eq!(mgr.progress(id), Some(0.5));
assert_eq!(mgr.message(id), Some("halfway".into()));
}
#[test]
fn progress_throttle_initial_reports() {
let throttle = ProgressThrottle::new(PROGRESS_THROTTLE_MS);
assert!(throttle.should_report());
}
#[test]
fn progress_throttle_suppresses_rapid() {
let throttle = ProgressThrottle::new(PROGRESS_THROTTLE_MS);
assert!(throttle.should_report());
assert!(!throttle.should_report());
assert_eq!(mgr.snapshots()[0].message.as_deref(), Some("halfway"));
}
}

View File

@@ -6,7 +6,7 @@ use walkdir::WalkDir;
use crate::db::queries::template as qt;
use crate::engine::{EngineError, EngineResult};
use crate::model::{Template, TemplateKind, TemplateStatus};
use crate::model::{Template, TemplateStatus};
use crate::util::frontmatter::read_template_file;
use crate::util::now_unix_ms;
@@ -18,17 +18,6 @@ pub struct TemplateRebuildReport {
pub errors: Vec<String>,
}
/// Parse a template kind string from frontmatter into a `TemplateKind`.
fn parse_template_kind(s: &str) -> Result<TemplateKind, String> {
match s {
"post" => Ok(TemplateKind::Post),
"list" => Ok(TemplateKind::List),
"not_found" | "notFound" | "not-found" => Ok(TemplateKind::NotFound),
"partial" => Ok(TemplateKind::Partial),
other => Err(format!("unknown template kind: '{other}'")),
}
}
/// Rebuild templates from the filesystem into the database.
///
/// Walks the `templates/` directory for `*.liquid` files, parses each via
@@ -93,7 +82,7 @@ pub(crate) fn rebuild_single_template(
.to_string_lossy()
.to_string();
let kind = parse_template_kind(&fm.kind).map_err(EngineError::Parse)?;
let kind = fm.kind.parse().map_err(EngineError::Parse)?;
let now = now_unix_ms();
// File exists on disk -> Published; content is None in DB
@@ -141,6 +130,7 @@ mod tests {
use super::*;
use crate::db::Database;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::model::TemplateKind;
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {

View File

@@ -55,25 +55,7 @@ pub struct TranslationValidationReport {
/// Per-item progress callback: (current_item, total_items, item_description).
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
/// Validate all translations in a project against consistency rules.
pub fn validate_translations(
conn: &Connection,
data_dir: &Path,
project_id: &str,
blog_languages: &[String],
main_language: &str,
) -> EngineResult<TranslationValidationReport> {
validate_translations_with_progress(
conn,
data_dir,
project_id,
blog_languages,
main_language,
None,
)
}
/// Like `validate_translations` but with optional per-item progress.
/// Validate all translations with optional per-item progress.
pub fn validate_translations_with_progress(
conn: &Connection,
data_dir: &Path,

View File

@@ -5,12 +5,13 @@ use std::sync::LazyLock;
/// Supported UI locales, matching the i18n.allium SupportedLanguage spec.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(usize)]
pub enum UiLocale {
En,
De,
Fr,
It,
Es,
En = 0,
De = 1,
Fr = 2,
It = 3,
Es = 4,
}
impl UiLocale {
@@ -153,13 +154,7 @@ static RENDER_CATALOGS: LazyLock<[Bundle; 5]> = LazyLock::new(|| {
});
fn locale_index(locale: UiLocale) -> usize {
match locale {
UiLocale::En => 0,
UiLocale::De => 1,
UiLocale::Fr => 2,
UiLocale::It => 3,
UiLocale::Es => 4,
}
locale as usize
}
fn format(bundle: &Bundle, key: &str, params: &[(&str, &str)]) -> Option<String> {
@@ -215,50 +210,22 @@ pub fn get_render_translations(language: &str) -> &'static HashMap<String, Strin
&RENDER_MAPS[locale_index(normalize_language(language))]
}
const RENDER_KEYS: &[&str] = &[
"render.archive",
"render.pagination.label",
"render.pagination.newer",
"render.pagination.older",
"render.notFound.message",
"render.notFound.back",
"render.photoArchive.empty",
"render.gallery.empty",
"render.tagCloud.empty",
"render.tagCloud.ariaLabel",
"render.calendar.open",
"render.calendar.close",
"render.calendar.title",
"render.calendar.loading",
"render.calendar.error",
"render.taxonomy.ariaLabel",
"render.backlinks.label",
"render.backlinks.ariaLabel",
"render.languageSwitcher.ariaLabel",
"render.video.youtubeTitle",
"render.video.vimeoTitle",
"render.month.1",
"render.month.2",
"render.month.3",
"render.month.4",
"render.month.5",
"render.month.6",
"render.month.7",
"render.month.8",
"render.month.9",
"render.month.10",
"render.month.11",
"render.month.12",
"render.search.placeholder",
"render.search.ariaLabel",
];
static RENDER_KEYS: LazyLock<Vec<String>> = LazyLock::new(|| {
resource(RENDER_EN)
.entries()
.filter_map(|entry| match entry {
fluent_syntax::ast::Entry::Message(message) => Some(message.id.name.replace('-', ".")),
_ => None,
})
.collect()
});
static RENDER_MAPS: LazyLock<[HashMap<String, String>; 5]> = LazyLock::new(|| {
std::array::from_fn(|index| {
let locale = UiLocale::all()[index];
RENDER_KEYS
.iter()
.map(|key| ((*key).to_owned(), translate_render(locale.code(), key)))
.map(|key| (key.clone(), translate_render(locale.code(), key)))
.collect()
})
});
@@ -295,14 +262,6 @@ mod tests {
assert_eq!(normalize_language("FR-fr"), UiLocale::Fr);
}
// SplitLocalization invariant: UiLocale is independent of content language
#[test]
fn ui_locale_is_independent_type() {
let ui = UiLocale::De;
let content_lang = "fr";
assert_ne!(ui.code(), content_lang);
}
// MenuTranslations invariant: menu labels come from locale catalog
#[test]
fn translate_menu_labels() {
@@ -313,13 +272,6 @@ mod tests {
assert_eq!(label, "Enregistrer");
}
#[test]
fn translate_falls_back_to_english() {
// Non-English locale falls back for missing keys
let result = translate(UiLocale::De, "menu.group.file");
assert_eq!(result, "Datei");
}
#[test]
fn translate_missing_key_returns_key() {
let result = translate(UiLocale::En, "nonexistent.key.xyz");

View File

@@ -2,7 +2,20 @@ 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)]
#[derive(
Debug,
Clone,
Serialize,
Deserialize,
diesel::Queryable,
diesel::Selectable,
diesel::Insertable,
diesel::AsChangeset,
)]
#[diesel(
table_name = crate::db::schema::generated_file_hashes,
check_for_backend(diesel::sqlite::Sqlite)
)]
pub struct GeneratedFileHash {
pub project_id: String,
pub relative_path: String,
@@ -10,7 +23,10 @@ pub struct GeneratedFileHash {
pub updated_at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, diesel::AsExpression, diesel::FromSqlRow,
)]
#[diesel(sql_type = diesel::sql_types::Text)]
#[serde(rename_all = "lowercase")]
pub enum NotificationEntity {
Post,
@@ -19,7 +35,10 @@ pub enum NotificationEntity {
Template,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, diesel::AsExpression, diesel::FromSqlRow,
)]
#[diesel(sql_type = diesel::sql_types::Text)]
#[serde(rename_all = "lowercase")]
pub enum NotificationAction {
Created,
@@ -27,14 +46,68 @@ pub enum NotificationAction {
Deleted,
}
impl std::str::FromStr for NotificationEntity {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"post" => Ok(Self::Post),
"media" => Ok(Self::Media),
"script" => Ok(Self::Script),
"template" => Ok(Self::Template),
_ => Err(format!("invalid NotificationEntity: {value}")),
}
}
}
impl NotificationEntity {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Post => "post",
Self::Media => "media",
Self::Script => "script",
Self::Template => "template",
}
}
}
impl std::str::FromStr for NotificationAction {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"created" => Ok(Self::Created),
"updated" => Ok(Self::Updated),
"deleted" => Ok(Self::Deleted),
_ => Err(format!("invalid NotificationAction: {value}")),
}
}
}
impl NotificationAction {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Created => "created",
Self::Updated => "updated",
Self::Deleted => "deleted",
}
}
}
/// Notification for CLI-to-app synchronization.
/// Matches the `db_notifications` table.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, diesel::Queryable, diesel::Selectable)]
#[diesel(
table_name = crate::db::schema::db_notifications,
check_for_backend(diesel::sqlite::Sqlite)
)]
pub struct DbNotification {
#[diesel(deserialize_as = i32)]
pub id: i64,
pub entity_type: NotificationEntity,
pub entity_id: String,
pub action: NotificationAction,
#[diesel(deserialize_as = crate::db::types::DbBool)]
pub from_cli: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub seen_at: Option<i64>,

View File

@@ -1,8 +1,20 @@
use diesel::ExpressionMethods;
use serde::{Deserialize, Serialize};
/// A media item (image, video, etc.).
/// Matches the `media` table schema.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(
Debug,
Clone,
Serialize,
Deserialize,
diesel::Queryable,
diesel::Selectable,
diesel::Insertable,
diesel::AsChangeset,
)]
#[diesel(table_name = crate::db::schema::media, check_for_backend(diesel::sqlite::Sqlite))]
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
pub struct Media {
pub id: String,
pub project_id: String,
@@ -30,6 +42,10 @@ pub struct Media {
pub checksum: Option<String>,
/// JSON-serialized string array in DB.
#[serde(default)]
#[diesel(
deserialize_as = crate::db::types::DbStringList,
serialize_as = crate::db::types::DbStringList
)]
pub tags: Vec<String>,
pub created_at: i64,
pub updated_at: i64,
@@ -37,7 +53,21 @@ pub struct Media {
/// A translation of media metadata into another language.
/// Matches the `media_translations` table.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(
Debug,
Clone,
Serialize,
Deserialize,
diesel::Queryable,
diesel::Selectable,
diesel::Insertable,
diesel::AsChangeset,
)]
#[diesel(
table_name = crate::db::schema::media_translations,
check_for_backend(diesel::sqlite::Sqlite)
)]
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
pub struct MediaTranslation {
pub id: String,
pub project_id: String,

View File

@@ -1,6 +1,10 @@
use diesel::ExpressionMethods;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, diesel::AsExpression, diesel::FromSqlRow,
)]
#[diesel(sql_type = diesel::sql_types::Text)]
#[serde(rename_all = "lowercase")]
pub enum PostStatus {
Draft,
@@ -9,17 +13,49 @@ pub enum PostStatus {
}
impl PostStatus {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Draft => "draft",
Self::Published => "published",
Self::Archived => "archived",
}
}
/// Returns true if this status is valid for a translation (draft or published only).
pub fn is_valid_for_translation(&self) -> bool {
matches!(self, PostStatus::Draft | PostStatus::Published)
}
}
impl std::str::FromStr for PostStatus {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"draft" => Ok(Self::Draft),
"published" => Ok(Self::Published),
"archived" => Ok(Self::Archived),
_ => Err(format!("invalid PostStatus: {value}")),
}
}
}
/// 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)]
#[derive(
Debug,
Clone,
Serialize,
Deserialize,
diesel::Queryable,
diesel::Selectable,
diesel::Insertable,
diesel::AsChangeset,
)]
#[diesel(table_name = crate::db::schema::posts, check_for_backend(diesel::sqlite::Sqlite))]
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
pub struct Post {
pub id: String,
pub project_id: String,
@@ -36,6 +72,10 @@ pub struct Post {
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(default)]
#[diesel(
deserialize_as = crate::db::types::DbBool,
serialize_as = crate::db::types::DbBool
)]
pub do_not_translate: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub template_slug: Option<String>,
@@ -44,9 +84,17 @@ pub struct Post {
pub checksum: Option<String>,
/// JSON-serialized string array in DB.
#[serde(default)]
#[diesel(
deserialize_as = crate::db::types::DbStringList,
serialize_as = crate::db::types::DbStringList
)]
pub tags: Vec<String>,
/// JSON-serialized string array in DB.
#[serde(default)]
#[diesel(
deserialize_as = crate::db::types::DbStringList,
serialize_as = crate::db::types::DbStringList
)]
pub categories: Vec<String>,
// Published snapshot fields (used for diff detection)
#[serde(skip_serializing_if = "Option::is_none")]
@@ -68,7 +116,21 @@ pub struct Post {
/// A translation of a post into another language.
/// Matches the `post_translations` table.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(
Debug,
Clone,
Serialize,
Deserialize,
diesel::Queryable,
diesel::Selectable,
diesel::Insertable,
diesel::AsChangeset,
)]
#[diesel(
table_name = crate::db::schema::post_translations,
check_for_backend(diesel::sqlite::Sqlite)
)]
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
pub struct PostTranslation {
pub id: String,
pub project_id: String,
@@ -91,7 +153,10 @@ pub struct PostTranslation {
/// A link between two posts (tracked for backlinks).
/// Matches the `post_links` table.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(
Debug, Clone, Serialize, Deserialize, diesel::Queryable, diesel::Selectable, diesel::Insertable,
)]
#[diesel(table_name = crate::db::schema::post_links, check_for_backend(diesel::sqlite::Sqlite))]
pub struct PostLink {
pub id: String,
pub source_post_id: String,
@@ -103,7 +168,10 @@ pub struct PostLink {
/// Association between a post and media item.
/// Matches the `post_media` table.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(
Debug, Clone, Serialize, Deserialize, diesel::Queryable, diesel::Selectable, diesel::Insertable,
)]
#[diesel(table_name = crate::db::schema::post_media, check_for_backend(diesel::sqlite::Sqlite))]
pub struct PostMedia {
pub id: String,
pub project_id: String,

View File

@@ -1,7 +1,19 @@
use diesel::ExpressionMethods;
use serde::{Deserialize, Serialize};
/// A bDS project. Matches the `projects` table schema.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(
Debug,
Clone,
Serialize,
Deserialize,
diesel::Queryable,
diesel::Selectable,
diesel::Insertable,
diesel::AsChangeset,
)]
#[diesel(table_name = crate::db::schema::projects, check_for_backend(diesel::sqlite::Sqlite))]
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
pub struct Project {
pub id: String,
pub name: String,
@@ -10,13 +22,27 @@ pub struct Project {
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data_path: Option<String>,
#[diesel(
deserialize_as = crate::db::types::DbBool,
serialize_as = crate::db::types::DbBool
)]
pub is_active: bool,
pub created_at: i64,
pub updated_at: i64,
}
/// Key-value settings. Matches the `settings` table.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(
Debug,
Clone,
Serialize,
Deserialize,
diesel::Queryable,
diesel::Selectable,
diesel::Insertable,
diesel::AsChangeset,
)]
#[diesel(table_name = crate::db::schema::settings, check_for_backend(diesel::sqlite::Sqlite))]
pub struct Setting {
pub key: String,
pub value: String,

View File

@@ -1,6 +1,10 @@
use diesel::ExpressionMethods;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, diesel::AsExpression, diesel::FromSqlRow,
)]
#[diesel(sql_type = diesel::sql_types::Text)]
#[serde(rename_all = "lowercase")]
pub enum ScriptKind {
Macro,
@@ -8,15 +12,73 @@ pub enum ScriptKind {
Transform,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, diesel::AsExpression, diesel::FromSqlRow,
)]
#[diesel(sql_type = diesel::sql_types::Text)]
#[serde(rename_all = "lowercase")]
pub enum ScriptStatus {
Draft,
Published,
}
impl ScriptKind {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Macro => "macro",
Self::Utility => "utility",
Self::Transform => "transform",
}
}
}
impl std::str::FromStr for ScriptKind {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"macro" => Ok(Self::Macro),
"utility" => Ok(Self::Utility),
"transform" => Ok(Self::Transform),
_ => Err(format!("invalid ScriptKind: {value}")),
}
}
}
impl ScriptStatus {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Draft => "draft",
Self::Published => "published",
}
}
}
impl std::str::FromStr for ScriptStatus {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"draft" => Ok(Self::Draft),
"published" => Ok(Self::Published),
_ => Err(format!("invalid ScriptStatus: {value}")),
}
}
}
/// A user-authored script. Matches the `scripts` table.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(
Debug,
Clone,
Serialize,
Deserialize,
diesel::Queryable,
diesel::Selectable,
diesel::Insertable,
diesel::AsChangeset,
)]
#[diesel(table_name = crate::db::schema::scripts, check_for_backend(diesel::sqlite::Sqlite))]
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
pub struct Script {
pub id: String,
pub project_id: String,
@@ -24,6 +86,10 @@ pub struct Script {
pub title: String,
pub kind: ScriptKind,
pub entrypoint: String,
#[diesel(
deserialize_as = crate::db::types::DbBool,
serialize_as = crate::db::types::DbBool
)]
pub enabled: bool,
pub version: i32,
pub file_path: String,

View File

@@ -1,6 +1,17 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(
Debug,
Clone,
Serialize,
Deserialize,
diesel::Queryable,
diesel::Selectable,
diesel::Insertable,
diesel::AsChangeset,
)]
#[diesel(table_name = crate::db::schema::tags, check_for_backend(diesel::sqlite::Sqlite))]
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
pub struct Tag {
pub id: String,
pub project_id: String,

View File

@@ -1,6 +1,10 @@
use diesel::ExpressionMethods;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, diesel::AsExpression, diesel::FromSqlRow,
)]
#[diesel(sql_type = diesel::sql_types::Text)]
#[serde(rename_all = "snake_case")]
pub enum TemplateKind {
Post,
@@ -9,21 +13,85 @@ pub enum TemplateKind {
Partial,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, diesel::AsExpression, diesel::FromSqlRow,
)]
#[diesel(sql_type = diesel::sql_types::Text)]
#[serde(rename_all = "lowercase")]
pub enum TemplateStatus {
Draft,
Published,
}
impl TemplateKind {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Post => "post",
Self::List => "list",
Self::NotFound => "not_found",
Self::Partial => "partial",
}
}
}
impl std::str::FromStr for TemplateKind {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"post" => Ok(Self::Post),
"list" => Ok(Self::List),
"not_found" | "notFound" | "not-found" => Ok(Self::NotFound),
"partial" => Ok(Self::Partial),
_ => Err(format!("invalid TemplateKind: {value}")),
}
}
}
impl TemplateStatus {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Draft => "draft",
Self::Published => "published",
}
}
}
impl std::str::FromStr for TemplateStatus {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"draft" => Ok(Self::Draft),
"published" => Ok(Self::Published),
_ => Err(format!("invalid TemplateStatus: {value}")),
}
}
}
/// A Liquid template. Matches the `templates` table.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(
Debug,
Clone,
Serialize,
Deserialize,
diesel::Queryable,
diesel::Selectable,
diesel::Insertable,
diesel::AsChangeset,
)]
#[diesel(table_name = crate::db::schema::templates, check_for_backend(diesel::sqlite::Sqlite))]
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
pub struct Template {
pub id: String,
pub project_id: String,
pub slug: String,
pub title: String,
pub kind: TemplateKind,
#[diesel(
deserialize_as = crate::db::types::DbBool,
serialize_as = crate::db::types::DbBool
)]
pub enabled: bool,
pub version: i32,
pub file_path: String,

View File

@@ -1,6 +1,10 @@
use std::ffi::OsString;
use std::fs;
use std::io::{self, Write};
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(1);
/// Write `content` to `path` atomically: write to a temp file in the same
/// directory, then rename. Creates parent directories if missing.
@@ -8,12 +12,36 @@ pub fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp_path = path.with_extension("tmp");
let mut file = fs::File::create(&tmp_path)?;
file.write_all(content)?;
file.sync_all()?;
fs::rename(&tmp_path, path)?;
Ok(())
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let file_name = path
.file_name()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?;
loop {
let mut temp_name = OsString::from(".");
temp_name.push(file_name);
temp_name.push(format!(
".{}.{}.tmp",
std::process::id(),
NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed)
));
let tmp_path = parent.join(temp_name);
let mut file = match fs::File::create_new(&tmp_path) {
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
};
let result = (|| {
file.write_all(content)?;
file.sync_all()?;
drop(file);
fs::rename(&tmp_path, path)
})();
if result.is_err() {
let _ = fs::remove_file(&tmp_path);
}
return result;
}
}
/// Convenience wrapper for UTF-8 string content.
@@ -50,4 +78,27 @@ mod tests {
atomic_write_str(&path, "v2").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
}
#[test]
fn concurrent_sibling_writes_do_not_share_a_temp_file() {
let dir = TempDir::new().unwrap();
let markdown = dir.path().join("a.en.md");
let metadata = dir.path().join("a.en.meta");
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let write = |path: std::path::PathBuf, content: &'static str| {
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
atomic_write_str(&path, content)
})
};
let first = write(markdown.clone(), "markdown");
let second = write(metadata.clone(), "metadata");
first.join().unwrap().unwrap();
second.join().unwrap().unwrap();
assert_eq!(fs::read_to_string(markdown).unwrap(), "markdown");
assert_eq!(fs::read_to_string(metadata).unwrap(), "metadata");
}
}

View File

@@ -7,7 +7,7 @@ pub fn content_hash(content: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(content);
let result = hasher.finalize();
hex::encode(result)
encode_hex(result)
}
/// Compute a hex-encoded SHA-256 hash of a file by streaming (8 KB chunks).
@@ -22,22 +22,15 @@ pub fn file_hash(path: &Path) -> std::io::Result<String> {
}
hasher.update(&buf[..n]);
}
Ok(hex::encode(hasher.finalize()))
Ok(encode_hex(hasher.finalize()))
}
// 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
}
fn encode_hex(bytes: impl AsRef<[u8]>) -> String {
bytes
.as_ref()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
#[cfg(test)]

View File

@@ -1,5 +1,149 @@
use crate::model::{Post, PostTranslation};
use crate::util::timestamp::{iso_to_unix_ms, unix_ms_to_iso};
use serde::{Deserialize, Deserializer};
fn scalar_string(value: serde_yaml::Value) -> Option<String> {
match value {
serde_yaml::Value::String(value) => Some(value),
serde_yaml::Value::Number(value) => Some(value.to_string()),
serde_yaml::Value::Bool(value) => Some(value.to_string()),
_ => None,
}
}
fn deserialize_scalar_string<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
scalar_string(serde_yaml::Value::deserialize(deserializer)?)
.ok_or_else(|| serde::de::Error::custom("expected a scalar string"))
}
fn deserialize_optional_scalar_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::<serde_yaml::Value>::deserialize(deserializer)?.and_then(scalar_string))
}
fn deserialize_optional_nonempty_scalar_string<'de, D>(
deserializer: D,
) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
Ok(deserialize_optional_scalar_string(deserializer)?.filter(|value| !value.is_empty()))
}
fn deserialize_optional_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::<serde_yaml::Value>::deserialize(deserializer)?
.and_then(|value| value.as_str().map(str::to_owned)))
}
fn deserialize_string<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
serde_yaml::Value::deserialize(deserializer)?
.as_str()
.map(str::to_owned)
.ok_or_else(|| serde::de::Error::custom("expected a string"))
}
fn deserialize_optional_nonempty_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
Ok(deserialize_optional_string(deserializer)?.filter(|value| !value.is_empty()))
}
fn deserialize_string_list<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
Ok(match serde_yaml::Value::deserialize(deserializer)? {
serde_yaml::Value::Sequence(values) => values
.iter()
.filter_map(|value| value.as_str().map(str::to_owned))
.collect(),
_ => Vec::new(),
})
}
fn deserialize_timestamp<'de, D>(deserializer: D) -> Result<i64, D::Error>
where
D: Deserializer<'de>,
{
let value = deserialize_scalar_string(deserializer)?;
iso_to_unix_ms(&value).map_err(serde::de::Error::custom)
}
fn deserialize_optional_timestamp<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
where
D: Deserializer<'de>,
{
Ok(deserialize_optional_scalar_string(deserializer)?
.and_then(|value| iso_to_unix_ms(&value).ok()))
}
fn deserialize_optional_string_timestamp<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
where
D: Deserializer<'de>,
{
Ok(deserialize_optional_string(deserializer)?.and_then(|value| iso_to_unix_ms(&value).ok()))
}
fn deserialize_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::<serde_yaml::Value>::deserialize(deserializer)?
.and_then(scalar_string)
.is_some_and(|value| value == "true"))
}
fn deserialize_bool_default_true<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::<serde_yaml::Value>::deserialize(deserializer)?
.and_then(scalar_string)
.is_none_or(|value| value == "true"))
}
fn default_true() -> bool {
true
}
fn default_version() -> i32 {
1
}
fn default_entrypoint() -> String {
"render".to_owned()
}
fn deserialize_version<'de, D>(deserializer: D) -> Result<i32, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::<serde_yaml::Value>::deserialize(deserializer)?
.and_then(scalar_string)
.and_then(|value| value.parse().ok())
.unwrap_or(1))
}
fn deserialize_entrypoint<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::<serde_yaml::Value>::deserialize(deserializer)?
.and_then(scalar_string)
.unwrap_or_else(default_entrypoint))
}
/// Split content at `---` delimiters into (yaml, body).
/// Returns `None` if the content does not start with `---`.
@@ -25,21 +169,48 @@ pub fn format_frontmatter(yaml: &str, body: &str) -> String {
// --- Post Frontmatter ---
/// Parsed post frontmatter fields (camelCase for YAML compatibility).
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PostFrontmatter {
#[serde(deserialize_with = "deserialize_scalar_string")]
pub id: String,
#[serde(deserialize_with = "deserialize_scalar_string")]
pub title: String,
#[serde(deserialize_with = "deserialize_scalar_string")]
pub slug: String,
#[serde(deserialize_with = "deserialize_scalar_string")]
pub status: String,
#[serde(deserialize_with = "deserialize_timestamp")]
pub created_at: i64,
#[serde(deserialize_with = "deserialize_timestamp")]
pub updated_at: i64,
#[serde(default, deserialize_with = "deserialize_string_list")]
pub tags: Vec<String>,
#[serde(default, deserialize_with = "deserialize_string_list")]
pub categories: Vec<String>,
#[serde(
default,
deserialize_with = "deserialize_optional_nonempty_scalar_string"
)]
pub excerpt: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_optional_nonempty_scalar_string"
)]
pub author: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_optional_nonempty_scalar_string"
)]
pub language: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_optional_nonempty_scalar_string"
)]
pub template_slug: Option<String>,
#[serde(default, deserialize_with = "deserialize_bool")]
pub do_not_translate: bool,
#[serde(default, deserialize_with = "deserialize_optional_timestamp")]
pub published_at: Option<i64>,
}
@@ -50,10 +221,7 @@ impl PostFrontmatter {
id: post.id.clone(),
title: post.title.clone(),
slug: post.slug.clone(),
status: serde_json::to_string(&post.status)
.unwrap_or_default()
.trim_matches('"')
.to_string(),
status: post.status.as_str().to_owned(),
created_at: post.created_at,
updated_at: post.updated_at,
tags: post.tags.clone(),
@@ -130,56 +298,7 @@ impl PostFrontmatter {
/// Parse from a YAML string.
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
let doc: serde_yaml::Value =
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
let map = doc
.as_mapping()
.ok_or("frontmatter is not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> {
map.get(serde_yaml::Value::String(key.to_string()))
.and_then(|v| match v {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()),
serde_yaml::Value::Bool(b) => Some(b.to_string()),
_ => None,
})
};
let get_string_list = |key: &str| -> Vec<String> {
map.get(serde_yaml::Value::String(key.to_string()))
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
};
let get_timestamp = |key: &str| -> Result<i64, String> {
let s = get_str(key).ok_or(format!("missing required field '{key}'"))?;
iso_to_unix_ms(&s)
};
Ok(Self {
id: get_str("id").ok_or("missing 'id'")?,
title: get_str("title").ok_or("missing 'title'")?,
slug: get_str("slug").ok_or("missing 'slug'")?,
status: get_str("status").ok_or("missing 'status'")?,
created_at: get_timestamp("createdAt")?,
updated_at: get_timestamp("updatedAt")?,
tags: get_string_list("tags"),
categories: get_string_list("categories"),
excerpt: get_str("excerpt").filter(|s| !s.is_empty()),
author: get_str("author").filter(|s| !s.is_empty()),
language: get_str("language").filter(|s| !s.is_empty()),
template_slug: get_str("templateSlug").filter(|s| !s.is_empty()),
do_not_translate: get_str("doNotTranslate")
.map(|s| s == "true")
.unwrap_or(false),
published_at: get_str("publishedAt").and_then(|s| iso_to_unix_ms(&s).ok()),
})
serde_yaml::from_str(yaml).map_err(|error| format!("YAML parse error: {error}"))
}
}
@@ -199,16 +318,26 @@ pub fn read_post_file(content: &str) -> Result<(PostFrontmatter, String), String
// --- Translation Frontmatter ---
/// Parsed translation frontmatter.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TranslationFrontmatter {
#[serde(default, deserialize_with = "deserialize_optional_string")]
pub id: Option<String>,
#[serde(deserialize_with = "deserialize_string")]
pub translation_for: String,
#[serde(deserialize_with = "deserialize_string")]
pub language: String,
#[serde(deserialize_with = "deserialize_string")]
pub title: String,
#[serde(default, deserialize_with = "deserialize_optional_nonempty_string")]
pub excerpt: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_string")]
pub status: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_string_timestamp")]
pub created_at: Option<i64>,
#[serde(default, deserialize_with = "deserialize_optional_string_timestamp")]
pub updated_at: Option<i64>,
#[serde(default, deserialize_with = "deserialize_optional_string_timestamp")]
pub published_at: Option<i64>,
}
@@ -220,12 +349,7 @@ impl TranslationFrontmatter {
language: t.language.clone(),
title: t.title.clone(),
excerpt: t.excerpt.clone(),
status: Some(
serde_json::to_string(&t.status)
.unwrap_or_default()
.trim_matches('"')
.to_string(),
),
status: Some(t.status.as_str().to_owned()),
created_at: Some(t.created_at),
updated_at: Some(t.updated_at),
published_at: t.published_at,
@@ -261,29 +385,7 @@ impl TranslationFrontmatter {
}
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
let doc: serde_yaml::Value =
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
let map = doc
.as_mapping()
.ok_or("frontmatter is not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> {
map.get(serde_yaml::Value::String(key.to_string()))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
};
Ok(Self {
id: get_str("id"),
translation_for: get_str("translationFor").ok_or("missing 'translationFor'")?,
language: get_str("language").ok_or("missing 'language'")?,
title: get_str("title").ok_or("missing 'title'")?,
excerpt: get_str("excerpt").filter(|s| !s.is_empty()),
status: get_str("status"),
created_at: get_str("createdAt").and_then(|value| iso_to_unix_ms(&value).ok()),
updated_at: get_str("updatedAt").and_then(|value| iso_to_unix_ms(&value).ok()),
published_at: get_str("publishedAt").and_then(|value| iso_to_unix_ms(&value).ok()),
})
serde_yaml::from_str(yaml).map_err(|error| format!("YAML parse error: {error}"))
}
}
@@ -303,16 +405,29 @@ pub fn read_translation_file(content: &str) -> Result<(TranslationFrontmatter, S
// --- Template Frontmatter ---
/// Parsed template frontmatter (double-quoted strings, matching TypeScript output).
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TemplateFrontmatter {
#[serde(deserialize_with = "deserialize_scalar_string")]
pub id: String,
#[serde(default, deserialize_with = "deserialize_optional_scalar_string")]
pub project_id: Option<String>,
#[serde(deserialize_with = "deserialize_scalar_string")]
pub slug: String,
#[serde(deserialize_with = "deserialize_scalar_string")]
pub title: String,
#[serde(deserialize_with = "deserialize_scalar_string")]
pub kind: String,
#[serde(
default = "default_true",
deserialize_with = "deserialize_bool_default_true"
)]
pub enabled: bool,
#[serde(default = "default_version", deserialize_with = "deserialize_version")]
pub version: i32,
#[serde(deserialize_with = "deserialize_timestamp")]
pub created_at: i64,
#[serde(deserialize_with = "deserialize_timestamp")]
pub updated_at: i64,
}
@@ -341,37 +456,7 @@ impl TemplateFrontmatter {
}
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
let doc: serde_yaml::Value =
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
let map = doc.as_mapping().ok_or("not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> {
map.get(serde_yaml::Value::String(key.to_string()))
.and_then(|v| match v {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()),
serde_yaml::Value::Bool(b) => Some(b.to_string()),
_ => None,
})
};
Ok(Self {
id: get_str("id").ok_or("missing 'id'")?,
project_id: get_str("projectId"),
slug: get_str("slug").ok_or("missing 'slug'")?,
title: get_str("title").ok_or("missing 'title'")?,
kind: get_str("kind").ok_or("missing 'kind'")?,
enabled: get_str("enabled").map(|s| s == "true").unwrap_or(true),
version: get_str("version")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(1),
created_at: get_str("createdAt")
.and_then(|s| iso_to_unix_ms(&s).ok())
.ok_or("missing 'createdAt'")?,
updated_at: get_str("updatedAt")
.and_then(|s| iso_to_unix_ms(&s).ok())
.ok_or("missing 'updatedAt'")?,
})
serde_yaml::from_str(yaml).map_err(|error| format!("YAML parse error: {error}"))
}
}
@@ -390,17 +475,34 @@ pub fn write_template_file(fm: &TemplateFrontmatter, body: &str) -> String {
// --- Script Frontmatter ---
/// Parsed script frontmatter (double-quoted strings like templates, plus entrypoint).
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScriptFrontmatter {
#[serde(deserialize_with = "deserialize_scalar_string")]
pub id: String,
#[serde(default, deserialize_with = "deserialize_optional_scalar_string")]
pub project_id: Option<String>,
#[serde(deserialize_with = "deserialize_scalar_string")]
pub slug: String,
#[serde(deserialize_with = "deserialize_scalar_string")]
pub title: String,
#[serde(deserialize_with = "deserialize_scalar_string")]
pub kind: String,
#[serde(
default = "default_entrypoint",
deserialize_with = "deserialize_entrypoint"
)]
pub entrypoint: String,
#[serde(
default = "default_true",
deserialize_with = "deserialize_bool_default_true"
)]
pub enabled: bool,
#[serde(default = "default_version", deserialize_with = "deserialize_version")]
pub version: i32,
#[serde(deserialize_with = "deserialize_timestamp")]
pub created_at: i64,
#[serde(deserialize_with = "deserialize_timestamp")]
pub updated_at: i64,
}
@@ -430,38 +532,7 @@ impl ScriptFrontmatter {
}
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
let doc: serde_yaml::Value =
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
let map = doc.as_mapping().ok_or("not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> {
map.get(serde_yaml::Value::String(key.to_string()))
.and_then(|v| match v {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()),
serde_yaml::Value::Bool(b) => Some(b.to_string()),
_ => None,
})
};
Ok(Self {
id: get_str("id").ok_or("missing 'id'")?,
project_id: get_str("projectId"),
slug: get_str("slug").ok_or("missing 'slug'")?,
title: get_str("title").ok_or("missing 'title'")?,
kind: get_str("kind").ok_or("missing 'kind'")?,
entrypoint: get_str("entrypoint").unwrap_or_else(|| "render".to_string()),
enabled: get_str("enabled").map(|s| s == "true").unwrap_or(true),
version: get_str("version")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(1),
created_at: get_str("createdAt")
.and_then(|s| iso_to_unix_ms(&s).ok())
.ok_or("missing 'createdAt'")?,
updated_at: get_str("updatedAt")
.and_then(|s| iso_to_unix_ms(&s).ok())
.ok_or("missing 'updatedAt'")?,
})
serde_yaml::from_str(yaml).map_err(|error| format!("YAML parse error: {error}"))
}
}
@@ -551,6 +622,27 @@ mod tests {
assert!(split_frontmatter("no frontmatter here").is_none());
}
#[test]
fn derived_deserialization_preserves_lenient_scalar_coercion() {
let yaml = "id: 42\ntitle: true\nslug: 7\nstatus: false\ncreatedAt: '2026-01-02T03:04:05.000Z'\nupdatedAt: '2026-01-02T03:04:05.000Z'\ntags: [rust, 2]\ncategories: invalid\nauthor: 9\ndoNotTranslate: true";
let parsed = PostFrontmatter::from_yaml(yaml).unwrap();
assert_eq!(parsed.id, "42");
assert_eq!(parsed.title, "true");
assert_eq!(parsed.slug, "7");
assert_eq!(parsed.status, "false");
assert_eq!(parsed.tags, vec!["rust"]);
assert!(parsed.categories.is_empty());
assert_eq!(parsed.author.as_deref(), Some("9"));
assert!(parsed.do_not_translate);
}
#[test]
fn translation_frontmatter_remains_string_only() {
let yaml = "translationFor: 42\nlanguage: en\ntitle: Title";
assert!(TranslationFrontmatter::from_yaml(yaml).is_err());
}
#[test]
fn parse_esmeralda() {
let path = fixture_dir().join("posts/2005/11/esmeralda.md");