mod reports;
use super::calendars::export::CalendarExport;
use super::encoding::{
ELAPSED_HOURS_FORMAT, duration_seconds, format_duration, format_guid, lag_tenths,
priority_value, relation_code,
};
use super::report::ExportReport;
use crate::InterchangeError;
use dpm_model::{Plan, Project, WorkItem, WorkItemId, WorkKind};
use reports::{dependency_reports, item_report, project_omissions};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExportResult {
pub xml: String,
pub report: ExportReport,
}
struct Row<'a> {
work: &'a WorkItem,
uid: u32,
outline_number: String,
level: usize,
}
pub fn export_mspdi(plan: &Plan, project_key: &str) -> Result<ExportResult, InterchangeError> {
let project = plan
.projects
.values()
.find(|p| p.key.0 == project_key)
.ok_or_else(|| InterchangeError::UnknownProject {
key: project_key.into(),
})?;
let rows = outline(plan, project);
let applicability = plan.applicability();
let uids: BTreeMap<WorkItemId, u32> = rows.iter().map(|r| (r.work.id, r.uid)).collect();
let calendars = (plan.calendars.as_ref())
.map(|calendars| CalendarExport::new(calendars, rows.iter().map(|r| r.work)));
let unrepresentable = |reason| InterchangeError::Unrepresentable {
key: project.key.0.clone(),
reason,
};
let mut xml = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
<Project xmlns=\"http://schemas.microsoft.com/project\">\n <SaveVersion>14</SaveVersion>\n",
);
let title = escape(&project.title).map_err(unrepresentable)?;
let project_guid = format_guid(project.id.0);
push_line(&mut xml, 1, &format!("<Name>{title}</Name>"));
push_line(&mut xml, 1, &format!("<GUID>{project_guid}</GUID>"));
push_line(&mut xml, 1, &format!("<Title>{title}</Title>"));
if let Some(calendars) = &calendars {
let uid = calendars.project_uid();
push_line(&mut xml, 1, &format!("<CalendarUID>{uid}</CalendarUID>"));
}
push_line(
&mut xml,
1,
&format!(
"<ExtendedAttributes><ExtendedAttribute><FieldID>{}</FieldID><FieldName>Text1</FieldName><Alias>{}</Alias></ExtendedAttribute></ExtendedAttributes>",
super::metadata::FIELD_ID,
super::metadata::ALIAS
),
);
if let Some(calendars) = &calendars {
calendars.write(&mut xml).map_err(unrepresentable)?;
}
push_line(&mut xml, 1, "<Tasks>");
let mut items = Vec::new();
for row in &rows {
write_task(&mut xml, plan, row, (&uids, calendars.as_ref())).map_err(|reason| {
InterchangeError::Unrepresentable {
key: row.work.key.0.clone(),
reason,
}
})?;
items.push(item_report(plan, &applicability, row));
}
push_line(&mut xml, 1, "</Tasks>");
xml.push_str("</Project>\n");
Ok(ExportResult {
xml,
report: ExportReport {
project: project.key.clone(),
project_guid,
items,
dependencies: dependency_reports(plan, &applicability, &uids),
omitted: project_omissions(plan, project, &uids)
.into_iter()
.chain(calendars.iter().flat_map(CalendarExport::omissions))
.collect(),
},
})
}
fn outline<'a>(plan: &'a Plan, project: &Project) -> Vec<Row<'a>> {
let mut children: BTreeMap<Option<WorkItemId>, Vec<&WorkItem>> = BTreeMap::new();
for work in plan.work_items.values().filter(|w| w.project == project.id) {
children.entry(work.parent).or_default().push(work);
}
for siblings in children.values_mut() {
siblings.sort_by(|a, b| a.order.cmp(&b.order).then(a.id.cmp(&b.id)));
}
let mut rows = Vec::new();
let roots = children.get(&None).cloned().unwrap_or_default();
let mut pending: Vec<(&WorkItem, String)> = roots
.iter()
.enumerate()
.rev()
.map(|(i, w)| (*w, (i + 1).to_string()))
.collect();
while let Some((work, number)) = pending.pop() {
let nested = children.get(&Some(work.id)).cloned().unwrap_or_default();
pending.extend(
nested
.iter()
.enumerate()
.rev()
.map(|(i, child)| (*child, format!("{number}.{}", i + 1))),
);
rows.push(Row {
work,
uid: u32::try_from(rows.len() + 1).unwrap_or(u32::MAX),
level: number.split('.').count(),
outline_number: number,
});
}
rows
}
type Numbering<'a, 'c> = (
&'a BTreeMap<WorkItemId, u32>,
Option<&'a CalendarExport<'c>>,
);
fn write_task(
xml: &mut String,
plan: &Plan,
row: &Row,
(uids, calendars): Numbering,
) -> Result<(), String> {
let work = row.work;
push_line(xml, 2, "<Task>");
for (name, value) in [
("UID", row.uid.to_string()),
("GUID", format_guid(work.id.0)),
("ID", row.uid.to_string()),
("Name", escape(&work.title)?),
("OutlineNumber", row.outline_number.clone()),
("OutlineLevel", row.level.to_string()),
(
"Priority",
priority_value(work.schedule.priority).to_string(),
),
] {
push_line(xml, 3, &format!("<{name}>{value}</{name}>"));
}
write_duration(xml, work, calendars);
let flag = |set: bool| if set { 1 } else { 0 };
push_line(
xml,
3,
&format!(
"<Milestone>{}</Milestone>",
flag(work.kind == WorkKind::Milestone)
),
);
push_line(
xml,
3,
&format!(
"<Summary>{}</Summary>",
flag(work.kind == WorkKind::WorkPackage)
),
);
if let Some(uid) = calendars.and_then(|c| c.task_uid(work)) {
push_line(xml, 3, &format!("<CalendarUID>{uid}</CalendarUID>"));
}
if !work.contract.objective.trim().is_empty() {
push_line(
xml,
3,
&format!("<Notes>{}</Notes>", escape(&work.contract.objective)?),
);
}
let metadata = escape(&super::metadata::encode(work)?)?;
push_line(
xml,
3,
&format!(
"<ExtendedAttribute><FieldID>{}</FieldID><Value>{metadata}</Value></ExtendedAttribute>",
super::metadata::FIELD_ID
),
);
write_links(xml, plan, work.id, (uids, calendars));
push_line(xml, 2, "</Task>");
Ok(())
}
fn write_duration(xml: &mut String, work: &WorkItem, calendars: Option<&CalendarExport>) {
let seconds = match work.kind {
WorkKind::Task => duration_seconds(work.schedule.estimate),
WorkKind::Milestone => 0,
WorkKind::WorkPackage => return,
};
let format = calendars.map_or(ELAPSED_HOURS_FORMAT, |c| c.duration_format(work));
push_line(
xml,
3,
&format!("<Duration>{}</Duration>", format_duration(seconds)),
);
push_line(
xml,
3,
&format!("<DurationFormat>{format}</DurationFormat>"),
);
}
fn write_links(xml: &mut String, plan: &Plan, successor: WorkItemId, (uids, calendars): Numbering) {
let mut links: Vec<_> = plan
.dependencies
.iter()
.filter(|d| d.successor == successor)
.filter_map(|d| Some((*uids.get(&d.predecessor)?, relation_code(d.kind), d)))
.collect();
links.sort_by_key(|(uid, code, _)| (*uid, *code));
for (predecessor, code, edge) in links {
push_line(xml, 3, "<PredecessorLink>");
for (name, value) in [
("PredecessorUID", predecessor.to_string()),
("Type", code.to_string()),
("CrossProject", "0".into()),
("LinkLag", lag_tenths(edge.lag_hours).to_string()),
(
"LagFormat",
calendars
.map_or(ELAPSED_HOURS_FORMAT, |_| CalendarExport::lag_format(edge))
.to_string(),
),
] {
push_line(xml, 4, &format!("<{name}>{value}</{name}>"));
}
push_line(xml, 3, "</PredecessorLink>");
}
}
pub(super) fn push_line(xml: &mut String, depth: usize, line: &str) {
xml.push_str(&" ".repeat(depth * 4));
xml.push_str(line);
xml.push('\n');
}
pub(super) fn escape(text: &str) -> Result<String, String> {
let mut escaped = String::with_capacity(text.len());
for character in text.chars() {
match character {
'&' => escaped.push_str("&"),
'<' => escaped.push_str("<"),
'>' => escaped.push_str(">"),
'\r' => escaped.push_str(" "),
'\t' | '\n' => escaped.push(character),
c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => {
return Err(format!(
"text contains U+{:04X}, which XML 1.0 cannot carry",
c as u32
));
}
c => escaped.push(c),
}
}
Ok(escaped)
}