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

37
Cargo.lock generated
View File

@@ -804,6 +804,7 @@ dependencies = [
"diesel_migrations", "diesel_migrations",
"fluent-bundle", "fluent-bundle",
"fluent-syntax", "fluent-syntax",
"htmd",
"image 0.25.10", "image 0.25.10",
"keyring", "keyring",
"libsqlite3-sys", "libsqlite3-sys",
@@ -814,6 +815,7 @@ dependencies = [
"pulldown-cmark", "pulldown-cmark",
"quick-xml 0.41.0", "quick-xml 0.41.0",
"rayon", "rayon",
"regex",
"reqwest", "reqwest",
"rust-stemmers", "rust-stemmers",
"serde", "serde",
@@ -849,6 +851,7 @@ dependencies = [
"chrono", "chrono",
"dirs 5.0.1", "dirs 5.0.1",
"fluent-syntax", "fluent-syntax",
"futures",
"iced", "iced",
"muda", "muda",
"objc2 0.6.4", "objc2 0.6.4",
@@ -3090,6 +3093,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" 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]] [[package]]
name = "html-escape" name = "html-escape"
version = "0.2.13" version = "0.2.13"
@@ -4341,6 +4355,18 @@ dependencies = [
"web_atoms", "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]] [[package]]
name = "matchit" name = "matchit"
version = "0.8.4" version = "0.8.4"
@@ -7103,6 +7129,7 @@ dependencies = [
"parking_lot 0.12.5", "parking_lot 0.12.5",
"phf_shared 0.13.1", "phf_shared 0.13.1",
"precomputed-hash", "precomputed-hash",
"serde",
] ]
[[package]] [[package]]
@@ -9443,6 +9470,16 @@ version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" 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]] [[package]]
name = "xmlwriter" name = "xmlwriter"
version = "0.1.0" version = "0.1.0"

View File

@@ -27,6 +27,7 @@ anyhow = "1"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
axum = "0.8" axum = "0.8"
walkdir = "2" walkdir = "2"
futures = "0.3"
image = "0.25" image = "0.25"
rust-stemmers = "1" rust-stemmers = "1"
sys-locale = "0.3" sys-locale = "0.3"
@@ -38,6 +39,8 @@ pulldown-cmark = "0.13"
liquid = "0.26" liquid = "0.26"
liquid-core = { version = "0.26", features = ["derive"] } liquid-core = { version = "0.26", features = ["derive"] }
quick-xml = "0.41" quick-xml = "0.41"
htmd = "0.5.4"
regex = "1"
rayon = "1.10" rayon = "1.10"
pagefind = "1.5.2" pagefind = "1.5.2"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }

View File

@@ -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. - 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. - 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. - 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. - 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. - 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. - Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation through cancellable section task groups.

View File

@@ -99,7 +99,7 @@ Available:
- Configurable online and airplane-mode OpenAI-compatible endpoints. - Configurable online and airplane-mode OpenAI-compatible endpoints.
- Secure API-key storage through the operating-system keychain. - Secure API-key storage through the operating-system keychain.
- Model catalog discovery and model selection. - 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. - Explicit offline gating and user-visible errors.
- SQLite fields for input, output, cache-read, and cache-write token usage. - SQLite fields for input, output, cache-read, and cache-write token usage.

View File

@@ -22,12 +22,14 @@ Open:
- Expose `bds.sync` through the scripting API using the shared Git workflow. - 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. - Streaming, untrusted-input-safe WXR parsing for site metadata, posts, pages, attachments, categories, and tags.
- The existing media importer is core functionality and does not implement this workflow. - 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 ### Conversational AI and agent tools — Open
@@ -39,7 +41,7 @@ Open:
- Agent integrations such as Claude Code and Copilot where required by the specs. - Agent integrations such as Claude Code and Copilot where required by the specs.
- Replace the current Chat placeholders with the working feature. - 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 ### Embeddings, semantic search, and duplicates — Open
@@ -123,12 +125,11 @@ Open:
## Suggested Order ## Suggested Order
1. WordPress import. 1. CLI, MCP, and domain events.
2. CLI, MCP, and domain events. 2. Conversational AI and agent tools.
3. Conversational AI and agent tools. 3. Embeddings and duplicate detection.
4. Embeddings and duplicate detection. 4. Documentation and menu UX.
5. Documentation and menu UX. 5. Headless server and TUI.
6. Headless server and TUI. 6. A2UI after conversational AI exists.
7. 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. 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.

View File

@@ -23,6 +23,8 @@ pulldown-cmark = { workspace = true }
liquid = { workspace = true } liquid = { workspace = true }
liquid-core = { workspace = true } liquid-core = { workspace = true }
quick-xml = { workspace = true } quick-xml = { workspace = true }
htmd = { workspace = true }
regex = { workspace = true }
rayon = { workspace = true } rayon = { workspace = true }
pagefind = { workspace = true } pagefind = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }

View File

@@ -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<ImportDefinition> {
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<Vec<ImportDefinition>> {
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(|_| ())
})
}

View File

@@ -1,4 +1,5 @@
pub mod generated_file_hash; pub mod generated_file_hash;
pub mod import_definition;
pub mod media; pub mod media;
pub mod media_translation; pub mod media_translation;
pub mod post; pub mod post;

View File

