feat: first take at M4
This commit is contained in:
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\""));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user