Implement complete WordPress WXR import workflow.
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
1827
crates/bds-core/src/engine/wordpress_import.rs
Normal file
1827
crates/bds-core/src/engine/wordpress_import.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user