generalbots/botserver/src/project/import.rs
Rodrigo Rodriguez (Pragmatismo) 037db5c381 feat: Major workspace reorganization and documentation update
- Add comprehensive documentation in botbook/ with 12 chapters
- Add botapp/ Tauri desktop application
- Add botdevice/ IoT device support
- Add botlib/ shared library crate
- Add botmodels/ Python ML models service
- Add botplugin/ browser extension
- Add botserver/ reorganized server code
- Add bottemplates/ bot templates
- Add bottest/ integration tests
- Add botui/ web UI server
- Add CI/CD workflows in .forgejo/workflows/
- Add AGENTS.md and PROD.md documentation
- Add dependency management scripts (DEPENDENCIES.sh/ps1)
- Remove legacy src/ structure and migrations
- Clean up temporary and backup files
2026-04-19 08:14:25 -03:00

958 lines
33 KiB
Rust

use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::Read;
use uuid::Uuid;
use super::{
DependencyType, Project, ProjectSettings, ProjectStatus, ProjectTask, Resource,
ResourceAssignment, ResourceType, TaskDependency, TaskPriority, TaskStatus, TaskType,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ImportFormat {
MsProjectXml,
MsProjectMpp,
Csv,
Json,
Jira,
Asana,
Trello,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportOptions {
pub format: ImportFormat,
pub organization_id: Uuid,
pub owner_id: Uuid,
pub import_resources: bool,
pub import_assignments: bool,
pub import_dependencies: bool,
pub import_custom_fields: bool,
pub map_users: HashMap<String, Uuid>,
pub default_resource_rate: f64,
pub preserve_task_ids: bool,
pub conflict_resolution: ConflictResolution,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ConflictResolution {
Skip,
Overwrite,
CreateNew,
Merge,
}
impl Default for ImportOptions {
fn default() -> Self {
Self {
format: ImportFormat::MsProjectXml,
organization_id: Uuid::nil(),
owner_id: Uuid::nil(),
import_resources: true,
import_assignments: true,
import_dependencies: true,
import_custom_fields: false,
map_users: HashMap::new(),
default_resource_rate: 50.0,
preserve_task_ids: false,
conflict_resolution: ConflictResolution::CreateNew,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportResult {
pub project: Project,
pub tasks: Vec<ProjectTask>,
pub resources: Vec<Resource>,
pub assignments: Vec<ResourceAssignment>,
pub warnings: Vec<ImportWarning>,
pub errors: Vec<ImportError>,
pub stats: ImportStats,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportStats {
pub tasks_imported: u32,
pub tasks_skipped: u32,
pub resources_imported: u32,
pub dependencies_imported: u32,
pub assignments_imported: u32,
pub custom_fields_imported: u32,
pub import_duration_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportWarning {
pub code: String,
pub message: String,
pub source_element: Option<String>,
pub suggested_action: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportError {
pub code: String,
pub message: String,
pub source_element: Option<String>,
pub fatal: bool,
}
#[derive(Debug, Clone, Deserialize)]
struct MsProjectXml {
#[serde(rename = "Name", default)]
name: Option<String>,
#[serde(rename = "Title", default)]
title: Option<String>,
#[serde(rename = "StartDate", default)]
start_date: Option<String>,
#[serde(rename = "FinishDate", default)]
finish_date: Option<String>,
#[serde(rename = "Tasks", default)]
tasks: Option<MsProjectTasks>,
#[serde(rename = "Resources", default)]
resources: Option<MsProjectResources>,
#[serde(rename = "Assignments", default)]
assignments: Option<MsProjectAssignments>,
}
#[derive(Debug, Clone, Deserialize, Default)]
struct MsProjectTasks {
#[serde(rename = "Task", default)]
task: Vec<MsProjectTask>,
}
#[derive(Debug, Clone, Deserialize)]
struct MsProjectTask {
#[serde(rename = "UID", default)]
uid: i32,
#[serde(rename = "Name", default)]
name: Option<String>,
#[serde(rename = "IsNull", default)]
is_null: Option<bool>,
#[serde(rename = "WBS", default)]
wbs: Option<String>,
#[serde(rename = "OutlineLevel", default)]
outline_level: Option<i32>,
#[serde(rename = "Priority", default)]
priority: Option<i32>,
#[serde(rename = "Start", default)]
start: Option<String>,
#[serde(rename = "Finish", default)]
finish: Option<String>,
#[serde(rename = "Duration", default)]
duration: Option<String>,
#[serde(rename = "Work", default)]
work: Option<String>,
#[serde(rename = "PercentComplete", default)]
percent_complete: Option<i32>,
#[serde(rename = "Cost", default)]
cost: Option<f64>,
#[serde(rename = "Milestone", default)]
milestone: Option<bool>,
#[serde(rename = "Summary", default)]
summary: Option<bool>,
#[serde(rename = "Critical", default)]
critical: Option<bool>,
#[serde(rename = "Notes", default)]
notes: Option<String>,
#[serde(rename = "PredecessorLink", default)]
predecessor_links: Vec<MsPredecessorLink>,
}
#[derive(Debug, Clone, Deserialize)]
struct MsPredecessorLink {
#[serde(rename = "PredecessorUID", default)]
predecessor_uid: i32,
#[serde(rename = "Type", default)]
link_type: Option<i32>,
#[serde(rename = "LinkLag", default)]
link_lag: Option<i32>,
}
#[derive(Debug, Clone, Deserialize, Default)]
struct MsProjectResources {
#[serde(rename = "Resource", default)]
resource: Vec<MsProjectResource>,
}
#[derive(Debug, Clone, Deserialize)]
struct MsProjectResource {
#[serde(rename = "UID", default)]
uid: i32,
#[serde(rename = "Name", default)]
name: Option<String>,
#[serde(rename = "Type", default)]
resource_type: Option<i32>,
#[serde(rename = "IsNull", default)]
is_null: Option<bool>,
#[serde(rename = "MaxUnits", default)]
max_units: Option<f64>,
#[serde(rename = "StandardRate", default)]
standard_rate: Option<f64>,
#[serde(rename = "OvertimeRate", default)]
overtime_rate: Option<f64>,
#[serde(rename = "CostPerUse", default)]
cost_per_use: Option<f64>,
#[serde(rename = "EmailAddress", default)]
email_address: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
struct MsProjectAssignments {
#[serde(rename = "Assignment", default)]
assignment: Vec<MsProjectAssignment>,
}
#[derive(Debug, Clone, Deserialize)]
struct MsProjectAssignment {
#[serde(rename = "UID", default)]
uid: i32,
#[serde(rename = "TaskUID", default)]
task_uid: i32,
#[serde(rename = "ResourceUID", default)]
resource_uid: i32,
#[serde(rename = "Units", default)]
units: Option<f64>,
#[serde(rename = "Work", default)]
work: Option<String>,
#[serde(rename = "Start", default)]
start: Option<String>,
#[serde(rename = "Finish", default)]
finish: Option<String>,
#[serde(rename = "Cost", default)]
cost: Option<f64>,
}
pub struct ProjectImportService;
fn parse_ms_date(s: &str) -> Option<chrono::NaiveDate> {
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")
.or_else(|_| chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d"))
.ok()
}
fn parse_ms_duration(duration: &Option<String>) -> Option<u32> {
duration.as_ref().and_then(|d| {
if d.starts_with("PT") {
let hours_str = d.trim_start_matches("PT").trim_end_matches('H');
hours_str.parse::<f64>().ok().map(|h| (h / 8.0).ceil() as u32)
} else {
Some(1)
}
})
}
fn parse_ms_work(work: &Option<String>) -> Option<f32> {
work.as_ref().and_then(|w| {
if w.starts_with("PT") {
let hours_str = w.trim_start_matches("PT").trim_end_matches('H');
hours_str.parse::<f32>().ok()
} else {
None
}
})
}
impl ProjectImportService {
pub fn new() -> Self {
Self
}
pub fn import<R: Read>(
&self,
reader: R,
options: ImportOptions,
) -> Result<ImportResult, String> {
let start_time = std::time::Instant::now();
let result = match options.format {
ImportFormat::MsProjectXml => self.import_ms_project_xml(reader, &options),
ImportFormat::MsProjectMpp => self.import_ms_project_mpp(reader, &options),
ImportFormat::Csv => self.import_csv(reader, &options),
ImportFormat::Json => self.import_json(reader, &options),
ImportFormat::Jira => self.import_generic_json(reader, &options, "Jira"),
ImportFormat::Asana => self.import_generic_json(reader, &options, "Asana"),
ImportFormat::Trello => self.import_generic_json(reader, &options, "Trello"),
};
result.map(|mut r| {
r.stats.import_duration_ms = start_time.elapsed().as_millis() as u64;
r
})
}
fn import_generic_json<R: Read>(
&self,
mut reader: R,
options: &ImportOptions,
source_name: &str,
) -> Result<ImportResult, String> {
let mut content = String::new();
reader
.read_to_string(&mut content)
.map_err(|e| format!("Failed to read {source_name} content: {e}"))?;
let project = Project {
id: Uuid::new_v4(),
organization_id: options.organization_id,
name: format!("Imported {source_name} Project"),
description: Some(format!("{source_name} import - manual task mapping may be required")),
start_date: Utc::now().date_naive(),
end_date: None,
status: ProjectStatus::Planning,
owner_id: options.owner_id,
created_at: Utc::now(),
updated_at: Utc::now(),
settings: ProjectSettings::default(),
};
Ok(ImportResult {
project,
tasks: Vec::new(),
resources: Vec::new(),
assignments: Vec::new(),
warnings: vec![ImportWarning {
code: format!("{}_BASIC_IMPORT", source_name.to_uppercase()),
message: format!("{source_name} import creates a basic project structure. Tasks may need manual adjustment."),
source_element: None,
suggested_action: Some("Review and adjust imported tasks as needed".to_string()),
}],
errors: Vec::new(),
stats: ImportStats {
tasks_imported: 0,
tasks_skipped: 0,
resources_imported: 0,
dependencies_imported: 0,
assignments_imported: 0,
custom_fields_imported: 0,
import_duration_ms: 0,
},
})
}
fn resolve_task_hierarchy(&self, tasks: &mut [ProjectTask]) {
let mut parent_map: HashMap<u32, Uuid> = HashMap::new();
for task in tasks.iter() {
parent_map.insert(task.outline_level, task.id);
}
for task in tasks.iter_mut() {
if task.outline_level > 1 {
if let Some(parent_id) = parent_map.get(&(task.outline_level - 1)) {
task.parent_id = Some(*parent_id);
}
}
}
}
fn import_ms_project_xml<R: Read>(
&self,
mut reader: R,
options: &ImportOptions,
) -> Result<ImportResult, String> {
let mut xml_content = String::new();
reader
.read_to_string(&mut xml_content)
.map_err(|e| format!("Failed to read XML content: {e}"))?;
let ms_project: MsProjectXml = quick_xml::de::from_str(&xml_content)
.map_err(|e| format!("Failed to parse MS Project XML: {e}"))?;
let mut warnings = Vec::new();
let errors = Vec::new();
let mut stats = ImportStats {
tasks_imported: 0,
tasks_skipped: 0,
resources_imported: 0,
dependencies_imported: 0,
assignments_imported: 0,
custom_fields_imported: 0,
import_duration_ms: 0,
};
let project_name = ms_project
.title
.or(ms_project.name)
.unwrap_or_else(|| "Imported Project".to_string());
let start_date = ms_project
.start_date
.as_ref()
.and_then(|s| parse_ms_date(s))
.unwrap_or_else(|| Utc::now().date_naive());
let end_date = ms_project
.finish_date
.as_ref()
.and_then(|s| parse_ms_date(s));
let project = Project {
id: Uuid::new_v4(),
organization_id: options.organization_id,
name: project_name,
description: None,
start_date,
end_date,
status: ProjectStatus::Planning,
owner_id: options.owner_id,
created_at: Utc::now(),
updated_at: Utc::now(),
settings: ProjectSettings::default(),
};
let mut tasks = Vec::new();
let mut task_uid_map: HashMap<i32, Uuid> = HashMap::new();
if let Some(ms_tasks) = &ms_project.tasks {
for ms_task in &ms_tasks.task {
if ms_task.is_null.unwrap_or(false) {
continue;
}
if ms_task.name.is_none() || ms_task.name.as_ref().map(|n| n.is_empty()).unwrap_or(true) {
stats.tasks_skipped += 1;
continue;
}
let task_id = Uuid::new_v4();
task_uid_map.insert(ms_task.uid, task_id);
let task_start = ms_task
.start
.as_ref()
.and_then(|s| parse_ms_date(s))
.unwrap_or(start_date);
let task_end = ms_task
.finish
.as_ref()
.and_then(|s| parse_ms_date(s))
.unwrap_or(task_start);
let duration_days = parse_ms_duration(&ms_task.duration).unwrap_or(1);
let priority = match ms_task.priority.unwrap_or(500) {
0..=200 => TaskPriority::Low,
201..=400 => TaskPriority::Normal,
401..=700 => TaskPriority::High,
_ => TaskPriority::Critical,
};
let task_type = if ms_task.milestone.unwrap_or(false) {
TaskType::Milestone
} else if ms_task.summary.unwrap_or(false) {
TaskType::Summary
} else {
TaskType::Task
};
let task = ProjectTask {
id: task_id,
project_id: project.id,
parent_id: None,
name: ms_task.name.clone().unwrap_or_default(),
description: None,
task_type,
start_date: task_start,
end_date: task_end,
duration_days,
percent_complete: ms_task.percent_complete.unwrap_or(0) as u8,
status: if ms_task.percent_complete.unwrap_or(0) >= 100 {
TaskStatus::Completed
} else if ms_task.percent_complete.unwrap_or(0) > 0 {
TaskStatus::InProgress
} else {
TaskStatus::NotStarted
},
priority,
assigned_to: Vec::new(),
dependencies: Vec::new(),
estimated_hours: parse_ms_work(&ms_task.work),
actual_hours: None,
cost: ms_task.cost,
notes: ms_task.notes.clone(),
wbs: ms_task.wbs.clone().unwrap_or_default(),
outline_level: ms_task.outline_level.unwrap_or(1) as u32,
is_milestone: ms_task.milestone.unwrap_or(false),
is_summary: ms_task.summary.unwrap_or(false),
is_critical: ms_task.critical.unwrap_or(false),
created_at: Utc::now(),
updated_at: Utc::now(),
};
tasks.push(task);
stats.tasks_imported += 1;
}
}
if options.import_dependencies {
if let Some(ms_tasks) = &ms_project.tasks {
for ms_task in &ms_tasks.task {
if let Some(task_id) = task_uid_map.get(&ms_task.uid) {
for pred_link in &ms_task.predecessor_links {
if let Some(pred_id) = task_uid_map.get(&pred_link.predecessor_uid) {
let dep_type = match pred_link.link_type.unwrap_or(1) {
0 => DependencyType::FinishToFinish,
1 => DependencyType::FinishToStart,
2 => DependencyType::StartToFinish,
3 => DependencyType::StartToStart,
_ => DependencyType::FinishToStart,
};
let lag_days = pred_link.link_lag.unwrap_or(0) / 4800;
if let Some(task) = tasks.iter_mut().find(|t| t.id == *task_id) {
task.dependencies.push(TaskDependency {
predecessor_id: *pred_id,
dependency_type: dep_type,
lag_days,
});
stats.dependencies_imported += 1;
}
} else {
warnings.push(ImportWarning {
code: "PRED_NOT_FOUND".to_string(),
message: format!(
"Predecessor task UID {} not found for task {}",
pred_link.predecessor_uid, ms_task.uid
),
source_element: Some(format!("Task UID {}", ms_task.uid)),
suggested_action: Some("Dependency will be skipped".to_string()),
});
}
}
}
}
}
}
let mut resources = Vec::new();
let mut resource_uid_map: HashMap<i32, Uuid> = HashMap::new();
if options.import_resources {
if let Some(ms_resources) = &ms_project.resources {
for ms_resource in &ms_resources.resource {
if ms_resource.is_null.unwrap_or(false) {
continue;
}
if ms_resource.name.is_none()
|| ms_resource.name.as_ref().map(|n| n.is_empty()).unwrap_or(true)
{
continue;
}
let resource_id = Uuid::new_v4();
resource_uid_map.insert(ms_resource.uid, resource_id);
let resource_type = match ms_resource.resource_type.unwrap_or(1) {
0 => ResourceType::Material,
1 => ResourceType::Work,
2 => ResourceType::Cost,
_ => ResourceType::Work,
};
let resource = Resource {
id: resource_id,
project_id: project.id,
user_id: options
.map_users
.get(ms_resource.name.as_ref().unwrap_or(&String::new()))
.copied(),
name: ms_resource.name.clone().unwrap_or_default(),
resource_type,
email: ms_resource.email_address.clone(),
max_units: ms_resource.max_units.unwrap_or(1.0) as f32,
standard_rate: Some(ms_resource.standard_rate.unwrap_or(options.default_resource_rate)),
overtime_rate: Some(ms_resource.overtime_rate.unwrap_or(0.0)),
cost_per_use: Some(ms_resource.cost_per_use.unwrap_or(0.0)),
calendar_id: None,
created_at: Utc::now(),
};
resources.push(resource);
stats.resources_imported += 1;
}
}
}
let mut assignments = Vec::new();
if options.import_assignments {
if let Some(ms_assignments) = &ms_project.assignments {
for ms_assignment in &ms_assignments.assignment {
let task_id = task_uid_map.get(&ms_assignment.task_uid);
let resource_id = resource_uid_map.get(&ms_assignment.resource_uid);
match (task_id, resource_id) {
(Some(tid), Some(rid)) => {
let assignment_start = ms_assignment
.start
.as_ref()
.and_then(|s| parse_ms_date(s))
.unwrap_or(start_date);
let assignment_end = ms_assignment
.finish
.as_ref()
.and_then(|s| parse_ms_date(s))
.unwrap_or(assignment_start);
let work_hours = parse_ms_work(&ms_assignment.work).unwrap_or(0.0);
let assignment = ResourceAssignment {
id: Uuid::new_v4(),
task_id: *tid,
resource_id: *rid,
units: ms_assignment.units.unwrap_or(1.0) as f32,
work_hours,
start_date: assignment_start,
end_date: assignment_end,
cost: ms_assignment.cost.unwrap_or(0.0),
};
if let Some(task) = tasks.iter_mut().find(|t| t.id == *tid) {
if !task.assigned_to.contains(rid) {
task.assigned_to.push(*rid);
}
}
assignments.push(assignment);
stats.assignments_imported += 1;
}
_ => {
warnings.push(ImportWarning {
code: "ASSIGNMENT_REF_MISSING".to_string(),
message: format!(
"Assignment {} references missing task or resource",
ms_assignment.uid
),
source_element: Some(format!("Assignment UID {}", ms_assignment.uid)),
suggested_action: Some("Assignment will be skipped".to_string()),
});
}
}
}
}
}
self.resolve_task_hierarchy(&mut tasks);
Ok(ImportResult {
project,
tasks,
resources,
assignments,
warnings,
errors,
stats,
})
}
fn import_ms_project_mpp<R: Read>(
&self,
_reader: R,
options: &ImportOptions,
) -> Result<ImportResult, String> {
let project = Project {
id: Uuid::new_v4(),
organization_id: options.organization_id,
name: "Imported MPP Project".to_string(),
description: Some("MPP format requires conversion. Please export as XML from MS Project.".to_string()),
start_date: Utc::now().date_naive(),
end_date: None,
status: ProjectStatus::Planning,
owner_id: options.owner_id,
created_at: Utc::now(),
updated_at: Utc::now(),
settings: ProjectSettings::default(),
};
Ok(ImportResult {
project,
tasks: Vec::new(),
resources: Vec::new(),
assignments: Vec::new(),
warnings: vec![ImportWarning {
code: "MPP_NOT_SUPPORTED".to_string(),
message: "Native MPP format is not fully supported. Please export as XML from MS Project for best results.".to_string(),
source_element: None,
suggested_action: Some("Use File > Save As > XML in MS Project".to_string()),
}],
errors: Vec::new(),
stats: ImportStats {
tasks_imported: 0,
tasks_skipped: 0,
resources_imported: 0,
dependencies_imported: 0,
assignments_imported: 0,
custom_fields_imported: 0,
import_duration_ms: 0,
},
})
}
fn import_csv<R: Read>(
&self,
mut reader: R,
options: &ImportOptions,
) -> Result<ImportResult, String> {
let mut content = String::new();
reader
.read_to_string(&mut content)
.map_err(|e| format!("Failed to read CSV content: {e}"))?;
let mut tasks = Vec::new();
let mut stats = ImportStats {
tasks_imported: 0,
tasks_skipped: 0,
resources_imported: 0,
dependencies_imported: 0,
assignments_imported: 0,
custom_fields_imported: 0,
import_duration_ms: 0,
};
let project = Project {
id: Uuid::new_v4(),
organization_id: options.organization_id,
name: "Imported CSV Project".to_string(),
description: None,
start_date: Utc::now().date_naive(),
end_date: None,
status: ProjectStatus::Planning,
owner_id: options.owner_id,
created_at: Utc::now(),
updated_at: Utc::now(),
settings: ProjectSettings::default(),
};
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() {
return Ok(ImportResult {
project,
tasks: Vec::new(),
resources: Vec::new(),
assignments: Vec::new(),
warnings: vec![ImportWarning {
code: "EMPTY_CSV".to_string(),
message: "CSV file is empty".to_string(),
source_element: None,
suggested_action: None,
}],
errors: Vec::new(),
stats,
});
}
let headers: Vec<&str> = lines[0].split(',').map(|s| s.trim()).collect();
let name_idx = headers.iter().position(|h| h.eq_ignore_ascii_case("name") || h.eq_ignore_ascii_case("task"));
let start_idx = headers.iter().position(|h| h.eq_ignore_ascii_case("start") || h.eq_ignore_ascii_case("start_date"));
let end_idx = headers.iter().position(|h| h.eq_ignore_ascii_case("end") || h.eq_ignore_ascii_case("finish") || h.eq_ignore_ascii_case("end_date"));
let duration_idx = headers.iter().position(|h| h.eq_ignore_ascii_case("duration"));
for line in lines.iter().skip(1) {
let fields: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
let name = name_idx
.and_then(|i| fields.get(i))
.map(|s| s.to_string())
.unwrap_or_else(|| format!("Task {}", tasks.len() + 1));
if name.is_empty() {
stats.tasks_skipped += 1;
continue;
}
let start_date = start_idx
.and_then(|i| fields.get(i))
.and_then(|s| parse_date_flexible(s))
.unwrap_or_else(|| Utc::now().date_naive());
let end_date = end_idx
.and_then(|i| fields.get(i))
.and_then(|s| parse_date_flexible(s))
.unwrap_or(start_date);
let duration_days = duration_idx
.and_then(|i| fields.get(i))
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(1);
let task = ProjectTask {
id: Uuid::new_v4(),
project_id: project.id,
parent_id: None,
name,
description: None,
task_type: TaskType::Task,
start_date,
end_date,
duration_days,
percent_complete: 0,
status: TaskStatus::NotStarted,
priority: TaskPriority::Normal,
assigned_to: Vec::new(),
dependencies: Vec::new(),
estimated_hours: None,
actual_hours: None,
cost: None,
notes: None,
wbs: format!("{}", tasks.len() + 1),
outline_level: 1,
is_milestone: false,
is_summary: false,
is_critical: false,
created_at: Utc::now(),
updated_at: Utc::now(),
};
tasks.push(task);
stats.tasks_imported += 1;
}
Ok(ImportResult {
project,
tasks,
resources: Vec::new(),
assignments: Vec::new(),
warnings: Vec::new(),
errors: Vec::new(),
stats,
})
}
fn import_json<R: Read>(
&self,
mut reader: R,
options: &ImportOptions,
) -> Result<ImportResult, String> {
let start = std::time::Instant::now();
let mut content = String::new();
reader
.read_to_string(&mut content)
.map_err(|e| format!("Failed to read JSON content: {e}"))?;
#[derive(Deserialize)]
struct JsonProject {
name: String,
description: Option<String>,
start_date: Option<String>,
tasks: Option<Vec<JsonTask>>,
}
#[derive(Deserialize)]
struct JsonTask {
name: String,
start_date: Option<String>,
end_date: Option<String>,
duration: Option<u32>,
progress: Option<u8>,
}
let json_project: JsonProject = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse JSON: {e}"))?;
let project = Project {
id: Uuid::new_v4(),
organization_id: options.organization_id,
name: json_project.name,
description: json_project.description,
start_date: json_project
.start_date
.as_ref()
.and_then(|s| parse_date_flexible(s))
.unwrap_or_else(|| Utc::now().date_naive()),
end_date: None,
status: ProjectStatus::Planning,
owner_id: options.owner_id,
created_at: Utc::now(),
updated_at: Utc::now(),
settings: ProjectSettings::default(),
};
let mut tasks = Vec::new();
let mut stats = ImportStats {
tasks_imported: 0,
tasks_skipped: 0,
resources_imported: 0,
dependencies_imported: 0,
assignments_imported: 0,
custom_fields_imported: 0,
import_duration_ms: 0,
};
if let Some(json_tasks) = json_project.tasks {
for (idx, json_task) in json_tasks.iter().enumerate() {
let start_date = json_task
.start_date
.as_ref()
.and_then(|s| parse_date_flexible(s))
.unwrap_or_else(|| Utc::now().date_naive());
let end_date = json_task
.end_date
.as_ref()
.and_then(|s| parse_date_flexible(s))
.unwrap_or(start_date);
let task = ProjectTask {
id: Uuid::new_v4(),
project_id: project.id,
parent_id: None,
name: json_task.name.clone(),
description: None,
task_type: TaskType::Task,
start_date,
end_date,
duration_days: json_task.duration.unwrap_or(1),
percent_complete: json_task.progress.unwrap_or(0),
status: TaskStatus::NotStarted,
priority: TaskPriority::Normal,
assigned_to: Vec::new(),
dependencies: Vec::new(),
estimated_hours: None,
actual_hours: None,
cost: None,
notes: None,
wbs: format!("{}", idx + 1),
outline_level: 1,
is_milestone: false,
is_summary: false,
is_critical: false,
created_at: Utc::now(),
updated_at: Utc::now(),
};
tasks.push(task);
stats.tasks_imported += 1;
}
}
let duration = start.elapsed().as_millis() as u64;
stats.import_duration_ms = duration;
Ok(ImportResult {
project,
tasks,
resources: Vec::new(),
assignments: Vec::new(),
warnings: Vec::new(),
errors: Vec::new(),
stats,
})
}
}
fn parse_date_flexible(s: &str) -> Option<chrono::NaiveDate> {
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
.or_else(|_| chrono::NaiveDate::parse_from_str(s, "%m/%d/%Y"))
.or_else(|_| chrono::NaiveDate::parse_from_str(s, "%d/%m/%Y"))
.ok()
}