@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::time::Duration; use std::time::Duration;
use crate::db::DbConnection as Connection; use crate::db::DbConnection as Connection;
@@ -83,6 +84,7 @@ pub struct AiModelInfo {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum OneShotOperation { pub enum OneShotOperation {
AnalyzeTaxonomy, AnalyzeTaxonomy,
MapImportTaxonomy,
AnalyzePost, AnalyzePost,
DetectLanguage, DetectLanguage,
TranslatePost { target_language: String }, TranslatePost { target_language: String },
@@ -102,6 +104,12 @@ pub struct TaxonomySuggestion {
pub categories: Vec<String>, 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PostAnalysisResult { pub struct PostAnalysisResult {
pub title: String, pub title: String,
@@ -146,6 +154,7 @@ pub struct TokenUsage {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum OneShotResponse { pub enum OneShotResponse {
Taxonomy(TaxonomySuggestion), Taxonomy(TaxonomySuggestion),
ImportTaxonomyMapping(ImportTaxonomyMapping),
PostAnalysis(PostAnalysisResult), PostAnalysis(PostAnalysisResult),
LanguageDetection(LanguageDetectionResult), LanguageDetection(LanguageDetectionResult),
Translation(TranslationResult), Translation(TranslationResult),
@@ -451,6 +460,7 @@ fn select_model(
let selected = match operation { let selected = match operation {
OneShotOperation::AnalyzeImage => settings.image_model.as_ref(), OneShotOperation::AnalyzeImage => settings.image_model.as_ref(),
OneShotOperation::AnalyzeTaxonomy OneShotOperation::AnalyzeTaxonomy
| OneShotOperation::MapImportTaxonomy
| OneShotOperation::AnalyzePost | OneShotOperation::AnalyzePost
| OneShotOperation::DetectLanguage | OneShotOperation::DetectLanguage
| OneShotOperation::TranslatePost { .. } | OneShotOperation::TranslatePost { .. }
@@ -475,6 +485,9 @@ fn build_system_prompt(base_prompt: &str, operation: &OneShotOperation) -> Strin
OneShotOperation::AnalyzeTaxonomy => { OneShotOperation::AnalyzeTaxonomy => {
"Return only JSON with tags and categories for the post." "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 => { OneShotOperation::AnalyzePost => {
"Return only JSON with title, excerpt, and slug suggestions for the post." "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)? serde_json::to_string(&request.content)?
) )
.into()), .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!( OneShotOperation::AnalyzePost => Ok(format!(
"Analyze this post and suggest title, excerpt, and slug: {}", "Analyze this post and suggest title, excerpt, and slug: {}",
serde_json::to_string(&request.content)? serde_json::to_string(&request.content)?
@@ -580,6 +598,24 @@ fn response_schema(operation: &OneShotOperation) -> (&'static str, Value) {
"required": ["tags", "categories"] "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 => ( OneShotOperation::AnalyzePost => (
"post_analysis", "post_analysis",
json!({ json!({
@@ -654,6 +690,9 @@ fn parse_one_shot_response(
OneShotOperation::AnalyzeTaxonomy => { OneShotOperation::AnalyzeTaxonomy => {
OneShotResponse::Taxonomy(serde_json::from_str(content)?) OneShotResponse::Taxonomy(serde_json::from_str(content)?)
} }
OneShotOperation::MapImportTaxonomy => {
OneShotResponse::ImportTaxonomyMapping(serde_json::from_str(content)?)
}
OneShotOperation::AnalyzePost => { OneShotOperation::AnalyzePost => {
OneShotResponse::PostAnalysis(serde_json::from_str(content)?) 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] #[test]
fn run_one_shot_supports_post_analysis_via_airplane_endpoint() { fn run_one_shot_supports_post_analysis_via_airplane_endpoint() {
let response = run_airplane_one_shot( let response = run_airplane_one_shot(

View File

@@ -63,6 +63,43 @@ pub fn import_media(
author: Option<&str>, author: Option<&str>,
language: Option<&str>, language: Option<&str>,
tags: Vec<String>, 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> { ) -> EngineResult<Media> {
// Validate file type per spec // Validate file type per spec
let ext = Path::new(original_name) let ext = Path::new(original_name)
@@ -77,12 +114,10 @@ pub fn import_media(
} }
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
let now = now_unix_ms();
let filename = format!("{id}.{ext}"); let filename = format!("{id}.{ext}");
// Compute target directory and copy file // 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 rel_file_path = format!("{dir_path}{filename}");
let abs_file_path = data_dir.join(&rel_file_path); let abs_file_path = data_dir.join(&rel_file_path);
@@ -128,8 +163,8 @@ pub fn import_media(
sidecar_path: sidecar_rel.clone(), sidecar_path: sidecar_rel.clone(),
checksum: Some(checksum), checksum: Some(checksum),
tags, tags,
created_at: now, created_at,
updated_at: now, updated_at: created_at,
}; };
// Write sidecar // Write sidecar

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,224 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ImportItemKind {
Post,
Page,
Media,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ImportItemStatus {
New,
Update,
Conflict,
ContentDuplicate,
Missing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ImportResolution {
Ignore,
Overwrite,
Import,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaxonomyKind {
Category,
Tag,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ImportedSite {
pub title: String,
pub url: Option<String>,
pub language: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImportCandidate {
pub kind: ImportItemKind,
pub source_id: Option<i64>,
pub title: String,
pub slug: Option<String>,
pub filename: Option<String>,
pub relative_path: Option<String>,
pub status: ImportItemStatus,
pub resolution: Option<ImportResolution>,
pub existing_id: Option<String>,
pub author: Option<String>,
pub excerpt: Option<String>,
pub content: Option<String>,
pub source_status: Option<String>,
#[serde(default)]
pub categories: Vec<String>,
#[serde(default)]
pub tags: Vec<String>,
pub source_path: Option<String>,
pub parent_source_id: Option<i64>,
pub created_at: Option<i64>,
pub updated_at: Option<i64>,
pub published_at: Option<i64>,
pub checksum: Option<String>,
pub mime_type: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaxonomyCandidate {
pub kind: TaxonomyKind,
pub name: String,
pub slug: Option<String>,
pub exists_in_project: bool,
pub mapped_to: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ImportCounts {
pub new_count: usize,
pub update_count: usize,
pub conflict_count: usize,
pub duplicate_count: usize,
pub missing_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImportDateBucket {
pub year: i32,
pub post_count: usize,
pub media_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImportMacroUsage {
pub name: String,
pub total_count: usize,
#[serde(default)]
pub post_slugs: Vec<String>,
#[serde(default)]
pub parameters: Vec<BTreeMap<String, String>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ImportReport {
pub source_file: String,
pub uploads_folder: Option<String>,
pub site: ImportedSite,
#[serde(default)]
pub posts: Vec<ImportCandidate>,
#[serde(default)]
pub pages: Vec<ImportCandidate>,
#[serde(default)]
pub media: Vec<ImportCandidate>,
#[serde(default)]
pub taxonomies: Vec<TaxonomyCandidate>,
pub post_counts: ImportCounts,
pub page_counts: ImportCounts,
pub media_counts: ImportCounts,
#[serde(default)]
pub date_distribution: Vec<ImportDateBucket>,
#[serde(default)]
pub macros: Vec<ImportMacroUsage>,
}
impl ImportReport {
pub fn importable_count(&self) -> usize {
let taxonomy = self
.taxonomies
.iter()
.filter(|item| !item.exists_in_project && item.mapped_to.is_none())
.count();
taxonomy
+ self
.posts
.iter()
.chain(&self.media)
.chain(&self.pages)
.filter(|item| match item.status {
ImportItemStatus::New => true,
ImportItemStatus::Conflict => matches!(
item.resolution,
Some(ImportResolution::Overwrite | ImportResolution::Import)
),
_ => false,
})
.count()
}
}
#[derive(
Debug,
Clone,
PartialEq,
Eq,
Serialize,
Deserialize,
diesel::Queryable,
diesel::Selectable,
diesel::Insertable,
diesel::AsChangeset,
)]
#[diesel(
table_name = crate::db::schema::import_definitions,
check_for_backend(diesel::sqlite::Sqlite)
)]
#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)]
pub struct ImportDefinition {
pub id: String,
pub project_id: String,
pub name: String,
pub wxr_file_path: Option<String>,
pub uploads_folder_path: Option<String>,
pub last_analysis_result: Option<String>,
pub created_at: i64,
pub updated_at: i64,
}
impl ImportDefinition {
pub fn analysis(&self) -> Result<Option<ImportReport>, serde_json::Error> {
self.last_analysis_result
.as_deref()
.map(serde_json::from_str)
.transpose()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ImportPhase {
Taxonomy,
Posts,
Media,
Pages,
Complete,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImportProgress {
pub phase: ImportPhase,
pub current: usize,
pub total: usize,
pub detail: String,
pub eta_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ImportExecutionCounts {
pub imported: usize,
pub skipped: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ImportExecutionResult {
pub taxonomy: ImportExecutionCounts,
pub posts: ImportExecutionCounts,
pub media: ImportExecutionCounts,
pub pages: ImportExecutionCounts,
}

View File

@@ -1,4 +1,5 @@
mod generation; mod generation;
mod import;
mod media; mod media;
pub mod metadata; pub mod metadata;
mod post; mod post;
@@ -11,6 +12,11 @@ pub use generation::{
DbNotification, GeneratedFileHash, NotificationAction, NotificationEntity, DbNotification, GeneratedFileHash, NotificationAction, NotificationEntity,
PublishingPreferences, SshMode, PublishingPreferences, SshMode,
}; };
pub use import::{
ImportCandidate, ImportCounts, ImportDateBucket, ImportDefinition, ImportExecutionCounts,
ImportExecutionResult, ImportItemKind, ImportItemStatus, ImportMacroUsage, ImportPhase,
ImportProgress, ImportReport, ImportResolution, ImportedSite, TaxonomyCandidate, TaxonomyKind,
};
pub use media::{Media, MediaTranslation}; pub use media::{Media, MediaTranslation};
pub use metadata::{CategorySettings, ProjectMetadata, TagEntry}; pub use metadata::{CategorySettings, ProjectMetadata, TagEntry};
pub use post::{Post, PostLink, PostMedia, PostStatus, PostTranslation}; pub use post::{Post, PostLink, PostMedia, PostStatus, PostTranslation};

View File

@@ -1738,6 +1738,7 @@ fn post_ai_content(data_dir: &Path, post: &Post) -> HostResult<Value> {
fn one_shot_json(value: engine::ai::OneShotResponse) -> HostResult<Value> { fn one_shot_json(value: engine::ai::OneShotResponse) -> HostResult<Value> {
let value = match value { let value = match value {
engine::ai::OneShotResponse::Taxonomy(value) => serde_json::to_value(value), engine::ai::OneShotResponse::Taxonomy(value) => serde_json::to_value(value),
engine::ai::OneShotResponse::ImportTaxonomyMapping(value) => serde_json::to_value(value),
engine::ai::OneShotResponse::PostAnalysis(value) => serde_json::to_value(value), engine::ai::OneShotResponse::PostAnalysis(value) => serde_json::to_value(value),
engine::ai::OneShotResponse::LanguageDetection(value) => serde_json::to_value(value), engine::ai::OneShotResponse::LanguageDetection(value) => serde_json::to_value(value),
engine::ai::OneShotResponse::Translation(value) => serde_json::to_value(value), engine::ai::OneShotResponse::Translation(value) => serde_json::to_value(value),

View File

@@ -0,0 +1,807 @@
use std::fs;
use std::path::Path;
use bds_core::db::Database;
use bds_core::db::fts::ensure_fts_tables;
use bds_core::engine::{meta, post, project, tag, wordpress_import as import};
use bds_core::model::{
ImportItemKind, ImportItemStatus, ImportResolution, PostStatus, TaxonomyKind,
};
use bds_core::util::frontmatter::read_post_file;
use bds_core::util::sidecar::read_sidecar;
use image::DynamicImage;
use tempfile::TempDir;
fn setup() -> (Database, TempDir, bds_core::model::Project) {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
ensure_fts_tables(db.conn()).unwrap();
let dir = TempDir::new().unwrap();
let project = project::create_project(
db.conn(),
"WordPress Import",
Some(dir.path().to_str().unwrap()),
)
.unwrap();
(db, dir, project)
}
fn write_image(path: &Path) {
DynamicImage::new_rgb8(4, 3).save(path).unwrap();
}
#[test]
fn parser_rejects_malformed_or_channel_less_xml_and_ignores_unknown_elements() {
assert!(import::parse_wxr_xml("<rss><channel>").is_err());
assert!(import::parse_wxr_xml("<rss></rss>").is_err());
let parsed = import::parse_wxr_xml(&sample_wxr(
"<unknown:payload>ignored</unknown:payload><item><title>Menu</title><wp:post_type>nav_menu_item</wp:post_type></item>",
))
.unwrap();
assert_eq!(parsed.site.title, "Legacy & Blog");
assert_eq!(parsed.posts.len(), 1);
assert_eq!(parsed.pages.len(), 1);
assert_eq!(parsed.media.len(), 1);
assert_eq!(parsed.categories[0].name, "General");
assert_eq!(parsed.tags[0].name, "News");
}
#[test]
fn analysis_converts_html_and_shortcodes_and_classifies_every_status() {
let (db, dir, project) = setup();
meta::add_category(dir.path(), "GENERAL").unwrap();
tag::create_tag(db.conn(), dir.path(), &project.id, "nEWs", None).unwrap();
let update = post::create_post(
db.conn(),
dir.path(),
&project.id,
"Update",
Some("Update body"),
vec![],
vec![],
None,
None,
None,
)
.unwrap();
post::update_post(
db.conn(),
dir.path(),
&update.id,
None,
Some("hello-world"),
None,
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
let duplicate = post::create_post(
db.conn(),
dir.path(),
&project.id,
"Duplicate",
Some("Duplicate body"),
vec![],
vec![],
None,
None,
None,
)
.unwrap();
assert_ne!(update.id, duplicate.id);
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 mut progress = Vec::new();
let report = import::analyze_wxr(
db.conn(),
dir.path(),
&project.id,
&wxr,
Some(dir.path().join("uploads").as_path()),
Some(&mut |value| progress.push(value)),
)
.unwrap();
assert!(
report.posts[0]
.content
.as_deref()
.unwrap()
.contains("**world**")
);
assert!(
report.posts[0]
.content
.as_deref()
.unwrap()
.contains("[[gallery ids=\"1,2\"]]"),
"{}",
report.posts[0].content.as_deref().unwrap()
);
assert_eq!(report.posts[0].status, ImportItemStatus::Conflict);
assert_eq!(report.posts[0].resolution, Some(ImportResolution::Ignore));
assert_eq!(report.pages[0].status, ImportItemStatus::New);
assert_eq!(report.media[0].status, ImportItemStatus::New);
assert!(
report
.taxonomies
.iter()
.any(|item| { item.kind == TaxonomyKind::Category && item.exists_in_project })
);
assert!(
report
.taxonomies
.iter()
.any(|item| { item.kind == TaxonomyKind::Tag && item.exists_in_project })
);
assert_eq!(report.date_distribution[0].year, 2024);
assert_eq!(report.macros[0].name, "gallery");
assert!(!progress.is_empty());
}
#[test]
fn analysis_distinguishes_new_updates_conflicts_duplicates_and_missing_media() {
let (db, dir, project) = setup();
let uploads = dir.path().join("uploads/2024/05");
fs::create_dir_all(&uploads).unwrap();
write_image(&uploads.join("same.png"));
fs::copy(uploads.join("same.png"), uploads.join("copy.png")).unwrap();
DynamicImage::new_rgb8(5, 3)
.save(uploads.join("different.png"))
.unwrap();
let old_different = dir.path().join("old-different.png");
DynamicImage::new_rgb8(2, 2).save(&old_different).unwrap();
bds_core::engine::media::import_media(
db.conn(),
dir.path(),
&project.id,
&uploads.join("same.png"),
"same.png",
None,
None,
None,
None,
None,
Vec::new(),
)
.unwrap();
bds_core::engine::media::import_media(
db.conn(),
dir.path(),
&project.id,
&old_different,
"different.png",
None,
None,
None,
None,
None,
Vec::new(),
)
.unwrap();
let same_body = "Hello **world**.\n\n[[gallery ids=\"1,2\"]]";
let update = post::create_post(
db.conn(),
dir.path(),
&project.id,
"Hello World",
Some(same_body),
vec![],
vec![],
None,
None,
None,
)
.unwrap();
assert_eq!(update.slug, "hello-world");
let collision = post::create_post(
db.conn(),
dir.path(),
&project.id,
"Collision",
Some("Local body"),
vec![],
vec![],
None,
None,
None,
)
.unwrap();
assert_eq!(collision.slug, "collision");
post::create_post(
db.conn(),
dir.path(),
&project.id,
"Local Duplicate",
Some("Shared duplicate body"),
vec![],
vec![],
None,
None,
None,
)
.unwrap();
let extras = format!(
r#"
<item><title>Collision</title><content:encoded><![CDATA[Incoming body]]></content:encoded><wp:post_id>102</wp:post_id><wp:post_name>collision</wp:post_name><wp:status>draft</wp:status><wp:post_type>post</wp:post_type></item>
<item><title>Duplicate Content</title><content:encoded><![CDATA[<p>Shared duplicate body</p>]]></content:encoded><wp:post_id>103</wp:post_id><wp:post_name>duplicate-content</wp:post_name><wp:status>draft</wp:status><wp:post_type>post</wp:post_type></item>
{}
{}
{}
{}
{}"#,
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::<Vec<_>>();
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::<Vec<_>>();
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#"<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:excerpt="http://wordpress.org/export/1.2/excerpt/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:wp="http://wordpress.org/export/1.2/" xmlns:unknown="urn:unknown">
<channel><title>Legacy &amp; Blog</title><link>https://legacy.example</link><language>en</language>{extra}
<wp:category><wp:cat_name><![CDATA[General]]></wp:cat_name><wp:category_nicename>general</wp:category_nicename><wp:category_parent></wp:category_parent></wp:category>
<wp:tag><wp:tag_slug>news</wp:tag_slug><wp:tag_name><![CDATA[News]]></wp:tag_name></wp:tag>
<item><title>Hello World</title><pubDate>Wed, 01 May 2024 12:00:00 +0000</pubDate><dc:creator><![CDATA[Importer]]></dc:creator><content:encoded><![CDATA[<p>Hello <strong>world</strong>.</p><p>[gallery ids="1,2"]</p>]]></content:encoded><excerpt:encoded>Legacy hello</excerpt:encoded><wp:post_id>101</wp:post_id><wp:post_date>2024-05-01 12:00:00</wp:post_date><wp:post_modified>2024-05-02 12:30:00</wp:post_modified><wp:post_name>hello-world</wp:post_name><wp:status>publish</wp:status><wp:post_type>post</wp:post_type><category domain="category"><![CDATA[General]]></category><category domain="post_tag"><![CDATA[News]]></category></item>
<item><title>About</title><pubDate>Thu, 02 May 2024 12:00:00 +0000</pubDate><dc:creator>Importer</dc:creator><content:encoded><![CDATA[<p>About page</p>]]></content:encoded><wp:post_id>201</wp:post_id><wp:post_date>2024-05-02 12:00:00</wp:post_date><wp:post_modified>2024-05-02 12:30:00</wp:post_modified><wp:post_name>about</wp:post_name><wp:status>draft</wp:status><wp:post_type>page</wp:post_type><category domain="category">General</category></item>
<item><title>Photo</title><pubDate>Fri, 03 May 2024 12:00:00 +0000</pubDate><content:encoded><![CDATA[Photo description]]></content:encoded><wp:post_id>301</wp:post_id><wp:post_parent>101</wp:post_parent><wp:post_name>photo</wp:post_name><wp:status>inherit</wp:status><wp:post_type>attachment</wp:post_type><wp:attachment_url>https://legacy.example/wp-content/uploads/2024/05/photo.png</wp:attachment_url></item>
</channel></rss>"#
)
}
fn attachment_item(id: i64, title: &str, filename: &str) -> String {
format!(
r#"<item><title>{title}</title><pubDate>Fri, 03 May 2024 12:00:00 +0000</pubDate><content:encoded>{title}</content:encoded><wp:post_id>{id}</wp:post_id><wp:post_parent>101</wp:post_parent><wp:post_name>{title}</wp:post_name><wp:status>inherit</wp:status><wp:post_type>attachment</wp:post_type><wp:attachment_url>https://legacy.example/wp-content/uploads/2024/05/{filename}</wp:attachment_url></item>"#
)
}

View File

@@ -17,6 +17,7 @@ open = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
futures = { workspace = true }
wry = "0.55" wry = "0.55"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]

View File

@@ -13,8 +13,8 @@ use bds_core::engine::ai::{self, AiEndpointConfig, AiEndpointKind};
use bds_core::engine::task::{TaskId, TaskManager, TaskStatus}; use bds_core::engine::task::{TaskId, TaskManager, TaskStatus};
use bds_core::i18n::{UiLocale, detect_os_locale}; use bds_core::i18n::{UiLocale, detect_os_locale};
use bds_core::model::{ use bds_core::model::{
Media, Post, PostStatus, PostTranslation, Project, PublishingPreferences, Script, SshMode, ImportDefinition, ImportReport, Media, Post, PostStatus, PostTranslation, Project,
Template, PublishingPreferences, Script, SshMode, Template,
}; };
use crate::components::webview::{self, WebViewConfig, WebViewController}; use crate::components::webview::{self, WebViewConfig, WebViewController};
@@ -32,6 +32,9 @@ use crate::views::{
DashboardTimelineMonth, DashboardTimelineMonth,
}, },
git::{GitDiffLoad, GitDiffState, GitNetworkCompletion, GitSnapshot, GitUiState}, git::{GitDiffLoad, GitDiffState, GitNetworkCompletion, GitSnapshot, GitUiState},
import_editor::{
ImportAnalysisEvent, ImportEditorMsg, ImportEditorState, ImportExecutionEvent,
},
media_editor::{LinkedPostItem, MediaEditorMsg, MediaEditorState}, media_editor::{LinkedPostItem, MediaEditorMsg, MediaEditorState},
metadata_diff::MetadataDiffState, metadata_diff::MetadataDiffState,
modal, modal,
@@ -118,6 +121,26 @@ pub enum Message {
media_id: String, media_id: String,
result: Result<Option<Media>, String>, result: Result<Option<Media>, String>,
}, },
ImportUploadsPicked {
definition_id: String,
path: Option<PathBuf>,
},
ImportWxrPicked {
definition_id: String,
path: Option<PathBuf>,
},
ImportAnalysisEvent {
definition_id: String,
event: ImportAnalysisEvent,
},
ImportExecutionEvent {
definition_id: String,
event: ImportExecutionEvent,
},
ImportAutoMapFinished {
definition_id: String,
result: Result<(ImportDefinition, ImportReport, usize), String>,
},
// Tasks // Tasks
TaskTick, TaskTick,
@@ -269,6 +292,7 @@ pub enum Message {
ScriptEditor(ScriptEditorMsg), ScriptEditor(ScriptEditorMsg),
Tags(TagsMsg), Tags(TagsMsg),
Settings(SettingsMsg), Settings(SettingsMsg),
ImportEditor(ImportEditorMsg),
// Editor data loading // Editor data loading
PostLoaded(Result<Post, String>), PostLoaded(Result<Post, String>),
@@ -295,6 +319,7 @@ pub enum Message {
CreateMedia, CreateMedia,
CreateScript, CreateScript,
CreateTemplate, CreateTemplate,
CreateImport,
Noop, Noop,
InitMenuBar, InitMenuBar,
@@ -798,6 +823,7 @@ pub struct BdsApp {
sidebar_media: Vec<Media>, sidebar_media: Vec<Media>,
sidebar_scripts: Vec<Script>, sidebar_scripts: Vec<Script>,
sidebar_templates: Vec<Template>, sidebar_templates: Vec<Template>,
sidebar_imports: Vec<ImportDefinition>,
sidebar_media_thumbs: HashMap<String, Option<std::path::PathBuf>>, sidebar_media_thumbs: HashMap<String, Option<std::path::PathBuf>>,
sidebar_posts_has_more: bool, sidebar_posts_has_more: bool,
sidebar_media_has_more: bool, sidebar_media_has_more: bool,
@@ -866,6 +892,7 @@ pub struct BdsApp {
media_editors: HashMap<String, MediaEditorState>, media_editors: HashMap<String, MediaEditorState>,
template_editors: HashMap<String, TemplateEditorState>, template_editors: HashMap<String, TemplateEditorState>,
script_editors: HashMap<String, ScriptEditorState>, script_editors: HashMap<String, ScriptEditorState>,
import_editors: HashMap<String, ImportEditorState>,
tags_view_state: Option<TagsViewState>, tags_view_state: Option<TagsViewState>,
settings_state: Option<SettingsViewState>, settings_state: Option<SettingsViewState>,
dashboard_state: Option<DashboardState>, dashboard_state: Option<DashboardState>,
@@ -967,6 +994,7 @@ impl BdsApp {
sidebar_media: Vec::new(), sidebar_media: Vec::new(),
sidebar_scripts: Vec::new(), sidebar_scripts: Vec::new(),
sidebar_templates: Vec::new(), sidebar_templates: Vec::new(),
sidebar_imports: Vec::new(),
sidebar_media_thumbs: HashMap::new(), sidebar_media_thumbs: HashMap::new(),
sidebar_posts_has_more: false, sidebar_posts_has_more: false,
sidebar_media_has_more: false, sidebar_media_has_more: false,
@@ -1009,6 +1037,7 @@ impl BdsApp {
media_editors: HashMap::new(), media_editors: HashMap::new(),
template_editors: HashMap::new(), template_editors: HashMap::new(),
script_editors: HashMap::new(), script_editors: HashMap::new(),
import_editors: HashMap::new(),
tags_view_state: None, tags_view_state: None,
settings_state: None, settings_state: None,
dashboard_state: None, dashboard_state: None,
@@ -1038,6 +1067,7 @@ impl BdsApp {
sidebar_media: Vec::new(), sidebar_media: Vec::new(),
sidebar_scripts: Vec::new(), sidebar_scripts: Vec::new(),
sidebar_templates: Vec::new(), sidebar_templates: Vec::new(),
sidebar_imports: Vec::new(),
sidebar_media_thumbs: HashMap::new(), sidebar_media_thumbs: HashMap::new(),
sidebar_posts_has_more: false, sidebar_posts_has_more: false,
sidebar_media_has_more: false, sidebar_media_has_more: false,
@@ -1079,6 +1109,7 @@ impl BdsApp {
media_editors: HashMap::new(), media_editors: HashMap::new(),
template_editors: HashMap::new(), template_editors: HashMap::new(),
script_editors: HashMap::new(), script_editors: HashMap::new(),
import_editors: HashMap::new(),
tags_view_state: None, tags_view_state: None,
settings_state: None, settings_state: None,
dashboard_state: None, dashboard_state: None,
@@ -1136,6 +1167,7 @@ impl BdsApp {
), ),
Message::CreateScript => self.create_sidebar_script(), Message::CreateScript => self.create_sidebar_script(),
Message::CreateTemplate => self.create_sidebar_template(), Message::CreateTemplate => self.create_sidebar_template(),
Message::CreateImport => self.create_sidebar_import(),
Message::OpenSettingsSection(section) => { Message::OpenSettingsSection(section) => {
self.sidebar_view = SidebarView::Settings; self.sidebar_view = SidebarView::Settings;
self.sidebar_visible = true; self.sidebar_visible = true;
@@ -1301,7 +1333,7 @@ impl BdsApp {
} }
} }
self.sync_menu_state(); self.sync_menu_state();
self.refresh_git_if_visible() Task::batch([self.refresh_counts(), self.refresh_git_if_visible()])
} }
Message::ProjectSwitched(result) => { Message::ProjectSwitched(result) => {
match result { match result {
@@ -1692,6 +1724,108 @@ impl BdsApp {
} }
self.refresh_sidebar_media() self.refresh_sidebar_media()
} }
Message::ImportUploadsPicked {
definition_id,
path,
} => {
if let Some(path) = path {
self.update_import_paths(&definition_id, None, Some(path.as_path()));
}
Task::none()
}
Message::ImportWxrPicked {
definition_id,
path,
} => {
if let Some(path) = path {
self.update_import_paths(&definition_id, Some(path.as_path()), None);
return self.start_import_analysis(&definition_id);
}
Task::none()
}
Message::ImportAnalysisEvent {
definition_id,
event,
} => {
if let Some(state) = self.import_editors.get_mut(&definition_id) {
match event {
ImportAnalysisEvent::Progress(progress) => state.progress = Some(progress),
ImportAnalysisEvent::Finished(result) => {
state.is_analyzing = false;
match *result {
Ok((definition, report)) => {
state.definition = definition.clone();
state.report = Some(report);
state.error = None;
if let Some(sidebar) = self
.sidebar_imports
.iter_mut()
.find(|item| item.id == definition_id)
{
*sidebar = definition;
}
}
Err(error) => state.error = Some(error),
}
}
}
}
Task::none()
}
Message::ImportExecutionEvent {
definition_id,
event,
} => {
if let Some(state) = self.import_editors.get_mut(&definition_id) {
match event {
ImportExecutionEvent::Progress(progress) => state.progress = Some(progress),
ImportExecutionEvent::Finished(result) => {
state.is_executing = false;
match result {
Ok(result) => {
state.result = Some(result);
state.error = None;
self.notify(
ToastLevel::Success,
&t(self.ui_locale, "import.toast.complete"),
);
return self.refresh_counts();
}
Err(error) => state.error = Some(error),
}
}
}
}
Task::none()
}
Message::ImportAutoMapFinished {
definition_id,
result,
} => {
if let Some(state) = self.import_editors.get_mut(&definition_id) {
state.is_analyzing = false;
match result {
Ok((definition, report, count)) => {
state.definition = definition;
state.report = Some(report);
state.error = None;
self.notify(
ToastLevel::Success,
&tw(
self.ui_locale,
"import.toast.mapped",
&[("count", &count.to_string())],
),
);
}
Err(error) => {
state.error = Some(error.clone());
self.notify(ToastLevel::Error, &error);
}
}
}
Task::none()
}
message @ (Message::MainWindowLoaded(_) | Message::EmbeddedPreviewReady(_)) => { message @ (Message::MainWindowLoaded(_) | Message::EmbeddedPreviewReady(_)) => {
self.handle_preview_message(message) self.handle_preview_message(message)
} }
@@ -2028,6 +2162,7 @@ impl BdsApp {
self.delete_template_editor(&id, true) self.delete_template_editor(&id, true)
} }
modal::ConfirmAction::DeleteTag(id) => self.delete_tag(&id), modal::ConfirmAction::DeleteTag(id) => self.delete_tag(&id),
modal::ConfirmAction::DeleteImport(id) => self.delete_import_definition(&id),
modal::ConfirmAction::MergeTags { sources, target } => { modal::ConfirmAction::MergeTags { sources, target } => {
self.merge_tags(&sources, &target) self.merge_tags(&sources, &target)
} }
@@ -2109,6 +2244,7 @@ impl BdsApp {
Message::ScriptEditor(msg) => self.handle_script_editor_msg(msg), Message::ScriptEditor(msg) => self.handle_script_editor_msg(msg),
Message::Tags(msg) => self.handle_tags_msg(msg), Message::Tags(msg) => self.handle_tags_msg(msg),
Message::Settings(msg) => self.handle_settings_msg(msg), Message::Settings(msg) => self.handle_settings_msg(msg),
Message::ImportEditor(msg) => self.handle_import_editor_msg(msg),
// ── Editor data loading ── // ── Editor data loading ──
Message::PostLoaded(result) => { Message::PostLoaded(result) => {
@@ -2238,6 +2374,7 @@ impl BdsApp {
&self.sidebar_media, &self.sidebar_media,
&self.sidebar_scripts, &self.sidebar_scripts,
&self.sidebar_templates, &self.sidebar_templates,
&self.sidebar_imports,
active_post_filter, active_post_filter,
&self.media_filter, &self.media_filter,
&self.sidebar_media_thumbs, &self.sidebar_media_thumbs,
@@ -2261,6 +2398,7 @@ impl BdsApp {
&self.media_editors, &self.media_editors,
&self.template_editors, &self.template_editors,
&self.script_editors, &self.script_editors,
&self.import_editors,
self.tags_view_state.as_ref(), self.tags_view_state.as_ref(),
self.settings_state.as_ref(), self.settings_state.as_ref(),
self.dashboard_state.as_ref(), self.dashboard_state.as_ref(),
@@ -2643,6 +2781,444 @@ impl BdsApp {
} }
} }
fn create_sidebar_import(&mut self) -> Task<Message> {
let (Some(db), Some(project)) = (&self.db, &self.active_project) else {
return Task::none();
};
match engine::wordpress_import::create_definition(
db.conn(),
&project.id,
&t(self.ui_locale, "import.untitled"),
) {
Ok(definition) => {
self.sidebar_imports.push(definition.clone());
let tab = Tab {
id: definition.id.clone(),
tab_type: TabType::Import,
title: definition.name.clone(),
is_transient: true,
is_dirty: false,
};
let index = tabs::open_tab(&mut self.tabs, tab);
if let Some(tab) = self.tabs.get(index).cloned() {
self.active_tab = Some(tab.id.clone());
self.load_editor_for_tab(&tab);
}
self.sync_menu_state();
}
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
}
Task::none()
}
fn handle_import_editor_msg(&mut self, message: ImportEditorMsg) -> Task<Message> {
let Some(definition_id) = self.active_tab.clone().filter(|id| {
self.tabs
.iter()
.any(|tab| tab.id == *id && tab.tab_type == TabType::Import)
}) else {
return Task::none();
};
match message {
ImportEditorMsg::NameChanged(name) => {
let Some(db) = &self.db else {
return Task::none();
};
match engine::wordpress_import::update_definition(
db.conn(),
&definition_id,
Some(&name),
None,
None,
None,
) {
Ok(definition) => {
if let Some(state) = self.import_editors.get_mut(&definition_id) {
state.definition = definition.clone();
}
if let Some(item) = self
.sidebar_imports
.iter_mut()
.find(|item| item.id == definition_id)
{
*item = definition;
}
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == definition_id)
{
tab.title = name;
}
}
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
}
Task::none()
}
ImportEditorMsg::PickUploads => crate::platform::dialog::pick_import_uploads_folder(
definition_id,
t(self.ui_locale, "import.uploadsFolder"),
),
ImportEditorMsg::PickWxr => crate::platform::dialog::pick_import_wxr_file(
definition_id,
t(self.ui_locale, "import.wxrFile"),
t(self.ui_locale, "import.wxrFilter"),
),
ImportEditorMsg::Analyze => self.start_import_analysis(&definition_id),
ImportEditorMsg::Execute => self.start_import_execution(&definition_id),
ImportEditorMsg::AutoMapTaxonomy => self.start_import_auto_mapping(&definition_id),
ImportEditorMsg::DeleteRequested => {
let name = self
.import_editors
.get(&definition_id)
.map(|state| state.definition.name.clone())
.unwrap_or_default();
self.active_modal = Some(modal::ModalState::Confirm {
title: t(self.ui_locale, "import.deleteTitle"),
message: tw(self.ui_locale, "import.deleteMessage", &[("name", &name)]),
on_confirm: modal::ConfirmAction::DeleteImport(definition_id),
});
Task::none()
}
ImportEditorMsg::SetResolution {
kind,
identity,
resolution,
} => {
let mut report = self
.import_editors
.get(&definition_id)
.and_then(|state| state.report.clone());
if let Some(report) = report.as_mut() {
match engine::wordpress_import::set_conflict_resolution(
report, kind, &identity, resolution,
)
.and_then(|_| self.persist_import_report(&definition_id, report))
{
Ok(definition) => {
if let Some(state) = self.import_editors.get_mut(&definition_id) {
state.definition = definition;
state.report = Some(report.clone());
}
}
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
}
}
Task::none()
}
ImportEditorMsg::SetTaxonomyMapping {
kind,
source,
target,
} => {
let mut report = self
.import_editors
.get(&definition_id)
.and_then(|state| state.report.clone());
if let Some(report) = report.as_mut() {
match engine::wordpress_import::set_taxonomy_mapping(
report,
kind,
&source,
target.as_deref(),
)
.and_then(|_| self.persist_import_report(&definition_id, report))
{
Ok(definition) => {
if let Some(state) = self.import_editors.get_mut(&definition_id) {
state.definition = definition;
state.report = Some(report.clone());
}
}
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
}
}
Task::none()
}
ImportEditorMsg::ToggleSection(section) => {
if let Some(state) = self.import_editors.get_mut(&definition_id)
&& !state.expanded.remove(&section)
{
state.expanded.insert(section);
}
Task::none()
}
}
}
fn update_import_paths(
&mut self,
definition_id: &str,
wxr_path: Option<&Path>,
uploads_path: Option<&Path>,
) {
let Some(db) = &self.db else { return };
match engine::wordpress_import::update_definition(
db.conn(),
definition_id,
None,
wxr_path.map(Some),
uploads_path.map(Some),
wxr_path.map(|_| None),
) {
Ok(definition) => {
if let Some(state) = self.import_editors.get_mut(definition_id) {
state.definition = definition.clone();
if wxr_path.is_some() {
state.report = None;
state.result = None;
}
}
if let Some(item) = self
.sidebar_imports
.iter_mut()
.find(|item| item.id == definition_id)
{
*item = definition;
}
}
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
}
}
fn delete_import_definition(&mut self, definition_id: &str) -> Task<Message> {
self.active_modal = None;
let Some(db) = &self.db else {
return Task::none();
};
match engine::wordpress_import::delete_definition(db.conn(), definition_id) {
Ok(()) => {
self.import_editors.remove(definition_id);
self.sidebar_imports
.retain(|definition| definition.id != definition_id);
self.close_entity_tab(definition_id);
self.notify(
ToastLevel::Success,
&t(self.ui_locale, "import.toast.deleted"),
);
}
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
}
Task::none()
}
fn persist_import_report(
&self,
definition_id: &str,
report: &ImportReport,
) -> engine::EngineResult<ImportDefinition> {
let db = self
.db
.as_ref()
.ok_or_else(|| engine::EngineError::Validation("database unavailable".to_string()))?;
engine::wordpress_import::update_definition(
db.conn(),
definition_id,
None,
None,
None,
Some(Some(report)),
)
}
fn start_import_analysis(&mut self, definition_id: &str) -> Task<Message> {
let Some(definition) = self
.import_editors
.get(definition_id)
.map(|state| state.definition.clone())
else {
return Task::none();
};
let Some(wxr_path) = definition.wxr_file_path.clone() else {
if let Some(state) = self.import_editors.get_mut(definition_id) {
state.error = Some(t(self.ui_locale, "import.error.wxrRequired"));
}
return Task::none();
};
let Some(project) = self
.projects
.iter()
.find(|project| project.id == definition.project_id)
else {
return Task::none();
};
let Some(data_dir) = project.data_path.as_deref().map(PathBuf::from) else {
return Task::none();
};
let state = self.import_editors.get_mut(definition_id).unwrap();
state.is_analyzing = true;
state.progress = None;
state.error = None;
state.result = None;
let uploads = definition.uploads_folder_path.clone();
let db_path = self.db_path.clone();
let data_dir = data_dir.clone();
let project_id = project.id.clone();
let definition_id_owned = definition_id.to_string();
let (sender, receiver) = futures::channel::mpsc::unbounded();
let worker_definition_id = definition_id_owned.clone();
tokio::task::spawn_blocking(move || {
let progress_sender = sender.clone();
let result = (|| {
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
let report = engine::wordpress_import::analyze_wxr(
db.conn(),
&data_dir,
&project_id,
Path::new(&wxr_path),
uploads.as_deref().map(Path::new),
Some(&mut |progress| {
let _ =
progress_sender.unbounded_send(ImportAnalysisEvent::Progress(progress));
}),
)
.map_err(|error| error.to_string())?;
let definition = engine::wordpress_import::update_definition(
db.conn(),
&worker_definition_id,
None,
None,
None,
Some(Some(&report)),
)
.map_err(|error| error.to_string())?;
Ok((definition, report))
})();
let _ = sender.unbounded_send(ImportAnalysisEvent::Finished(Box::new(result)));
});
Task::run(receiver, move |event| Message::ImportAnalysisEvent {
definition_id: definition_id_owned.clone(),
event,
})
}
fn start_import_execution(&mut self, definition_id: &str) -> Task<Message> {
let Some(definition) = self
.import_editors
.get(definition_id)
.map(|state| state.definition.clone())
else {
return Task::none();
};
let Some(report) = self
.import_editors
.get(definition_id)
.and_then(|state| state.report.clone())
else {
return Task::none();
};
let Some(project) = self
.projects
.iter()
.find(|project| project.id == definition.project_id)
else {
return Task::none();
};
let Some(data_dir) = project.data_path.as_deref().map(PathBuf::from) else {
return Task::none();
};
let state = self.import_editors.get_mut(definition_id).unwrap();
state.is_executing = true;
state.progress = None;
state.error = None;
state.result = None;
let db_path = self.db_path.clone();
let data_dir = data_dir.clone();
let project_id = project.id.clone();
let default_author = engine::meta::read_project_json(&data_dir)
.ok()
.and_then(|metadata| metadata.default_author);
let definition_id_owned = definition_id.to_string();
let (sender, receiver) = futures::channel::mpsc::unbounded();
tokio::task::spawn_blocking(move || {
let progress_sender = sender.clone();
let result = (|| {
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
engine::wordpress_import::execute_import(
db.conn(),
&data_dir,
&project_id,
&report,
default_author.as_deref(),
Some(&mut |progress| {
let _ = progress_sender
.unbounded_send(ImportExecutionEvent::Progress(progress));
}),
)
.map_err(|error| error.to_string())
})();
let _ = sender.unbounded_send(ImportExecutionEvent::Finished(result));
});
Task::run(receiver, move |event| Message::ImportExecutionEvent {
definition_id: definition_id_owned.clone(),
event,
})
}
fn start_import_auto_mapping(&mut self, definition_id: &str) -> Task<Message> {
let Some(definition) = self
.import_editors
.get(definition_id)
.map(|state| state.definition.clone())
else {
return Task::none();
};
let Some(mut report) = self
.import_editors
.get(definition_id)
.and_then(|state| state.report.clone())
else {
return Task::none();
};
let Some(project) = self
.projects
.iter()
.find(|project| project.id == definition.project_id)
else {
return Task::none();
};
let Some(data_dir) = project.data_path.as_deref().map(PathBuf::from) else {
return Task::none();
};
let state = self.import_editors.get_mut(definition_id).unwrap();
state.is_analyzing = true;
state.error = None;
let db_path = self.db_path.clone();
let data_dir = data_dir.clone();
let project_id = project.id.clone();
let offline_mode = self.offline_mode;
let definition_id_owned = definition_id.to_string();
let result_definition_id = definition_id_owned.clone();
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
let count = engine::wordpress_import::auto_map_taxonomy(
db.conn(),
&data_dir,
&project_id,
offline_mode,
&mut report,
)
.map_err(|error| error.to_string())?;
let definition = engine::wordpress_import::update_definition(
db.conn(),
&definition_id_owned,
None,
None,
None,
Some(Some(&report)),
)
.map_err(|error| error.to_string())?;
Ok((definition, report, count))
})
.await
.map_err(|error| error.to_string())?
},
move |result| Message::ImportAutoMapFinished {
definition_id: result_definition_id.clone(),
result,
},
)
}
fn flush_active_post_editor(&mut self) { fn flush_active_post_editor(&mut self) {
let Some(active_id) = self.active_tab.clone() else { let Some(active_id) = self.active_tab.clone() else {
return; return;
@@ -2812,6 +3388,9 @@ impl BdsApp {
self.sidebar_templates = self.sidebar_templates =
bds_core::db::queries::template::list_templates_by_project(db.conn(), &project.id) bds_core::db::queries::template::list_templates_by_project(db.conn(), &project.id)
.unwrap_or_default(); .unwrap_or_default();
self.sidebar_imports =
engine::wordpress_import::list_definitions(db.conn(), &project.id)
.unwrap_or_default();
// Read pico theme from project metadata for status bar badge // Read pico theme from project metadata for status bar badge
if let Some(ref data_dir) = self.data_dir if let Some(ref data_dir) = self.data_dir
@@ -5416,6 +5995,38 @@ impl BdsApp {
} }
} }
} }
TabType::Import => {
if !self.import_editors.contains_key(&tab.id) {
match engine::wordpress_import::get_definition(db.conn(), &tab.id) {
Ok(definition) => {
let project_data_dir = self
.projects
.iter()
.find(|project| project.id == definition.project_id)
.and_then(|project| project.data_path.as_deref())
.map(PathBuf::from)
.or_else(|| self.data_dir.clone());
let categories = project_data_dir
.as_deref()
.and_then(|path| engine::meta::read_categories_json(path).ok())
.unwrap_or_default();
let tags = bds_core::db::queries::tag::list_tags_by_project(
db.conn(),
&definition.project_id,
)
.unwrap_or_default()
.into_iter()
.map(|tag| tag.name)
.collect();
self.import_editors.insert(
definition.id.clone(),
ImportEditorState::new(definition, categories, tags),
);
}
Err(error) => self.notify_operation_failed("activity.import", error),
}
}
}
TabType::Tags => { TabType::Tags => {
if self.tags_view_state.is_none() { if self.tags_view_state.is_none() {
let project_id = self let project_id = self
@@ -6302,6 +6913,7 @@ mod tests {
use crate::i18n::t; use crate::i18n::t;
use crate::state::ToastLevel; use crate::state::ToastLevel;
use crate::state::sidebar_filter::{MediaFilter, PostFilter}; use crate::state::sidebar_filter::{MediaFilter, PostFilter};
use crate::state::tabs::{Tab, TabType};
use crate::views::media_editor::{MediaEditorMsg, MediaEditorState}; use crate::views::media_editor::{MediaEditorMsg, MediaEditorState};
use crate::views::modal; use crate::views::modal;
use crate::views::post_editor::{PostEditorMsg, PostEditorState}; use crate::views::post_editor::{PostEditorMsg, PostEditorState};
@@ -6313,7 +6925,7 @@ mod tests {
use bds_core::db::queries::project::insert_project; use bds_core::db::queries::project::insert_project;
use bds_core::engine::generation::GenerationReport; use bds_core::engine::generation::GenerationReport;
use bds_core::engine::task::{TaskStatus, TaskStatus::*}; use bds_core::engine::task::{TaskStatus, TaskStatus::*};
use bds_core::engine::{ai, media, post, script, template}; use bds_core::engine::{ai, media, post, script, template, wordpress_import};
use bds_core::i18n::UiLocale; use bds_core::i18n::UiLocale;
use bds_core::model::{Project, ScriptKind, TemplateKind}; use bds_core::model::{Project, ScriptKind, TemplateKind};
use chrono::{Datelike, TimeZone}; use chrono::{Datelike, TimeZone};
@@ -6391,6 +7003,42 @@ mod tests {
format!("http://{}", addr) format!("http://{}", addr)
} }
#[test]
fn import_definition_loads_saved_analysis_into_real_editor_state() {
let (db, project, tempdir) = setup();
let definition =
wordpress_import::create_definition(db.conn(), &project.id, "Legacy").unwrap();
let mut report = wordpress_import::empty_report();
report.site.title = "Legacy Blog".to_string();
report.source_file = tempdir.path().join("legacy.xml").display().to_string();
wordpress_import::update_definition(
db.conn(),
&definition.id,
None,
None,
None,
Some(Some(&report)),
)
.unwrap();
let mut app = BdsApp::new_for_tests(db, project, tempdir.path().to_path_buf());
let _ = app.refresh_counts();
assert_eq!(app.sidebar_imports.len(), 1);
let tab = Tab {
id: definition.id.clone(),
tab_type: TabType::Import,
title: "Legacy".to_string(),
is_transient: true,
is_dirty: false,
};
app.load_editor_for_tab(&tab);
let editor = app.import_editors.get(&definition.id).unwrap();
assert_eq!(editor.definition.name, "Legacy");
assert_eq!(editor.report.as_ref().unwrap().site.title, "Legacy Blog");
assert!(editor.category_options.iter().any(|name| name == "article"));
}
fn make_app(db: Database, project: Project, tmp: &TempDir) -> BdsApp { fn make_app(db: Database, project: Project, tmp: &TempDir) -> BdsApp {
BdsApp::new_for_tests(db, project, tmp.path().to_path_buf()) BdsApp::new_for_tests(db, project, tmp.path().to_path_buf())
} }

View File

@@ -95,3 +95,44 @@ pub fn pick_media_replacement(
|(media_id, path)| Message::MediaReplacementPicked { media_id, path }, |(media_id, path)| Message::MediaReplacementPicked { media_id, path },
) )
} }
/// Pick the WordPress uploads directory for an import definition.
pub fn pick_import_uploads_folder(definition_id: String, title: String) -> Task<Message> {
Task::perform(
async move {
let path = rfd::AsyncFileDialog::new()
.set_title(&title)
.pick_folder()
.await
.map(|handle| handle.path().to_path_buf());
(definition_id, path)
},
|(definition_id, path)| Message::ImportUploadsPicked {
definition_id,
path,
},
)
}
/// Pick a WordPress WXR export for an import definition.
pub fn pick_import_wxr_file(
definition_id: String,
title: String,
filter_label: String,
) -> Task<Message> {
Task::perform(
async move {
let path = rfd::AsyncFileDialog::new()
.set_title(&title)
.add_filter(&filter_label, &["xml", "wxr"])
.pick_file()
.await
.map(|handle| handle.path().to_path_buf());
(definition_id, path)
},
|(definition_id, path)| Message::ImportWxrPicked {
definition_id,
path,
},
)
}

View File

@@ -0,0 +1,777 @@
use std::collections::HashSet;
use bds_core::i18n::UiLocale;
use bds_core::model::{
ImportCandidate, ImportDefinition, ImportExecutionResult, ImportItemKind, ImportItemStatus,
ImportPhase, ImportProgress, ImportReport, ImportResolution, TaxonomyKind,
};
use iced::widget::{Space, button, column, container, progress_bar, row, scrollable, text};
use iced::{Alignment, Color, Element, Length};
use crate::app::Message;
use crate::components::inputs;
use crate::i18n::{t, tw};
#[derive(Debug, Clone)]
pub struct ImportEditorState {
pub definition: ImportDefinition,
pub report: Option<ImportReport>,
pub category_options: Vec<String>,
pub tag_options: Vec<String>,
pub expanded: HashSet<ImportSection>,
pub is_analyzing: bool,
pub is_executing: bool,
pub progress: Option<ImportProgress>,
pub result: Option<ImportExecutionResult>,
pub error: Option<String>,
}
impl ImportEditorState {
pub fn new(
definition: ImportDefinition,
category_options: Vec<String>,
tag_options: Vec<String>,
) -> Self {
let report = definition.analysis().ok().flatten();
Self {
definition,
report,
category_options,
tag_options,
expanded: HashSet::from([
ImportSection::Conflicts,
ImportSection::Taxonomy,
ImportSection::Posts,
]),
is_analyzing: false,
is_executing: false,
progress: None,
result: None,
error: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ImportSection {
Conflicts,
Posts,
Pages,
Media,
Taxonomy,
Macros,
}
#[derive(Debug, Clone)]
pub enum ImportEditorMsg {
NameChanged(String),
PickUploads,
PickWxr,
Analyze,
Execute,
AutoMapTaxonomy,
DeleteRequested,
SetResolution {
kind: ImportItemKind,
identity: String,
resolution: ImportResolution,
},
SetTaxonomyMapping {
kind: TaxonomyKind,
source: String,
target: Option<String>,
},
ToggleSection(ImportSection),
}
#[derive(Debug, Clone)]
pub enum ImportAnalysisEvent {
Progress(ImportProgress),
Finished(Box<Result<(ImportDefinition, ImportReport), String>>),
}
#[derive(Debug, Clone)]
pub enum ImportExecutionEvent {
Progress(ImportProgress),
Finished(Result<ImportExecutionResult, String>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ResolutionOption {
value: ImportResolution,
label: String,
}
impl std::fmt::Display for ResolutionOption {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.label)
}
}
pub fn view<'a>(state: &'a ImportEditorState, locale: UiLocale) -> Element<'a, Message> {
let busy = state.is_analyzing || state.is_executing;
let name = inputs::labeled_input(
&t(locale, "import.name"),
&t(locale, "import.namePlaceholder"),
&state.definition.name,
|value| Message::ImportEditor(ImportEditorMsg::NameChanged(value)),
);
let header = inputs::card(
column![
row![
name,
button(text(t(locale, "modal.confirmDelete.delete")))
.on_press_maybe(
(!busy).then_some(Message::ImportEditor(ImportEditorMsg::DeleteRequested,))
)
.padding([8, 12])
.style(inputs::danger_button),
button(text(t(locale, "import.analyze")))
.on_press_maybe(
(!busy && state.definition.wxr_file_path.is_some())
.then_some(Message::ImportEditor(ImportEditorMsg::Analyze),)
)
.padding([8, 12])
.style(inputs::primary_button),
]
.spacing(12)
.align_y(Alignment::End),
text(t(locale, "import.description"))
.size(12)
.color(Color::from_rgb(0.60, 0.60, 0.65)),
]
.spacing(10),
);
let uploads = path_row(
locale,
"import.uploadsFolder",
state.definition.uploads_folder_path.as_deref(),
"import.noFolder",
ImportEditorMsg::PickUploads,
);
let wxr = path_row(
locale,
"import.wxrFile",
state.definition.wxr_file_path.as_deref(),
"import.noWxr",
ImportEditorMsg::PickWxr,
);
let files = inputs::card(column![uploads, wxr].spacing(12));
let mut content: Vec<Element<'a, Message>> = vec![header.into(), files.into()];
if busy || state.progress.is_some() {
content.push(progress_view(state, locale));
}
if let Some(error) = &state.error {
content.push(
inputs::card(
text(tw(locale, "import.failed", &[("error", error)]))
.size(13)
.color(Color::from_rgb(0.95, 0.38, 0.36)),
)
.into(),
);
}
if let Some(result) = &state.result {
content.push(result_view(result, locale));
}
if let Some(report) = &state.report {
content.push(summary_view(report, locale));
content.push(execute_toolbar(report, state, locale));
let conflicts = report
.posts
.iter()
.chain(&report.pages)
.chain(&report.media)
.filter(|item| item.status == ImportItemStatus::Conflict)
.collect::<Vec<_>>();
if !conflicts.is_empty() {
content.extend(section(
state,
locale,
ImportSection::Conflicts,
"import.conflicts",
conflict_rows(&conflicts, locale),
));
}
if !report.posts.is_empty() {
content.extend(section(
state,
locale,
ImportSection::Posts,
"import.posts",
candidate_rows(&report.posts, locale),
));
}
if !report.pages.is_empty() {
content.extend(section(
state,
locale,
ImportSection::Pages,
"import.pages",
candidate_rows(&report.pages, locale),
));
}
if !report.media.is_empty() {
content.extend(section(
state,
locale,
ImportSection::Media,
"import.media",
candidate_rows(&report.media, locale),
));
}
if !report.taxonomies.is_empty() {
content.extend(section(
state,
locale,
ImportSection::Taxonomy,
"import.taxonomy",
taxonomy_rows(state, report, locale),
));
}
if !report.macros.is_empty() {
content.extend(section(
state,
locale,
ImportSection::Macros,
"import.macros",
macro_rows(report, locale),
));
}
} else if !state.is_analyzing {
content.push(
inputs::card(
text(t(locale, "import.empty"))
.size(13)
.color(Color::from_rgb(0.60, 0.60, 0.65)),
)
.into(),
);
}
scrollable(
iced::widget::Column::with_children(content)
.spacing(10)
.padding(16)
.width(Length::Fill),
)
.height(Length::Fill)
.into()
}
fn path_row<'a>(
locale: UiLocale,
label_key: &str,
path: Option<&str>,
empty_key: &str,
action: ImportEditorMsg,
) -> Element<'a, Message> {
row![
column![
text(t(locale, label_key))
.size(12)
.color(inputs::LABEL_COLOR),
text(
path.map(str::to_string)
.unwrap_or_else(|| t(locale, empty_key))
)
.size(13)
.color(if path.is_some() {
Color::from_rgb(0.85, 0.85, 0.88)
} else {
Color::from_rgb(0.50, 0.50, 0.55)
}),
]
.spacing(5)
.width(Length::Fill),
button(text(t(locale, "common.open")))
.on_press(Message::ImportEditor(action))
.padding([7, 12])
.style(inputs::secondary_button),
]
.spacing(12)
.align_y(Alignment::Center)
.into()
}
fn progress_view<'a>(state: &ImportEditorState, locale: UiLocale) -> Element<'a, Message> {
let (phase, current, total, detail, eta) = state.progress.as_ref().map_or_else(
|| {
(
if state.is_analyzing {
t(locale, "import.phase.analysis")
} else {
t(locale, "import.phase.starting")
},
0,
1,
String::new(),
None,
)
},
|progress| {
(
phase_label(progress.phase, locale),
progress.current,
progress.total.max(1),
progress.detail.clone(),
progress.eta_ms,
)
},
);
let ratio = current as f32 / total as f32;
let eta = eta
.map(|milliseconds| {
tw(
locale,
"import.eta",
&[("seconds", &(milliseconds / 1_000).to_string())],
)
})
.unwrap_or_default();
inputs::card(
column![
row![
text(phase).size(13),
Space::with_width(Length::Fill),
text(format!("{current} / {total}"))
.size(12)
.color(inputs::LABEL_COLOR),
],
progress_bar(0.0..=1.0, ratio.clamp(0.0, 1.0)),
text(format!("{detail} {eta}"))
.size(12)
.color(Color::from_rgb(0.60, 0.60, 0.65)),
]
.spacing(8),
)
.into()
}
fn summary_view<'a>(report: &'a ImportReport, locale: UiLocale) -> Element<'a, Message> {
let source = std::path::Path::new(&report.source_file)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(&report.source_file);
let counts = row![
stat(locale, "import.posts", &report.post_counts),
stat(locale, "import.pages", &report.page_counts),
stat(locale, "import.media", &report.media_counts),
column![
text(t(locale, "import.taxonomy"))
.size(12)
.color(inputs::LABEL_COLOR),
text(report.taxonomies.len().to_string()).size(22),
]
.spacing(4)
.width(Length::Fill),
]
.spacing(14);
let dates = report
.date_distribution
.iter()
.map(|bucket| {
format!(
"{}: {} / {}",
bucket.year, bucket.post_count, bucket.media_count
)
})
.collect::<Vec<_>>()
.join(" · ");
inputs::card(
column![
text(&report.site.title).size(20),
text(format!(
"{} · {} · {}",
report.site.url.as_deref().unwrap_or(""),
report.site.language.as_deref().unwrap_or(""),
source
))
.size(12)
.color(Color::from_rgb(0.60, 0.60, 0.65)),
counts,
text(tw(locale, "import.dateDistribution", &[("dates", &dates)]))
.size(12)
.color(inputs::LABEL_COLOR),
]
.spacing(10),
)
.into()
}
fn stat<'a>(
locale: UiLocale,
label_key: &str,
counts: &bds_core::model::ImportCounts,
) -> Element<'a, Message> {
column![
text(t(locale, label_key))
.size(12)
.color(inputs::LABEL_COLOR),
text(
(counts.new_count
+ counts.update_count
+ counts.conflict_count
+ counts.duplicate_count
+ counts.missing_count)
.to_string()
)
.size(22),
text(format!(
"{} {} · {} {} · {} {} · {} {} · {} {}",
counts.new_count,
t(locale, "import.status.new"),
counts.update_count,
t(locale, "import.status.update"),
counts.conflict_count,
t(locale, "import.status.conflict"),
counts.duplicate_count,
t(locale, "import.status.duplicate"),
counts.missing_count,
t(locale, "import.status.missing"),
))
.size(10)
.color(Color::from_rgb(0.58, 0.58, 0.62)),
]
.spacing(4)
.width(Length::Fill)
.into()
}
fn execute_toolbar<'a>(
report: &ImportReport,
state: &ImportEditorState,
locale: UiLocale,
) -> Element<'a, Message> {
let count = report.importable_count();
inputs::toolbar(
vec![
text(tw(locale, "import.ready", &[("count", &count.to_string())]))
.size(13)
.into(),
],
vec![
button(text(t(locale, "import.autoMap")))
.on_press_maybe(
(!state.is_analyzing && !state.is_executing)
.then_some(Message::ImportEditor(ImportEditorMsg::AutoMapTaxonomy)),
)
.padding([8, 12])
.style(inputs::secondary_button)
.into(),
button(text(tw(
locale,
"import.execute",
&[("count", &count.to_string())],
)))
.on_press_maybe(
(count > 0 && !state.is_analyzing && !state.is_executing)
.then_some(Message::ImportEditor(ImportEditorMsg::Execute)),
)
.padding([8, 12])
.style(inputs::primary_button)
.into(),
],
)
}
fn result_view<'a>(result: &ImportExecutionResult, locale: UiLocale) -> Element<'a, Message> {
let imported = result.taxonomy.imported
+ result.posts.imported
+ result.media.imported
+ result.pages.imported;
let skipped = result.taxonomy.skipped
+ result.posts.skipped
+ result.media.skipped
+ result.pages.skipped;
inputs::card(
text(tw(
locale,
"import.complete",
&[
("imported", &imported.to_string()),
("skipped", &skipped.to_string()),
],
))
.size(13)
.color(Color::from_rgb(0.45, 0.80, 0.50)),
)
.into()
}
fn section<'a>(
state: &ImportEditorState,
locale: UiLocale,
section: ImportSection,
title_key: &str,
body: Element<'a, Message>,
) -> Vec<Element<'a, Message>> {
let expanded = state.expanded.contains(&section);
let header = inputs::card(
button(
row![
text(if expanded { "" } else { "" }).size(12),
text(t(locale, title_key)).size(13),
]
.spacing(8),
)
.on_press(Message::ImportEditor(ImportEditorMsg::ToggleSection(
section,
)))
.padding([6, 8])
.width(Length::Fill)
.style(inputs::disclosure_button),
)
.padding(6)
.into();
if expanded {
vec![header, inputs::card(body).into()]
} else {
vec![header]
}
}
fn conflict_rows<'a>(conflicts: &[&'a ImportCandidate], locale: UiLocale) -> Element<'a, Message> {
let options = vec![
ResolutionOption {
value: ImportResolution::Ignore,
label: t(locale, "import.resolution.ignore"),
},
ResolutionOption {
value: ImportResolution::Overwrite,
label: t(locale, "import.resolution.overwrite"),
},
ResolutionOption {
value: ImportResolution::Import,
label: t(locale, "import.resolution.import"),
},
];
let rows = conflicts
.iter()
.map(|item| {
let identity = item
.slug
.as_deref()
.or(item.filename.as_deref())
.unwrap_or("")
.to_string();
let identity_label = identity.clone();
let selected = options
.iter()
.find(|option| Some(option.value) == item.resolution);
let kind = item.kind;
row![
column![
text(identity_label).size(13),
text(&item.title).size(11).color(inputs::LABEL_COLOR)
]
.spacing(3)
.width(Length::Fill),
container(inputs::labeled_select(
&t(locale, "import.resolution"),
&options,
selected,
move |option| Message::ImportEditor(ImportEditorMsg::SetResolution {
kind,
identity: identity.clone(),
resolution: option.value,
}),
))
.width(Length::Fixed(240.0)),
]
.spacing(12)
.align_y(Alignment::Center)
.into()
})
.collect::<Vec<_>>();
iced::widget::Column::with_children(rows).spacing(10).into()
}
fn candidate_rows<'a>(items: &'a [ImportCandidate], locale: UiLocale) -> Element<'a, Message> {
let rows = items
.iter()
.map(|item| {
let identity = item
.slug
.as_deref()
.or(item.filename.as_deref())
.unwrap_or("");
let detail = match item.kind {
ImportItemKind::Media => item.relative_path.as_deref().unwrap_or("").to_string(),
_ => format!(
"{} · {} · {}",
item.source_status.as_deref().unwrap_or(""),
item.author.as_deref().unwrap_or(""),
item.categories.join(", ")
),
};
row![
column![
text(&item.title).size(13),
text(identity).size(11).color(inputs::LABEL_COLOR)
]
.spacing(3)
.width(Length::FillPortion(2)),
text(detail)
.size(11)
.color(Color::from_rgb(0.62, 0.62, 0.66))
.width(Length::FillPortion(3)),
text(status_label(item.status, locale)).size(11),
]
.spacing(12)
.align_y(Alignment::Center)
.into()
})
.collect::<Vec<_>>();
iced::widget::Column::with_children(rows).spacing(10).into()
}
fn taxonomy_rows<'a>(
state: &'a ImportEditorState,
report: &'a ImportReport,
locale: UiLocale,
) -> Element<'a, Message> {
let rows = report
.taxonomies
.iter()
.map(|item| {
let options = match item.kind {
TaxonomyKind::Category => &state.category_options,
TaxonomyKind::Tag => &state.tag_options,
};
let source = item.name.clone();
let kind = item.kind;
let status = if item.exists_in_project {
t(locale, "import.taxonomyExisting")
} else if item.mapped_to.is_some() {
t(locale, "import.taxonomyMapped")
} else {
t(locale, "import.taxonomyNew")
};
let select: Element<'a, Message> = if item.exists_in_project {
text("").size(12).into()
} else {
let selected = item
.mapped_to
.as_ref()
.and_then(|mapped| options.iter().find(|option| *option == mapped));
row![
container(inputs::labeled_select(
&t(locale, "import.mapTo"),
options,
selected,
move |target| Message::ImportEditor(ImportEditorMsg::SetTaxonomyMapping {
kind,
source: source.clone(),
target: Some(target),
}),
))
.width(Length::Fixed(260.0)),
button(text(t(locale, "common.clear")))
.on_press_maybe(item.mapped_to.is_some().then_some(Message::ImportEditor(
ImportEditorMsg::SetTaxonomyMapping {
kind,
source: item.name.clone(),
target: None,
},
)))
.padding([7, 10])
.style(inputs::secondary_button),
]
.spacing(8)
.align_y(Alignment::End)
.into()
};
row![
column![
text(&item.name).size(13),
text(match item.kind {
TaxonomyKind::Category => t(locale, "import.category"),
TaxonomyKind::Tag => t(locale, "import.tag"),
})
.size(11)
.color(inputs::LABEL_COLOR),
]
.spacing(3)
.width(Length::Fill),
text(status).size(11).width(Length::Fixed(90.0)),
select,
]
.spacing(12)
.align_y(Alignment::Center)
.into()
})
.collect::<Vec<_>>();
iced::widget::Column::with_children(rows).spacing(10).into()
}
fn macro_rows<'a>(report: &'a ImportReport, locale: UiLocale) -> Element<'a, Message> {
let rows = report
.macros
.iter()
.map(|usage| {
let parameters = usage
.parameters
.iter()
.map(|values| {
values
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>()
.join(", ")
})
.collect::<Vec<_>>()
.join(" · ");
column![
row![
text(format!("[[{}]]", usage.name)).size(13),
Space::with_width(Length::Fill),
text(tw(
locale,
"import.macroUses",
&[("count", &usage.total_count.to_string())],
))
.size(11)
.color(inputs::LABEL_COLOR),
],
text(parameters).size(11).color(inputs::LABEL_COLOR),
text(usage.post_slugs.join(", "))
.size(11)
.color(Color::from_rgb(0.58, 0.58, 0.62)),
]
.spacing(4)
.into()
})
.collect::<Vec<_>>();
iced::widget::Column::with_children(rows).spacing(10).into()
}
fn status_label(status: ImportItemStatus, locale: UiLocale) -> String {
t(
locale,
match status {
ImportItemStatus::New => "import.status.new",
ImportItemStatus::Update => "import.status.update",
ImportItemStatus::Conflict => "import.status.conflict",
ImportItemStatus::ContentDuplicate => "import.status.duplicate",
ImportItemStatus::Missing => "import.status.missing",
},
)
}
fn phase_label(phase: ImportPhase, locale: UiLocale) -> String {
t(
locale,
match phase {
ImportPhase::Taxonomy => "import.phase.taxonomy",
ImportPhase::Posts => "import.phase.posts",
ImportPhase::Media => "import.phase.media",
ImportPhase::Pages => "import.phase.pages",
ImportPhase::Complete => "import.phase.complete",
},
)
}

View File

@@ -1,6 +1,7 @@
pub mod activity_bar; pub mod activity_bar;
pub mod dashboard; pub mod dashboard;
pub mod git; pub mod git;
pub mod import_editor;
pub mod media_editor; pub mod media_editor;
pub mod metadata_diff; pub mod metadata_diff;
pub mod modal; pub mod modal;

View File

@@ -123,6 +123,7 @@ pub enum ConfirmAction {
DeleteTemplate(String), DeleteTemplate(String),
ForceDeleteTemplate(String), ForceDeleteTemplate(String),
DeleteTag(String), DeleteTag(String),
DeleteImport(String),
MergeTags { MergeTags {
sources: Vec<String>, sources: Vec<String>,
target: String, target: String,

View File

@@ -5,7 +5,7 @@ use iced::widget::{Space, button, column, container, image, row, scrollable, tex
use iced::{Background, Border, Color, Element, Length, Theme}; use iced::{Background, Border, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale; use bds_core::i18n::UiLocale;
use bds_core::model::{Media, Post, Script, Template}; use bds_core::model::{ImportDefinition, Media, Post, Script, Template};
use crate::app::Message; use crate::app::Message;
use crate::components::inputs; use crate::components::inputs;
@@ -627,6 +627,7 @@ pub fn view(
media: &[Media], media: &[Media],
scripts: &[Script], scripts: &[Script],
templates: &[Template], templates: &[Template],
imports: &[ImportDefinition],
post_filter: &PostFilter, post_filter: &PostFilter,
media_filter: &MediaFilter, media_filter: &MediaFilter,
media_thumbs: &std::collections::HashMap<String, Option<PathBuf>>, media_thumbs: &std::collections::HashMap<String, Option<PathBuf>>,
@@ -653,6 +654,7 @@ pub fn view(
SidebarView::Media => Some(Message::CreateMedia), SidebarView::Media => Some(Message::CreateMedia),
SidebarView::Scripts => Some(Message::CreateScript), SidebarView::Scripts => Some(Message::CreateScript),
SidebarView::Templates => Some(Message::CreateTemplate), SidebarView::Templates => Some(Message::CreateTemplate),
SidebarView::Import => Some(Message::CreateImport),
_ => None, _ => None,
}; };
@@ -1079,6 +1081,57 @@ pub fn view(
iced::widget::Column::with_children(items).spacing(1).into() iced::widget::Column::with_children(items).spacing(1).into()
} }
} }
SidebarView::Import => {
if imports.is_empty() {
text(t(locale, placeholder_key(sidebar_view)))
.size(12)
.shaping(Shaping::Advanced)
.color(muted)
.into()
} else {
let items = imports
.iter()
.map(|definition| {
let definition_name = definition.name.clone();
let style = if active_tab == Some(definition.id.as_str()) {
item_active_style
} else {
item_style
};
let analyzed = if definition.last_analysis_result.is_some() {
t(locale, "import.sidebar.analyzed")
} else {
t(locale, "import.sidebar.pending")
};
button(
container(
column![
text(definition_name).size(12).shaping(Shaping::Advanced),
text(analyzed)
.size(10)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.50, 0.50, 0.55)),
]
.spacing(1),
)
.width(Length::Fill),
)
.on_press(Message::OpenTab(Tab {
id: definition.id.clone(),
tab_type: TabType::Import,
title: definition.name.clone(),
is_transient: true,
is_dirty: false,
}))
.padding([5, 8])
.width(Length::Fill)
.style(style)
.into()
})
.collect::<Vec<Element<'static, Message>>>();
iced::widget::Column::with_children(items).spacing(1).into()
}
}
SidebarView::Settings => { SidebarView::Settings => {
// Per sidebar_views.allium SettingsNav: 9 fixed-order sections // Per sidebar_views.allium SettingsNav: 9 fixed-order sections
use crate::views::settings_view::SettingsSection; use crate::views::settings_view::SettingsSection;

View File

@@ -7,7 +7,7 @@ use iced::{Alignment, Background, Color, Element, Length, Padding, Theme};
use bds_core::engine::git::GitCommit; use bds_core::engine::git::GitCommit;
use bds_core::i18n::UiLocale; use bds_core::i18n::UiLocale;
use bds_core::model::{Media, Post, Project, Script, Template}; use bds_core::model::{ImportDefinition, Media, Post, Project, Script, Template};
use crate::app::Message; use crate::app::Message;
use crate::state::navigation::{OutputEntry, PanelTab, SidebarView, TaskSnapshot}; use crate::state::navigation::{OutputEntry, PanelTab, SidebarView, TaskSnapshot};
@@ -87,6 +87,7 @@ pub fn view<'a>(
sidebar_media: &'a [Media], sidebar_media: &'a [Media],
sidebar_scripts: &'a [Script], sidebar_scripts: &'a [Script],
sidebar_templates: &'a [Template], sidebar_templates: &'a [Template],
sidebar_imports: &'a [ImportDefinition],
// Sidebar filters // Sidebar filters
post_filter: &'a PostFilter, post_filter: &'a PostFilter,
media_filter: &'a MediaFilter, media_filter: &'a MediaFilter,
@@ -119,6 +120,7 @@ pub fn view<'a>(
media_editors: &'a HashMap<String, MediaEditorState>, media_editors: &'a HashMap<String, MediaEditorState>,
template_editors: &'a HashMap<String, TemplateEditorState>, template_editors: &'a HashMap<String, TemplateEditorState>,
script_editors: &'a HashMap<String, ScriptEditorState>, script_editors: &'a HashMap<String, ScriptEditorState>,
import_editors: &'a HashMap<String, crate::views::import_editor::ImportEditorState>,
tags_view_state: Option<&'a TagsViewState>, tags_view_state: Option<&'a TagsViewState>,
settings_state: Option<&'a SettingsViewState>, settings_state: Option<&'a SettingsViewState>,
dashboard_state: Option<&'a DashboardState>, dashboard_state: Option<&'a DashboardState>,
@@ -147,6 +149,7 @@ pub fn view<'a>(
media_editors, media_editors,
template_editors, template_editors,
script_editors, script_editors,
import_editors,
tags_view_state, tags_view_state,
settings_state, settings_state,
dashboard_state, dashboard_state,
@@ -200,6 +203,7 @@ pub fn view<'a>(
sidebar_media, sidebar_media,
sidebar_scripts, sidebar_scripts,
sidebar_templates, sidebar_templates,
sidebar_imports,
post_filter, post_filter,
media_filter, media_filter,
sidebar_media_thumbs, sidebar_media_thumbs,
@@ -382,6 +386,7 @@ fn route_content_area<'a>(
media_editors: &'a HashMap<String, MediaEditorState>, media_editors: &'a HashMap<String, MediaEditorState>,
template_editors: &'a HashMap<String, TemplateEditorState>, template_editors: &'a HashMap<String, TemplateEditorState>,
script_editors: &'a HashMap<String, ScriptEditorState>, script_editors: &'a HashMap<String, ScriptEditorState>,
import_editors: &'a HashMap<String, crate::views::import_editor::ImportEditorState>,
tags_view_state: Option<&'a TagsViewState>, tags_view_state: Option<&'a TagsViewState>,
settings_state: Option<&'a SettingsViewState>, settings_state: Option<&'a SettingsViewState>,
dashboard_state: Option<&'a DashboardState>, dashboard_state: Option<&'a DashboardState>,
@@ -397,6 +402,7 @@ fn route_content_area<'a>(
media_editors, media_editors,
template_editors, template_editors,
script_editors, script_editors,
import_editors,
tags_view_state, tags_view_state,
settings_state, settings_state,
dashboard_state, dashboard_state,
@@ -445,6 +451,13 @@ fn route_content_area<'a>(
loading_view(locale) loading_view(locale)
} }
} }
ContentRoute::Import(tab_id) => {
if let Some(state) = import_editors.get(tab_id) {
crate::views::import_editor::view(state, locale)
} else {
loading_view(locale)
}
}
ContentRoute::Tags => { ContentRoute::Tags => {
if let Some(state) = tags_view_state { if let Some(state) = tags_view_state {
tags_view::view(state, locale) tags_view::view(state, locale)
@@ -490,6 +503,7 @@ enum ContentRoute<'a> {
Media(&'a str), Media(&'a str),
Templates(&'a str), Templates(&'a str),
Scripts(&'a str), Scripts(&'a str),
Import(&'a str),
Tags, Tags,
Settings, Settings,
SiteValidation, SiteValidation,
@@ -510,6 +524,7 @@ fn route_kind<'a>(
media_editors: &'a HashMap<String, MediaEditorState>, media_editors: &'a HashMap<String, MediaEditorState>,
template_editors: &'a HashMap<String, TemplateEditorState>, template_editors: &'a HashMap<String, TemplateEditorState>,
script_editors: &'a HashMap<String, ScriptEditorState>, script_editors: &'a HashMap<String, ScriptEditorState>,
import_editors: &'a HashMap<String, crate::views::import_editor::ImportEditorState>,
tags_view_state: Option<&'a TagsViewState>, tags_view_state: Option<&'a TagsViewState>,
settings_state: Option<&'a SettingsViewState>, settings_state: Option<&'a SettingsViewState>,
dashboard_state: Option<&'a DashboardState>, dashboard_state: Option<&'a DashboardState>,
@@ -555,6 +570,13 @@ fn route_kind<'a>(
ContentRoute::Loading ContentRoute::Loading
} }
} }
TabType::Import => {
if import_editors.contains_key(tab_id) {
ContentRoute::Import(tab_id)
} else {
ContentRoute::Loading
}
}
TabType::Tags => { TabType::Tags => {
if tags_view_state.is_some() { if tags_view_state.is_some() {
ContentRoute::Tags ContentRoute::Tags
@@ -574,7 +596,6 @@ fn route_kind<'a>(
TabType::GitDiff => ContentRoute::GitDiff(tab_id), TabType::GitDiff => ContentRoute::GitDiff(tab_id),
TabType::Style TabType::Style
| TabType::Chat | TabType::Chat
| TabType::Import
| TabType::MenuEditor | TabType::MenuEditor
| TabType::Documentation | TabType::Documentation
| TabType::ApiDocumentation | TabType::ApiDocumentation
@@ -633,11 +654,11 @@ mod tests {
let empty_media = HashMap::new(); let empty_media = HashMap::new();
let empty_templates = HashMap::new(); let empty_templates = HashMap::new();
let empty_scripts = HashMap::new(); let empty_scripts = HashMap::new();
let empty_imports = HashMap::new();
let site_validation_state = SiteValidationState::default(); let site_validation_state = SiteValidationState::default();
let unsupported = [ let unsupported = [
TabType::Style, TabType::Style,
TabType::Chat, TabType::Chat,
TabType::Import,
TabType::MenuEditor, TabType::MenuEditor,
TabType::Documentation, TabType::Documentation,
TabType::ApiDocumentation, TabType::ApiDocumentation,
@@ -653,6 +674,7 @@ mod tests {
&empty_media, &empty_media,
&empty_templates, &empty_templates,
&empty_scripts, &empty_scripts,
&empty_imports,
None, None,
None, None,
None, None,
@@ -671,6 +693,7 @@ mod tests {
let empty_media = HashMap::new(); let empty_media = HashMap::new();
let empty_templates = HashMap::new(); let empty_templates = HashMap::new();
let empty_scripts = HashMap::new(); let empty_scripts = HashMap::new();
let empty_imports = HashMap::new();
let site_validation_state = SiteValidationState::default(); let site_validation_state = SiteValidationState::default();
let tabs = vec![tab("git-diff:file.txt", TabType::GitDiff, "file.txt")]; let tabs = vec![tab("git-diff:file.txt", TabType::GitDiff, "file.txt")];
let route = route_kind( let route = route_kind(
@@ -680,6 +703,7 @@ mod tests {
&empty_media, &empty_media,
&empty_templates, &empty_templates,
&empty_scripts, &empty_scripts,
&empty_imports,
None, None,
None, None,
None, None,
@@ -694,6 +718,7 @@ mod tests {
let empty_media = HashMap::new(); let empty_media = HashMap::new();
let empty_templates = HashMap::new(); let empty_templates = HashMap::new();
let empty_scripts = HashMap::new(); let empty_scripts = HashMap::new();
let empty_imports = HashMap::new();
let site_validation_state = SiteValidationState::default(); let site_validation_state = SiteValidationState::default();
for (tab_type, expected) in [ for (tab_type, expected) in [
@@ -708,6 +733,7 @@ mod tests {
&empty_media, &empty_media,
&empty_templates, &empty_templates,
&empty_scripts, &empty_scripts,
&empty_imports,
None, None,
None, None,
None, None,
@@ -733,6 +759,7 @@ mod tests {
let empty_media = HashMap::new(); let empty_media = HashMap::new();
let empty_templates = HashMap::new(); let empty_templates = HashMap::new();
let empty_scripts = HashMap::new(); let empty_scripts = HashMap::new();
let empty_imports = HashMap::new();
let site_validation_state = SiteValidationState::default(); let site_validation_state = SiteValidationState::default();
let route = route_kind( let route = route_kind(
&[], &[],
@@ -741,6 +768,7 @@ mod tests {
&empty_media, &empty_media,
&empty_templates, &empty_templates,
&empty_scripts, &empty_scripts,
&empty_imports,
None, None,
None, None,
Some(&dashboard), Some(&dashboard),
@@ -758,6 +786,7 @@ mod tests {
let empty_media = HashMap::new(); let empty_media = HashMap::new();
let empty_templates = HashMap::new(); let empty_templates = HashMap::new();
let empty_scripts = HashMap::new(); let empty_scripts = HashMap::new();
let empty_imports = HashMap::new();
let site_validation_state = SiteValidationState::default(); let site_validation_state = SiteValidationState::default();
let tabs = vec![tab( let tabs = vec![tab(
"site_validation", "site_validation",
@@ -772,6 +801,7 @@ mod tests {
&empty_media, &empty_media,
&empty_templates, &empty_templates,
&empty_scripts, &empty_scripts,
&empty_imports,
None, None,
None, None,
None, None,

View File

@@ -518,3 +518,58 @@ git-noDiff = Keine Unterschiede
git-noFileHistory = Kein Verlauf für diese Datei git-noFileHistory = Kein Verlauf für diese Datei
git-operationDone = Git { $operation } abgeschlossen git-operationDone = Git { $operation } abgeschlossen
git-runningOperation = Git { $operation } git-runningOperation = Git { $operation }
import-name = Importname
import-namePlaceholder = Unbenannter Import
import-untitled = Unbenannter Import
import-description = WordPress-Export (WXR) und Upload-Ordner wählen, alle Einträge prüfen und anschließend importieren.
import-uploadsFolder = Upload-Ordner
import-wxrFile = WXR-Datei
import-wxrFilter = WordPress-Export
import-noFolder = Kein Ordner ausgewählt
import-noWxr = Keine WXR-Datei ausgewählt
import-analyze = Analysieren
import-empty = Wähle eine WordPress-Exportdatei, um die Analyse zu starten.
import-posts = Beiträge
import-pages = Seiten
import-media = Medien
import-taxonomy = Kategorien & Tags
import-conflicts = Slug- und Dateinamenskonflikte
import-macros = Makros
import-ready = Bereit zum Import: { $count } Einträge
import-execute = { $count } Einträge importieren
import-autoMap = Taxonomie mit KI analysieren
import-complete = Import abgeschlossen: { $imported } importiert, { $skipped } übersprungen
import-failed = Import fehlgeschlagen: { $error }
import-dateDistribution = Datumsverteilung (Beiträge / Medien): { $dates }
import-resolution = Lösung
import-resolution-ignore = Ignorieren
import-resolution-overwrite = Überschreiben
import-resolution-import = Mit neuem Slug importieren
import-mapTo = Vorhandenem Eintrag zuordnen
import-category = Kategorie
import-tag = Tag
import-taxonomyExisting = Vorhanden
import-taxonomyMapped = Zugeordnet
import-taxonomyNew = Neu
import-macroUses = { $count } Verwendungen
import-eta = noch etwa { $seconds }s
import-status-new = neu
import-status-update = Aktualisierung
import-status-conflict = Konflikt
import-status-duplicate = Duplikat
import-status-missing = fehlt
import-phase-analysis = WXR-Datei wird analysiert…
import-phase-starting = Wird gestartet…
import-phase-taxonomy = Kategorien und Tags werden importiert…
import-phase-posts = Beiträge werden importiert…
import-phase-media = Medien werden importiert…
import-phase-pages = Seiten werden importiert…
import-phase-complete = Import abgeschlossen
import-error-wxrRequired = Vor der Analyse eine WXR-Datei auswählen.
import-toast-complete = WordPress-Import abgeschlossen.
import-toast-mapped = KI hat { $count } Taxonomiebegriffe zugeordnet.
import-sidebar-analyzed = Analyse gespeichert
import-sidebar-pending = Nicht analysiert
import-deleteTitle = Importdefinition löschen?
import-deleteMessage = „{ $name }“ und die gespeicherte Analyse löschen? Bereits importierte Projektinhalte bleiben erhalten.
import-toast-deleted = Importdefinition gelöscht.

View File

@@ -518,3 +518,58 @@ git-noDiff = No differences
git-noFileHistory = No history for this file git-noFileHistory = No history for this file
git-operationDone = Git { $operation } completed git-operationDone = Git { $operation } completed
git-runningOperation = Git { $operation } git-runningOperation = Git { $operation }
import-name = Import name
import-namePlaceholder = Untitled import
import-untitled = Untitled Import
import-description = Select a WordPress export (WXR) and its uploads folder, review every item, then run the import.
import-uploadsFolder = Uploads Folder
import-wxrFile = WXR File
import-wxrFilter = WordPress export
import-noFolder = No folder selected
import-noWxr = No WXR file selected
import-analyze = Analyze
import-empty = Select a WordPress export file to begin analysis.
import-posts = Posts
import-pages = Pages
import-media = Media
import-taxonomy = Categories & Tags
import-conflicts = Slug and Filename Conflicts
import-macros = Macros
import-ready = Ready to import: { $count } items
import-execute = Import { $count } Items
import-autoMap = Analyze taxonomy with AI
import-complete = Import complete: { $imported } imported, { $skipped } skipped
import-failed = Import failed: { $error }
import-dateDistribution = Date distribution (posts / media): { $dates }
import-resolution = Resolution
import-resolution-ignore = Ignore
import-resolution-overwrite = Overwrite
import-resolution-import = Import with new slug
import-mapTo = Map to existing
import-category = Category
import-tag = Tag
import-taxonomyExisting = Existing
import-taxonomyMapped = Mapped
import-taxonomyNew = New
import-macroUses = { $count } uses
import-eta = about { $seconds }s remaining
import-status-new = new
import-status-update = update
import-status-conflict = conflict
import-status-duplicate = duplicate
import-status-missing = missing
import-phase-analysis = Analyzing WXR file…
import-phase-starting = Starting…
import-phase-taxonomy = Importing categories and tags…
import-phase-posts = Importing posts…
import-phase-media = Importing media…
import-phase-pages = Importing pages…
import-phase-complete = Import complete
import-error-wxrRequired = Select a WXR file before analyzing.
import-toast-complete = WordPress import completed.
import-toast-mapped = AI mapped { $count } taxonomy terms.
import-sidebar-analyzed = Analysis saved
import-sidebar-pending = Not analyzed
import-deleteTitle = Delete import definition?
import-deleteMessage = Delete “{ $name }” and its saved analysis? Imported project content is not removed.
import-toast-deleted = Import definition deleted.

View File

@@ -518,3 +518,58 @@ git-noDiff = Sin diferencias
git-noFileHistory = No hay historial para este archivo git-noFileHistory = No hay historial para este archivo
git-operationDone = Git { $operation } completado git-operationDone = Git { $operation } completado
git-runningOperation = Git { $operation } git-runningOperation = Git { $operation }
import-name = Nombre de la importación
import-namePlaceholder = Importación sin título
import-untitled = Importación sin título
import-description = Selecciona una exportación de WordPress (WXR) y su carpeta de archivos, revisa cada elemento y ejecuta la importación.
import-uploadsFolder = Carpeta de archivos
import-wxrFile = Archivo WXR
import-wxrFilter = Exportación de WordPress
import-noFolder = Ninguna carpeta seleccionada
import-noWxr = Ningún archivo WXR seleccionado
import-analyze = Analizar
import-empty = Selecciona una exportación de WordPress para iniciar el análisis.
import-posts = Entradas
import-pages = Páginas
import-media = Medios
import-taxonomy = Categorías y etiquetas
import-conflicts = Conflictos de slug y nombre de archivo
import-macros = Macros
import-ready = Listo para importar: { $count } elementos
import-execute = Importar { $count } elementos
import-autoMap = Analizar taxonomía con IA
import-complete = Importación completa: { $imported } importados, { $skipped } omitidos
import-failed = Error de importación: { $error }
import-dateDistribution = Distribución por fecha (entradas / medios): { $dates }
import-resolution = Resolución
import-resolution-ignore = Ignorar
import-resolution-overwrite = Sobrescribir
import-resolution-import = Importar con slug nuevo
import-mapTo = Asociar con existente
import-category = Categoría
import-tag = Etiqueta
import-taxonomyExisting = Existente
import-taxonomyMapped = Asociado
import-taxonomyNew = Nuevo
import-macroUses = { $count } usos
import-eta = quedan unos { $seconds }s
import-status-new = nuevo
import-status-update = actualización
import-status-conflict = conflicto
import-status-duplicate = duplicado
import-status-missing = ausente
import-phase-analysis = Analizando archivo WXR…
import-phase-starting = Iniciando…
import-phase-taxonomy = Importando categorías y etiquetas…
import-phase-posts = Importando entradas…
import-phase-media = Importando medios…
import-phase-pages = Importando páginas…
import-phase-complete = Importación completa
import-error-wxrRequired = Selecciona un archivo WXR antes de analizar.
import-toast-complete = Importación de WordPress completa.
import-toast-mapped = La IA asoció { $count } términos de taxonomía.
import-sidebar-analyzed = Análisis guardado
import-sidebar-pending = Sin analizar
import-deleteTitle = ¿Eliminar la definición de importación?
import-deleteMessage = ¿Eliminar «{ $name }» y su análisis guardado? El contenido ya importado se conservará.
import-toast-deleted = Definición de importación eliminada.

View File

@@ -518,3 +518,58 @@ git-noDiff = Aucune différence
git-noFileHistory = Aucun historique pour ce fichier git-noFileHistory = Aucun historique pour ce fichier
git-operationDone = Git { $operation } terminé git-operationDone = Git { $operation } terminé
git-runningOperation = Git { $operation } git-runningOperation = Git { $operation }
import-name = Nom de limport
import-namePlaceholder = Import sans titre
import-untitled = Import sans titre
import-description = Sélectionnez un export WordPress (WXR) et son dossier de médias, vérifiez chaque élément, puis lancez limport.
import-uploadsFolder = Dossier des médias
import-wxrFile = Fichier WXR
import-wxrFilter = Export WordPress
import-noFolder = Aucun dossier sélectionné
import-noWxr = Aucun fichier WXR sélectionné
import-analyze = Analyser
import-empty = Sélectionnez un export WordPress pour commencer lanalyse.
import-posts = Articles
import-pages = Pages
import-media = Médias
import-taxonomy = Catégories et étiquettes
import-conflicts = Conflits de slug et de nom de fichier
import-macros = Macros
import-ready = Prêt à importer : { $count } éléments
import-execute = Importer { $count } éléments
import-autoMap = Analyser la taxonomie avec lIA
import-complete = Import terminé : { $imported } importés, { $skipped } ignorés
import-failed = Échec de limport : { $error }
import-dateDistribution = Répartition par date (articles / médias) : { $dates }
import-resolution = Résolution
import-resolution-ignore = Ignorer
import-resolution-overwrite = Écraser
import-resolution-import = Importer avec un nouveau slug
import-mapTo = Associer à lexistant
import-category = Catégorie
import-tag = Étiquette
import-taxonomyExisting = Existant
import-taxonomyMapped = Associé
import-taxonomyNew = Nouveau
import-macroUses = { $count } utilisations
import-eta = environ { $seconds }s restantes
import-status-new = nouveau
import-status-update = mise à jour
import-status-conflict = conflit
import-status-duplicate = doublon
import-status-missing = manquant
import-phase-analysis = Analyse du fichier WXR…
import-phase-starting = Démarrage…
import-phase-taxonomy = Import des catégories et étiquettes…
import-phase-posts = Import des articles…
import-phase-media = Import des médias…
import-phase-pages = Import des pages…
import-phase-complete = Import terminé
import-error-wxrRequired = Sélectionnez un fichier WXR avant lanalyse.
import-toast-complete = Import WordPress terminé.
import-toast-mapped = LIA a associé { $count } termes de taxonomie.
import-sidebar-analyzed = Analyse enregistrée
import-sidebar-pending = Non analysé
import-deleteTitle = Supprimer la définition dimport ?
import-deleteMessage = Supprimer « { $name } » et son analyse enregistrée ? Le contenu déjà importé sera conservé.
import-toast-deleted = Définition dimport supprimée.

View File

@@ -518,3 +518,58 @@ git-noDiff = Nessuna differenza
git-noFileHistory = Nessuna cronologia per questo file git-noFileHistory = Nessuna cronologia per questo file
git-operationDone = Git { $operation } completato git-operationDone = Git { $operation } completato
git-runningOperation = Git { $operation } git-runningOperation = Git { $operation }
import-name = Nome importazione
import-namePlaceholder = Importazione senza titolo
import-untitled = Importazione senza titolo
import-description = Seleziona unesportazione WordPress (WXR) e la cartella dei caricamenti, controlla ogni elemento ed esegui limportazione.
import-uploadsFolder = Cartella caricamenti
import-wxrFile = File WXR
import-wxrFilter = Esportazione WordPress
import-noFolder = Nessuna cartella selezionata
import-noWxr = Nessun file WXR selezionato
import-analyze = Analizza
import-empty = Seleziona unesportazione WordPress per iniziare lanalisi.
import-posts = Articoli
import-pages = Pagine
import-media = Media
import-taxonomy = Categorie e tag
import-conflicts = Conflitti di slug e nome file
import-macros = Macro
import-ready = Pronti da importare: { $count } elementi
import-execute = Importa { $count } elementi
import-autoMap = Analizza tassonomia con IA
import-complete = Importazione completata: { $imported } importati, { $skipped } ignorati
import-failed = Importazione non riuscita: { $error }
import-dateDistribution = Distribuzione date (articoli / media): { $dates }
import-resolution = Risoluzione
import-resolution-ignore = Ignora
import-resolution-overwrite = Sovrascrivi
import-resolution-import = Importa con nuovo slug
import-mapTo = Associa a esistente
import-category = Categoria
import-tag = Tag
import-taxonomyExisting = Esistente
import-taxonomyMapped = Associato
import-taxonomyNew = Nuovo
import-macroUses = { $count } utilizzi
import-eta = circa { $seconds }s rimanenti
import-status-new = nuovo
import-status-update = aggiornamento
import-status-conflict = conflitto
import-status-duplicate = duplicato
import-status-missing = mancante
import-phase-analysis = Analisi del file WXR…
import-phase-starting = Avvio…
import-phase-taxonomy = Importazione di categorie e tag…
import-phase-posts = Importazione degli articoli…
import-phase-media = Importazione dei media…
import-phase-pages = Importazione delle pagine…
import-phase-complete = Importazione completata
import-error-wxrRequired = Seleziona un file WXR prima dellanalisi.
import-toast-complete = Importazione WordPress completata.
import-toast-mapped = LIA ha associato { $count } termini di tassonomia.
import-sidebar-analyzed = Analisi salvata
import-sidebar-pending = Non analizzato
import-deleteTitle = Eliminare la definizione di importazione?
import-deleteMessage = Eliminare “{ $name }” e lanalisi salvata? I contenuti già importati resteranno invariati.
import-toast-deleted = Definizione di importazione eliminata.