Skip to main content

dpm_interchange/mspdi/
export.rs

1//! Write one project's work as the supported MSPDI subset.
2//!
3//! Output depends only on the plan: tasks follow the outline with siblings in explicit order, UIDs
4//! number that order, and GUIDs are the stable work and project identities. Without workspace
5//! calendars, durations and lags are written in elapsed hours. With them, the calendars in use are
6//! written too, and durations and lags on working time are written in working hours.
7
8mod reports;
9
10use super::calendars::export::CalendarExport;
11use super::encoding::{
12    ELAPSED_HOURS_FORMAT, duration_seconds, format_duration, format_guid, lag_tenths,
13    priority_value, relation_code,
14};
15use super::report::ExportReport;
16use crate::InterchangeError;
17use dpm_model::{Plan, Project, WorkItem, WorkItemId, WorkKind};
18use reports::{dependency_reports, item_report, project_omissions};
19use serde::{Deserialize, Serialize};
20use std::collections::BTreeMap;
21
22/// Written document and its report.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct ExportResult {
25    /// Complete MSPDI document.
26    pub xml: String,
27    /// Per-item preserved, approximated and omitted data.
28    pub report: ExportReport,
29}
30
31struct Row<'a> {
32    work: &'a WorkItem,
33    uid: u32,
34    outline_number: String,
35    level: usize,
36}
37
38/// Export the work of one project (not its child projects) as MSPDI.
39pub fn export_mspdi(plan: &Plan, project_key: &str) -> Result<ExportResult, InterchangeError> {
40    let project = plan
41        .projects
42        .values()
43        .find(|p| p.key.0 == project_key)
44        .ok_or_else(|| InterchangeError::UnknownProject {
45            key: project_key.into(),
46        })?;
47    let rows = outline(plan, project);
48    let applicability = plan.applicability();
49    let uids: BTreeMap<WorkItemId, u32> = rows.iter().map(|r| (r.work.id, r.uid)).collect();
50    let calendars = (plan.calendars.as_ref())
51        .map(|calendars| CalendarExport::new(calendars, rows.iter().map(|r| r.work)));
52    let unrepresentable = |reason| InterchangeError::Unrepresentable {
53        key: project.key.0.clone(),
54        reason,
55    };
56    let mut xml = String::from(
57        "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
58         <Project xmlns=\"http://schemas.microsoft.com/project\">\n    <SaveVersion>14</SaveVersion>\n",
59    );
60    let title = escape(&project.title).map_err(unrepresentable)?;
61    let project_guid = format_guid(project.id.0);
62    push_line(&mut xml, 1, &format!("<Name>{title}</Name>"));
63    push_line(&mut xml, 1, &format!("<GUID>{project_guid}</GUID>"));
64    push_line(&mut xml, 1, &format!("<Title>{title}</Title>"));
65    if let Some(calendars) = &calendars {
66        let uid = calendars.project_uid();
67        push_line(&mut xml, 1, &format!("<CalendarUID>{uid}</CalendarUID>"));
68    }
69    push_line(
70        &mut xml,
71        1,
72        &format!(
73            "<ExtendedAttributes><ExtendedAttribute><FieldID>{}</FieldID><FieldName>Text1</FieldName><Alias>{}</Alias></ExtendedAttribute></ExtendedAttributes>",
74            super::metadata::FIELD_ID,
75            super::metadata::ALIAS
76        ),
77    );
78    if let Some(calendars) = &calendars {
79        calendars.write(&mut xml).map_err(unrepresentable)?;
80    }
81    push_line(&mut xml, 1, "<Tasks>");
82    let mut items = Vec::new();
83    for row in &rows {
84        write_task(&mut xml, plan, row, (&uids, calendars.as_ref())).map_err(|reason| {
85            InterchangeError::Unrepresentable {
86                key: row.work.key.0.clone(),
87                reason,
88            }
89        })?;
90        items.push(item_report(plan, &applicability, row));
91    }
92    push_line(&mut xml, 1, "</Tasks>");
93    xml.push_str("</Project>\n");
94    Ok(ExportResult {
95        xml,
96        report: ExportReport {
97            project: project.key.clone(),
98            project_guid,
99            items,
100            dependencies: dependency_reports(plan, &applicability, &uids),
101            omitted: project_omissions(plan, project, &uids)
102                .into_iter()
103                .chain(calendars.iter().flat_map(CalendarExport::omissions))
104                .collect(),
105        },
106    })
107}
108
109fn outline<'a>(plan: &'a Plan, project: &Project) -> Vec<Row<'a>> {
110    let mut children: BTreeMap<Option<WorkItemId>, Vec<&WorkItem>> = BTreeMap::new();
111    for work in plan.work_items.values().filter(|w| w.project == project.id) {
112        children.entry(work.parent).or_default().push(work);
113    }
114    for siblings in children.values_mut() {
115        siblings.sort_by(|a, b| a.order.cmp(&b.order).then(a.id.cmp(&b.id)));
116    }
117    let mut rows = Vec::new();
118    let roots = children.get(&None).cloned().unwrap_or_default();
119    let mut pending: Vec<(&WorkItem, String)> = roots
120        .iter()
121        .enumerate()
122        .rev()
123        .map(|(i, w)| (*w, (i + 1).to_string()))
124        .collect();
125    while let Some((work, number)) = pending.pop() {
126        let nested = children.get(&Some(work.id)).cloned().unwrap_or_default();
127        pending.extend(
128            nested
129                .iter()
130                .enumerate()
131                .rev()
132                .map(|(i, child)| (*child, format!("{number}.{}", i + 1))),
133        );
134        rows.push(Row {
135            work,
136            uid: u32::try_from(rows.len() + 1).unwrap_or(u32::MAX),
137            level: number.split('.').count(),
138            outline_number: number,
139        });
140    }
141    rows
142}
143
144/// Task UIDs by work, and the calendar numbering when the workspace has calendars.
145type Numbering<'a, 'c> = (
146    &'a BTreeMap<WorkItemId, u32>,
147    Option<&'a CalendarExport<'c>>,
148);
149
150fn write_task(
151    xml: &mut String,
152    plan: &Plan,
153    row: &Row,
154    (uids, calendars): Numbering,
155) -> Result<(), String> {
156    let work = row.work;
157    push_line(xml, 2, "<Task>");
158    for (name, value) in [
159        ("UID", row.uid.to_string()),
160        ("GUID", format_guid(work.id.0)),
161        ("ID", row.uid.to_string()),
162        ("Name", escape(&work.title)?),
163        ("OutlineNumber", row.outline_number.clone()),
164        ("OutlineLevel", row.level.to_string()),
165        (
166            "Priority",
167            priority_value(work.schedule.priority).to_string(),
168        ),
169    ] {
170        push_line(xml, 3, &format!("<{name}>{value}</{name}>"));
171    }
172    write_duration(xml, work, calendars);
173    let flag = |set: bool| if set { 1 } else { 0 };
174    push_line(
175        xml,
176        3,
177        &format!(
178            "<Milestone>{}</Milestone>",
179            flag(work.kind == WorkKind::Milestone)
180        ),
181    );
182    push_line(
183        xml,
184        3,
185        &format!(
186            "<Summary>{}</Summary>",
187            flag(work.kind == WorkKind::WorkPackage)
188        ),
189    );
190    if let Some(uid) = calendars.and_then(|c| c.task_uid(work)) {
191        push_line(xml, 3, &format!("<CalendarUID>{uid}</CalendarUID>"));
192    }
193    if !work.contract.objective.trim().is_empty() {
194        push_line(
195            xml,
196            3,
197            &format!("<Notes>{}</Notes>", escape(&work.contract.objective)?),
198        );
199    }
200    let metadata = escape(&super::metadata::encode(work)?)?;
201    push_line(
202        xml,
203        3,
204        &format!(
205            "<ExtendedAttribute><FieldID>{}</FieldID><Value>{metadata}</Value></ExtendedAttribute>",
206            super::metadata::FIELD_ID
207        ),
208    );
209    write_links(xml, plan, work.id, (uids, calendars));
210    push_line(xml, 2, "</Task>");
211    Ok(())
212}
213
214/// Duration of a task or milestone in working hours of its calendar, or in elapsed hours without
215/// calendars or on `always`.
216fn write_duration(xml: &mut String, work: &WorkItem, calendars: Option<&CalendarExport>) {
217    let seconds = match work.kind {
218        WorkKind::Task => duration_seconds(work.schedule.estimate),
219        WorkKind::Milestone => 0,
220        WorkKind::WorkPackage => return,
221    };
222    let format = calendars.map_or(ELAPSED_HOURS_FORMAT, |c| c.duration_format(work));
223    push_line(
224        xml,
225        3,
226        &format!("<Duration>{}</Duration>", format_duration(seconds)),
227    );
228    push_line(
229        xml,
230        3,
231        &format!("<DurationFormat>{format}</DurationFormat>"),
232    );
233}
234
235/// Links into one successor from exported predecessors, ordered by predecessor UID and type.
236fn write_links(xml: &mut String, plan: &Plan, successor: WorkItemId, (uids, calendars): Numbering) {
237    let mut links: Vec<_> = plan
238        .dependencies
239        .iter()
240        .filter(|d| d.successor == successor)
241        .filter_map(|d| Some((*uids.get(&d.predecessor)?, relation_code(d.kind), d)))
242        .collect();
243    links.sort_by_key(|(uid, code, _)| (*uid, *code));
244    for (predecessor, code, edge) in links {
245        push_line(xml, 3, "<PredecessorLink>");
246        for (name, value) in [
247            ("PredecessorUID", predecessor.to_string()),
248            ("Type", code.to_string()),
249            ("CrossProject", "0".into()),
250            ("LinkLag", lag_tenths(edge.lag_hours).to_string()),
251            (
252                "LagFormat",
253                calendars
254                    .map_or(ELAPSED_HOURS_FORMAT, |_| CalendarExport::lag_format(edge))
255                    .to_string(),
256            ),
257        ] {
258            push_line(xml, 4, &format!("<{name}>{value}</{name}>"));
259        }
260        push_line(xml, 3, "</PredecessorLink>");
261    }
262}
263
264pub(super) fn push_line(xml: &mut String, depth: usize, line: &str) {
265    xml.push_str(&" ".repeat(depth * 4));
266    xml.push_str(line);
267    xml.push('\n');
268}
269
270/// Escape text for element content; a carriage return is written as a reference so parsers do
271/// not normalize it away. Characters XML 1.0 cannot carry are rejected.
272pub(super) fn escape(text: &str) -> Result<String, String> {
273    let mut escaped = String::with_capacity(text.len());
274    for character in text.chars() {
275        match character {
276            '&' => escaped.push_str("&amp;"),
277            '<' => escaped.push_str("&lt;"),
278            '>' => escaped.push_str("&gt;"),
279            '\r' => escaped.push_str("&#13;"),
280            '\t' | '\n' => escaped.push(character),
281            c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => {
282                return Err(format!(
283                    "text contains U+{:04X}, which XML 1.0 cannot carry",
284                    c as u32
285                ));
286            }
287            c => escaped.push(c),
288        }
289    }
290    Ok(escaped)
291}