feat: first take at M4
This commit is contained in:
740
Cargo.lock
generated
740
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,11 @@ rust-stemmers = "1"
|
||||
sys-locale = "0.3"
|
||||
dirs = "5"
|
||||
open = "5"
|
||||
pulldown-cmark = "0.13"
|
||||
liquid = "0.26"
|
||||
liquid-core = { version = "0.26", features = ["derive"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
|
||||
|
||||
# UI framework
|
||||
iced = { version = "0.13", features = ["wgpu", "advanced", "image", "svg", "tokio"] }
|
||||
|
||||
@@ -19,6 +19,11 @@ walkdir = { workspace = true }
|
||||
image = { workspace = true }
|
||||
rust-stemmers = { workspace = true }
|
||||
sys-locale = { workspace = true }
|
||||
pulldown-cmark = { workspace = true }
|
||||
liquid = { workspace = true }
|
||||
liquid-core = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
keyring = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
94
crates/bds-core/src/db/queries/generated_file_hash.rs
Normal file
94
crates/bds-core/src/db/queries/generated_file_hash.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{generated_file_hash_from_row, GENERATED_FILE_HASH_COLUMNS};
|
||||
use crate::model::GeneratedFileHash;
|
||||
|
||||
pub fn get_generated_file_hash(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
relative_path: &str,
|
||||
) -> rusqlite::Result<GeneratedFileHash> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT {GENERATED_FILE_HASH_COLUMNS} FROM generated_file_hashes WHERE project_id = ?1 AND relative_path = ?2"
|
||||
),
|
||||
params![project_id, relative_path],
|
||||
generated_file_hash_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn upsert_generated_file_hash(conn: &Connection, hash: &GeneratedFileHash) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO generated_file_hashes (project_id, relative_path, content_hash, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(project_id, relative_path)
|
||||
DO UPDATE SET content_hash = excluded.content_hash, updated_at = excluded.updated_at",
|
||||
params![hash.project_id, hash.relative_path, hash.content_hash, hash.updated_at],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_generated_file_hash(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
relative_path: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM generated_file_hashes WHERE project_id = ?1 AND relative_path = ?2",
|
||||
params![project_id, relative_path],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_generated_file_hashes_by_project(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<GeneratedFileHash>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {GENERATED_FILE_HASH_COLUMNS} FROM generated_file_hashes WHERE project_id = ?1 ORDER BY relative_path"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![project_id], generated_file_hash_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_and_get_generated_hash() {
|
||||
let db = setup();
|
||||
let hash = GeneratedFileHash {
|
||||
project_id: "p1".into(),
|
||||
relative_path: "index.html".into(),
|
||||
content_hash: "abc".into(),
|
||||
updated_at: 42,
|
||||
};
|
||||
|
||||
upsert_generated_file_hash(db.conn(), &hash).unwrap();
|
||||
let stored = get_generated_file_hash(db.conn(), "p1", "index.html").unwrap();
|
||||
assert_eq!(stored.content_hash, "abc");
|
||||
|
||||
upsert_generated_file_hash(
|
||||
db.conn(),
|
||||
&GeneratedFileHash {
|
||||
content_hash: "def".into(),
|
||||
updated_at: 99,
|
||||
..hash
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let stored = get_generated_file_hash(db.conn(), "p1", "index.html").unwrap();
|
||||
assert_eq!(stored.content_hash, "def");
|
||||
assert_eq!(stored.updated_at, 99);
|
||||
}
|
||||
}
|
||||
@@ -9,3 +9,4 @@ pub mod post_media;
|
||||
pub mod template;
|
||||
pub mod script;
|
||||
pub mod setting;
|
||||
pub mod generated_file_hash;
|
||||
|
||||
756
crates/bds-core/src/engine/ai.rs
Normal file
756
crates/bds-core/src/engine/ai.rs
Normal file
@@ -0,0 +1,756 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use keyring::Entry;
|
||||
use reqwest::blocking::Client;
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::db::queries::setting;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::util::now_unix_ms;
|
||||
|
||||
const KEYRING_SERVICE: &str = "RuDS";
|
||||
const KEYRING_SETTING_PREFIX: &str = "ai.endpoint";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AiEndpointKind {
|
||||
Online,
|
||||
Airplane,
|
||||
}
|
||||
|
||||
impl AiEndpointKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Online => "online",
|
||||
Self::Airplane => "airplane",
|
||||
}
|
||||
}
|
||||
|
||||
fn settings_prefix(self) -> String {
|
||||
format!("ai.endpoint.{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AiEndpointConfig {
|
||||
pub kind: AiEndpointKind,
|
||||
pub url: String,
|
||||
pub model: String,
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StoredAiEndpointConfig {
|
||||
pub kind: AiEndpointKind,
|
||||
pub url: String,
|
||||
pub model: String,
|
||||
pub api_key_configured: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct AiSettings {
|
||||
pub offline_mode: bool,
|
||||
pub default_model: Option<String>,
|
||||
pub title_model: Option<String>,
|
||||
pub image_model: Option<String>,
|
||||
pub system_prompt: String,
|
||||
pub online_endpoint: StoredAiEndpointConfig,
|
||||
pub airplane_endpoint: StoredAiEndpointConfig,
|
||||
}
|
||||
|
||||
impl Default for StoredAiEndpointConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
kind: AiEndpointKind::Online,
|
||||
url: String::new(),
|
||||
model: String::new(),
|
||||
api_key_configured: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AiModelInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub context_window: Option<u64>,
|
||||
pub max_output_tokens: Option<u64>,
|
||||
pub supports_vision: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum OneShotOperation {
|
||||
AnalyzeTaxonomy,
|
||||
AnalyzePost,
|
||||
DetectLanguage,
|
||||
TranslatePost { target_language: String },
|
||||
AnalyzeImage,
|
||||
TranslateMedia { target_language: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OneShotRequest {
|
||||
pub operation: OneShotOperation,
|
||||
pub content: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TaxonomySuggestion {
|
||||
pub tags: Vec<String>,
|
||||
pub categories: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PostAnalysisResult {
|
||||
pub title: String,
|
||||
pub excerpt: String,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LanguageDetectionResult {
|
||||
pub language_code: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TranslationResult {
|
||||
pub title: String,
|
||||
pub excerpt: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImageAnalysisResult {
|
||||
pub title: String,
|
||||
pub alt: String,
|
||||
pub caption: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MediaTranslationResult {
|
||||
pub title: String,
|
||||
pub alt: String,
|
||||
pub caption: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum OneShotResponse {
|
||||
Taxonomy(TaxonomySuggestion),
|
||||
PostAnalysis(PostAnalysisResult),
|
||||
LanguageDetection(LanguageDetectionResult),
|
||||
Translation(TranslationResult),
|
||||
ImageAnalysis(ImageAnalysisResult),
|
||||
MediaTranslation(MediaTranslationResult),
|
||||
}
|
||||
|
||||
pub fn load_ai_settings(conn: &Connection, offline_mode: bool) -> EngineResult<AiSettings> {
|
||||
let online_endpoint = load_endpoint(conn, AiEndpointKind::Online)?;
|
||||
let airplane_endpoint = load_endpoint(conn, AiEndpointKind::Airplane)?;
|
||||
let default_model = get_optional_setting(conn, "ai.default_model")?;
|
||||
let title_model = get_optional_setting(conn, "ai.title_model")?;
|
||||
let image_model = get_optional_setting(conn, "ai.image_model")?;
|
||||
let system_prompt = get_optional_setting(conn, "ai.system_prompt")?.unwrap_or_default();
|
||||
|
||||
Ok(AiSettings {
|
||||
offline_mode,
|
||||
default_model,
|
||||
title_model,
|
||||
image_model,
|
||||
system_prompt,
|
||||
online_endpoint,
|
||||
airplane_endpoint,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save_endpoint(conn: &Connection, endpoint: &AiEndpointConfig) -> EngineResult<()> {
|
||||
validate_endpoint_config(endpoint)?;
|
||||
let checked_at = now_unix_ms();
|
||||
set_setting(conn, &endpoint_setting_key(endpoint.kind, "url"), endpoint.url.trim(), checked_at)?;
|
||||
set_setting(conn, &endpoint_setting_key(endpoint.kind, "model"), endpoint.model.trim(), checked_at)?;
|
||||
if endpoint.kind == AiEndpointKind::Online {
|
||||
let entry = endpoint_keyring_entry(endpoint.kind)?;
|
||||
if let Some(api_key) = &endpoint.api_key {
|
||||
if api_key.trim().is_empty() {
|
||||
entry.delete_credential().ok();
|
||||
set_setting(conn, &endpoint_setting_key(endpoint.kind, "api_key_configured"), "false", checked_at)?;
|
||||
} else {
|
||||
entry.set_password(api_key.trim()).map_err(keyring_error)?;
|
||||
set_setting(conn, &endpoint_setting_key(endpoint.kind, "api_key_configured"), "true", checked_at)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_model_preferences(
|
||||
conn: &Connection,
|
||||
default_model: Option<&str>,
|
||||
title_model: Option<&str>,
|
||||
image_model: Option<&str>,
|
||||
system_prompt: &str,
|
||||
) -> EngineResult<()> {
|
||||
let updated_at = now_unix_ms();
|
||||
set_optional_setting(conn, "ai.default_model", default_model, updated_at)?;
|
||||
set_optional_setting(conn, "ai.title_model", title_model, updated_at)?;
|
||||
set_optional_setting(conn, "ai.image_model", image_model, updated_at)?;
|
||||
set_setting(conn, "ai.system_prompt", system_prompt, updated_at)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn active_endpoint(conn: &Connection, offline_mode: bool) -> EngineResult<AiEndpointConfig> {
|
||||
let kind = if offline_mode { AiEndpointKind::Airplane } else { AiEndpointKind::Online };
|
||||
let stored = load_endpoint(conn, kind)?;
|
||||
if stored.url.trim().is_empty() {
|
||||
return Err(EngineError::Validation(format!(
|
||||
"AI unavailable - configure {} endpoint in Settings",
|
||||
kind.as_str()
|
||||
)));
|
||||
}
|
||||
let api_key = if kind == AiEndpointKind::Online {
|
||||
let entry = endpoint_keyring_entry(kind)?;
|
||||
let password = entry.get_password().map_err(keyring_error)?;
|
||||
if password.trim().is_empty() {
|
||||
return Err(EngineError::Validation(
|
||||
"AI unavailable - configure online endpoint in Settings".to_string(),
|
||||
));
|
||||
}
|
||||
Some(password)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(AiEndpointConfig {
|
||||
kind,
|
||||
url: stored.url,
|
||||
model: stored.model,
|
||||
api_key,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_endpoint_api_key(kind: AiEndpointKind) -> EngineResult<Option<String>> {
|
||||
let entry = endpoint_keyring_entry(kind)?;
|
||||
match entry.get_password() {
|
||||
Ok(password) if password.trim().is_empty() => Ok(None),
|
||||
Ok(password) => Ok(Some(password)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(error) => Err(keyring_error(error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<AiModelInfo>> {
|
||||
validate_endpoint_config(endpoint)?;
|
||||
let client = build_http_client()?;
|
||||
let request = client.get(models_url(&endpoint.url));
|
||||
let response = with_auth(request, endpoint)
|
||||
.send()?
|
||||
.error_for_status()?;
|
||||
let body: Value = response.json()?;
|
||||
let models = body
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| EngineError::Parse("model catalog response missing data array".to_string()))?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for model in models {
|
||||
let id = model
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| EngineError::Parse("model entry missing id".to_string()))?
|
||||
.to_string();
|
||||
let name = model
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(&id)
|
||||
.to_string();
|
||||
let context_window = model
|
||||
.get("context_window")
|
||||
.or_else(|| model.get("contextWindow"))
|
||||
.and_then(Value::as_u64);
|
||||
let max_output_tokens = model
|
||||
.get("max_output_tokens")
|
||||
.or_else(|| model.get("maxOutputTokens"))
|
||||
.and_then(Value::as_u64);
|
||||
let supports_vision = model
|
||||
.get("modalities")
|
||||
.and_then(Value::as_array)
|
||||
.map(|modalities| modalities.iter().any(|value| value.as_str() == Some("vision")))
|
||||
.unwrap_or(false);
|
||||
result.push(AiModelInfo {
|
||||
id,
|
||||
name,
|
||||
context_window,
|
||||
max_output_tokens,
|
||||
supports_vision,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn test_endpoint(endpoint: &AiEndpointConfig) -> EngineResult<()> {
|
||||
let _ = refresh_model_catalog(endpoint)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_one_shot(
|
||||
conn: &Connection,
|
||||
offline_mode: bool,
|
||||
request: &OneShotRequest,
|
||||
) -> EngineResult<OneShotResponse> {
|
||||
let settings = load_ai_settings(conn, offline_mode)?;
|
||||
let endpoint = active_endpoint(conn, offline_mode)?;
|
||||
let model = select_model(&settings, &endpoint, &request.operation)?;
|
||||
let prompt = build_one_shot_prompt(request)?;
|
||||
let schema = response_schema(&request.operation);
|
||||
let payload = json!({
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": build_system_prompt(&settings.system_prompt, &request.operation),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
}
|
||||
],
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": schema.0,
|
||||
"schema": schema.1,
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
});
|
||||
let client = build_http_client()?;
|
||||
let response = with_auth(client.post(chat_completions_url(&endpoint.url)).json(&payload), &endpoint)
|
||||
.send()?
|
||||
.error_for_status()?;
|
||||
let body: Value = response.json()?;
|
||||
let content = body
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|choices| choices.first())
|
||||
.and_then(|choice| choice.get("message"))
|
||||
.and_then(|message| message.get("content"))
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| EngineError::Parse("chat completions response missing message content".to_string()))?;
|
||||
parse_one_shot_response(request, content)
|
||||
}
|
||||
|
||||
fn build_http_client() -> EngineResult<Client> {
|
||||
Ok(Client::builder().timeout(Duration::from_secs(5)).build()?)
|
||||
}
|
||||
|
||||
fn load_endpoint(conn: &Connection, kind: AiEndpointKind) -> EngineResult<StoredAiEndpointConfig> {
|
||||
let url = get_optional_setting(conn, &endpoint_setting_key(kind, "url"))?.unwrap_or_default();
|
||||
let model = get_optional_setting(conn, &endpoint_setting_key(kind, "model"))?.unwrap_or_default();
|
||||
let api_key_configured = get_optional_setting(conn, &endpoint_setting_key(kind, "api_key_configured"))?
|
||||
.map(|value| value == "true")
|
||||
.unwrap_or(false);
|
||||
Ok(StoredAiEndpointConfig {
|
||||
kind,
|
||||
url,
|
||||
model,
|
||||
api_key_configured,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_endpoint_config(endpoint: &AiEndpointConfig) -> EngineResult<()> {
|
||||
if endpoint.url.trim().is_empty() {
|
||||
return Err(EngineError::Validation("endpoint url is required".to_string()));
|
||||
}
|
||||
if endpoint.model.trim().is_empty() {
|
||||
return Err(EngineError::Validation("endpoint model is required".to_string()));
|
||||
}
|
||||
if endpoint.kind == AiEndpointKind::Online
|
||||
&& endpoint.api_key.as_ref().map(|value| value.trim().is_empty()).unwrap_or(true)
|
||||
{
|
||||
return Err(EngineError::Validation("online endpoint api key is required".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn select_model(settings: &AiSettings, endpoint: &AiEndpointConfig, operation: &OneShotOperation) -> EngineResult<String> {
|
||||
let selected = match operation {
|
||||
OneShotOperation::AnalyzeImage => settings.image_model.as_ref(),
|
||||
OneShotOperation::AnalyzeTaxonomy
|
||||
| OneShotOperation::AnalyzePost
|
||||
| OneShotOperation::DetectLanguage
|
||||
| OneShotOperation::TranslatePost { .. }
|
||||
| OneShotOperation::TranslateMedia { .. } => settings.title_model.as_ref().or(settings.default_model.as_ref()),
|
||||
}
|
||||
.filter(|model| !model.trim().is_empty())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| endpoint.model.clone());
|
||||
if selected.trim().is_empty() {
|
||||
return Err(EngineError::Validation("AI unavailable - configure model in Settings".to_string()));
|
||||
}
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
fn build_system_prompt(base_prompt: &str, operation: &OneShotOperation) -> String {
|
||||
let operation_prompt = match operation {
|
||||
OneShotOperation::AnalyzeTaxonomy => "Return only JSON with tags and categories for the post.",
|
||||
OneShotOperation::AnalyzePost => "Return only JSON with title, excerpt, and slug suggestions for the post.",
|
||||
OneShotOperation::DetectLanguage => "Return only JSON with the detected language_code.",
|
||||
OneShotOperation::TranslatePost { target_language } => {
|
||||
return format!("{} Translate the post into {} and return only JSON with title, excerpt, and content.", base_prompt.trim(), target_language);
|
||||
}
|
||||
OneShotOperation::AnalyzeImage => "Return only JSON with title, alt, and caption suggestions for the image.",
|
||||
OneShotOperation::TranslateMedia { target_language } => {
|
||||
return format!("{} Translate the media metadata into {} and return only JSON with title, alt, and caption.", base_prompt.trim(), target_language);
|
||||
}
|
||||
};
|
||||
if base_prompt.trim().is_empty() {
|
||||
operation_prompt.to_string()
|
||||
} else {
|
||||
format!("{} {}", base_prompt.trim(), operation_prompt)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_one_shot_prompt(request: &OneShotRequest) -> EngineResult<String> {
|
||||
match &request.operation {
|
||||
OneShotOperation::AnalyzeTaxonomy => Ok(format!(
|
||||
"Suggest tags and categories for this post: {}",
|
||||
serde_json::to_string(&request.content)?
|
||||
)),
|
||||
OneShotOperation::AnalyzePost => Ok(format!(
|
||||
"Analyze this post and suggest title, excerpt, and slug: {}",
|
||||
serde_json::to_string(&request.content)?
|
||||
)),
|
||||
OneShotOperation::DetectLanguage => Ok(format!(
|
||||
"Detect the language of this text: {}",
|
||||
serde_json::to_string(&request.content)?
|
||||
)),
|
||||
OneShotOperation::TranslatePost { target_language } => Ok(format!(
|
||||
"Translate this post to {}: {}",
|
||||
target_language,
|
||||
serde_json::to_string(&request.content)?
|
||||
)),
|
||||
OneShotOperation::AnalyzeImage => Ok(format!(
|
||||
"Analyze this image metadata and return title, alt, and caption suggestions: {}",
|
||||
serde_json::to_string(&request.content)?
|
||||
)),
|
||||
OneShotOperation::TranslateMedia { target_language } => Ok(format!(
|
||||
"Translate this media metadata to {}: {}",
|
||||
target_language,
|
||||
serde_json::to_string(&request.content)?
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn response_schema(operation: &OneShotOperation) -> (&'static str, Value) {
|
||||
match operation {
|
||||
OneShotOperation::AnalyzeTaxonomy => (
|
||||
"taxonomy_suggestion",
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tags": { "type": "array", "items": { "type": "string" } },
|
||||
"categories": { "type": "array", "items": { "type": "string" } }
|
||||
},
|
||||
"required": ["tags", "categories"]
|
||||
}),
|
||||
),
|
||||
OneShotOperation::AnalyzePost => (
|
||||
"post_analysis",
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"title": { "type": "string" },
|
||||
"excerpt": { "type": "string" },
|
||||
"slug": { "type": "string" }
|
||||
},
|
||||
"required": ["title", "excerpt", "slug"]
|
||||
}),
|
||||
),
|
||||
OneShotOperation::DetectLanguage => (
|
||||
"language_detection",
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"language_code": { "type": "string" }
|
||||
},
|
||||
"required": ["language_code"]
|
||||
}),
|
||||
),
|
||||
OneShotOperation::TranslatePost { .. } => (
|
||||
"post_translation",
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"title": { "type": "string" },
|
||||
"excerpt": { "type": "string" },
|
||||
"content": { "type": "string" }
|
||||
},
|
||||
"required": ["title", "excerpt", "content"]
|
||||
}),
|
||||
),
|
||||
OneShotOperation::AnalyzeImage => (
|
||||
"image_analysis",
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"title": { "type": "string" },
|
||||
"alt": { "type": "string" },
|
||||
"caption": { "type": "string" }
|
||||
},
|
||||
"required": ["title", "alt", "caption"]
|
||||
}),
|
||||
),
|
||||
OneShotOperation::TranslateMedia { .. } => (
|
||||
"media_translation",
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"title": { "type": "string" },
|
||||
"alt": { "type": "string" },
|
||||
"caption": { "type": "string" }
|
||||
},
|
||||
"required": ["title", "alt", "caption"]
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_one_shot_response(request: &OneShotRequest, content: &str) -> EngineResult<OneShotResponse> {
|
||||
Ok(match request.operation {
|
||||
OneShotOperation::AnalyzeTaxonomy => OneShotResponse::Taxonomy(serde_json::from_str(content)?),
|
||||
OneShotOperation::AnalyzePost => OneShotResponse::PostAnalysis(serde_json::from_str(content)?),
|
||||
OneShotOperation::DetectLanguage => OneShotResponse::LanguageDetection(serde_json::from_str(content)?),
|
||||
OneShotOperation::TranslatePost { .. } => OneShotResponse::Translation(serde_json::from_str(content)?),
|
||||
OneShotOperation::AnalyzeImage => OneShotResponse::ImageAnalysis(serde_json::from_str(content)?),
|
||||
OneShotOperation::TranslateMedia { .. } => OneShotResponse::MediaTranslation(serde_json::from_str(content)?),
|
||||
})
|
||||
}
|
||||
|
||||
fn endpoint_setting_key(kind: AiEndpointKind, suffix: &str) -> String {
|
||||
format!("{}.{}", kind.settings_prefix(), suffix)
|
||||
}
|
||||
|
||||
fn endpoint_keyring_entry(kind: AiEndpointKind) -> EngineResult<Entry> {
|
||||
Entry::new(KEYRING_SERVICE, &format!("{}.{}", KEYRING_SETTING_PREFIX, kind.as_str())).map_err(keyring_error)
|
||||
}
|
||||
|
||||
fn keyring_error(error: keyring::Error) -> EngineError {
|
||||
EngineError::Validation(error.to_string())
|
||||
}
|
||||
|
||||
fn set_setting(conn: &Connection, key: &str, value: &str, updated_at: i64) -> EngineResult<()> {
|
||||
setting::set_setting_value(conn, key, value, updated_at)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_optional_setting(conn: &Connection, key: &str, value: Option<&str>, updated_at: i64) -> EngineResult<()> {
|
||||
set_setting(conn, key, value.unwrap_or(""), updated_at)
|
||||
}
|
||||
|
||||
fn get_optional_setting(conn: &Connection, key: &str) -> EngineResult<Option<String>> {
|
||||
match setting::get_setting_by_key(conn, key) {
|
||||
Ok(setting) if setting.value.trim().is_empty() => Ok(None),
|
||||
Ok(setting) => Ok(Some(setting.value)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(error) => Err(EngineError::Db(error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn models_url(base_url: &str) -> String {
|
||||
join_openai_path(base_url, "models")
|
||||
}
|
||||
|
||||
fn chat_completions_url(base_url: &str) -> String {
|
||||
join_openai_path(base_url, "chat/completions")
|
||||
}
|
||||
|
||||
fn join_openai_path(base_url: &str, suffix: &str) -> String {
|
||||
let trimmed = base_url.trim_end_matches('/');
|
||||
if trimmed.ends_with("/v1") {
|
||||
format!("{trimmed}/{suffix}")
|
||||
} else {
|
||||
format!("{trimmed}/v1/{suffix}")
|
||||
}
|
||||
}
|
||||
|
||||
fn with_auth(
|
||||
request: reqwest::blocking::RequestBuilder,
|
||||
endpoint: &AiEndpointConfig,
|
||||
) -> reqwest::blocking::RequestBuilder {
|
||||
if let Some(api_key) = &endpoint.api_key {
|
||||
request.bearer_auth(api_key)
|
||||
} else {
|
||||
request
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn clear_keyring(kind: AiEndpointKind) {
|
||||
let entry = endpoint_keyring_entry(kind).unwrap();
|
||||
entry.delete_credential().ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_empty_defaults() {
|
||||
clear_keyring(AiEndpointKind::Online);
|
||||
let db = setup();
|
||||
let settings = load_ai_settings(db.conn(), false).unwrap();
|
||||
assert!(!settings.offline_mode);
|
||||
assert!(settings.online_endpoint.url.is_empty());
|
||||
assert!(settings.airplane_endpoint.url.is_empty());
|
||||
assert!(settings.default_model.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_online_endpoint_with_keychain_secret() {
|
||||
clear_keyring(AiEndpointKind::Online);
|
||||
let db = setup();
|
||||
save_endpoint(
|
||||
db.conn(),
|
||||
&AiEndpointConfig {
|
||||
kind: AiEndpointKind::Online,
|
||||
url: "https://example.test/v1".to_string(),
|
||||
model: "gpt-4.1-mini".to_string(),
|
||||
api_key: Some("secret-token".to_string()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let active = active_endpoint(db.conn(), false).unwrap();
|
||||
assert_eq!(active.url, "https://example.test/v1");
|
||||
assert_eq!(active.model, "gpt-4.1-mini");
|
||||
assert_eq!(active.api_key.as_deref(), Some("secret-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn airplane_endpoint_does_not_require_api_key() {
|
||||
let db = setup();
|
||||
save_endpoint(
|
||||
db.conn(),
|
||||
&AiEndpointConfig {
|
||||
kind: AiEndpointKind::Airplane,
|
||||
url: "http://localhost:11434/v1".to_string(),
|
||||
model: "llama3.2".to_string(),
|
||||
api_key: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let active = active_endpoint(db.conn(), true).unwrap();
|
||||
assert_eq!(active.kind, AiEndpointKind::Airplane);
|
||||
assert!(active.api_key.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_model_catalog_parses_openai_models_shape() {
|
||||
let server = spawn_test_server(|request| {
|
||||
assert!(request.starts_with("GET /v1/models HTTP/1.1"));
|
||||
http_ok(
|
||||
r#"{"data":[{"id":"gpt-4.1-mini","name":"GPT 4.1 mini","context_window":128000,"max_output_tokens":8192,"modalities":["text"]},{"id":"gpt-4.1","modalities":["text","vision"]}]}"#,
|
||||
)
|
||||
});
|
||||
let models = refresh_model_catalog(&AiEndpointConfig {
|
||||
kind: AiEndpointKind::Airplane,
|
||||
url: server,
|
||||
model: "gpt-4.1-mini".to_string(),
|
||||
api_key: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(models.len(), 2);
|
||||
assert_eq!(models[0].name, "GPT 4.1 mini");
|
||||
assert!(models[1].supports_vision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_one_shot_uses_active_endpoint_and_parses_response() {
|
||||
clear_keyring(AiEndpointKind::Online);
|
||||
let server = spawn_test_server(|request| {
|
||||
if request.starts_with("GET /v1/models HTTP/1.1") {
|
||||
return http_ok(r#"{"data":[{"id":"gpt-4.1-mini"}]}"#);
|
||||
}
|
||||
assert!(request.starts_with("POST /v1/chat/completions HTTP/1.1"));
|
||||
assert!(request.contains("authorization: Bearer secret-token") || request.contains("Authorization: Bearer secret-token"));
|
||||
http_ok(
|
||||
r#"{"choices":[{"message":{"content":"{\"title\":\"Better title\",\"excerpt\":\"Short summary\",\"slug\":\"better-title\"}"}}]}"#,
|
||||
)
|
||||
});
|
||||
|
||||
let db = setup();
|
||||
save_endpoint(
|
||||
db.conn(),
|
||||
&AiEndpointConfig {
|
||||
kind: AiEndpointKind::Online,
|
||||
url: server,
|
||||
model: "gpt-4.1-mini".to_string(),
|
||||
api_key: Some("secret-token".to_string()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save_model_preferences(db.conn(), None, Some("gpt-4.1-mini"), None, "").unwrap();
|
||||
|
||||
let response = run_one_shot(
|
||||
db.conn(),
|
||||
false,
|
||||
&OneShotRequest {
|
||||
operation: OneShotOperation::AnalyzePost,
|
||||
content: json!({"title":"Draft title","excerpt":"","content":"Body"}),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
OneShotResponse::PostAnalysis(PostAnalysisResult {
|
||||
title: "Better title".to_string(),
|
||||
excerpt: "Short summary".to_string(),
|
||||
slug: "better-title".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn spawn_test_server(handler: impl Fn(String) -> String + Send + 'static) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
thread::spawn(move || {
|
||||
for stream in listener.incoming().take(2) {
|
||||
let mut stream = stream.unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let size = stream.read(&mut buffer).unwrap();
|
||||
let request = String::from_utf8_lossy(&buffer[..size]).to_string();
|
||||
let response = handler(request);
|
||||
stream.write_all(response.as_bytes()).unwrap();
|
||||
}
|
||||
});
|
||||
format!("http://{}", addr)
|
||||
}
|
||||
|
||||
fn http_ok(body: &str) -> String {
|
||||
format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,12 @@ impl From<std::io::Error> for EngineError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for EngineError {
|
||||
fn from(e: reqwest::Error) -> Self {
|
||||
Self::Parse(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for EngineError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
Self::Parse(e.to_string())
|
||||
|
||||
227
crates/bds-core/src/engine/generation.rs
Normal file
227
crates/bds-core/src/engine/generation.rs
Normal file
@@ -0,0 +1,227 @@
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::{Post, ProjectMetadata};
|
||||
use crate::render::{
|
||||
GeneratedWriteOutcome, build_calendar_json, build_canonical_post_path,
|
||||
render_markdown_to_html, render_starter_list_page, render_starter_single_post_page,
|
||||
write_generated_file,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PublishedPostSource {
|
||||
pub post: Post,
|
||||
pub body_markdown: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct GenerationReport {
|
||||
pub written_paths: Vec<String>,
|
||||
pub skipped_paths: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn generate_starter_site(
|
||||
conn: &Connection,
|
||||
output_dir: &Path,
|
||||
project_id: &str,
|
||||
metadata: &ProjectMetadata,
|
||||
posts: &[PublishedPostSource],
|
||||
language: &str,
|
||||
) -> EngineResult<GenerationReport> {
|
||||
let mut report = GenerationReport::default();
|
||||
|
||||
let list_input = posts
|
||||
.iter()
|
||||
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let index_page = render_starter_list_page(&list_input, metadata, language)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
write_out(conn, output_dir, project_id, &index_page.relative_path, &index_page.html, &mut report)?;
|
||||
|
||||
for source in posts {
|
||||
let rendered = render_starter_single_post_page(&source.post, &source.body_markdown, metadata, language)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
write_out(conn, output_dir, project_id, &rendered.relative_path, &rendered.html, &mut report)?;
|
||||
}
|
||||
|
||||
write_out(
|
||||
conn,
|
||||
output_dir,
|
||||
project_id,
|
||||
"calendar.json",
|
||||
&build_calendar_json(&posts.iter().map(|source| source.post.clone()).collect::<Vec<_>>())?,
|
||||
&mut report,
|
||||
)?;
|
||||
|
||||
let rss = build_rss_xml(metadata, posts, language);
|
||||
write_out(conn, output_dir, project_id, "rss.xml", &rss, &mut report)?;
|
||||
write_out(conn, output_dir, project_id, "feed.xml", &rss, &mut report)?;
|
||||
write_out(conn, output_dir, project_id, "atom.xml", &build_atom_xml(metadata, posts, language), &mut report)?;
|
||||
write_out(conn, output_dir, project_id, "sitemap.xml", &build_sitemap_xml(metadata, posts, language), &mut report)?;
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn write_out(
|
||||
conn: &Connection,
|
||||
output_dir: &Path,
|
||||
project_id: &str,
|
||||
relative_path: &str,
|
||||
content: &str,
|
||||
report: &mut GenerationReport,
|
||||
) -> EngineResult<()> {
|
||||
match write_generated_file(conn, output_dir, project_id, relative_path, content)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?
|
||||
{
|
||||
GeneratedWriteOutcome::Written => report.written_paths.push(relative_path.to_string()),
|
||||
GeneratedWriteOutcome::SkippedUnchanged => report.skipped_paths.push(relative_path.to_string()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String {
|
||||
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
|
||||
let last_build = posts
|
||||
.iter()
|
||||
.filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at)))
|
||||
.max()
|
||||
.unwrap_or_else(Utc::now);
|
||||
|
||||
let mut xml = vec![
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>".to_string(),
|
||||
"<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">".to_string(),
|
||||
" <channel>".to_string(),
|
||||
format!(" <title>{}</title>", escape_xml(&metadata.name)),
|
||||
format!(" <link>{base_url}/</link>"),
|
||||
format!(" <description>{}</description>", escape_xml(metadata.description.as_deref().unwrap_or(""))),
|
||||
format!(" <lastBuildDate>{}</lastBuildDate>", last_build.format("%a, %d %b %Y %H:%M:%S GMT")),
|
||||
" <generator>bDS</generator>".to_string(),
|
||||
];
|
||||
|
||||
for source in posts {
|
||||
let url = format!("{base_url}{}", build_canonical_post_path(&source.post, language, metadata.main_language.as_deref().unwrap_or("en")));
|
||||
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at)).unwrap_or_else(Utc::now);
|
||||
xml.push(" <item>".to_string());
|
||||
xml.push(format!(" <title>{}</title>", escape_xml(&source.post.title)));
|
||||
xml.push(format!(" <link>{url}</link>"));
|
||||
xml.push(format!(" <guid isPermaLink=\"true\">{url}</guid>"));
|
||||
xml.push(format!(" <pubDate>{}</pubDate>", published.format("%a, %d %b %Y %H:%M:%S GMT")));
|
||||
if let Some(author) = &source.post.author {
|
||||
xml.push(format!(" <author>{}</author>", escape_xml(author)));
|
||||
}
|
||||
xml.push(format!(" <dc:language>{}</dc:language>", escape_xml(language)));
|
||||
let html = render_markdown_to_html(&source.body_markdown);
|
||||
xml.push(format!(" <description><![CDATA[{html}]]></description>"));
|
||||
xml.push(format!(" <content:encoded><![CDATA[{html}]]></content:encoded>"));
|
||||
for category in &source.post.categories {
|
||||
xml.push(format!(" <category>{}</category>", escape_xml(category)));
|
||||
}
|
||||
for tag in &source.post.tags {
|
||||
xml.push(format!(" <category>{}</category>", escape_xml(tag)));
|
||||
}
|
||||
xml.push(" </item>".to_string());
|
||||
}
|
||||
|
||||
xml.push(" </channel>".to_string());
|
||||
xml.push("</rss>".to_string());
|
||||
xml.join("\n")
|
||||
}
|
||||
|
||||
fn build_atom_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String {
|
||||
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
|
||||
let updated = posts
|
||||
.iter()
|
||||
.filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at)))
|
||||
.max()
|
||||
.unwrap_or_else(Utc::now);
|
||||
let mut xml = vec![
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>".to_string(),
|
||||
"<feed xmlns=\"http://www.w3.org/2005/Atom\">".to_string(),
|
||||
format!(" <title>{}</title>", escape_xml(&metadata.name)),
|
||||
format!(" <subtitle>{}</subtitle>", escape_xml(metadata.description.as_deref().unwrap_or(""))),
|
||||
format!(" <id>{base_url}/</id>"),
|
||||
format!(" <link href=\"{base_url}/\" rel=\"alternate\" />"),
|
||||
format!(" <link href=\"{base_url}/atom.xml\" rel=\"self\" />"),
|
||||
format!(" <updated>{}</updated>", updated.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)),
|
||||
];
|
||||
|
||||
for source in posts {
|
||||
let url = format!("{base_url}{}", build_canonical_post_path(&source.post, language, metadata.main_language.as_deref().unwrap_or("en")));
|
||||
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at)).unwrap_or_else(Utc::now);
|
||||
let html = render_markdown_to_html(&source.body_markdown);
|
||||
xml.push(format!(" <entry xml:lang=\"{}\">", escape_xml(language)));
|
||||
xml.push(format!(" <title>{}</title>", escape_xml(&source.post.title)));
|
||||
xml.push(format!(" <id>{url}</id>"));
|
||||
xml.push(format!(" <link href=\"{url}\" />"));
|
||||
xml.push(format!(" <updated>{}</updated>", published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)));
|
||||
xml.push(format!(" <published>{}</published>", published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)));
|
||||
if let Some(author) = &source.post.author {
|
||||
xml.push(format!(" <author><name>{}</name></author>", escape_xml(author)));
|
||||
}
|
||||
xml.push(format!(" <summary type=\"xhtml\"><div xmlns=\"http://www.w3.org/1999/xhtml\">{html}</div></summary>"));
|
||||
xml.push(format!(" <content type=\"xhtml\"><div xmlns=\"http://www.w3.org/1999/xhtml\">{html}</div></content>"));
|
||||
for category in &source.post.categories {
|
||||
xml.push(format!(" <category term=\"{}\" />", escape_xml(category)));
|
||||
}
|
||||
for tag in &source.post.tags {
|
||||
xml.push(format!(" <category term=\"{}\" />", escape_xml(tag)));
|
||||
}
|
||||
xml.push(" </entry>".to_string());
|
||||
}
|
||||
|
||||
xml.push("</feed>".to_string());
|
||||
xml.join("\n")
|
||||
}
|
||||
|
||||
fn build_sitemap_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String {
|
||||
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
|
||||
let index_lastmod = posts
|
||||
.iter()
|
||||
.filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at)))
|
||||
.max()
|
||||
.unwrap_or_else(Utc::now)
|
||||
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
||||
|
||||
let mut xml = vec![
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>".to_string(),
|
||||
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">".to_string(),
|
||||
" <url>".to_string(),
|
||||
format!(" <loc>{base_url}/</loc>"),
|
||||
format!(" <lastmod>{index_lastmod}</lastmod>"),
|
||||
" <changefreq>daily</changefreq>".to_string(),
|
||||
" <priority>1.0</priority>".to_string(),
|
||||
" </url>".to_string(),
|
||||
];
|
||||
|
||||
for source in posts {
|
||||
let url = format!("{base_url}{}", build_canonical_post_path(&source.post, language, metadata.main_language.as_deref().unwrap_or("en")));
|
||||
let lastmod = timestamp(source.post.published_at.unwrap_or(source.post.created_at))
|
||||
.unwrap_or_else(Utc::now)
|
||||
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
||||
xml.push(" <url>".to_string());
|
||||
xml.push(format!(" <loc>{url}</loc>"));
|
||||
xml.push(format!(" <lastmod>{lastmod}</lastmod>"));
|
||||
xml.push(" <changefreq>weekly</changefreq>".to_string());
|
||||
xml.push(" <priority>0.8</priority>".to_string());
|
||||
xml.push(" </url>".to_string());
|
||||
}
|
||||
|
||||
xml.push("</urlset>".to_string());
|
||||
xml.join("\n")
|
||||
}
|
||||
|
||||
fn timestamp(timestamp_ms: i64) -> Option<DateTime<Utc>> {
|
||||
chrono::Utc.timestamp_millis_opt(timestamp_ms).single()
|
||||
}
|
||||
|
||||
fn escape_xml(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod error;
|
||||
pub mod ai;
|
||||
pub mod context;
|
||||
pub mod project;
|
||||
pub mod meta;
|
||||
@@ -15,6 +16,8 @@ pub mod metadata_diff;
|
||||
pub mod rebuild;
|
||||
pub mod search;
|
||||
pub mod calendar;
|
||||
pub mod generation;
|
||||
pub mod preview;
|
||||
pub mod validate_translations;
|
||||
pub mod validate_media;
|
||||
pub mod validate_content;
|
||||
|
||||
126
crates/bds-core/src/engine/preview.rs
Normal file
126
crates/bds-core/src/engine/preview.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::engine::generation::PublishedPostSource;
|
||||
use crate::model::ProjectMetadata;
|
||||
use crate::render::{build_canonical_post_path, render_starter_list_page, render_starter_single_post_page};
|
||||
|
||||
pub fn render_preview_path(
|
||||
path: &str,
|
||||
metadata: &ProjectMetadata,
|
||||
posts: &[PublishedPostSource],
|
||||
) -> EngineResult<Option<String>> {
|
||||
let normalized = if path.is_empty() { "/" } else { path };
|
||||
let main_language = metadata.main_language.as_deref().unwrap_or("en");
|
||||
|
||||
if normalized == "/" {
|
||||
let list_posts = posts
|
||||
.iter()
|
||||
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
return render_starter_list_page(&list_posts, metadata, main_language)
|
||||
.map(|page| Some(page.html))
|
||||
.map_err(|error| EngineError::Parse(error.to_string()));
|
||||
}
|
||||
|
||||
let (language, route_path) = split_language_prefix(normalized, metadata);
|
||||
if let Some(source) = posts.iter().find(|source| {
|
||||
build_canonical_post_path(&source.post, &language, main_language) == route_path
|
||||
}) {
|
||||
return render_starter_single_post_page(&source.post, &source.body_markdown, metadata, &language)
|
||||
.map(|page| Some(page.html))
|
||||
.map_err(|error| EngineError::Parse(error.to_string()));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn split_language_prefix(path: &str, metadata: &ProjectMetadata) -> (String, String) {
|
||||
let trimmed = path.trim_start_matches('/');
|
||||
let mut segments = trimmed.split('/');
|
||||
let first = segments.next().unwrap_or_default();
|
||||
if metadata.blog_languages.iter().any(|language| language == first) {
|
||||
let remainder = segments.collect::<Vec<_>>().join("/");
|
||||
return (first.to_string(), format!("/{first}/{}", remainder.trim_start_matches('/')));
|
||||
}
|
||||
|
||||
(
|
||||
metadata.main_language.as_deref().unwrap_or("en").to_string(),
|
||||
path.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{Post, PostStatus};
|
||||
|
||||
fn make_metadata() -> ProjectMetadata {
|
||||
ProjectMetadata {
|
||||
name: "Blog".into(),
|
||||
description: None,
|
||||
public_url: Some("https://example.com".into()),
|
||||
main_language: Some("en".into()),
|
||||
default_author: None,
|
||||
max_posts_per_page: 50,
|
||||
blogmark_category: None,
|
||||
pico_theme: None,
|
||||
semantic_similarity_enabled: false,
|
||||
blog_languages: vec!["en".into(), "de".into()],
|
||||
}
|
||||
}
|
||||
|
||||
fn make_post() -> PublishedPostSource {
|
||||
PublishedPostSource {
|
||||
post: Post {
|
||||
id: "post-1".into(),
|
||||
project_id: "project-1".into(),
|
||||
title: "Hello".into(),
|
||||
slug: "hello".into(),
|
||||
excerpt: None,
|
||||
content: Some("Body".into()),
|
||||
status: PostStatus::Published,
|
||||
author: None,
|
||||
language: Some("en".into()),
|
||||
do_not_translate: false,
|
||||
template_slug: None,
|
||||
file_path: String::new(),
|
||||
checksum: None,
|
||||
tags: vec![],
|
||||
categories: vec![],
|
||||
published_title: None,
|
||||
published_content: None,
|
||||
published_tags: None,
|
||||
published_categories: None,
|
||||
published_excerpt: None,
|
||||
created_at: 1_710_000_000_000,
|
||||
updated_at: 1_710_000_000_000,
|
||||
published_at: Some(1_710_000_000_000),
|
||||
},
|
||||
body_markdown: "Hello **world**".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_preview_renders_index_page() {
|
||||
let html = render_preview_path("/", &make_metadata(), &[make_post()])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(html.contains("post-list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_renders_single_post_for_canonical_path() {
|
||||
let html = render_preview_path("/2024/03/09/hello", &make_metadata(), &[make_post()])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(html.contains("<h1>Hello</h1>"));
|
||||
assert!(html.contains("<strong>world</strong>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_renders_language_prefixed_single_post() {
|
||||
let html = render_preview_path("/de/2024/03/09/hello", &make_metadata(), &[make_post()])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(html.contains("lang=\"de\""));
|
||||
}
|
||||
}
|
||||
104
crates/bds-core/src/render/generation.rs
Normal file
104
crates/bds-core/src/render/generation.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::{Datelike, TimeZone, Utc};
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::db::queries::generated_file_hash as qhash;
|
||||
use crate::model::{GeneratedFileHash, Post};
|
||||
use crate::util::{atomic_write_str, content_hash, now_unix_ms};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GeneratedWriteOutcome {
|
||||
Written,
|
||||
SkippedUnchanged,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct CalendarArchiveData {
|
||||
pub years: BTreeMap<String, usize>,
|
||||
pub months: BTreeMap<String, usize>,
|
||||
pub days: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
pub fn write_generated_file(
|
||||
conn: &Connection,
|
||||
output_dir: &Path,
|
||||
project_id: &str,
|
||||
relative_path: &str,
|
||||
content: &str,
|
||||
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let hash = content_hash(content.as_bytes());
|
||||
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) {
|
||||
if existing.content_hash == hash {
|
||||
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
|
||||
}
|
||||
}
|
||||
|
||||
let target_path = output_dir.join(relative_path);
|
||||
if let Some(parent) = target_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
atomic_write_str(&target_path, content)?;
|
||||
|
||||
qhash::upsert_generated_file_hash(
|
||||
conn,
|
||||
&GeneratedFileHash {
|
||||
project_id: project_id.to_string(),
|
||||
relative_path: relative_path.to_string(),
|
||||
content_hash: hash,
|
||||
updated_at: now_unix_ms(),
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(GeneratedWriteOutcome::Written)
|
||||
}
|
||||
|
||||
pub fn build_core_generation_paths(main_language: &str, blog_languages: &[String]) -> Vec<String> {
|
||||
let mut paths = vec![
|
||||
"index.html".to_string(),
|
||||
"sitemap.xml".to_string(),
|
||||
"feed.xml".to_string(),
|
||||
"atom.xml".to_string(),
|
||||
"calendar.json".to_string(),
|
||||
];
|
||||
|
||||
for language in blog_languages {
|
||||
if language != main_language {
|
||||
paths.push(format!("{language}/index.html"));
|
||||
paths.push(format!("{language}/feed.xml"));
|
||||
paths.push(format!("{language}/atom.xml"));
|
||||
}
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
pub fn build_calendar_archive_data(posts: &[Post]) -> CalendarArchiveData {
|
||||
let mut years = BTreeMap::new();
|
||||
let mut months = BTreeMap::new();
|
||||
let mut days = BTreeMap::new();
|
||||
|
||||
for post in posts {
|
||||
let timestamp_ms = post.published_at.unwrap_or(post.created_at);
|
||||
let Some(created_at) = Utc.timestamp_millis_opt(timestamp_ms).single() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let year = created_at.year().to_string();
|
||||
let month = format!("{year}-{:02}", created_at.month());
|
||||
let day = format!("{month}-{:02}", created_at.day());
|
||||
|
||||
*years.entry(year).or_insert(0) += 1;
|
||||
*months.entry(month).or_insert(0) += 1;
|
||||
*days.entry(day).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
CalendarArchiveData { years, months, days }
|
||||
}
|
||||
|
||||
pub fn build_calendar_json(posts: &[Post]) -> serde_json::Result<String> {
|
||||
serde_json::to_string_pretty(&build_calendar_archive_data(posts))
|
||||
}
|
||||
25
crates/bds-core/src/render/markdown.rs
Normal file
25
crates/bds-core/src/render/markdown.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use pulldown_cmark::{Options, Parser, html};
|
||||
|
||||
pub fn render_markdown_to_html(markdown: &str) -> String {
|
||||
let mut options = Options::empty();
|
||||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
options.insert(Options::ENABLE_TABLES);
|
||||
options.insert(Options::ENABLE_TASKLISTS);
|
||||
|
||||
let parser = Parser::new_ext(markdown, options);
|
||||
let mut output = String::new();
|
||||
html::push_html(&mut output, parser);
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::render_markdown_to_html;
|
||||
|
||||
#[test]
|
||||
fn renders_commonmark_to_html() {
|
||||
let rendered = render_markdown_to_html("# Title\n\nSome *text*.");
|
||||
assert!(rendered.contains("<h1>Title</h1>"));
|
||||
assert!(rendered.contains("<p>Some <em>text</em>.</p>"));
|
||||
}
|
||||
}
|
||||
@@ -1 +1,20 @@
|
||||
// Rendering pipeline — stubs for M0, implemented in M4.
|
||||
mod markdown;
|
||||
mod generation;
|
||||
mod page_renderer;
|
||||
mod routes;
|
||||
mod template_lookup;
|
||||
|
||||
pub use generation::{
|
||||
CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json,
|
||||
build_core_generation_paths, write_generated_file,
|
||||
};
|
||||
pub use markdown::render_markdown_to_html;
|
||||
pub use page_renderer::{RenderError, render_liquid_template};
|
||||
pub use routes::{
|
||||
RenderedPage, build_canonical_post_path, render_starter_list_page,
|
||||
render_starter_single_post_page,
|
||||
};
|
||||
pub use template_lookup::{
|
||||
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError,
|
||||
resolve_post_template,
|
||||
};
|
||||
|
||||
335
crates/bds-core/src/render/page_renderer.rs
Normal file
335
crates/bds-core/src/render/page_renderer.rs
Normal file
@@ -0,0 +1,335 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use liquid::ParserBuilder;
|
||||
use liquid::partials::{EagerCompiler, InMemorySource};
|
||||
use liquid_core::{
|
||||
Display_filter, Expression, Filter, FilterParameters, FilterReflection,
|
||||
FromFilterParameters, ParseFilter, Runtime, Value, ValueView,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::i18n::translate_render;
|
||||
use crate::render::render_markdown_to_html;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RenderError {
|
||||
#[error("liquid error: {0}")]
|
||||
Liquid(#[from] liquid::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct HtmlRewriteContext {
|
||||
canonical_post_path_by_slug: HashMap<String, String>,
|
||||
canonical_media_path_by_source_path: HashMap<String, String>,
|
||||
}
|
||||
|
||||
pub fn render_liquid_template<T: Serialize>(
|
||||
template_source: &str,
|
||||
partials: &HashMap<String, String>,
|
||||
context: &T,
|
||||
) -> Result<String, RenderError> {
|
||||
let mut compiled_partials: EagerCompiler<InMemorySource> = EagerCompiler::empty();
|
||||
for (name, content) in partials {
|
||||
compiled_partials.add(format!("{name}.liquid"), content.clone());
|
||||
}
|
||||
|
||||
let parser = ParserBuilder::with_stdlib()
|
||||
.filter(I18n)
|
||||
.filter(Markdown)
|
||||
.partials(compiled_partials)
|
||||
.build()?;
|
||||
let template = parser.parse(template_source)?;
|
||||
let globals = liquid::to_object(context)?;
|
||||
Ok(template.render(&globals)?)
|
||||
}
|
||||
|
||||
#[derive(Debug, FilterParameters)]
|
||||
struct I18nArgs {
|
||||
#[parameter(description = "Render language", arg_type = "str")]
|
||||
language: Expression,
|
||||
}
|
||||
|
||||
#[derive(Clone, ParseFilter, FilterReflection)]
|
||||
#[filter(
|
||||
name = "i18n",
|
||||
description = "Translate a render key for a content language.",
|
||||
parameters(I18nArgs),
|
||||
parsed(I18nFilter)
|
||||
)]
|
||||
struct I18n;
|
||||
|
||||
#[derive(Debug, FromFilterParameters, Display_filter)]
|
||||
#[name = "i18n"]
|
||||
struct I18nFilter {
|
||||
#[parameters]
|
||||
args: I18nArgs,
|
||||
}
|
||||
|
||||
impl Filter for I18nFilter {
|
||||
fn evaluate(&self, input: &dyn ValueView, runtime: &dyn Runtime) -> liquid_core::Result<Value> {
|
||||
let args = self.args.evaluate(runtime)?;
|
||||
let key = input.to_kstr();
|
||||
let language = args.language.to_kstr();
|
||||
Ok(Value::scalar(translate_render(language.as_str(), key.as_str())))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, FilterParameters)]
|
||||
struct MarkdownArgs {
|
||||
#[parameter(description = "Post id", arg_type = "str")]
|
||||
post_id: Option<Expression>,
|
||||
#[parameter(description = "Post data by id", arg_type = "any")]
|
||||
post_data_json_by_id: Option<Expression>,
|
||||
#[parameter(description = "Canonical post path map", arg_type = "any")]
|
||||
canonical_post_path_by_slug: Option<Expression>,
|
||||
#[parameter(description = "Canonical media path map", arg_type = "any")]
|
||||
canonical_media_path_by_source_path: Option<Expression>,
|
||||
#[parameter(description = "Render language", arg_type = "str")]
|
||||
language: Option<Expression>,
|
||||
#[parameter(description = "Language prefix", arg_type = "str")]
|
||||
language_prefix: Option<Expression>,
|
||||
}
|
||||
|
||||
#[derive(Clone, ParseFilter, FilterReflection)]
|
||||
#[filter(
|
||||
name = "markdown",
|
||||
description = "Render markdown to HTML and rewrite preview URLs.",
|
||||
parameters(MarkdownArgs),
|
||||
parsed(MarkdownFilter)
|
||||
)]
|
||||
struct Markdown;
|
||||
|
||||
#[derive(Debug, FromFilterParameters, Display_filter)]
|
||||
#[name = "markdown"]
|
||||
struct MarkdownFilter {
|
||||
#[parameters]
|
||||
args: MarkdownArgs,
|
||||
}
|
||||
|
||||
impl Filter for MarkdownFilter {
|
||||
fn evaluate(&self, input: &dyn ValueView, runtime: &dyn Runtime) -> liquid_core::Result<Value> {
|
||||
let args = self.args.evaluate(runtime)?;
|
||||
let markdown = input.to_kstr();
|
||||
let rewrite_context = HtmlRewriteContext {
|
||||
canonical_post_path_by_slug: args
|
||||
.canonical_post_path_by_slug
|
||||
.as_ref()
|
||||
.map(value_to_string_map)
|
||||
.unwrap_or_default(),
|
||||
canonical_media_path_by_source_path: args
|
||||
.canonical_media_path_by_source_path
|
||||
.as_ref()
|
||||
.map(value_to_string_map)
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
|
||||
let rendered = render_markdown_to_html(markdown.as_str());
|
||||
Ok(Value::scalar(rewrite_rendered_html_urls(&rendered, &rewrite_context)))
|
||||
}
|
||||
}
|
||||
|
||||
fn value_to_string_map(value: &impl ValueView) -> HashMap<String, String> {
|
||||
value
|
||||
.as_object()
|
||||
.map(|object| {
|
||||
object
|
||||
.iter()
|
||||
.map(|(key, value)| (key.to_string(), value.to_kstr().into_owned().to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn rewrite_rendered_html_urls(html: &str, rewrite_context: &impl RewriteContextView) -> String {
|
||||
let rewritten = rewrite_attribute_urls(html, "href", |href| normalize_preview_href(href, rewrite_context));
|
||||
rewrite_attribute_urls(&rewritten, "src", |src| normalize_preview_src(src, rewrite_context))
|
||||
}
|
||||
|
||||
pub(crate) trait RewriteContextView {
|
||||
fn canonical_post_path_by_slug(&self) -> &HashMap<String, String>;
|
||||
fn canonical_media_path_by_source_path(&self) -> &HashMap<String, String>;
|
||||
}
|
||||
|
||||
impl RewriteContextView for HtmlRewriteContext {
|
||||
fn canonical_post_path_by_slug(&self) -> &HashMap<String, String> {
|
||||
&self.canonical_post_path_by_slug
|
||||
}
|
||||
|
||||
fn canonical_media_path_by_source_path(&self) -> &HashMap<String, String> {
|
||||
&self.canonical_media_path_by_source_path
|
||||
}
|
||||
}
|
||||
|
||||
fn rewrite_attribute_urls(
|
||||
html: &str,
|
||||
attribute: &str,
|
||||
normalize: impl Fn(&str) -> String,
|
||||
) -> String {
|
||||
let mut result = String::with_capacity(html.len());
|
||||
let mut cursor = 0;
|
||||
|
||||
while let Some(offset) = html[cursor..].find(attribute) {
|
||||
let attr_start = cursor + offset;
|
||||
result.push_str(&html[cursor..attr_start]);
|
||||
result.push_str(attribute);
|
||||
|
||||
let after_attr = attr_start + attribute.len();
|
||||
if !html[after_attr..].starts_with('=') {
|
||||
cursor = after_attr;
|
||||
continue;
|
||||
}
|
||||
result.push('=');
|
||||
|
||||
let quote_index = after_attr + 1;
|
||||
let Some(quote) = html[quote_index..].chars().next() else {
|
||||
cursor = after_attr + 1;
|
||||
continue;
|
||||
};
|
||||
if quote != '\'' && quote != '"' {
|
||||
cursor = after_attr + 1;
|
||||
continue;
|
||||
}
|
||||
result.push(quote);
|
||||
|
||||
let value_start = quote_index + quote.len_utf8();
|
||||
let Some(value_end_rel) = html[value_start..].find(quote) else {
|
||||
result.push_str(&html[value_start..]);
|
||||
return result;
|
||||
};
|
||||
let value_end = value_start + value_end_rel;
|
||||
result.push_str(&normalize(&html[value_start..value_end]));
|
||||
result.push(quote);
|
||||
cursor = value_end + quote.len_utf8();
|
||||
}
|
||||
|
||||
result.push_str(&html[cursor..]);
|
||||
result
|
||||
}
|
||||
|
||||
fn normalize_preview_href(raw_href: &str, rewrite_context: &impl RewriteContextView) -> String {
|
||||
if raw_href.is_empty() || is_external_or_special_url(raw_href) {
|
||||
return raw_href.to_string();
|
||||
}
|
||||
|
||||
let (path_part, suffix) = split_path_suffix(raw_href.trim());
|
||||
if let Some(normalized) = normalize_day_route(path_part) {
|
||||
return format!("{normalized}{suffix}");
|
||||
}
|
||||
|
||||
if let Some(slug) = extract_post_slug(path_part) {
|
||||
let canonical = rewrite_context
|
||||
.canonical_post_path_by_slug()
|
||||
.get(&slug)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("/posts/{slug}"));
|
||||
return format!("{canonical}{suffix}");
|
||||
}
|
||||
|
||||
if let Some(media_source_key) = extract_media_source_key(path_part) {
|
||||
let canonical = rewrite_context
|
||||
.canonical_media_path_by_source_path()
|
||||
.get(&media_source_key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("/{media_source_key}"));
|
||||
return format!("{canonical}{suffix}");
|
||||
}
|
||||
|
||||
raw_href.to_string()
|
||||
}
|
||||
|
||||
fn normalize_preview_src(raw_src: &str, rewrite_context: &impl RewriteContextView) -> String {
|
||||
if raw_src.is_empty() || is_external_or_special_url(raw_src) {
|
||||
return raw_src.to_string();
|
||||
}
|
||||
|
||||
let (path_part, suffix) = split_path_suffix(raw_src.trim());
|
||||
if let Some(media_source_key) = extract_media_source_key(path_part) {
|
||||
let canonical = rewrite_context
|
||||
.canonical_media_path_by_source_path()
|
||||
.get(&media_source_key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("/{media_source_key}"));
|
||||
return format!("{canonical}{suffix}");
|
||||
}
|
||||
|
||||
raw_src.to_string()
|
||||
}
|
||||
|
||||
fn is_external_or_special_url(value: &str) -> bool {
|
||||
let normalized = value.trim();
|
||||
if normalized.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if normalized.starts_with('#') || normalized.starts_with("//") {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut seen_alpha = false;
|
||||
for ch in normalized.chars() {
|
||||
if ch == ':' {
|
||||
return seen_alpha;
|
||||
}
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '+' | '.' | '-') {
|
||||
seen_alpha = true;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn split_path_suffix(value: &str) -> (&str, &str) {
|
||||
let split_index = value.find(['?', '#']).unwrap_or(value.len());
|
||||
(&value[..split_index], &value[split_index..])
|
||||
}
|
||||
|
||||
fn normalize_day_route(path: &str) -> Option<String> {
|
||||
let segments: Vec<_> = path.trim_start_matches('/').split('/').collect();
|
||||
if segments.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let [year, month, day, slug] = segments.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
if year.len() != 4 || !year.chars().all(|ch| ch.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
let month = month.parse::<u32>().ok()?;
|
||||
let day = day.parse::<u32>().ok()?;
|
||||
if slug.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!("/{year}/{month:02}/{day:02}/{slug}"))
|
||||
}
|
||||
|
||||
fn extract_post_slug(path: &str) -> Option<String> {
|
||||
let trimmed = path.trim_start_matches('/');
|
||||
let segments: Vec<_> = trimmed.split('/').collect();
|
||||
match segments.as_slice() {
|
||||
["post" | "posts", slug] => Some(trim_html_suffix(slug)),
|
||||
["post" | "posts", year, month, slug] if year.len() == 4 && month.chars().all(|ch| ch.is_ascii_digit()) => {
|
||||
Some(trim_html_suffix(slug))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_media_source_key(path: &str) -> Option<String> {
|
||||
let trimmed = path.trim_start_matches('/');
|
||||
let segments: Vec<_> = trimmed.split('/').collect();
|
||||
match segments.as_slice() {
|
||||
["media", year, month, filename] if year.len() == 4 && month.len() == 2 => {
|
||||
Some(format!("media/{year}/{month}/{}", filename.to_lowercase()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_html_suffix(value: &str) -> String {
|
||||
value
|
||||
.trim_end_matches(".html")
|
||||
.trim_end_matches(".htm")
|
||||
.to_string()
|
||||
}
|
||||
474
crates/bds-core/src/render/routes.rs
Normal file
474
crates/bds-core/src/render/routes.rs
Normal file
@@ -0,0 +1,474 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{Datelike, TimeZone, Utc};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::i18n::normalize_language;
|
||||
use crate::model::{Post, ProjectMetadata};
|
||||
use crate::render::{RenderError, render_liquid_template};
|
||||
|
||||
const STARTER_SINGLE_POST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/single-post.liquid");
|
||||
const STARTER_POST_LIST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/post-list.liquid");
|
||||
const STARTER_HEAD_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/head.liquid");
|
||||
const STARTER_MENU_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/menu.liquid");
|
||||
const STARTER_LANGUAGE_SWITCHER_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid");
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RenderedPage {
|
||||
pub relative_path: String,
|
||||
pub html: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct AlternateLinkContext {
|
||||
href: String,
|
||||
hreflang: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct BlogLanguageContext {
|
||||
is_current: bool,
|
||||
code: String,
|
||||
flag: String,
|
||||
href: String,
|
||||
href_prefix: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct DayBlockContext {
|
||||
show_date_marker: bool,
|
||||
date_label: String,
|
||||
posts: Vec<serde_json::Value>,
|
||||
show_separator: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct ListTemplateContext {
|
||||
language: String,
|
||||
language_prefix: String,
|
||||
page_title: String,
|
||||
pico_stylesheet_href: Option<String>,
|
||||
html_theme_attribute: Option<String>,
|
||||
alternate_links: Vec<AlternateLinkContext>,
|
||||
blog_languages: Vec<BlogLanguageContext>,
|
||||
menu_items: Vec<serde_json::Value>,
|
||||
calendar_initial_year: i32,
|
||||
calendar_initial_month: u32,
|
||||
archive_context: Option<serde_json::Value>,
|
||||
show_archive_range_heading: bool,
|
||||
min_date: Option<serde_json::Value>,
|
||||
max_date: Option<serde_json::Value>,
|
||||
day_blocks: Vec<DayBlockContext>,
|
||||
is_list_page: bool,
|
||||
is_first_page: bool,
|
||||
is_last_page: bool,
|
||||
has_prev_page: bool,
|
||||
has_next_page: bool,
|
||||
prev_page_href: Option<String>,
|
||||
next_page_href: Option<String>,
|
||||
canonical_post_path_by_slug: HashMap<String, String>,
|
||||
canonical_media_path_by_source_path: HashMap<String, String>,
|
||||
post_data_json_by_id: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct PostTemplateContext<'a> {
|
||||
language: &'a str,
|
||||
language_prefix: String,
|
||||
page_title: &'a str,
|
||||
pico_stylesheet_href: Option<String>,
|
||||
html_theme_attribute: Option<String>,
|
||||
alternate_links: Vec<AlternateLinkContext>,
|
||||
blog_languages: Vec<BlogLanguageContext>,
|
||||
menu_items: Vec<serde_json::Value>,
|
||||
calendar_initial_year: i32,
|
||||
calendar_initial_month: u32,
|
||||
post: serde_json::Value,
|
||||
post_categories: Vec<String>,
|
||||
post_tags: Vec<String>,
|
||||
tag_color_by_name: HashMap<String, String>,
|
||||
backlinks: Vec<serde_json::Value>,
|
||||
canonical_post_path_by_slug: HashMap<String, String>,
|
||||
canonical_media_path_by_source_path: HashMap<String, String>,
|
||||
post_data_json_by_id: HashMap<String, String>,
|
||||
}
|
||||
|
||||
pub fn build_canonical_post_path(post: &Post, language: &str, main_language: &str) -> String {
|
||||
let timestamp_ms = post.published_at.unwrap_or(post.created_at);
|
||||
let Some(timestamp) = Utc.timestamp_millis_opt(timestamp_ms).single() else {
|
||||
return fallback_language_path(post, language, main_language);
|
||||
};
|
||||
|
||||
let base = format!(
|
||||
"/{:04}/{:02}/{:02}/{}",
|
||||
timestamp.year(),
|
||||
timestamp.month(),
|
||||
timestamp.day(),
|
||||
post.slug
|
||||
);
|
||||
|
||||
if language.eq_ignore_ascii_case(main_language) {
|
||||
base
|
||||
} else {
|
||||
format!("/{language}{base}")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_starter_single_post_page(
|
||||
post: &Post,
|
||||
body_markdown: &str,
|
||||
metadata: &ProjectMetadata,
|
||||
language: &str,
|
||||
) -> Result<RenderedPage, RenderError> {
|
||||
let relative_path = format!("{}/index.html", build_canonical_post_path(post, language, main_language(metadata)).trim_start_matches('/'));
|
||||
let canonical_path = build_canonical_post_path(post, language, main_language(metadata));
|
||||
let (calendar_initial_year, calendar_initial_month) = calendar_initial_parts(post);
|
||||
let context = PostTemplateContext {
|
||||
language,
|
||||
language_prefix: language_prefix(language, main_language(metadata)),
|
||||
page_title: &post.title,
|
||||
pico_stylesheet_href: metadata
|
||||
.pico_theme
|
||||
.as_ref()
|
||||
.map(|_| "/assets/pico.min.css".to_string()),
|
||||
html_theme_attribute: None,
|
||||
alternate_links: build_alternate_links(post, metadata, language),
|
||||
blog_languages: build_blog_languages(post, metadata, language),
|
||||
menu_items: vec![],
|
||||
calendar_initial_year,
|
||||
calendar_initial_month,
|
||||
post: serde_json::json!({
|
||||
"id": post.id,
|
||||
"title": post.title,
|
||||
"content": body_markdown,
|
||||
}),
|
||||
post_categories: post.categories.clone(),
|
||||
post_tags: post.tags.clone(),
|
||||
tag_color_by_name: post
|
||||
.tags
|
||||
.iter()
|
||||
.map(|tag| (tag.clone(), String::new()))
|
||||
.collect(),
|
||||
backlinks: vec![],
|
||||
canonical_post_path_by_slug: HashMap::from([(post.slug.clone(), canonical_path)]),
|
||||
canonical_media_path_by_source_path: HashMap::new(),
|
||||
post_data_json_by_id: HashMap::new(),
|
||||
};
|
||||
|
||||
let html = render_liquid_template(
|
||||
STARTER_SINGLE_POST_TEMPLATE,
|
||||
&starter_partials(),
|
||||
&context,
|
||||
)?;
|
||||
|
||||
Ok(RenderedPage { relative_path, html })
|
||||
}
|
||||
|
||||
pub fn render_starter_list_page(
|
||||
posts: &[(Post, String)],
|
||||
metadata: &ProjectMetadata,
|
||||
language: &str,
|
||||
) -> Result<RenderedPage, RenderError> {
|
||||
let relative_path = if language.eq_ignore_ascii_case(main_language(metadata)) {
|
||||
"index.html".to_string()
|
||||
} else {
|
||||
format!("{language}/index.html")
|
||||
};
|
||||
|
||||
let canonical_paths = posts
|
||||
.iter()
|
||||
.map(|(post, _)| {
|
||||
(
|
||||
post.slug.clone(),
|
||||
build_canonical_post_path(post, language, main_language(metadata)),
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let (calendar_initial_year, calendar_initial_month) = posts
|
||||
.first()
|
||||
.map(|(post, _)| calendar_initial_parts(post))
|
||||
.unwrap_or((1970, 1));
|
||||
|
||||
let context = ListTemplateContext {
|
||||
language: language.to_string(),
|
||||
language_prefix: language_prefix(language, main_language(metadata)),
|
||||
page_title: metadata.name.clone(),
|
||||
pico_stylesheet_href: metadata
|
||||
.pico_theme
|
||||
.as_ref()
|
||||
.map(|_| "/assets/pico.min.css".to_string()),
|
||||
html_theme_attribute: None,
|
||||
alternate_links: vec![],
|
||||
blog_languages: build_blog_languages_for_index(metadata, language),
|
||||
menu_items: vec![],
|
||||
calendar_initial_year,
|
||||
calendar_initial_month,
|
||||
archive_context: None,
|
||||
show_archive_range_heading: false,
|
||||
min_date: None,
|
||||
max_date: None,
|
||||
day_blocks: build_day_blocks(posts),
|
||||
is_list_page: false,
|
||||
is_first_page: true,
|
||||
is_last_page: true,
|
||||
has_prev_page: false,
|
||||
has_next_page: false,
|
||||
prev_page_href: None,
|
||||
next_page_href: None,
|
||||
canonical_post_path_by_slug: canonical_paths,
|
||||
canonical_media_path_by_source_path: HashMap::new(),
|
||||
post_data_json_by_id: HashMap::new(),
|
||||
};
|
||||
|
||||
let html = render_liquid_template(
|
||||
&starter_post_list_template(),
|
||||
&starter_partials(),
|
||||
&context,
|
||||
)?;
|
||||
|
||||
Ok(RenderedPage { relative_path, html })
|
||||
}
|
||||
|
||||
fn starter_partials() -> HashMap<String, String> {
|
||||
HashMap::from([
|
||||
("partials/head".to_string(), STARTER_HEAD_PARTIAL.to_string()),
|
||||
("partials/menu".to_string(), STARTER_MENU_PARTIAL.to_string()),
|
||||
(
|
||||
"partials/language-switcher".to_string(),
|
||||
STARTER_LANGUAGE_SWITCHER_PARTIAL.to_string(),
|
||||
),
|
||||
(
|
||||
"partials/menu-items".to_string(),
|
||||
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}".to_string(),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
fn starter_post_list_template() -> String {
|
||||
STARTER_POST_LIST_TEMPLATE.replace(
|
||||
"{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, language_prefix: language_prefix %}",
|
||||
"{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, language_prefix: language_prefix, alternate_links: alternate_links %}",
|
||||
)
|
||||
}
|
||||
|
||||
fn main_language(metadata: &ProjectMetadata) -> &str {
|
||||
metadata.main_language.as_deref().unwrap_or("en")
|
||||
}
|
||||
|
||||
fn language_prefix(language: &str, main_language: &str) -> String {
|
||||
if language.eq_ignore_ascii_case(main_language) {
|
||||
String::new()
|
||||
} else {
|
||||
format!("/{language}")
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_language_path(post: &Post, language: &str, main_language: &str) -> String {
|
||||
if language.eq_ignore_ascii_case(main_language) {
|
||||
format!("/posts/{}", post.slug)
|
||||
} else {
|
||||
format!("/{language}/posts/{}", post.slug)
|
||||
}
|
||||
}
|
||||
|
||||
fn calendar_initial_parts(post: &Post) -> (i32, u32) {
|
||||
let timestamp_ms = post.published_at.unwrap_or(post.created_at);
|
||||
Utc.timestamp_millis_opt(timestamp_ms)
|
||||
.single()
|
||||
.map(|timestamp| (timestamp.year(), timestamp.month()))
|
||||
.unwrap_or((1970, 1))
|
||||
}
|
||||
|
||||
fn build_alternate_links(post: &Post, metadata: &ProjectMetadata, current_language: &str) -> Vec<AlternateLinkContext> {
|
||||
metadata
|
||||
.blog_languages
|
||||
.iter()
|
||||
.map(|language| AlternateLinkContext {
|
||||
href: build_absolute_post_url(post, metadata, language),
|
||||
hreflang: language.clone(),
|
||||
})
|
||||
.chain(std::iter::once(AlternateLinkContext {
|
||||
href: build_absolute_post_url(post, metadata, current_language),
|
||||
hreflang: "x-default".to_string(),
|
||||
}))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_blog_languages(post: &Post, metadata: &ProjectMetadata, current_language: &str) -> Vec<BlogLanguageContext> {
|
||||
metadata
|
||||
.blog_languages
|
||||
.iter()
|
||||
.map(|language| BlogLanguageContext {
|
||||
is_current: language.eq_ignore_ascii_case(current_language),
|
||||
code: language.clone(),
|
||||
flag: render_flag(language),
|
||||
href: build_absolute_post_url(post, metadata, language),
|
||||
href_prefix: language_prefix(language, main_language(metadata)),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_blog_languages_for_index(metadata: &ProjectMetadata, current_language: &str) -> Vec<BlogLanguageContext> {
|
||||
metadata
|
||||
.blog_languages
|
||||
.iter()
|
||||
.map(|language| BlogLanguageContext {
|
||||
is_current: language.eq_ignore_ascii_case(current_language),
|
||||
code: language.clone(),
|
||||
flag: render_flag(language),
|
||||
href: build_absolute_index_url(metadata, language),
|
||||
href_prefix: language_prefix(language, main_language(metadata)),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_absolute_post_url(post: &Post, metadata: &ProjectMetadata, language: &str) -> String {
|
||||
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
|
||||
format!("{base_url}{}", build_canonical_post_path(post, language, main_language(metadata)))
|
||||
}
|
||||
|
||||
fn build_absolute_index_url(metadata: &ProjectMetadata, language: &str) -> String {
|
||||
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
|
||||
let suffix = if language.eq_ignore_ascii_case(main_language(metadata)) {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("/{language}/")
|
||||
};
|
||||
format!("{base_url}{suffix}")
|
||||
}
|
||||
|
||||
fn render_flag(language: &str) -> String {
|
||||
normalize_language(language).flag_emoji().to_string()
|
||||
}
|
||||
|
||||
fn build_day_blocks(posts: &[(Post, String)]) -> Vec<DayBlockContext> {
|
||||
let mut blocks: Vec<DayBlockContext> = Vec::new();
|
||||
let mut current_key: Option<String> = None;
|
||||
|
||||
for (post, body) in posts {
|
||||
let timestamp_ms = post.published_at.unwrap_or(post.created_at);
|
||||
let Some(timestamp) = Utc.timestamp_millis_opt(timestamp_ms).single() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let key = format!("{:04}-{:02}-{:02}", timestamp.year(), timestamp.month(), timestamp.day());
|
||||
if current_key.as_deref() != Some(key.as_str()) {
|
||||
if let Some(last) = blocks.last_mut() {
|
||||
last.show_separator = true;
|
||||
}
|
||||
current_key = Some(key);
|
||||
blocks.push(DayBlockContext {
|
||||
show_date_marker: true,
|
||||
date_label: format!("{:02}.{:02}.{:04}", timestamp.day(), timestamp.month(), timestamp.year()),
|
||||
posts: Vec::new(),
|
||||
show_separator: false,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(block) = blocks.last_mut() {
|
||||
block.posts.push(serde_json::json!({
|
||||
"id": post.id,
|
||||
"slug": post.slug,
|
||||
"title": post.title,
|
||||
"content": post.excerpt.clone().unwrap_or_else(|| body.clone()),
|
||||
"show_title": true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
blocks
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::PostStatus;
|
||||
|
||||
fn make_post() -> Post {
|
||||
Post {
|
||||
id: "post-1".into(),
|
||||
project_id: "project-1".into(),
|
||||
title: "Hello".into(),
|
||||
slug: "hello".into(),
|
||||
excerpt: None,
|
||||
content: Some("Body".into()),
|
||||
status: PostStatus::Published,
|
||||
author: None,
|
||||
language: Some("en".into()),
|
||||
do_not_translate: false,
|
||||
template_slug: None,
|
||||
file_path: String::new(),
|
||||
checksum: None,
|
||||
tags: vec![],
|
||||
categories: vec![],
|
||||
published_title: None,
|
||||
published_content: None,
|
||||
published_tags: None,
|
||||
published_categories: None,
|
||||
published_excerpt: None,
|
||||
created_at: 1_710_000_000_000,
|
||||
updated_at: 1_710_000_000_000,
|
||||
published_at: Some(1_710_000_000_000),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_metadata() -> ProjectMetadata {
|
||||
ProjectMetadata {
|
||||
name: "Blog".into(),
|
||||
description: None,
|
||||
public_url: Some("https://example.com".into()),
|
||||
main_language: Some("en".into()),
|
||||
default_author: None,
|
||||
max_posts_per_page: 50,
|
||||
blogmark_category: None,
|
||||
pico_theme: None,
|
||||
semantic_similarity_enabled: false,
|
||||
blog_languages: vec!["en".into(), "de".into()],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_post_paths_follow_language_prefix_rule() {
|
||||
let post = make_post();
|
||||
assert_eq!(build_canonical_post_path(&post, "en", "en"), "/2024/03/09/hello");
|
||||
assert_eq!(build_canonical_post_path(&post, "de", "en"), "/de/2024/03/09/hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starter_single_post_renderer_uses_canonical_route_and_language_links() {
|
||||
let post = make_post();
|
||||
let metadata = make_metadata();
|
||||
let rendered = render_starter_single_post_page(&post, "Body with [link](/posts/hello)", &metadata, "en").unwrap();
|
||||
|
||||
assert_eq!(rendered.relative_path, "2024/03/09/hello/index.html");
|
||||
assert!(rendered.html.contains("https://example.com/2024/03/09/hello"));
|
||||
assert!(rendered.html.contains("https://example.com/de/2024/03/09/hello"));
|
||||
assert!(rendered.html.contains("href=\"/2024/03/09/hello\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starter_list_renderer_groups_posts_and_uses_language_specific_index_path() {
|
||||
let metadata = make_metadata();
|
||||
let first = make_post();
|
||||
let mut second = make_post();
|
||||
second.id = "post-2".into();
|
||||
second.slug = "next".into();
|
||||
second.title = "Next".into();
|
||||
second.published_at = Some(1_710_086_400_000);
|
||||
second.created_at = 1_710_086_400_000;
|
||||
|
||||
let rendered = render_starter_list_page(
|
||||
&[(first, "First body".into()), (second, "Second body".into())],
|
||||
&metadata,
|
||||
"de",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rendered.relative_path, "de/index.html");
|
||||
assert!(rendered.html.contains("archive-day-group"));
|
||||
assert!(rendered.html.contains("09.03.2024"));
|
||||
assert!(rendered.html.contains("10.03.2024"));
|
||||
assert!(rendered.html.contains("href=\"/de/2024/03/10/next\""));
|
||||
}
|
||||
}
|
||||
75
crates/bds-core/src/render/template_lookup.rs
Normal file
75
crates/bds-core/src/render/template_lookup.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::model::{Post, Tag, Template, TemplateKind};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RenderCategorySettings {
|
||||
pub post_template_slug: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderTemplateLookup<'a> {
|
||||
pub post: &'a Post,
|
||||
pub templates: &'a [Template],
|
||||
pub tags: &'a [Tag],
|
||||
pub category_settings: &'a HashMap<String, RenderCategorySettings>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TemplateLookupError {
|
||||
MissingExplicitTemplate(String),
|
||||
MissingDefaultTemplate,
|
||||
}
|
||||
|
||||
pub fn resolve_post_template<'a>(lookup: RenderTemplateLookup<'a>) -> Result<&'a Template, TemplateLookupError> {
|
||||
if let Some(explicit_slug) = lookup.post.template_slug.as_deref() {
|
||||
return lookup
|
||||
.templates
|
||||
.iter()
|
||||
.find(|template| is_enabled_post_template(template, explicit_slug))
|
||||
.ok_or_else(|| TemplateLookupError::MissingExplicitTemplate(explicit_slug.to_string()));
|
||||
}
|
||||
|
||||
for post_tag in &lookup.post.tags {
|
||||
if let Some(template_slug) = lookup
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.name.eq_ignore_ascii_case(post_tag))
|
||||
.and_then(|tag| tag.post_template_slug.as_deref())
|
||||
{
|
||||
if let Some(template) = lookup
|
||||
.templates
|
||||
.iter()
|
||||
.find(|template| is_enabled_post_template(template, template_slug))
|
||||
{
|
||||
return Ok(template);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for category_name in &lookup.post.categories {
|
||||
if let Some(template_slug) = lookup
|
||||
.category_settings
|
||||
.get(category_name)
|
||||
.and_then(|settings| settings.post_template_slug.as_deref())
|
||||
{
|
||||
if let Some(template) = lookup
|
||||
.templates
|
||||
.iter()
|
||||
.find(|template| is_enabled_post_template(template, template_slug))
|
||||
{
|
||||
return Ok(template);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lookup
|
||||
.templates
|
||||
.iter()
|
||||
.find(|template| is_enabled_post_template(template, "post"))
|
||||
.ok_or(TemplateLookupError::MissingDefaultTemplate)
|
||||
}
|
||||
|
||||
fn is_enabled_post_template(template: &Template, slug: &str) -> bool {
|
||||
template.enabled && template.kind == TemplateKind::Post && template.slug == slug
|
||||
}
|
||||
128
crates/bds-core/tests/m4_generation_engine.rs
Normal file
128
crates/bds-core/tests/m4_generation_engine.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use bds_core::db::queries::project::insert_project;
|
||||
use bds_core::db::Database;
|
||||
use bds_core::engine::generation::{PublishedPostSource, generate_starter_site};
|
||||
use bds_core::model::{Post, PostStatus, Project, ProjectMetadata};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_project() -> Project {
|
||||
Project {
|
||||
id: "p1".into(),
|
||||
name: "Blog".into(),
|
||||
slug: "blog".into(),
|
||||
description: None,
|
||||
data_path: None,
|
||||
is_active: false,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_metadata() -> ProjectMetadata {
|
||||
ProjectMetadata {
|
||||
name: "Blog".into(),
|
||||
description: Some("desc".into()),
|
||||
public_url: Some("https://example.com".into()),
|
||||
main_language: Some("en".into()),
|
||||
default_author: None,
|
||||
max_posts_per_page: 50,
|
||||
blogmark_category: None,
|
||||
pico_theme: None,
|
||||
semantic_similarity_enabled: false,
|
||||
blog_languages: vec!["en".into()],
|
||||
}
|
||||
}
|
||||
|
||||
fn make_post(slug: &str, published_at: i64) -> Post {
|
||||
Post {
|
||||
id: format!("post-{slug}"),
|
||||
project_id: "p1".into(),
|
||||
title: slug.into(),
|
||||
slug: slug.into(),
|
||||
excerpt: None,
|
||||
content: Some("Body".into()),
|
||||
status: PostStatus::Published,
|
||||
author: Some("alice".into()),
|
||||
language: Some("en".into()),
|
||||
do_not_translate: false,
|
||||
template_slug: None,
|
||||
file_path: String::new(),
|
||||
checksum: None,
|
||||
tags: vec!["rust".into()],
|
||||
categories: vec!["article".into()],
|
||||
published_title: None,
|
||||
published_content: None,
|
||||
published_tags: None,
|
||||
published_categories: None,
|
||||
published_excerpt: None,
|
||||
created_at: published_at,
|
||||
updated_at: published_at,
|
||||
published_at: Some(published_at),
|
||||
}
|
||||
}
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_project()).unwrap();
|
||||
(db, TempDir::new().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_engine_writes_core_and_single_post_artifacts() {
|
||||
let (db, dir) = setup();
|
||||
let metadata = make_metadata();
|
||||
let posts = vec![
|
||||
PublishedPostSource {
|
||||
post: make_post("hello", 1_710_000_000_000),
|
||||
body_markdown: "Hello **world**".into(),
|
||||
},
|
||||
PublishedPostSource {
|
||||
post: make_post("next", 1_710_086_400_000),
|
||||
body_markdown: "Next post".into(),
|
||||
},
|
||||
];
|
||||
|
||||
let report = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
|
||||
|
||||
assert!(report.written_paths.contains(&"index.html".to_string()));
|
||||
assert!(report.written_paths.contains(&"calendar.json".to_string()));
|
||||
assert!(report.written_paths.contains(&"rss.xml".to_string()));
|
||||
assert!(report.written_paths.contains(&"feed.xml".to_string()));
|
||||
assert!(report.written_paths.contains(&"atom.xml".to_string()));
|
||||
assert!(report.written_paths.contains(&"sitemap.xml".to_string()));
|
||||
assert!(report.written_paths.contains(&"2024/03/09/hello/index.html".to_string()));
|
||||
assert!(report.written_paths.contains(&"2024/03/10/next/index.html".to_string()));
|
||||
|
||||
assert!(dir.path().join("index.html").exists());
|
||||
assert!(dir.path().join("rss.xml").exists());
|
||||
assert!(dir.path().join("feed.xml").exists());
|
||||
assert!(dir.path().join("atom.xml").exists());
|
||||
assert!(dir.path().join("sitemap.xml").exists());
|
||||
assert!(dir.path().join("2024/03/09/hello/index.html").exists());
|
||||
|
||||
let rss = std::fs::read_to_string(dir.path().join("rss.xml")).unwrap();
|
||||
assert!(rss.contains("<rss version=\"2.0\""));
|
||||
assert!(rss.contains("https://example.com/2024/03/09/hello"));
|
||||
|
||||
let sitemap = std::fs::read_to_string(dir.path().join("sitemap.xml")).unwrap();
|
||||
assert!(sitemap.contains("https://example.com/2024/03/09/hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_engine_skips_unchanged_outputs_on_second_run() {
|
||||
let (db, dir) = setup();
|
||||
let metadata = make_metadata();
|
||||
let posts = vec![PublishedPostSource {
|
||||
post: make_post("hello", 1_710_000_000_000),
|
||||
body_markdown: "Hello **world**".into(),
|
||||
}];
|
||||
|
||||
let first = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
|
||||
let second = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
|
||||
|
||||
assert!(!first.written_paths.is_empty());
|
||||
assert!(second.skipped_paths.contains(&"index.html".to_string()));
|
||||
assert!(second.skipped_paths.contains(&"calendar.json".to_string()));
|
||||
assert!(second.skipped_paths.contains(&"rss.xml".to_string()));
|
||||
assert!(second.skipped_paths.contains(&"feed.xml".to_string()));
|
||||
}
|
||||
108
crates/bds-core/tests/m4_generation_primitives.rs
Normal file
108
crates/bds-core/tests/m4_generation_primitives.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use std::fs;
|
||||
|
||||
use bds_core::db::queries::generated_file_hash::get_generated_file_hash;
|
||||
use bds_core::db::queries::project::insert_project;
|
||||
use bds_core::db::Database;
|
||||
use bds_core::model::{Post, PostStatus, Project};
|
||||
use bds_core::render::{
|
||||
GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths,
|
||||
write_generated_file,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_project() -> Project {
|
||||
Project {
|
||||
id: "p1".into(),
|
||||
name: "Blog".into(),
|
||||
slug: "blog".into(),
|
||||
description: None,
|
||||
data_path: None,
|
||||
is_active: false,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_post(slug: &str, published_at: i64) -> Post {
|
||||
Post {
|
||||
id: format!("post-{slug}"),
|
||||
project_id: "p1".into(),
|
||||
title: slug.into(),
|
||||
slug: slug.into(),
|
||||
excerpt: None,
|
||||
content: Some("Body".into()),
|
||||
status: PostStatus::Published,
|
||||
author: None,
|
||||
language: Some("en".into()),
|
||||
do_not_translate: false,
|
||||
template_slug: None,
|
||||
file_path: String::new(),
|
||||
checksum: None,
|
||||
tags: vec![],
|
||||
categories: vec![],
|
||||
published_title: None,
|
||||
published_content: None,
|
||||
published_tags: None,
|
||||
published_categories: None,
|
||||
published_excerpt: None,
|
||||
created_at: published_at,
|
||||
updated_at: published_at,
|
||||
published_at: Some(published_at),
|
||||
}
|
||||
}
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_project()).unwrap();
|
||||
(db, TempDir::new().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_write_skips_unchanged_content() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
let first = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap();
|
||||
let second = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap();
|
||||
let third = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "changed").unwrap();
|
||||
|
||||
assert_eq!(first, GeneratedWriteOutcome::Written);
|
||||
assert_eq!(second, GeneratedWriteOutcome::SkippedUnchanged);
|
||||
assert_eq!(third, GeneratedWriteOutcome::Written);
|
||||
|
||||
let stored = get_generated_file_hash(db.conn(), "p1", "index.html").unwrap();
|
||||
assert_eq!(stored.relative_path, "index.html");
|
||||
assert!(fs::read_to_string(dir.path().join("index.html")).unwrap().contains("changed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_generation_paths_include_language_prefixed_variants() {
|
||||
let paths = build_core_generation_paths("en", &["en".into(), "de".into(), "fr".into()]);
|
||||
assert!(paths.contains(&"index.html".to_string()));
|
||||
assert!(paths.contains(&"sitemap.xml".to_string()));
|
||||
assert!(paths.contains(&"feed.xml".to_string()));
|
||||
assert!(paths.contains(&"atom.xml".to_string()));
|
||||
assert!(paths.contains(&"calendar.json".to_string()));
|
||||
assert!(paths.contains(&"de/index.html".to_string()));
|
||||
assert!(paths.contains(&"de/feed.xml".to_string()));
|
||||
assert!(paths.contains(&"de/atom.xml".to_string()));
|
||||
assert!(paths.contains(&"fr/index.html".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_json_groups_posts_by_year_month_day() {
|
||||
let posts = vec![
|
||||
make_post("a", 1_710_000_000_000),
|
||||
make_post("b", 1_710_000_000_000),
|
||||
make_post("c", 1_712_678_400_000),
|
||||
];
|
||||
|
||||
let json = build_calendar_json(&posts).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(parsed["years"]["2024"], 3);
|
||||
assert_eq!(parsed["months"]["2024-03"], 2);
|
||||
assert_eq!(parsed["months"]["2024-04"], 1);
|
||||
assert_eq!(parsed["days"]["2024-03-09"], 2);
|
||||
assert_eq!(parsed["days"]["2024-04-09"], 1);
|
||||
}
|
||||
117
crates/bds-core/tests/m4_page_renderer.rs
Normal file
117
crates/bds-core/tests/m4_page_renderer.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use bds_core::render::render_liquid_template;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SinglePostContext {
|
||||
language: String,
|
||||
language_prefix: String,
|
||||
page_title: String,
|
||||
pico_stylesheet_href: Option<String>,
|
||||
html_theme_attribute: Option<String>,
|
||||
alternate_links: Vec<serde_json::Value>,
|
||||
blog_languages: Vec<serde_json::Value>,
|
||||
menu_items: Vec<serde_json::Value>,
|
||||
calendar_initial_year: i32,
|
||||
calendar_initial_month: i32,
|
||||
post: serde_json::Value,
|
||||
post_categories: Vec<String>,
|
||||
post_tags: Vec<String>,
|
||||
tag_color_by_name: HashMap<String, String>,
|
||||
backlinks: Vec<serde_json::Value>,
|
||||
canonical_post_path_by_slug: HashMap<String, String>,
|
||||
canonical_media_path_by_source_path: HashMap<String, String>,
|
||||
post_data_json_by_id: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i18n_filter_renders_partial_content_language() {
|
||||
let template = "{% render 'partials/label', label: 'render.archive', language: language %}";
|
||||
let partials = HashMap::from([(
|
||||
"partials/label".to_string(),
|
||||
"{{ label | i18n: language }}".to_string(),
|
||||
)]);
|
||||
let context = serde_json::json!({ "language": "de" });
|
||||
|
||||
let rendered = render_liquid_template(template, &partials, &context).unwrap();
|
||||
assert_eq!(rendered, "Archiv");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_filter_rewrites_post_and_media_urls() {
|
||||
let template = "{{ body | markdown: nil, nil, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}";
|
||||
let partials = HashMap::new();
|
||||
let context = serde_json::json!({
|
||||
"body": "[Post](/posts/hello) ",
|
||||
"canonical_post_path_by_slug": {"hello": "/2024/03/09/hello"},
|
||||
"canonical_media_path_by_source_path": {"media/2024/03/pic.png": "/assets/pic.png"},
|
||||
"language": "en",
|
||||
"language_prefix": ""
|
||||
});
|
||||
|
||||
let rendered = render_liquid_template(template, &partials, &context).unwrap();
|
||||
assert!(rendered.contains("href=\"/2024/03/09/hello\""));
|
||||
assert!(rendered.contains("src=\"/assets/pic.png\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starter_single_post_template_renders_with_partials() {
|
||||
let template = include_str!("../../../assets/starter-templates/single-post.liquid");
|
||||
let partials = HashMap::from([
|
||||
(
|
||||
"partials/head".to_string(),
|
||||
include_str!("../../../assets/starter-templates/partials/head.liquid").to_string(),
|
||||
),
|
||||
(
|
||||
"partials/menu".to_string(),
|
||||
include_str!("../../../assets/starter-templates/partials/menu.liquid").to_string(),
|
||||
),
|
||||
(
|
||||
"partials/language-switcher".to_string(),
|
||||
include_str!("../../../assets/starter-templates/partials/language-switcher.liquid").to_string(),
|
||||
),
|
||||
(
|
||||
"partials/menu-items".to_string(),
|
||||
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}".to_string(),
|
||||
),
|
||||
]);
|
||||
|
||||
let context = SinglePostContext {
|
||||
language: "en".into(),
|
||||
language_prefix: String::new(),
|
||||
page_title: "Hello".into(),
|
||||
pico_stylesheet_href: None,
|
||||
html_theme_attribute: None,
|
||||
alternate_links: vec![],
|
||||
blog_languages: vec![serde_json::json!({
|
||||
"is_current": true,
|
||||
"code": "en",
|
||||
"flag": "GB",
|
||||
"href": "/",
|
||||
"href_prefix": ""
|
||||
})],
|
||||
menu_items: vec![],
|
||||
calendar_initial_year: 2024,
|
||||
calendar_initial_month: 3,
|
||||
post: serde_json::json!({
|
||||
"id": "post-1",
|
||||
"title": "Hello",
|
||||
"content": "A **world** post with [link](/posts/hello).",
|
||||
}),
|
||||
post_categories: vec![],
|
||||
post_tags: vec![],
|
||||
tag_color_by_name: HashMap::new(),
|
||||
backlinks: vec![],
|
||||
canonical_post_path_by_slug: HashMap::from([("hello".into(), "/2024/03/09/hello".into())]),
|
||||
canonical_media_path_by_source_path: HashMap::new(),
|
||||
post_data_json_by_id: HashMap::new(),
|
||||
};
|
||||
|
||||
let rendered = render_liquid_template(template, &partials, &context).unwrap();
|
||||
assert!(rendered.contains("<h1>Hello</h1>"));
|
||||
assert!(rendered.contains("<strong>world</strong>"));
|
||||
assert!(rendered.contains("href=\"/2024/03/09/hello\""));
|
||||
assert!(rendered.contains("data-pagefind-body"));
|
||||
}
|
||||
174
crates/bds-core/tests/m4_render.rs
Normal file
174
crates/bds-core/tests/m4_render.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bds_core::model::{Post, PostStatus, Tag, Template, TemplateKind, TemplateStatus};
|
||||
use bds_core::render::{
|
||||
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError,
|
||||
render_markdown_to_html, resolve_post_template,
|
||||
};
|
||||
|
||||
fn make_post() -> Post {
|
||||
Post {
|
||||
id: "post-1".into(),
|
||||
project_id: "project-1".into(),
|
||||
title: "Post".into(),
|
||||
slug: "post".into(),
|
||||
excerpt: None,
|
||||
content: Some("# Hello".into()),
|
||||
status: PostStatus::Published,
|
||||
author: None,
|
||||
language: Some("en".into()),
|
||||
do_not_translate: false,
|
||||
template_slug: None,
|
||||
file_path: "posts/2026/04/10/post.md".into(),
|
||||
checksum: None,
|
||||
tags: vec![],
|
||||
categories: vec![],
|
||||
published_title: None,
|
||||
published_content: None,
|
||||
published_tags: None,
|
||||
published_categories: None,
|
||||
published_excerpt: None,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
published_at: Some(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_template(slug: &str) -> Template {
|
||||
Template {
|
||||
id: format!("template-{slug}"),
|
||||
project_id: "project-1".into(),
|
||||
slug: slug.into(),
|
||||
title: slug.into(),
|
||||
kind: TemplateKind::Post,
|
||||
enabled: true,
|
||||
version: 1,
|
||||
file_path: format!("templates/{slug}.liquid"),
|
||||
status: TemplateStatus::Published,
|
||||
content: Some(format!("template:{slug}")),
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_tag(name: &str, post_template_slug: Option<&str>) -> Tag {
|
||||
Tag {
|
||||
id: format!("tag-{name}"),
|
||||
project_id: "project-1".into(),
|
||||
name: name.into(),
|
||||
color: None,
|
||||
post_template_slug: post_template_slug.map(ToOwned::to_owned),
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_lookup_prefers_post_specific_template() {
|
||||
let mut post = make_post();
|
||||
post.template_slug = Some("custom".into());
|
||||
post.tags = vec!["rust".into()];
|
||||
post.categories = vec!["article".into()];
|
||||
|
||||
let templates = vec![make_template("post"), make_template("tag-template"), make_template("custom")];
|
||||
let tags = vec![make_tag("rust", Some("tag-template"))];
|
||||
let mut categories = HashMap::new();
|
||||
categories.insert(
|
||||
"article".into(),
|
||||
RenderCategorySettings {
|
||||
post_template_slug: Some("category-template".into()),
|
||||
},
|
||||
);
|
||||
|
||||
let resolved = resolve_post_template(RenderTemplateLookup {
|
||||
post: &post,
|
||||
templates: &templates,
|
||||
tags: &tags,
|
||||
category_settings: &categories,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolved.slug, "custom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_lookup_falls_back_to_tag_then_category_then_default() {
|
||||
let mut post = make_post();
|
||||
post.tags = vec!["rust".into()];
|
||||
post.categories = vec!["article".into()];
|
||||
|
||||
let templates = vec![
|
||||
make_template("post"),
|
||||
make_template("tag-template"),
|
||||
make_template("category-template"),
|
||||
];
|
||||
let tags = vec![make_tag("rust", Some("tag-template"))];
|
||||
let mut categories = HashMap::new();
|
||||
categories.insert(
|
||||
"article".into(),
|
||||
RenderCategorySettings {
|
||||
post_template_slug: Some("category-template".into()),
|
||||
},
|
||||
);
|
||||
|
||||
let resolved = resolve_post_template(RenderTemplateLookup {
|
||||
post: &post,
|
||||
templates: &templates,
|
||||
tags: &tags,
|
||||
category_settings: &categories,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(resolved.slug, "tag-template");
|
||||
|
||||
let category_post = Post {
|
||||
tags: vec![],
|
||||
..post.clone()
|
||||
};
|
||||
let resolved = resolve_post_template(RenderTemplateLookup {
|
||||
post: &category_post,
|
||||
templates: &templates,
|
||||
tags: &tags,
|
||||
category_settings: &categories,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(resolved.slug, "category-template");
|
||||
|
||||
let default_post = Post {
|
||||
tags: vec![],
|
||||
categories: vec![],
|
||||
..post
|
||||
};
|
||||
let empty_categories = HashMap::new();
|
||||
let resolved = resolve_post_template(RenderTemplateLookup {
|
||||
post: &default_post,
|
||||
templates: &templates,
|
||||
tags: &tags,
|
||||
category_settings: &empty_categories,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(resolved.slug, "post");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_lookup_errors_when_explicit_template_missing() {
|
||||
let mut post = make_post();
|
||||
post.template_slug = Some("missing".into());
|
||||
let templates = vec![make_template("post")];
|
||||
|
||||
let err = resolve_post_template(RenderTemplateLookup {
|
||||
post: &post,
|
||||
templates: &templates,
|
||||
tags: &[],
|
||||
category_settings: &HashMap::new(),
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err, TemplateLookupError::MissingExplicitTemplate("missing".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_render_produces_html() {
|
||||
let html = render_markdown_to_html("# Hello\n\nA paragraph with **bold** text.");
|
||||
assert!(html.contains("<h1>Hello</h1>"));
|
||||
assert!(html.contains("<strong>bold</strong>"));
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use iced::{Element, Subscription, Task};
|
||||
use bds_core::db::Database;
|
||||
use bds_core::engine::task::{TaskId, TaskManager, TaskStatus};
|
||||
use bds_core::engine;
|
||||
use bds_core::engine::ai::{self, AiEndpointConfig, AiEndpointKind};
|
||||
use bds_core::i18n::{detect_os_locale, UiLocale};
|
||||
use bds_core::model::{Media, Post, PostStatus, Project, PublishingPreferences, Script, SshMode, Template};
|
||||
|
||||
@@ -28,7 +29,7 @@ use crate::views::{
|
||||
template_editor::{TemplateEditorState, TemplateEditorMsg},
|
||||
script_editor::{ScriptEditorState, ScriptEditorMsg},
|
||||
tags_view::{self, TagsMsg, TagsSection, TagsViewState},
|
||||
settings_view::{default_category_rows, SettingsCategoryRow, SettingsViewState, SettingsMsg},
|
||||
settings_view::{default_category_rows, AiModelOption, SettingsCategoryRow, SettingsViewState, SettingsMsg},
|
||||
dashboard::{DashboardCategory, DashboardRecentPost, DashboardState, DashboardStats, DashboardTag, DashboardTimelineMonth},
|
||||
};
|
||||
|
||||
@@ -257,6 +258,15 @@ fn persist_media_editor_state_impl(
|
||||
}
|
||||
}
|
||||
|
||||
fn load_generation_post_body(data_dir: &Path, post: &Post) -> Result<String, String> {
|
||||
if let Some(content) = &post.content {
|
||||
return Ok(content.clone());
|
||||
}
|
||||
let raw = std::fs::read_to_string(data_dir.join(&post.file_path)).map_err(|e| e.to_string())?;
|
||||
let (_frontmatter, body) = bds_core::util::frontmatter::read_post_file(&raw)?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
fn save_template_editor_state_impl(
|
||||
db: &Database,
|
||||
project_id: &str,
|
||||
@@ -1766,11 +1776,47 @@ impl BdsApp {
|
||||
"engine.generateSiteStarted",
|
||||
|db_path, project_id, data_dir, tm, tid| {
|
||||
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
||||
tm.report_progress(tid, Some(0.20), Some("Generating calendar...".into()));
|
||||
engine::calendar::regenerate_calendar(db.conn(), &data_dir, &project_id)
|
||||
let metadata = engine::meta::read_project_json(&data_dir).map_err(|e| e.to_string())?;
|
||||
if metadata.public_url.as_deref().unwrap_or("").trim().is_empty() {
|
||||
return Err("public URL is required before generating the site".to_string());
|
||||
}
|
||||
let main_language = metadata.main_language.clone().unwrap_or_else(|| "en".to_string());
|
||||
let all_posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
tm.report_progress(tid, Some(0.90), Some("Calendar written".into()));
|
||||
Ok("done".to_string())
|
||||
let published_posts = all_posts
|
||||
.into_iter()
|
||||
.filter(|post| post.status == PostStatus::Published)
|
||||
.collect::<Vec<_>>();
|
||||
let total = published_posts.len().max(1) as f32;
|
||||
let mut sources = Vec::new();
|
||||
for (index, post) in published_posts.into_iter().enumerate() {
|
||||
tm.report_progress(
|
||||
tid,
|
||||
Some(((index as f32) / total) * 0.7),
|
||||
Some(format!("Rendering {}", post.slug)),
|
||||
);
|
||||
let body_markdown = load_generation_post_body(&data_dir, &post)?;
|
||||
sources.push(engine::generation::PublishedPostSource { post, body_markdown });
|
||||
}
|
||||
let output_dir = data_dir.join("html");
|
||||
std::fs::create_dir_all(&output_dir).map_err(|e| e.to_string())?;
|
||||
tm.report_progress(tid, Some(0.85), Some("Writing generated files".into()));
|
||||
let report = engine::generation::generate_starter_site(
|
||||
db.conn(),
|
||||
&output_dir,
|
||||
&project_id,
|
||||
&metadata,
|
||||
&sources,
|
||||
&main_language,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
tm.report_progress(tid, Some(1.0), Some("Site generation complete".into()));
|
||||
Ok(format!(
|
||||
"written={}, skipped={}, output={}",
|
||||
report.written_paths.len(),
|
||||
report.skipped_paths.len(),
|
||||
output_dir.display(),
|
||||
))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -4526,6 +4572,30 @@ impl BdsApp {
|
||||
if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "ai.system_prompt") {
|
||||
state.system_prompt = iced::widget::text_editor::Content::with_text(&setting.value);
|
||||
}
|
||||
if let Ok(ai_settings) = ai::load_ai_settings(db.conn(), self.offline_mode) {
|
||||
state.online_endpoint_url = ai_settings.online_endpoint.url;
|
||||
state.online_endpoint_model = ai_settings.online_endpoint.model;
|
||||
state.online_api_key_configured = ai_settings.online_endpoint.api_key_configured;
|
||||
if !state.online_endpoint_model.is_empty() {
|
||||
state.online_model_options = vec![AiModelOption {
|
||||
id: state.online_endpoint_model.clone(),
|
||||
label: state.online_endpoint_model.clone(),
|
||||
supports_vision: false,
|
||||
}];
|
||||
}
|
||||
state.airplane_endpoint_url = ai_settings.airplane_endpoint.url;
|
||||
state.airplane_endpoint_model = ai_settings.airplane_endpoint.model;
|
||||
if !state.airplane_endpoint_model.is_empty() {
|
||||
state.airplane_model_options = vec![AiModelOption {
|
||||
id: state.airplane_endpoint_model.clone(),
|
||||
label: state.airplane_endpoint_model.clone(),
|
||||
supports_vision: false,
|
||||
}];
|
||||
}
|
||||
state.default_model = ai_settings.default_model.unwrap_or_default();
|
||||
state.title_model = ai_settings.title_model.unwrap_or_default();
|
||||
state.image_model = ai_settings.image_model.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
state.offline_mode = self.offline_mode;
|
||||
state
|
||||
@@ -4972,17 +5042,44 @@ impl BdsApp {
|
||||
state.offline_mode = b;
|
||||
return Task::done(Message::SetOfflineMode(b));
|
||||
}
|
||||
SettingsMsg::OnlineEndpointUrlChanged(value) => { state.online_endpoint_url = value; }
|
||||
SettingsMsg::OnlineEndpointModelChanged(value) => { state.online_endpoint_model = value; }
|
||||
SettingsMsg::OnlineApiKeyChanged(value) => { state.online_api_key_input = value; }
|
||||
SettingsMsg::AirplaneEndpointUrlChanged(value) => { state.airplane_endpoint_url = value; }
|
||||
SettingsMsg::AirplaneEndpointModelChanged(value) => { state.airplane_endpoint_model = value; }
|
||||
SettingsMsg::DefaultModelChanged(value) => { state.default_model = value; }
|
||||
SettingsMsg::TitleModelChanged(value) => { state.title_model = value; }
|
||||
SettingsMsg::ImageModelChanged(value) => { state.image_model = value; }
|
||||
SettingsMsg::RefreshOnlineModels => {
|
||||
if let Some(db) = &self.db {
|
||||
match Self::refresh_ai_models(
|
||||
db,
|
||||
state,
|
||||
AiEndpointKind::Online,
|
||||
) {
|
||||
Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")),
|
||||
Err(error) => self.notify(ToastLevel::Error, &format!("Save failed: {error}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsMsg::RefreshAirplaneModels => {
|
||||
if let Some(db) = &self.db {
|
||||
match Self::refresh_ai_models(
|
||||
db,
|
||||
state,
|
||||
AiEndpointKind::Airplane,
|
||||
) {
|
||||
Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")),
|
||||
Err(error) => self.notify(ToastLevel::Error, &format!("Save failed: {error}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsMsg::SystemPromptAction(action) => {
|
||||
state.system_prompt.perform(action);
|
||||
}
|
||||
SettingsMsg::SaveSystemPrompt => {
|
||||
SettingsMsg::SaveAi => {
|
||||
if let Some(db) = &self.db {
|
||||
match bds_core::db::queries::setting::set_setting_value(
|
||||
db.conn(),
|
||||
"ai.system_prompt",
|
||||
&state.system_prompt.text(),
|
||||
bds_core::util::now_unix_ms(),
|
||||
) {
|
||||
match Self::save_ai_settings_state(db, state) {
|
||||
Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")),
|
||||
Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")),
|
||||
}
|
||||
@@ -5396,4 +5493,86 @@ impl BdsApp {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_ai_models(
|
||||
db: &Database,
|
||||
state: &mut SettingsViewState,
|
||||
kind: AiEndpointKind,
|
||||
) -> Result<(), String> {
|
||||
let endpoint = Self::compose_ai_endpoint(state, kind)?;
|
||||
let models = ai::refresh_model_catalog(&endpoint).map_err(|error| error.to_string())?;
|
||||
let options = models
|
||||
.into_iter()
|
||||
.map(|model| AiModelOption {
|
||||
id: model.id,
|
||||
label: model.name,
|
||||
supports_vision: model.supports_vision,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
match kind {
|
||||
AiEndpointKind::Online => state.online_model_options = options,
|
||||
AiEndpointKind::Airplane => state.airplane_model_options = options,
|
||||
}
|
||||
let _ = db;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_ai_settings_state(
|
||||
db: &Database,
|
||||
state: &mut SettingsViewState,
|
||||
) -> Result<(), String> {
|
||||
let online_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Online)?;
|
||||
let airplane_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Airplane)?;
|
||||
ai::test_endpoint(&online_endpoint).map_err(|error| error.to_string())?;
|
||||
ai::test_endpoint(&airplane_endpoint).map_err(|error| error.to_string())?;
|
||||
ai::save_endpoint(db.conn(), &online_endpoint).map_err(|error| error.to_string())?;
|
||||
ai::save_endpoint(db.conn(), &airplane_endpoint).map_err(|error| error.to_string())?;
|
||||
ai::save_model_preferences(
|
||||
db.conn(),
|
||||
(!state.default_model.trim().is_empty()).then_some(state.default_model.as_str()),
|
||||
(!state.title_model.trim().is_empty()).then_some(state.title_model.as_str()),
|
||||
(!state.image_model.trim().is_empty()).then_some(state.image_model.as_str()),
|
||||
&state.system_prompt.text(),
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
state.online_api_key_input.clear();
|
||||
state.online_api_key_configured = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compose_ai_endpoint(
|
||||
state: &SettingsViewState,
|
||||
kind: AiEndpointKind,
|
||||
) -> Result<AiEndpointConfig, String> {
|
||||
let (url, model, configured) = match kind {
|
||||
AiEndpointKind::Online => (
|
||||
state.online_endpoint_url.trim().to_string(),
|
||||
state.online_endpoint_model.trim().to_string(),
|
||||
state.online_api_key_configured,
|
||||
),
|
||||
AiEndpointKind::Airplane => (
|
||||
state.airplane_endpoint_url.trim().to_string(),
|
||||
state.airplane_endpoint_model.trim().to_string(),
|
||||
false,
|
||||
),
|
||||
};
|
||||
let api_key = if kind == AiEndpointKind::Online {
|
||||
let input = state.online_api_key_input.trim();
|
||||
if !input.is_empty() {
|
||||
Some(input.to_string())
|
||||
} else if configured {
|
||||
ai::load_endpoint_api_key(kind).map_err(|error| error.to_string())?
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(AiEndpointConfig {
|
||||
kind,
|
||||
url,
|
||||
model,
|
||||
api_key,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,19 @@ use crate::app::Message;
|
||||
use crate::components::inputs;
|
||||
use crate::i18n::{t, tw};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AiModelOption {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub supports_vision: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AiModelOption {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.label)
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapsible section identifiers per editor_settings.allium.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum SettingsSection {
|
||||
@@ -115,6 +128,17 @@ pub struct SettingsViewState {
|
||||
pub ssh_remote_path: String,
|
||||
// AI
|
||||
pub offline_mode: bool,
|
||||
pub online_endpoint_url: String,
|
||||
pub online_endpoint_model: String,
|
||||
pub online_api_key_input: String,
|
||||
pub online_api_key_configured: bool,
|
||||
pub online_model_options: Vec<AiModelOption>,
|
||||
pub airplane_endpoint_url: String,
|
||||
pub airplane_endpoint_model: String,
|
||||
pub airplane_model_options: Vec<AiModelOption>,
|
||||
pub default_model: String,
|
||||
pub title_model: String,
|
||||
pub image_model: String,
|
||||
pub system_prompt: text_editor::Content,
|
||||
// Technology
|
||||
pub semantic_similarity_enabled: bool,
|
||||
@@ -156,6 +180,17 @@ impl Clone for SettingsViewState {
|
||||
ssh_username: self.ssh_username.clone(),
|
||||
ssh_remote_path: self.ssh_remote_path.clone(),
|
||||
offline_mode: self.offline_mode,
|
||||
online_endpoint_url: self.online_endpoint_url.clone(),
|
||||
online_endpoint_model: self.online_endpoint_model.clone(),
|
||||
online_api_key_input: self.online_api_key_input.clone(),
|
||||
online_api_key_configured: self.online_api_key_configured,
|
||||
online_model_options: self.online_model_options.clone(),
|
||||
airplane_endpoint_url: self.airplane_endpoint_url.clone(),
|
||||
airplane_endpoint_model: self.airplane_endpoint_model.clone(),
|
||||
airplane_model_options: self.airplane_model_options.clone(),
|
||||
default_model: self.default_model.clone(),
|
||||
title_model: self.title_model.clone(),
|
||||
image_model: self.image_model.clone(),
|
||||
system_prompt: text_editor::Content::with_text(&self.system_prompt.text()),
|
||||
semantic_similarity_enabled: self.semantic_similarity_enabled,
|
||||
}
|
||||
@@ -190,6 +225,17 @@ impl Default for SettingsViewState {
|
||||
ssh_username: String::new(),
|
||||
ssh_remote_path: String::new(),
|
||||
offline_mode: false,
|
||||
online_endpoint_url: String::new(),
|
||||
online_endpoint_model: String::new(),
|
||||
online_api_key_input: String::new(),
|
||||
online_api_key_configured: false,
|
||||
online_model_options: Vec::new(),
|
||||
airplane_endpoint_url: String::new(),
|
||||
airplane_endpoint_model: String::new(),
|
||||
airplane_model_options: Vec::new(),
|
||||
default_model: String::new(),
|
||||
title_model: String::new(),
|
||||
image_model: String::new(),
|
||||
system_prompt: text_editor::Content::new(),
|
||||
semantic_similarity_enabled: false,
|
||||
}
|
||||
@@ -263,8 +309,18 @@ pub enum SettingsMsg {
|
||||
ClearPublishing,
|
||||
// AI
|
||||
OfflineModeChanged(bool),
|
||||
OnlineEndpointUrlChanged(String),
|
||||
OnlineEndpointModelChanged(String),
|
||||
OnlineApiKeyChanged(String),
|
||||
RefreshOnlineModels,
|
||||
AirplaneEndpointUrlChanged(String),
|
||||
AirplaneEndpointModelChanged(String),
|
||||
RefreshAirplaneModels,
|
||||
DefaultModelChanged(String),
|
||||
TitleModelChanged(String),
|
||||
ImageModelChanged(String),
|
||||
SystemPromptAction(text_editor::Action),
|
||||
SaveSystemPrompt,
|
||||
SaveAi,
|
||||
ResetSystemPrompt,
|
||||
// Technology
|
||||
SemanticSimilarityChanged(bool),
|
||||
@@ -659,11 +715,92 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
||||
}
|
||||
|
||||
fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
|
||||
let active_model_options = if state.offline_mode {
|
||||
&state.airplane_model_options
|
||||
} else {
|
||||
&state.online_model_options
|
||||
};
|
||||
let model_options = std::iter::once(AiModelOption {
|
||||
id: String::new(),
|
||||
label: t(locale, "tags.noTemplate"),
|
||||
supports_vision: false,
|
||||
})
|
||||
.chain(active_model_options.iter().cloned())
|
||||
.collect::<Vec<_>>();
|
||||
let image_model_options = std::iter::once(AiModelOption {
|
||||
id: String::new(),
|
||||
label: t(locale, "tags.noTemplate"),
|
||||
supports_vision: false,
|
||||
})
|
||||
.chain(active_model_options.iter().filter(|option| option.supports_vision).cloned())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let offline = inputs::labeled_checkbox(
|
||||
&t(locale, "settings.offlineMode"),
|
||||
state.offline_mode,
|
||||
|b| Message::Settings(SettingsMsg::OfflineModeChanged(b)),
|
||||
);
|
||||
let online_url = inputs::labeled_input(
|
||||
&t(locale, "settings.onlineEndpointUrl"),
|
||||
"https://api.example.com/v1",
|
||||
&state.online_endpoint_url,
|
||||
|value| Message::Settings(SettingsMsg::OnlineEndpointUrlChanged(value)),
|
||||
);
|
||||
let online_model = inputs::labeled_input(
|
||||
&t(locale, "settings.onlineEndpointModel"),
|
||||
"gpt-4.1-mini",
|
||||
&state.online_endpoint_model,
|
||||
|value| Message::Settings(SettingsMsg::OnlineEndpointModelChanged(value)),
|
||||
);
|
||||
let keychain_placeholder = t(locale, "settings.keychainConfigured");
|
||||
let online_api_key = inputs::labeled_input(
|
||||
&t(locale, "settings.onlineApiKey"),
|
||||
if state.online_api_key_configured {
|
||||
&keychain_placeholder
|
||||
} else {
|
||||
"sk-..."
|
||||
},
|
||||
&state.online_api_key_input,
|
||||
|value| Message::Settings(SettingsMsg::OnlineApiKeyChanged(value)),
|
||||
);
|
||||
let online_refresh = button(text(t(locale, "settings.refreshModels")).size(13))
|
||||
.on_press(Message::Settings(SettingsMsg::RefreshOnlineModels))
|
||||
.padding([6, 16]);
|
||||
|
||||
let airplane_url = inputs::labeled_input(
|
||||
&t(locale, "settings.airplaneEndpointUrl"),
|
||||
"http://localhost:11434/v1",
|
||||
&state.airplane_endpoint_url,
|
||||
|value| Message::Settings(SettingsMsg::AirplaneEndpointUrlChanged(value)),
|
||||
);
|
||||
let airplane_model = inputs::labeled_input(
|
||||
&t(locale, "settings.airplaneEndpointModel"),
|
||||
"llama3.2",
|
||||
&state.airplane_endpoint_model,
|
||||
|value| Message::Settings(SettingsMsg::AirplaneEndpointModelChanged(value)),
|
||||
);
|
||||
let airplane_refresh = button(text(t(locale, "settings.refreshModels")).size(13))
|
||||
.on_press(Message::Settings(SettingsMsg::RefreshAirplaneModels))
|
||||
.padding([6, 16]);
|
||||
|
||||
let default_model = inputs::labeled_select(
|
||||
&t(locale, "settings.defaultModel"),
|
||||
&model_options,
|
||||
model_options.iter().find(|option| option.id == state.default_model),
|
||||
|option| Message::Settings(SettingsMsg::DefaultModelChanged(option.id)),
|
||||
);
|
||||
let title_model = inputs::labeled_select(
|
||||
&t(locale, "settings.titleModel"),
|
||||
&model_options,
|
||||
model_options.iter().find(|option| option.id == state.title_model),
|
||||
|option| Message::Settings(SettingsMsg::TitleModelChanged(option.id)),
|
||||
);
|
||||
let image_model = inputs::labeled_select(
|
||||
&t(locale, "settings.imageAnalysisModel"),
|
||||
&image_model_options,
|
||||
image_model_options.iter().find(|option| option.id == state.image_model),
|
||||
|option| Message::Settings(SettingsMsg::ImageModelChanged(option.id)),
|
||||
);
|
||||
let prompt = column![
|
||||
text(t(locale, "settings.systemPrompt")).size(12).color(inputs::LABEL_COLOR).shaping(Shaping::Advanced),
|
||||
text_editor(&state.system_prompt)
|
||||
@@ -674,7 +811,7 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
|
||||
.spacing(4);
|
||||
let btns = row![
|
||||
button(text(t(locale, "common.save")).size(13))
|
||||
.on_press(Message::Settings(SettingsMsg::SaveSystemPrompt))
|
||||
.on_press(Message::Settings(SettingsMsg::SaveAi))
|
||||
.style(inputs::primary_button)
|
||||
.padding([6, 16]),
|
||||
button(text(t(locale, "settings.resetToDefault")).size(13))
|
||||
@@ -683,7 +820,21 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
|
||||
]
|
||||
.spacing(8);
|
||||
|
||||
column![offline, prompt, btns]
|
||||
column![
|
||||
offline,
|
||||
text(t(locale, "settings.onlineEndpointSection")).size(12).color(inputs::LABEL_COLOR),
|
||||
online_url,
|
||||
row![online_model, online_api_key].spacing(12),
|
||||
online_refresh,
|
||||
text(t(locale, "settings.airplaneEndpointSection")).size(12).color(inputs::LABEL_COLOR),
|
||||
airplane_url,
|
||||
row![airplane_model, airplane_refresh].spacing(12),
|
||||
default_model,
|
||||
title_model,
|
||||
image_model,
|
||||
prompt,
|
||||
btns,
|
||||
]
|
||||
.spacing(8)
|
||||
.padding([0, 16])
|
||||
.into()
|
||||
|
||||
@@ -311,6 +311,18 @@
|
||||
"settings.wrapLongLines": "Lange Zeilen umbrechen",
|
||||
"settings.hideUnchangedRegions": "Unveränderte Bereiche ausblenden",
|
||||
"settings.offlineMode": "Flugmodus",
|
||||
"settings.onlineEndpointSection": "Online-Endpunkt",
|
||||
"settings.onlineEndpointUrl": "URL des Online-Endpunkts",
|
||||
"settings.onlineEndpointModel": "Modell des Online-Endpunkts",
|
||||
"settings.onlineApiKey": "Online-API-Schlüssel",
|
||||
"settings.airplaneEndpointSection": "Flugmodus-Endpunkt",
|
||||
"settings.airplaneEndpointUrl": "URL des Flugmodus-Endpunkts",
|
||||
"settings.airplaneEndpointModel": "Modell des Flugmodus-Endpunkts",
|
||||
"settings.refreshModels": "Modelle aktualisieren",
|
||||
"settings.defaultModel": "Standardmodell",
|
||||
"settings.titleModel": "Titelmodell",
|
||||
"settings.imageAnalysisModel": "Bildanalysemodell",
|
||||
"settings.keychainConfigured": "Im Schlüsselbund gespeichert",
|
||||
"settings.systemPrompt": "System-Prompt",
|
||||
"settings.resetToDefault": "Auf Standard zurücksetzen",
|
||||
"settings.luaRuntimeOnly": "Lua ist die einzige Skriptlaufzeit in der Rust-App.",
|
||||
|
||||
@@ -311,6 +311,18 @@
|
||||
"settings.wrapLongLines": "Wrap Long Lines",
|
||||
"settings.hideUnchangedRegions": "Hide Unchanged Regions",
|
||||
"settings.offlineMode": "Airplane Mode",
|
||||
"settings.onlineEndpointSection": "Online Endpoint",
|
||||
"settings.onlineEndpointUrl": "Online Endpoint URL",
|
||||
"settings.onlineEndpointModel": "Online Endpoint Model",
|
||||
"settings.onlineApiKey": "Online API Key",
|
||||
"settings.airplaneEndpointSection": "Airplane Endpoint",
|
||||
"settings.airplaneEndpointUrl": "Airplane Endpoint URL",
|
||||
"settings.airplaneEndpointModel": "Airplane Endpoint Model",
|
||||
"settings.refreshModels": "Refresh Models",
|
||||
"settings.defaultModel": "Default Model",
|
||||
"settings.titleModel": "Title Model",
|
||||
"settings.imageAnalysisModel": "Image Analysis Model",
|
||||
"settings.keychainConfigured": "Stored in keychain",
|
||||
"settings.systemPrompt": "System Prompt",
|
||||
"settings.resetToDefault": "Reset to Default",
|
||||
"settings.luaRuntimeOnly": "Lua is the only scripting runtime in the Rust app.",
|
||||
|
||||
@@ -311,6 +311,18 @@
|
||||
"settings.wrapLongLines": "Ajuste de línea automático",
|
||||
"settings.hideUnchangedRegions": "Ocultar regiones sin cambios",
|
||||
"settings.offlineMode": "Modo avión",
|
||||
"settings.onlineEndpointSection": "Extremo en línea",
|
||||
"settings.onlineEndpointUrl": "URL del extremo en línea",
|
||||
"settings.onlineEndpointModel": "Modelo del extremo en línea",
|
||||
"settings.onlineApiKey": "Clave API en línea",
|
||||
"settings.airplaneEndpointSection": "Extremo de modo avión",
|
||||
"settings.airplaneEndpointUrl": "URL del extremo de modo avión",
|
||||
"settings.airplaneEndpointModel": "Modelo del extremo de modo avión",
|
||||
"settings.refreshModels": "Actualizar modelos",
|
||||
"settings.defaultModel": "Modelo predeterminado",
|
||||
"settings.titleModel": "Modelo de títulos",
|
||||
"settings.imageAnalysisModel": "Modelo de análisis de imágenes",
|
||||
"settings.keychainConfigured": "Guardado en el llavero",
|
||||
"settings.systemPrompt": "Prompt del sistema",
|
||||
"settings.resetToDefault": "Restaurar valores predeterminados",
|
||||
"settings.luaRuntimeOnly": "Lua es el único tiempo de ejecución de scripts en la app Rust.",
|
||||
|
||||
@@ -311,6 +311,18 @@
|
||||
"settings.wrapLongLines": "Retour à la ligne automatique",
|
||||
"settings.hideUnchangedRegions": "Masquer les régions inchangées",
|
||||
"settings.offlineMode": "Mode avion",
|
||||
"settings.onlineEndpointSection": "Point de terminaison en ligne",
|
||||
"settings.onlineEndpointUrl": "URL du point de terminaison en ligne",
|
||||
"settings.onlineEndpointModel": "Modèle du point de terminaison en ligne",
|
||||
"settings.onlineApiKey": "Clé API en ligne",
|
||||
"settings.airplaneEndpointSection": "Point de terminaison mode avion",
|
||||
"settings.airplaneEndpointUrl": "URL du point de terminaison mode avion",
|
||||
"settings.airplaneEndpointModel": "Modèle du point de terminaison mode avion",
|
||||
"settings.refreshModels": "Rafraîchir les modèles",
|
||||
"settings.defaultModel": "Modèle par défaut",
|
||||
"settings.titleModel": "Modèle de titres",
|
||||
"settings.imageAnalysisModel": "Modèle d'analyse d'image",
|
||||
"settings.keychainConfigured": "Stocké dans le trousseau",
|
||||
"settings.systemPrompt": "Prompt système",
|
||||
"settings.resetToDefault": "Rétablir les valeurs par défaut",
|
||||
"settings.luaRuntimeOnly": "Lua est l'unique moteur de script dans l'application Rust.",
|
||||
|
||||
@@ -311,6 +311,18 @@
|
||||
"settings.wrapLongLines": "A capo automatico",
|
||||
"settings.hideUnchangedRegions": "Nascondi regioni invariate",
|
||||
"settings.offlineMode": "Modalità aereo",
|
||||
"settings.onlineEndpointSection": "Endpoint online",
|
||||
"settings.onlineEndpointUrl": "URL endpoint online",
|
||||
"settings.onlineEndpointModel": "Modello endpoint online",
|
||||
"settings.onlineApiKey": "Chiave API online",
|
||||
"settings.airplaneEndpointSection": "Endpoint modalità aereo",
|
||||
"settings.airplaneEndpointUrl": "URL endpoint modalità aereo",
|
||||
"settings.airplaneEndpointModel": "Modello endpoint modalità aereo",
|
||||
"settings.refreshModels": "Aggiorna modelli",
|
||||
"settings.defaultModel": "Modello predefinito",
|
||||
"settings.titleModel": "Modello titoli",
|
||||
"settings.imageAnalysisModel": "Modello analisi immagini",
|
||||
"settings.keychainConfigured": "Salvato nel portachiavi",
|
||||
"settings.systemPrompt": "Prompt di sistema",
|
||||
"settings.resetToDefault": "Ripristina predefiniti",
|
||||
"settings.luaRuntimeOnly": "Lua è l'unico runtime di scripting nell'app Rust.",
|
||||
|
||||
Reference in New Issue
Block a user