From 429f471e4ef55e6caca13cbc83b0c474d3168289 Mon Sep 17 00:00:00 2001
From: Chili Palmer
Date: Sun, 19 Jul 2026 13:17:45 +0200
Subject: [PATCH] Implement complete WordPress WXR import workflow.
---
Cargo.lock | 37 +
Cargo.toml | 3 +
README.md | 1 +
RUST_PLAN_CORE.md | 2 +-
RUST_PLAN_EXTENSION.md | 25 +-
crates/bds-core/Cargo.toml | 2 +
.../src/db/queries/import_definition.rs | 62 +
crates/bds-core/src/db/queries/mod.rs | 1 +
crates/bds-core/src/engine/ai.rs | 66 +
crates/bds-core/src/engine/media.rs | 45 +-
crates/bds-core/src/engine/mod.rs | 1 +
.../bds-core/src/engine/wordpress_import.rs | 1827 +++++++++++++++++
crates/bds-core/src/model/import.rs | 224 ++
crates/bds-core/src/model/mod.rs | 6 +
crates/bds-core/src/scripting/core_host.rs | 1 +
crates/bds-core/tests/wordpress_import.rs | 807 ++++++++
crates/bds-ui/Cargo.toml | 1 +
crates/bds-ui/src/app.rs | 656 +++++-
crates/bds-ui/src/platform/dialog.rs | 41 +
crates/bds-ui/src/views/import_editor.rs | 777 +++++++
crates/bds-ui/src/views/mod.rs | 1 +
crates/bds-ui/src/views/modal.rs | 1 +
crates/bds-ui/src/views/sidebar.rs | 55 +-
crates/bds-ui/src/views/workspace.rs | 36 +-
locales/ui/de.ftl | 55 +
locales/ui/en.ftl | 55 +
locales/ui/es.ftl | 55 +
locales/ui/fr.ftl | 55 +
locales/ui/it.ftl | 55 +
29 files changed, 4927 insertions(+), 26 deletions(-)
create mode 100644 crates/bds-core/src/db/queries/import_definition.rs
create mode 100644 crates/bds-core/src/engine/wordpress_import.rs
create mode 100644 crates/bds-core/src/model/import.rs
create mode 100644 crates/bds-core/tests/wordpress_import.rs
create mode 100644 crates/bds-ui/src/views/import_editor.rs
diff --git a/Cargo.lock b/Cargo.lock
index d761592..03281fb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -804,6 +804,7 @@ dependencies = [
"diesel_migrations",
"fluent-bundle",
"fluent-syntax",
+ "htmd",
"image 0.25.10",
"keyring",
"libsqlite3-sys",
@@ -814,6 +815,7 @@ dependencies = [
"pulldown-cmark",
"quick-xml 0.41.0",
"rayon",
+ "regex",
"reqwest",
"rust-stemmers",
"serde",
@@ -849,6 +851,7 @@ dependencies = [
"chrono",
"dirs 5.0.1",
"fluent-syntax",
+ "futures",
"iced",
"muda",
"objc2 0.6.4",
@@ -3090,6 +3093,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
+[[package]]
+name = "htmd"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7eee9b00ee2e599b4f86507157e3db786e7a3319fc225f0e9584151dbea2291d"
+dependencies = [
+ "html5ever",
+ "markup5ever_rcdom",
+ "phf 0.13.1",
+]
+
[[package]]
name = "html-escape"
version = "0.2.13"
@@ -4341,6 +4355,18 @@ dependencies = [
"web_atoms",
]
+[[package]]
+name = "markup5ever_rcdom"
+version = "0.38.0+unofficial"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "333171ccdf66e915257740d44e38ea5b1b19ce7b45d33cc35cb6f118fbd981ff"
+dependencies = [
+ "html5ever",
+ "markup5ever",
+ "tendril",
+ "xml5ever",
+]
+
[[package]]
name = "matchit"
version = "0.8.4"
@@ -7103,6 +7129,7 @@ dependencies = [
"parking_lot 0.12.5",
"phf_shared 0.13.1",
"precomputed-hash",
+ "serde",
]
[[package]]
@@ -9443,6 +9470,16 @@ version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
+[[package]]
+name = "xml5ever"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3dc9559429edf0cd3f327cc0afd9d6b36fa8cec6d93107b7fbe64f806b5f2d9"
+dependencies = [
+ "log",
+ "markup5ever",
+]
+
[[package]]
name = "xmlwriter"
version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 0e6317d..c411b88 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -27,6 +27,7 @@ anyhow = "1"
tokio = { version = "1", features = ["full"] }
axum = "0.8"
walkdir = "2"
+futures = "0.3"
image = "0.25"
rust-stemmers = "1"
sys-locale = "0.3"
@@ -38,6 +39,8 @@ pulldown-cmark = "0.13"
liquid = "0.26"
liquid-core = { version = "0.26", features = ["derive"] }
quick-xml = "0.41"
+htmd = "0.5.4"
+regex = "1"
rayon = "1.10"
pagefind = "1.5.2"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
diff --git a/README.md b/README.md
index 4ac8e33..f020e08 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ The project is under active development. Core blogging workflows are broadly ava
- Native Iced desktop workspace with localized menus, tabs, sidebars, dialogs, tasks, and embedded Wry previews.
- Post and translation authoring with draft/published lifecycle, metadata, tags, categories, links, media, and batch gallery-image import.
- Media import, thumbnails, metadata translations, filters, validation, and post assignment.
+- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
- Template and Lua script management with explicit syntax-check feedback, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms.
- SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, and FTS5 search.
- Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation through cancellable section task groups.
diff --git a/RUST_PLAN_CORE.md b/RUST_PLAN_CORE.md
index 952aee2..86e47b3 100644
--- a/RUST_PLAN_CORE.md
+++ b/RUST_PLAN_CORE.md
@@ -99,7 +99,7 @@ Available:
- Configurable online and airplane-mode OpenAI-compatible endpoints.
- Secure API-key storage through the operating-system keychain.
- Model catalog discovery and model selection.
-- Post translation, media translation, image alt text, post analysis, taxonomy analysis, and language detection.
+- Post translation, media translation, image alt text, post analysis, taxonomy analysis, WordPress-import taxonomy mapping, and language detection.
- Explicit offline gating and user-visible errors.
- SQLite fields for input, output, cache-read, and cache-write token usage.
diff --git a/RUST_PLAN_EXTENSION.md b/RUST_PLAN_EXTENSION.md
index 42e72e9..e65e767 100644
--- a/RUST_PLAN_EXTENSION.md
+++ b/RUST_PLAN_EXTENSION.md
@@ -22,12 +22,14 @@ Open:
- Expose `bds.sync` through the scripting API using the shared Git workflow.
-### WordPress import — Open
+### WordPress import — Complete
-Open:
+Done:
-- WXR parsing, import analysis, recoverable execution, saved import definitions, and import UI.
-- The existing media importer is core functionality and does not implement this workflow.
+- Streaming, untrusted-input-safe WXR parsing for site metadata, posts, pages, attachments, categories, and tags.
+- HTML5-to-Markdown conversion, WordPress shortcode conversion, complete new/update/conflict/duplicate/missing classification, date and macro analysis, and saved project-scoped import definitions.
+- Localized native import sidebar/editor with WXR and uploads pickers, cached analysis reopening, conflict resolution, manual and airplane-gated AI taxonomy mapping, item review, live progress/ETA, and execution results.
+- Taxonomy/posts/media/pages execution through core persistence engines in recoverable 500-item batches, including filesystem rollback, source metadata/status/timestamps, unique-slug import, overwrite/ignore behavior, and media-parent links.
### Conversational AI and agent tools — Open
@@ -39,7 +41,7 @@ Open:
- Agent integrations such as Claude Code and Copilot where required by the specs.
- Replace the current Chat placeholders with the working feature.
-Core endpoint settings, offline gating, key storage, model discovery, and six one-shot operations are already implemented.
+Core endpoint settings, offline gating, key storage, model discovery, and seven one-shot operations are already implemented.
### Embeddings, semantic search, and duplicates — Open
@@ -123,12 +125,11 @@ Open:
## Suggested Order
-1. WordPress import.
-2. CLI, MCP, and domain events.
-3. Conversational AI and agent tools.
-4. Embeddings and duplicate detection.
-5. Documentation and menu UX.
-6. Headless server and TUI.
-7. A2UI after conversational AI exists.
+1. CLI, MCP, and domain events.
+2. Conversational AI and agent tools.
+3. Embeddings and duplicate detection.
+4. Documentation and menu UX.
+5. Headless server and TUI.
+6. A2UI after conversational AI exists.
The order may change when an extension directly unlocks a concrete user workflow; it must not create a parallel data model or bypass core engines.
diff --git a/crates/bds-core/Cargo.toml b/crates/bds-core/Cargo.toml
index feb4ecc..88835fc 100644
--- a/crates/bds-core/Cargo.toml
+++ b/crates/bds-core/Cargo.toml
@@ -23,6 +23,8 @@ pulldown-cmark = { workspace = true }
liquid = { workspace = true }
liquid-core = { workspace = true }
quick-xml = { workspace = true }
+htmd = { workspace = true }
+regex = { workspace = true }
rayon = { workspace = true }
pagefind = { workspace = true }
reqwest = { workspace = true }
diff --git a/crates/bds-core/src/db/queries/import_definition.rs b/crates/bds-core/src/db/queries/import_definition.rs
new file mode 100644
index 0000000..b952be8
--- /dev/null
+++ b/crates/bds-core/src/db/queries/import_definition.rs
@@ -0,0 +1,62 @@
+use diesel::prelude::*;
+
+use crate::db::DbConnection;
+use crate::db::schema::import_definitions;
+use crate::model::ImportDefinition;
+
+pub fn insert_import_definition(
+ conn: &DbConnection,
+ definition: &ImportDefinition,
+) -> QueryResult<()> {
+ conn.with(|connection| {
+ diesel::insert_into(import_definitions::table)
+ .values(definition.clone())
+ .execute(connection)
+ .map(|_| ())
+ })
+}
+
+pub fn get_import_definition(conn: &DbConnection, id: &str) -> QueryResult {
+ conn.with(|connection| {
+ import_definitions::table
+ .filter(import_definitions::id.eq(id))
+ .select(ImportDefinition::as_select())
+ .first(connection)
+ })
+}
+
+pub fn list_import_definitions(
+ conn: &DbConnection,
+ project_id: &str,
+) -> QueryResult> {
+ conn.with(|connection| {
+ import_definitions::table
+ .filter(import_definitions::project_id.eq(project_id))
+ .order((
+ import_definitions::updated_at.desc(),
+ import_definitions::created_at.desc(),
+ ))
+ .select(ImportDefinition::as_select())
+ .load(connection)
+ })
+}
+
+pub fn update_import_definition(
+ conn: &DbConnection,
+ definition: &ImportDefinition,
+) -> QueryResult<()> {
+ conn.with(|connection| {
+ diesel::update(import_definitions::table.filter(import_definitions::id.eq(&definition.id)))
+ .set(definition.clone())
+ .execute(connection)
+ .map(|_| ())
+ })
+}
+
+pub fn delete_import_definition(conn: &DbConnection, id: &str) -> QueryResult<()> {
+ conn.with(|connection| {
+ diesel::delete(import_definitions::table.filter(import_definitions::id.eq(id)))
+ .execute(connection)
+ .map(|_| ())
+ })
+}
diff --git a/crates/bds-core/src/db/queries/mod.rs b/crates/bds-core/src/db/queries/mod.rs
index a0b5ed7..1fb3d1d 100644
--- a/crates/bds-core/src/db/queries/mod.rs
+++ b/crates/bds-core/src/db/queries/mod.rs
@@ -1,4 +1,5 @@
pub mod generated_file_hash;
+pub mod import_definition;
pub mod media;
pub mod media_translation;
pub mod post;
diff --git a/crates/bds-core/src/engine/ai.rs b/crates/bds-core/src/engine/ai.rs
index ca00dd0..8577c88 100644
--- a/crates/bds-core/src/engine/ai.rs
+++ b/crates/bds-core/src/engine/ai.rs
@@ -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,
}
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ImportTaxonomyMapping {
+ pub category_mappings: BTreeMap,
+ pub tag_mappings: BTreeMap,
+}
+
#[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
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(
diff --git a/crates/bds-core/src/engine/media.rs b/crates/bds-core/src/engine/media.rs
index 2691e2a..cf41483 100644
--- a/crates/bds-core/src/engine/media.rs
+++ b/crates/bds-core/src/engine/media.rs
@@ -63,6 +63,43 @@ pub fn import_media(
author: Option<&str>,
language: Option<&str>,
tags: Vec,
+) -> EngineResult {
+ 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,
+ created_at: i64,
) -> EngineResult {
// 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
diff --git a/crates/bds-core/src/engine/mod.rs b/crates/bds-core/src/engine/mod.rs
index e9260d0..c36b77b 100644
--- a/crates/bds-core/src/engine/mod.rs
+++ b/crates/bds-core/src/engine/mod.rs
@@ -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};
diff --git a/crates/bds-core/src/engine/wordpress_import.rs b/crates/bds-core/src/engine/wordpress_import.rs
new file mode 100644
index 0000000..04cfc7a
--- /dev/null
+++ b/crates/bds-core/src/engine/wordpress_import.rs
@@ -0,0 +1,1827 @@
+use std::collections::{BTreeMap, HashMap, HashSet};
+use std::fs;
+use std::io::BufReader;
+use std::panic::{AssertUnwindSafe, catch_unwind};
+use std::path::{Component, Path, PathBuf};
+use std::time::Instant;
+
+use chrono::{DateTime, Datelike, NaiveDateTime, Utc};
+use quick_xml::Reader;
+use quick_xml::events::{BytesStart, Event};
+use regex::Regex;
+use serde_json::json;
+use uuid::Uuid;
+
+use crate::db::DbConnection as Connection;
+use crate::db::queries::{
+ import_definition as qd, media as qm, media_translation as qmt, post as qp,
+ post_translation as qpt, tag as qt,
+};
+use crate::engine::{EngineError, EngineResult, ai, media, meta, post, post_media, tag};
+use crate::model::{
+ ImportCandidate, ImportCounts, ImportDateBucket, ImportDefinition, ImportExecutionCounts,
+ ImportExecutionResult, ImportItemKind, ImportItemStatus, ImportMacroUsage, ImportPhase,
+ ImportProgress, ImportReport, ImportResolution, ImportedSite, Media, Post, TaxonomyCandidate,
+ TaxonomyKind,
+};
+use crate::util::{
+ atomic_write_str, content_hash, file_hash, media_sidecar_path, media_translation_sidecar_path,
+ now_unix_ms, post_file_path, slugify,
+};
+
+const TRANSACTION_BATCH_SIZE: usize = 500;
+
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct WxrSite {
+ pub title: String,
+ pub link: String,
+ pub description: String,
+ pub language: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct WxrTaxonomy {
+ pub name: String,
+ pub slug: String,
+ pub parent: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct WxrPost {
+ pub source_id: Option,
+ pub title: String,
+ pub slug: String,
+ pub content: String,
+ pub excerpt: String,
+ pub published_at: Option,
+ pub created_at: Option,
+ pub updated_at: Option,
+ pub creator: String,
+ pub status: String,
+ pub post_type: String,
+ pub categories: Vec,
+ pub tags: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct WxrMedia {
+ pub source_id: Option,
+ pub title: String,
+ pub url: String,
+ pub filename: String,
+ pub relative_path: String,
+ pub published_at: Option,
+ pub parent_source_id: Option,
+ pub mime_type: String,
+ pub description: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct WxrExport {
+ pub site: WxrSite,
+ pub posts: Vec,
+ pub pages: Vec,
+ pub media: Vec,
+ pub categories: Vec,
+ pub tags: Vec,
+}
+
+#[derive(Debug, Default)]
+struct RawItem {
+ post_id: String,
+ title: String,
+ content: String,
+ excerpt: String,
+ pub_date: String,
+ post_date: String,
+ post_modified: String,
+ creator: String,
+ status: String,
+ post_type: String,
+ post_name: String,
+ post_parent: String,
+ attachment_url: String,
+ categories: Vec,
+ tags: Vec,
+}
+
+#[derive(Debug, Default)]
+struct ParserState {
+ stack: Vec,
+ channel_seen: bool,
+ site: WxrSite,
+ categories: Vec,
+ tags: Vec,
+ items: Vec,
+ current_category: Option,
+ current_tag: Option,
+ current_item: Option,
+ current_taxonomy_domain: Option,
+ text: String,
+}
+
+pub fn parse_wxr_file(path: &Path) -> EngineResult {
+ let file = fs::File::open(path)?;
+ parse_wxr_reader(Reader::from_reader(BufReader::new(file)))
+}
+
+pub fn parse_wxr_xml(xml: &str) -> EngineResult {
+ parse_wxr_reader(Reader::from_reader(xml.as_bytes()))
+}
+
+fn parse_wxr_reader(mut reader: Reader) -> EngineResult {
+ reader.config_mut().expand_empty_elements = true;
+ reader.config_mut().check_end_names = true;
+ let mut state = ParserState::default();
+ let mut buffer = Vec::new();
+
+ loop {
+ match reader.read_event_into(&mut buffer) {
+ Ok(Event::Start(start)) => parser_start(&mut state, &start, reader.decoder())?,
+ Ok(Event::End(end)) => {
+ let name = reader
+ .decoder()
+ .decode(end.name().as_ref())
+ .map_err(|error| EngineError::Parse(error.to_string()))?
+ .into_owned();
+ parser_end(&mut state, &name);
+ }
+ Ok(Event::Text(text)) => {
+ state.text.push_str(
+ &text
+ .decode()
+ .map_err(|error| EngineError::Parse(error.to_string()))?,
+ );
+ }
+ Ok(Event::CData(text)) => {
+ state.text.push_str(
+ &text
+ .decode()
+ .map_err(|error| EngineError::Parse(error.to_string()))?,
+ );
+ }
+ Ok(Event::GeneralRef(reference)) => {
+ let name = reference
+ .decode()
+ .map_err(|error| EngineError::Parse(error.to_string()))?;
+ state.text.push_str(match name.as_ref() {
+ "amp" => "&",
+ "lt" => "<",
+ "gt" => ">",
+ "quot" => "\"",
+ "apos" => "'",
+ _ => "",
+ });
+ }
+ Ok(Event::Eof) => break,
+ Ok(_) => {}
+ Err(error) => {
+ return Err(EngineError::Parse(format!(
+ "invalid WXR XML at byte {}: {error}",
+ reader.error_position()
+ )));
+ }
+ }
+ buffer.clear();
+ }
+
+ if !state.channel_seen {
+ return Err(EngineError::Validation(
+ "invalid WXR file: no RSS channel element found".to_string(),
+ ));
+ }
+ if !state.stack.is_empty() {
+ return Err(EngineError::Parse(
+ "invalid WXR XML: document ended before all elements were closed".to_string(),
+ ));
+ }
+ Ok(build_wxr_export(state))
+}
+
+fn parser_start(
+ state: &mut ParserState,
+ start: &BytesStart<'_>,
+ decoder: quick_xml::encoding::Decoder,
+) -> EngineResult<()> {
+ let name = decoder
+ .decode(start.name().as_ref())
+ .map_err(|error| EngineError::Parse(error.to_string()))?
+ .into_owned();
+ let parent = state.stack.last().map(String::as_str);
+
+ match (parent, name.as_str()) {
+ (Some("rss"), "channel") => state.channel_seen = true,
+ (Some("channel"), "wp:category") => state.current_category = Some(WxrTaxonomy::default()),
+ (Some("channel"), "wp:tag") => state.current_tag = Some(WxrTaxonomy::default()),
+ (Some("channel"), "item") => state.current_item = Some(RawItem::default()),
+ (Some("item"), "category") => {
+ state.current_taxonomy_domain = attribute(start, b"domain", decoder)?;
+ }
+ _ => {}
+ }
+ state.stack.push(name);
+ state.text.clear();
+ Ok(())
+}
+
+fn attribute(
+ start: &BytesStart<'_>,
+ expected: &[u8],
+ decoder: quick_xml::encoding::Decoder,
+) -> EngineResult
]]>103duplicate-contentdraftpost
+{}
+{}
+{}
+{}
+{}"#,
+ attachment_item(302, "Same", "same.png"),
+ attachment_item(303, "Copy", "copy.png"),
+ attachment_item(304, "Different", "different.png"),
+ attachment_item(305, "Missing", "missing.png"),
+ attachment_item(306, "Escape", "../escape.png"),
+ );
+ let wxr = dir.path().join("classifications.xml");
+ fs::write(&wxr, sample_wxr(&extras)).unwrap();
+ let posts_before = serde_json::to_value(
+ bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id).unwrap(),
+ )
+ .unwrap();
+ let media_before = serde_json::to_value(
+ bds_core::db::queries::media::list_media_by_project(db.conn(), &project.id).unwrap(),
+ )
+ .unwrap();
+ let tags_before = serde_json::to_value(
+ bds_core::db::queries::tag::list_tags_by_project(db.conn(), &project.id).unwrap(),
+ )
+ .unwrap();
+ let categories_before = fs::read(dir.path().join("meta/categories.json")).unwrap();
+ let report = import::analyze_wxr(
+ db.conn(),
+ dir.path(),
+ &project.id,
+ &wxr,
+ Some(dir.path().join("uploads").as_path()),
+ None,
+ )
+ .unwrap();
+ assert_eq!(
+ serde_json::to_value(
+ bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id).unwrap()
+ )
+ .unwrap(),
+ posts_before
+ );
+ assert_eq!(
+ serde_json::to_value(
+ bds_core::db::queries::media::list_media_by_project(db.conn(), &project.id).unwrap()
+ )
+ .unwrap(),
+ media_before
+ );
+ assert_eq!(
+ serde_json::to_value(
+ bds_core::db::queries::tag::list_tags_by_project(db.conn(), &project.id).unwrap()
+ )
+ .unwrap(),
+ tags_before
+ );
+ assert_eq!(
+ fs::read(dir.path().join("meta/categories.json")).unwrap(),
+ categories_before
+ );
+
+ let post_statuses = report
+ .posts
+ .iter()
+ .map(|item| item.status)
+ .collect::>();
+ assert!(post_statuses.contains(&ImportItemStatus::Update));
+ assert!(post_statuses.contains(&ImportItemStatus::Conflict));
+ assert!(post_statuses.contains(&ImportItemStatus::ContentDuplicate));
+ let media_status = |name: &str| {
+ report
+ .media
+ .iter()
+ .find(|item| item.filename.as_deref() == Some(name))
+ .unwrap()
+ .status
+ };
+ assert_eq!(media_status("same.png"), ImportItemStatus::Update);
+ assert_eq!(media_status("copy.png"), ImportItemStatus::ContentDuplicate);
+ assert_eq!(media_status("different.png"), ImportItemStatus::Conflict);
+ assert_eq!(media_status("missing.png"), ImportItemStatus::Missing);
+ assert_eq!(media_status("escape.png"), ImportItemStatus::Missing);
+ assert!(
+ report
+ .media
+ .iter()
+ .find(|item| item.filename.as_deref() == Some("escape.png"))
+ .unwrap()
+ .source_path
+ .is_none()
+ );
+}
+
+#[test]
+fn definitions_round_trip_saved_analysis_and_are_project_scoped() {
+ let (db, dir, project) = setup();
+ let definition = import::create_definition(db.conn(), &project.id, "Legacy").unwrap();
+ let wxr = dir.path().join("legacy.xml");
+ fs::write(&wxr, sample_wxr("")).unwrap();
+ let report = import::analyze_wxr(db.conn(), dir.path(), &project.id, &wxr, None, None).unwrap();
+
+ let updated = import::update_definition(
+ db.conn(),
+ &definition.id,
+ Some("Renamed"),
+ Some(Some(wxr.as_path())),
+ Some(Some(dir.path())),
+ Some(Some(&report)),
+ )
+ .unwrap();
+ assert_eq!(updated.name, "Renamed");
+ assert_eq!(updated.analysis().unwrap().unwrap(), report);
+ assert_eq!(
+ import::list_definitions(db.conn(), &project.id)
+ .unwrap()
+ .len(),
+ 1
+ );
+ import::delete_definition(db.conn(), &definition.id).unwrap();
+ assert!(import::get_definition(db.conn(), &definition.id).is_err());
+}
+
+#[test]
+fn execution_uses_core_engines_preserves_metadata_and_links_media_parent() {
+ let (db, dir, project) = setup();
+ let uploads = dir.path().join("uploads/2024/05");
+ fs::create_dir_all(&uploads).unwrap();
+ write_image(&uploads.join("photo.png"));
+ let wxr = dir.path().join("legacy.xml");
+ fs::write(&wxr, sample_wxr("")).unwrap();
+ let report = import::analyze_wxr(
+ db.conn(),
+ dir.path(),
+ &project.id,
+ &wxr,
+ Some(dir.path().join("uploads").as_path()),
+ None,
+ )
+ .unwrap();
+
+ let mut progress = Vec::new();
+ let result = import::execute_import(
+ db.conn(),
+ dir.path(),
+ &project.id,
+ &report,
+ Some("Fallback Author"),
+ Some(&mut |value| progress.push(value)),
+ )
+ .unwrap();
+ assert_eq!(result.posts.imported, 1);
+ assert_eq!(result.pages.imported, 1);
+ assert_eq!(result.media.imported, 1);
+
+ let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id).unwrap();
+ let imported = posts
+ .iter()
+ .find(|post| post.slug == "hello-world")
+ .unwrap();
+ assert_eq!(imported.status, PostStatus::Published);
+ assert_eq!(imported.author.as_deref(), Some("Importer"));
+ assert_eq!(imported.created_at, 1_714_564_800_000);
+ assert_eq!(imported.updated_at, 1_714_653_000_000);
+ assert_eq!(imported.published_at, Some(1_714_564_800_000));
+ assert_eq!(imported.file_path, "posts/2024/05/hello-world.md");
+ assert!(dir.path().join(&imported.file_path).is_file());
+ let raw = fs::read_to_string(dir.path().join(&imported.file_path)).unwrap();
+ let (frontmatter, _) = read_post_file(&raw).unwrap();
+ assert_eq!(frontmatter.created_at, imported.created_at);
+ assert_eq!(frontmatter.updated_at, imported.updated_at);
+ assert_eq!(frontmatter.published_at, imported.published_at);
+ let page = posts.iter().find(|post| post.slug == "about").unwrap();
+ assert!(page.categories.iter().any(|category| category == "page"));
+
+ let media =
+ bds_core::db::queries::media::list_media_by_project(db.conn(), &project.id).unwrap();
+ assert_eq!(media[0].alt.as_deref(), Some("Photo description"));
+ assert_eq!(media[0].author.as_deref(), Some("Fallback Author"));
+ assert_eq!(media[0].created_at, 1_714_737_600_000);
+ assert!(media[0].file_path.starts_with("media/2024/05/"));
+ let sidecar =
+ read_sidecar(&fs::read_to_string(dir.path().join(&media[0].sidecar_path)).unwrap())
+ .unwrap();
+ assert_eq!(sidecar.created_at, media[0].created_at);
+ let links =
+ bds_core::db::queries::post_media::list_post_media_by_media(db.conn(), &media[0].id)
+ .unwrap();
+ assert_eq!(links[0].post_id, imported.id);
+ let phases = progress.iter().map(|item| item.phase).collect::>();
+ assert!(phases.windows(2).all(|window| window[0] <= window[1]));
+ assert_eq!(phases.last(), Some(&bds_core::model::ImportPhase::Complete));
+ for phase in [
+ bds_core::model::ImportPhase::Taxonomy,
+ bds_core::model::ImportPhase::Posts,
+ bds_core::model::ImportPhase::Media,
+ bds_core::model::ImportPhase::Pages,
+ ] {
+ assert!(
+ progress
+ .iter()
+ .any(|item| item.phase == phase && item.current == 0),
+ "missing start event for {phase:?}"
+ );
+ }
+
+ let diff =
+ bds_core::engine::metadata_diff::compute_metadata_diff(db.conn(), dir.path(), &project.id)
+ .unwrap();
+ assert!(diff.errors.is_empty(), "{:?}", diff.errors);
+ assert!(
+ diff.diffs
+ .iter()
+ .all(|item| item.entity_type != "post" && item.entity_type != "media"),
+ "{:?}",
+ diff.diffs
+ );
+ assert!(
+ bds_core::engine::post::rebuild_posts_from_filesystem(db.conn(), dir.path(), &project.id,)
+ .unwrap()
+ .errors
+ .is_empty()
+ );
+ assert!(
+ bds_core::engine::media::rebuild_media_from_filesystem(db.conn(), dir.path(), &project.id,)
+ .unwrap()
+ .errors
+ .is_empty()
+ );
+}
+
+#[test]
+fn conflict_import_uses_unique_slug_and_progress_panics_do_not_abort() {
+ let (db, dir, project) = setup();
+ let existing = post::create_post(
+ db.conn(),
+ dir.path(),
+ &project.id,
+ "Hello World",
+ Some("local"),
+ vec![],
+ vec![],
+ None,
+ None,
+ None,
+ )
+ .unwrap();
+ assert_eq!(existing.slug, "hello-world");
+ let wxr = dir.path().join("legacy.xml");
+ fs::write(&wxr, sample_wxr("")).unwrap();
+ let mut report = import::analyze_wxr(
+ db.conn(),
+ dir.path(),
+ &project.id,
+ &wxr,
+ None,
+ Some(&mut |_| panic!("observer failure")),
+ )
+ .unwrap();
+ import::set_conflict_resolution(
+ &mut report,
+ ImportItemKind::Post,
+ "hello-world",
+ ImportResolution::Import,
+ )
+ .unwrap();
+ import::execute_import(
+ db.conn(),
+ dir.path(),
+ &project.id,
+ &report,
+ None,
+ Some(&mut |_| panic!("observer failure")),
+ )
+ .unwrap();
+ let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id).unwrap();
+ assert_eq!(posts.len(), 3);
+ assert!(
+ posts
+ .iter()
+ .any(|post| post.slug.starts_with("hello-world-") && post.slug != "hello-world")
+ );
+}
+
+#[test]
+fn default_ignore_resolution_skips_conflict_without_mutating_existing_post() {
+ let (db, dir, project) = setup();
+ let existing = post::create_post(
+ db.conn(),
+ dir.path(),
+ &project.id,
+ "Hello World",
+ Some("Local body"),
+ vec![],
+ vec![],
+ None,
+ None,
+ None,
+ )
+ .unwrap();
+ let wxr = dir.path().join("ignore.xml");
+ fs::write(&wxr, sample_wxr("")).unwrap();
+ let mut report =
+ import::analyze_wxr(db.conn(), dir.path(), &project.id, &wxr, None, None).unwrap();
+ report.taxonomies.clear();
+ report.pages.clear();
+ report.media.clear();
+ assert_eq!(report.posts[0].resolution, Some(ImportResolution::Ignore));
+
+ let result =
+ import::execute_import(db.conn(), dir.path(), &project.id, &report, None, None).unwrap();
+ assert_eq!(result.posts.imported, 0);
+ assert_eq!(result.posts.skipped, 1);
+ let unchanged = bds_core::db::queries::post::get_post_by_id(db.conn(), &existing.id).unwrap();
+ assert_eq!(unchanged.content.as_deref(), Some("Local body"));
+}
+
+#[test]
+fn overwrite_resolution_preserves_existing_post_and_media_identity() {
+ let (db, dir, project) = setup();
+ let existing_post = post::create_post(
+ db.conn(),
+ dir.path(),
+ &project.id,
+ "Hello World",
+ Some("Local post body"),
+ vec![],
+ vec![],
+ None,
+ None,
+ None,
+ )
+ .unwrap();
+ let old_image = dir.path().join("old-photo.png");
+ DynamicImage::new_rgb8(2, 2).save(&old_image).unwrap();
+ let existing_media = bds_core::engine::media::import_media(
+ db.conn(),
+ dir.path(),
+ &project.id,
+ &old_image,
+ "photo.png",
+ Some("Old photo"),
+ Some("Old alt"),
+ None,
+ None,
+ None,
+ Vec::new(),
+ )
+ .unwrap();
+ let uploads = dir.path().join("uploads/2024/05");
+ fs::create_dir_all(&uploads).unwrap();
+ write_image(&uploads.join("photo.png"));
+ let wxr = dir.path().join("overwrite.xml");
+ fs::write(&wxr, sample_wxr("")).unwrap();
+ let mut report = import::analyze_wxr(
+ db.conn(),
+ dir.path(),
+ &project.id,
+ &wxr,
+ Some(dir.path().join("uploads").as_path()),
+ None,
+ )
+ .unwrap();
+ import::set_conflict_resolution(
+ &mut report,
+ ImportItemKind::Post,
+ "hello-world",
+ ImportResolution::Overwrite,
+ )
+ .unwrap();
+ import::set_conflict_resolution(
+ &mut report,
+ ImportItemKind::Media,
+ "photo.png",
+ ImportResolution::Overwrite,
+ )
+ .unwrap();
+
+ import::execute_import(db.conn(), dir.path(), &project.id, &report, None, None).unwrap();
+ let overwritten_post =
+ bds_core::db::queries::post::get_post_by_id(db.conn(), &existing_post.id).unwrap();
+ assert_eq!(overwritten_post.id, existing_post.id);
+ assert_eq!(overwritten_post.status, PostStatus::Published);
+ assert_eq!(
+ overwritten_post.published_content.as_deref(),
+ report.posts[0].content.as_deref()
+ );
+ let overwritten_media =
+ bds_core::db::queries::media::get_media_by_id(db.conn(), &existing_media.id).unwrap();
+ assert_eq!(overwritten_media.id, existing_media.id);
+ assert_eq!(overwritten_media.alt.as_deref(), Some("Photo description"));
+ assert_ne!(overwritten_media.checksum, existing_media.checksum);
+}
+
+#[test]
+fn airplane_ai_mapping_requires_a_local_endpoint() {
+ let (db, _dir, _project) = setup();
+ let mut report = import::empty_report();
+ report.taxonomies.push(import::taxonomy_candidate(
+ TaxonomyKind::Tag,
+ "Legacy News",
+ false,
+ ));
+ let error = import::auto_map_taxonomy(db.conn(), _dir.path(), &_project.id, true, &mut report)
+ .unwrap_err();
+ assert!(error.to_string().contains("airplane"));
+ assert!(report.taxonomies[0].mapped_to.is_none());
+}
+
+#[test]
+fn execution_rejects_a_saved_taxonomy_mapping_to_a_nonexistent_term() {
+ let (db, dir, project) = setup();
+ let mut report = import::empty_report();
+ let mut candidate = import::taxonomy_candidate(TaxonomyKind::Category, "Old", false);
+ candidate.mapped_to = Some("Does Not Exist".to_string());
+ report.taxonomies.push(candidate);
+
+ let error = import::execute_import(db.conn(), dir.path(), &project.id, &report, None, None)
+ .unwrap_err();
+ assert!(error.to_string().contains("mapping target does not exist"));
+ assert!(
+ !meta::read_categories_json(dir.path())
+ .unwrap()
+ .iter()
+ .any(|category| category == "Old")
+ );
+}
+
+#[test]
+fn execution_commits_complete_500_item_batches_and_rolls_back_database_and_files_for_failure() {
+ let (db, dir, project) = setup();
+ let wxr = dir.path().join("legacy.xml");
+ fs::write(&wxr, sample_wxr("")).unwrap();
+ let mut report =
+ import::analyze_wxr(db.conn(), dir.path(), &project.id, &wxr, None, None).unwrap();
+ let template = report.posts[0].clone();
+ report.taxonomies.clear();
+ report.media.clear();
+ report.pages.clear();
+ report.posts = (0..=500)
+ .map(|index| {
+ let mut item = template.clone();
+ item.source_id = Some(10_000 + index);
+ item.title = format!("Batch {index}");
+ item.slug = Some(format!("batch-{index}"));
+ item.status = ImportItemStatus::New;
+ item.resolution = None;
+ item.existing_id = None;
+ item
+ })
+ .collect();
+ let mut failing = template;
+ failing.title = "Fail after file write".to_string();
+ failing.slug = Some("batch-500".to_string());
+ failing.status = ImportItemStatus::New;
+ failing.resolution = None;
+ failing.existing_id = None;
+ report.posts.push(failing);
+
+ assert!(
+ import::execute_import(db.conn(), dir.path(), &project.id, &report, None, None).is_err()
+ );
+ let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id).unwrap();
+ assert_eq!(posts.len(), 500);
+ assert!(posts.iter().any(|post| post.slug == "batch-499"));
+ assert!(!posts.iter().any(|post| post.slug == "batch-500"));
+ assert!(!dir.path().join("posts/2024/05/batch-500.md").exists());
+ assert!(dir.path().join("posts/2024/05/batch-499.md").is_file());
+}
+
+#[test]
+fn failed_media_batch_restores_an_overwritten_binary_sidecar_and_database_row() {
+ let (db, dir, project) = setup();
+ let old_image = dir.path().join("old.png");
+ DynamicImage::new_rgb8(2, 2).save(&old_image).unwrap();
+ let existing = bds_core::engine::media::import_media(
+ db.conn(),
+ dir.path(),
+ &project.id,
+ &old_image,
+ "photo.png",
+ Some("Old title"),
+ Some("Old alt"),
+ None,
+ None,
+ None,
+ Vec::new(),
+ )
+ .unwrap();
+ let original_binary = fs::read(dir.path().join(&existing.file_path)).unwrap();
+ let original_sidecar = fs::read(dir.path().join(&existing.sidecar_path)).unwrap();
+
+ let uploads = dir.path().join("uploads/2024/05");
+ fs::create_dir_all(&uploads).unwrap();
+ write_image(&uploads.join("photo.png"));
+ let wxr = dir.path().join("rollback-overwrite.xml");
+ fs::write(&wxr, sample_wxr("")).unwrap();
+ let mut report = import::analyze_wxr(
+ db.conn(),
+ dir.path(),
+ &project.id,
+ &wxr,
+ Some(dir.path().join("uploads").as_path()),
+ None,
+ )
+ .unwrap();
+ report.taxonomies.clear();
+ report.posts.clear();
+ report.pages.clear();
+ import::set_conflict_resolution(
+ &mut report,
+ ImportItemKind::Media,
+ "photo.png",
+ ImportResolution::Overwrite,
+ )
+ .unwrap();
+ let mut missing = report.media[0].clone();
+ missing.title = "Missing".to_string();
+ missing.filename = Some("missing.png".to_string());
+ missing.source_path = Some(
+ dir.path()
+ .join("missing.png")
+ .to_string_lossy()
+ .into_owned(),
+ );
+ missing.status = ImportItemStatus::New;
+ missing.resolution = None;
+ missing.existing_id = None;
+ report.media.push(missing);
+
+ assert!(
+ import::execute_import(db.conn(), dir.path(), &project.id, &report, None, None).is_err()
+ );
+ let restored = bds_core::db::queries::media::get_media_by_id(db.conn(), &existing.id).unwrap();
+ assert_eq!(
+ serde_json::to_value(&restored).unwrap(),
+ serde_json::to_value(&existing).unwrap()
+ );
+ assert_eq!(
+ fs::read(dir.path().join(&existing.file_path)).unwrap(),
+ original_binary
+ );
+ assert_eq!(
+ fs::read(dir.path().join(&existing.sidecar_path)).unwrap(),
+ original_sidecar
+ );
+}
+
+fn sample_wxr(extra: &str) -> String {
+ format!(
+ r#"
+
+Legacy & Bloghttps://legacy.exampleen{extra}
+general
+news
+- Hello WorldWed, 01 May 2024 12:00:00 +0000Hello world.
[gallery ids="1,2"]
]]>Legacy hello1012024-05-01 12:00:002024-05-02 12:30:00hello-worldpublishpost
+- AboutThu, 02 May 2024 12:00:00 +0000ImporterAbout page]]>2012024-05-02 12:00:002024-05-02 12:30:00aboutdraftpageGeneral
+- PhotoFri, 03 May 2024 12:00:00 +0000301101photoinheritattachmenthttps://legacy.example/wp-content/uploads/2024/05/photo.png
+"#
+ )
+}
+
+fn attachment_item(id: i64, title: &str, filename: &str) -> String {
+ format!(
+ r#"- {title}Fri, 03 May 2024 12:00:00 +0000{title}{id}101{title}inheritattachmenthttps://legacy.example/wp-content/uploads/2024/05/{filename}
"#
+ )
+}
diff --git a/crates/bds-ui/Cargo.toml b/crates/bds-ui/Cargo.toml
index c202549..fea7d31 100644
--- a/crates/bds-ui/Cargo.toml
+++ b/crates/bds-ui/Cargo.toml
@@ -17,6 +17,7 @@ open = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }
uuid = { workspace = true }
+futures = { workspace = true }
wry = "0.55"
[target.'cfg(target_os = "macos")'.dependencies]
diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs
index 9d7ece6..b184b80 100644
--- a/crates/bds-ui/src/app.rs
+++ b/crates/bds-ui/src/app.rs
@@ -13,8 +13,8 @@ use bds_core::engine::ai::{self, AiEndpointConfig, AiEndpointKind};
use bds_core::engine::task::{TaskId, TaskManager, TaskStatus};
use bds_core::i18n::{UiLocale, detect_os_locale};
use bds_core::model::{
- Media, Post, PostStatus, PostTranslation, Project, PublishingPreferences, Script, SshMode,
- Template,
+ ImportDefinition, ImportReport, Media, Post, PostStatus, PostTranslation, Project,
+ PublishingPreferences, Script, SshMode, Template,
};
use crate::components::webview::{self, WebViewConfig, WebViewController};
@@ -32,6 +32,9 @@ use crate::views::{
DashboardTimelineMonth,
},
git::{GitDiffLoad, GitDiffState, GitNetworkCompletion, GitSnapshot, GitUiState},
+ import_editor::{
+ ImportAnalysisEvent, ImportEditorMsg, ImportEditorState, ImportExecutionEvent,
+ },
media_editor::{LinkedPostItem, MediaEditorMsg, MediaEditorState},
metadata_diff::MetadataDiffState,
modal,
@@ -118,6 +121,26 @@ pub enum Message {
media_id: String,
result: Result, String>,
},
+ ImportUploadsPicked {
+ definition_id: String,
+ path: Option,
+ },
+ ImportWxrPicked {
+ definition_id: String,
+ path: Option,
+ },
+ ImportAnalysisEvent {
+ definition_id: String,
+ event: ImportAnalysisEvent,
+ },
+ ImportExecutionEvent {
+ definition_id: String,
+ event: ImportExecutionEvent,
+ },
+ ImportAutoMapFinished {
+ definition_id: String,
+ result: Result<(ImportDefinition, ImportReport, usize), String>,
+ },
// Tasks
TaskTick,
@@ -269,6 +292,7 @@ pub enum Message {
ScriptEditor(ScriptEditorMsg),
Tags(TagsMsg),
Settings(SettingsMsg),
+ ImportEditor(ImportEditorMsg),
// Editor data loading
PostLoaded(Result),
@@ -295,6 +319,7 @@ pub enum Message {
CreateMedia,
CreateScript,
CreateTemplate,
+ CreateImport,
Noop,
InitMenuBar,
@@ -798,6 +823,7 @@ pub struct BdsApp {
sidebar_media: Vec,
sidebar_scripts: Vec