1mod 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct ExportResult {
25 pub xml: String,
27 pub report: ExportReport,
29}
30
31struct Row<'a> {
32 work: &'a WorkItem,
33 uid: u32,
34 outline_number: String,
35 level: usize,
36}
37
38pub 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
144type 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
214fn 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
235fn 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
270pub(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("&"),
277 '<' => escaped.push_str("<"),
278 '>' => escaped.push_str(">"),
279 '\r' => escaped.push_str(" "),
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}