Implement complete WordPress WXR import workflow.
This commit is contained in:
37
Cargo.lock
generated
37
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
62
crates/bds-core/src/db/queries/import_definition.rs
Normal file
62
crates/bds-core/src/db/queries/import_definition.rs
Normal 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(|_| ())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
1827
crates/bds-core/src/engine/wordpress_import.rs
Normal file
1827
crates/bds-core/src/engine/wordpress_import.rs
Normal file
File diff suppressed because it is too large
Load Diff
224
crates/bds-core/src/model/import.rs
Normal file
224
crates/bds-core/src/model/import.rs
Normal 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,
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
807
crates/bds-core/tests/wordpress_import.rs
Normal file
807
crates/bds-core/tests/wordpress_import.rs
Normal 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 & 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>"#
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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(§ion)
|
||||||
|
{
|
||||||
|
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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
777
crates/bds-ui/src/views/import_editor.rs
Normal file
777
crates/bds-ui/src/views/import_editor.rs
Normal 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(§ion);
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 l’import
|
||||||
|
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 l’import.
|
||||||
|
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 l’analyse.
|
||||||
|
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 l’IA
|
||||||
|
import-complete = Import terminé : { $imported } importés, { $skipped } ignorés
|
||||||
|
import-failed = Échec de l’import : { $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 à l’existant
|
||||||
|
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 l’analyse.
|
||||||
|
import-toast-complete = Import WordPress terminé.
|
||||||
|
import-toast-mapped = L’IA a associé { $count } termes de taxonomie.
|
||||||
|
import-sidebar-analyzed = Analyse enregistrée
|
||||||
|
import-sidebar-pending = Non analysé
|
||||||
|
import-deleteTitle = Supprimer la définition d’import ?
|
||||||
|
import-deleteMessage = Supprimer « { $name } » et son analyse enregistrée ? Le contenu déjà importé sera conservé.
|
||||||
|
import-toast-deleted = Définition d’import supprimée.
|
||||||
|
|||||||
@@ -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 un’esportazione WordPress (WXR) e la cartella dei caricamenti, controlla ogni elemento ed esegui l’importazione.
|
||||||
|
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 un’esportazione WordPress per iniziare l’analisi.
|
||||||
|
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 dell’analisi.
|
||||||
|
import-toast-complete = Importazione WordPress completata.
|
||||||
|
import-toast-mapped = L’IA 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 l’analisi salvata? I contenuti già importati resteranno invariati.
|
||||||
|
import-toast-deleted = Definizione di importazione eliminata.
|
||||||
|
|||||||
Reference in New Issue
Block a user