Implement complete WordPress WXR import workflow.

This commit is contained in:
2026-07-19 13:17:45 +02:00
parent 9dab0ca57e
commit 429f471e4e
29 changed files with 4927 additions and 26 deletions

View File

@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::time::Duration;
use crate::db::DbConnection as Connection;
@@ -83,6 +84,7 @@ pub struct AiModelInfo {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OneShotOperation {
AnalyzeTaxonomy,
MapImportTaxonomy,
AnalyzePost,
DetectLanguage,
TranslatePost { target_language: String },
@@ -102,6 +104,12 @@ pub struct TaxonomySuggestion {
pub categories: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImportTaxonomyMapping {
pub category_mappings: BTreeMap<String, String>,
pub tag_mappings: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PostAnalysisResult {
pub title: String,
@@ -146,6 +154,7 @@ pub struct TokenUsage {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OneShotResponse {
Taxonomy(TaxonomySuggestion),
ImportTaxonomyMapping(ImportTaxonomyMapping),
PostAnalysis(PostAnalysisResult),
LanguageDetection(LanguageDetectionResult),
Translation(TranslationResult),
@@ -451,6 +460,7 @@ fn select_model(
let selected = match operation {
OneShotOperation::AnalyzeImage => settings.image_model.as_ref(),
OneShotOperation::AnalyzeTaxonomy
| OneShotOperation::MapImportTaxonomy
| OneShotOperation::AnalyzePost
| OneShotOperation::DetectLanguage
| OneShotOperation::TranslatePost { .. }
@@ -475,6 +485,9 @@ fn build_system_prompt(base_prompt: &str, operation: &OneShotOperation) -> Strin
OneShotOperation::AnalyzeTaxonomy => {
"Return only JSON with tags and categories for the post."
}
OneShotOperation::MapImportTaxonomy => {
"Map each imported category and tag to an existing equivalent when one exists. Return JSON objects keyed by each imported term; omit terms without a sound match."
}
OneShotOperation::AnalyzePost => {
"Return only JSON with title, excerpt, and slug suggestions for the post."
}
@@ -511,6 +524,11 @@ fn build_one_shot_user_content(request: &OneShotRequest) -> EngineResult<Value>
serde_json::to_string(&request.content)?
)
.into()),
OneShotOperation::MapImportTaxonomy => Ok(format!(
"Map these imported taxonomy terms to existing project terms without inventing terms: {}",
serde_json::to_string(&request.content)?
)
.into()),
OneShotOperation::AnalyzePost => Ok(format!(
"Analyze this post and suggest title, excerpt, and slug: {}",
serde_json::to_string(&request.content)?
@@ -580,6 +598,24 @@ fn response_schema(operation: &OneShotOperation) -> (&'static str, Value) {
"required": ["tags", "categories"]
}),
),
OneShotOperation::MapImportTaxonomy => (
"import_taxonomy_mapping",
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"category_mappings": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"tag_mappings": {
"type": "object",
"additionalProperties": { "type": "string" }
}
},
"required": ["category_mappings", "tag_mappings"]
}),
),
OneShotOperation::AnalyzePost => (
"post_analysis",
json!({
@@ -654,6 +690,9 @@ fn parse_one_shot_response(
OneShotOperation::AnalyzeTaxonomy => {
OneShotResponse::Taxonomy(serde_json::from_str(content)?)
}
OneShotOperation::MapImportTaxonomy => {
OneShotResponse::ImportTaxonomyMapping(serde_json::from_str(content)?)
}
OneShotOperation::AnalyzePost => {
OneShotResponse::PostAnalysis(serde_json::from_str(content)?)
}
@@ -939,6 +978,33 @@ mod tests {
);
}
#[test]
fn run_one_shot_supports_import_taxonomy_mapping_via_airplane_endpoint() {
let response = run_airplane_one_shot(
OneShotRequest {
operation: OneShotOperation::MapImportTaxonomy,
content: json!({
"imported_categories": ["Old Engineering"],
"imported_tags": ["Old Rust"],
"existing_categories": ["Engineering"],
"existing_tags": ["Rust"]
}),
},
r#"{"choices":[{"message":{"content":"{\"category_mappings\":{\"Old Engineering\":\"Engineering\"},\"tag_mappings\":{\"Old Rust\":\"Rust\"}}"}}],"usage":{"prompt_tokens":10,"completion_tokens":5}}"#,
);
assert_eq!(
response,
OneShotResponse::ImportTaxonomyMapping(ImportTaxonomyMapping {
category_mappings: BTreeMap::from([(
"Old Engineering".to_string(),
"Engineering".to_string(),
)]),
tag_mappings: BTreeMap::from([("Old Rust".to_string(), "Rust".to_string(),)]),
})
);
}
#[test]
fn run_one_shot_supports_post_analysis_via_airplane_endpoint() {
let response = run_airplane_one_shot(

View File

@@ -63,6 +63,43 @@ pub fn import_media(
author: Option<&str>,
language: Option<&str>,
tags: Vec<String>,
) -> EngineResult<Media> {
import_media_at(
conn,
data_dir,
project_id,
source_path,
original_name,
title,
alt,
caption,
author,
language,
tags,
now_unix_ms(),
)
}
/// Import media while preserving a trusted source timestamp. Importers use
/// this path so the canonical date-based location and sidecar metadata match
/// the source system; interactive imports continue to use the current time.
#[expect(
clippy::too_many_arguments,
reason = "arguments are the user-supplied media metadata fields plus source timestamp"
)]
pub(crate) fn import_media_at(
conn: &Connection,
data_dir: &Path,
project_id: &str,
source_path: &Path,
original_name: &str,
title: Option<&str>,
alt: Option<&str>,
caption: Option<&str>,
author: Option<&str>,
language: Option<&str>,
tags: Vec<String>,
created_at: i64,
) -> EngineResult<Media> {
// Validate file type per spec
let ext = Path::new(original_name)
@@ -77,12 +114,10 @@ pub fn import_media(
}
let id = Uuid::new_v4().to_string();
let now = now_unix_ms();
let filename = format!("{id}.{ext}");
// Compute target directory and copy file
let dir_path = media_dir_path(now);
let dir_path = media_dir_path(created_at);
let rel_file_path = format!("{dir_path}{filename}");
let abs_file_path = data_dir.join(&rel_file_path);
@@ -128,8 +163,8 @@ pub fn import_media(
sidecar_path: sidecar_rel.clone(),
checksum: Some(checksum),
tags,
created_at: now,
updated_at: now,
created_at,
updated_at: created_at,
};
// Write sidecar

View File

@@ -27,5 +27,6 @@ pub mod template_rebuild;
pub mod validate_media;
pub mod validate_site;
pub mod validate_translations;
pub mod wordpress_import;
pub use error::{EngineError, EngineResult};

File diff suppressed because it is too large Load Diff