construction-gantt 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/README.md +5 -0
- package/dist/export/index.cjs +1 -0
- package/dist/export/index.d.cts +112 -0
- package/dist/export/index.d.cts.map +1 -0
- package/dist/export/index.d.ts +112 -0
- package/dist/export/index.d.ts.map +1 -0
- package/dist/export/index.js +1 -0
- package/dist/index.cjs +3492 -0
- package/dist/index.d.cts +628 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +628 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3461 -0
- package/dist/pdf-CAQDrX0w.cjs +120 -0
- package/dist/pdf-CBaoJRTI.js +120 -0
- package/dist/png-C8t74695.cjs +88 -0
- package/dist/png-DKZeKnRh.js +88 -0
- package/dist/xlsx-5FRPFck7.js +89 -0
- package/dist/xlsx-Gh5L_NL3.cjs +111 -0
- package/package.json +86 -0
- package/src/Gantt.css +23 -0
- package/src/Gantt.tsx +636 -0
- package/src/SpikeGantt.tsx +114 -0
- package/src/analysis.ts +83 -0
- package/src/baseline.ts +119 -0
- package/src/calendars/canterbury-table.ts +44 -0
- package/src/calendars/internal/computus.ts +25 -0
- package/src/calendars/internal/date-utils.ts +13 -0
- package/src/calendars/internal/mondayisation.ts +46 -0
- package/src/calendars/internal/month-rules.ts +65 -0
- package/src/calendars/matariki-table.ts +63 -0
- package/src/calendars/nz-holidays.ts +214 -0
- package/src/editing/command-history.ts +78 -0
- package/src/editing/commands.ts +327 -0
- package/src/editing/composite-command.ts +64 -0
- package/src/editing/draft-project.ts +59 -0
- package/src/editing/errors.ts +14 -0
- package/src/editing/factories.ts +92 -0
- package/src/editing/use-editable-project.ts +122 -0
- package/src/export/index.ts +12 -0
- package/src/export/offscreen.tsx +89 -0
- package/src/export/pdf-dimensions.ts +64 -0
- package/src/export/pdf.ts +68 -0
- package/src/export/png.ts +48 -0
- package/src/export/types.ts +42 -0
- package/src/export/xlsx.ts +70 -0
- package/src/index.ts +89 -0
- package/src/mspdi/parse.ts +820 -0
- package/src/mspdi/serialize.ts +352 -0
- package/src/mspdi/types.ts +53 -0
- package/src/schedule.ts +470 -0
- package/src/topological-sort.ts +51 -0
- package/src/types.ts +254 -0
- package/src/visibility.ts +35 -0
- package/src/working-time.ts +235 -0
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
// MSPDI XML → internal Project. Hand-mapped, supported-subset only.
|
|
2
|
+
// Unrecognised MSPDI elements surface in `droppedFields` rather than
|
|
3
|
+
// being silently discarded.
|
|
4
|
+
|
|
5
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
6
|
+
import type {
|
|
7
|
+
Assignment,
|
|
8
|
+
Baseline,
|
|
9
|
+
BaselineIndex,
|
|
10
|
+
BaselineTaskSnapshot,
|
|
11
|
+
Calendar,
|
|
12
|
+
CalendarException,
|
|
13
|
+
DayOfWeek,
|
|
14
|
+
DependencyType,
|
|
15
|
+
Link,
|
|
16
|
+
Project,
|
|
17
|
+
Resource,
|
|
18
|
+
Task,
|
|
19
|
+
TaskId,
|
|
20
|
+
TaskType,
|
|
21
|
+
WorkInterval,
|
|
22
|
+
} from '../types.js';
|
|
23
|
+
import type { DroppedField, MspdiParseResult } from './types.js';
|
|
24
|
+
|
|
25
|
+
// Field names we know about and either map or intentionally ignore on parse.
|
|
26
|
+
// Everything outside this set lands in `droppedFields`.
|
|
27
|
+
//
|
|
28
|
+
// The list is intentionally broad: real MS Project exports emit ~50 fields
|
|
29
|
+
// per Task, most of which are MS-Project-computed state (CPM results, EV,
|
|
30
|
+
// rates, costs, leveling). We don't preserve these on round-trip — our
|
|
31
|
+
// engine recomputes the equivalent fields. Listing them here keeps
|
|
32
|
+
// `droppedFields` focused on genuinely-unknown elements rather than
|
|
33
|
+
// recompute-able noise.
|
|
34
|
+
const KNOWN_TASK_FIELDS = new Set([
|
|
35
|
+
// Mapped — read into the internal Task shape
|
|
36
|
+
'UID',
|
|
37
|
+
'ID',
|
|
38
|
+
'Name',
|
|
39
|
+
'Start',
|
|
40
|
+
'Finish',
|
|
41
|
+
'Duration',
|
|
42
|
+
'ConstraintType',
|
|
43
|
+
'Milestone',
|
|
44
|
+
'Summary',
|
|
45
|
+
'OutlineLevel',
|
|
46
|
+
'PredecessorLink',
|
|
47
|
+
'Baseline',
|
|
48
|
+
// Allowed but ignored (default-bearing structure or recompute-able state)
|
|
49
|
+
'Type', // task type code; only Milestone + Summary flags affect us
|
|
50
|
+
'IsNull',
|
|
51
|
+
'CreateDate',
|
|
52
|
+
'WBS',
|
|
53
|
+
'OutlineNumber',
|
|
54
|
+
'Priority',
|
|
55
|
+
'PercentComplete',
|
|
56
|
+
'PercentWorkComplete',
|
|
57
|
+
'PhysicalPercentComplete',
|
|
58
|
+
'EarnedValueMethod',
|
|
59
|
+
'DurationFormat',
|
|
60
|
+
'Work',
|
|
61
|
+
'ResumeValid',
|
|
62
|
+
'EffortDriven',
|
|
63
|
+
'Recurring',
|
|
64
|
+
'OverAllocated',
|
|
65
|
+
'Estimated',
|
|
66
|
+
'Critical',
|
|
67
|
+
'IsSubproject',
|
|
68
|
+
'IsSubprojectReadOnly',
|
|
69
|
+
'ExternalTask',
|
|
70
|
+
// CPM results — recomputed by our engine
|
|
71
|
+
'EarlyStart',
|
|
72
|
+
'EarlyFinish',
|
|
73
|
+
'LateStart',
|
|
74
|
+
'LateFinish',
|
|
75
|
+
'StartVariance',
|
|
76
|
+
'FinishVariance',
|
|
77
|
+
'WorkVariance',
|
|
78
|
+
'FreeSlack',
|
|
79
|
+
'TotalSlack',
|
|
80
|
+
// Cost + work tracking — outside our v0.2 scope
|
|
81
|
+
'FixedCost',
|
|
82
|
+
'FixedCostAccrual',
|
|
83
|
+
'Cost',
|
|
84
|
+
'OvertimeCost',
|
|
85
|
+
'OvertimeWork',
|
|
86
|
+
'ActualDuration',
|
|
87
|
+
'ActualCost',
|
|
88
|
+
'ActualOvertimeCost',
|
|
89
|
+
'ActualWork',
|
|
90
|
+
'ActualOvertimeWork',
|
|
91
|
+
'RegularWork',
|
|
92
|
+
'RemainingDuration',
|
|
93
|
+
'RemainingCost',
|
|
94
|
+
'RemainingWork',
|
|
95
|
+
'RemainingOvertimeCost',
|
|
96
|
+
'RemainingOvertimeWork',
|
|
97
|
+
'ACWP',
|
|
98
|
+
'CV',
|
|
99
|
+
'BCWS',
|
|
100
|
+
'BCWP',
|
|
101
|
+
// Calendar override per task + leveling — not in our v0.2 scope
|
|
102
|
+
'CalendarUID',
|
|
103
|
+
'LevelAssignments',
|
|
104
|
+
'LevelingCanSplit',
|
|
105
|
+
'LevelingDelay',
|
|
106
|
+
'LevelingDelayFormat',
|
|
107
|
+
'IgnoreResourceCalendar',
|
|
108
|
+
'HideBar',
|
|
109
|
+
'Rollup',
|
|
110
|
+
// Server/publishing — meaningless outside MS Project Server context
|
|
111
|
+
'IsPublished',
|
|
112
|
+
'CommitmentType',
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
const KNOWN_PROJECT_FIELDS = new Set([
|
|
116
|
+
'Name',
|
|
117
|
+
'Title',
|
|
118
|
+
'Author',
|
|
119
|
+
'StartDate',
|
|
120
|
+
'Tasks',
|
|
121
|
+
// ignored without dropping for v0.2 first cut (will be supported in
|
|
122
|
+
// future commits — listed here so we don't noise up droppedFields)
|
|
123
|
+
'Calendars',
|
|
124
|
+
'Resources',
|
|
125
|
+
'Assignments',
|
|
126
|
+
'WBSMasks',
|
|
127
|
+
'OutlineCodes',
|
|
128
|
+
'ExtendedAttributes',
|
|
129
|
+
// pure metadata that consumers should preserve via meta-roundtrip but
|
|
130
|
+
// doesn't enter our Project shape
|
|
131
|
+
'Manager',
|
|
132
|
+
'Company',
|
|
133
|
+
'Subject',
|
|
134
|
+
'Category',
|
|
135
|
+
'Keywords',
|
|
136
|
+
'Comments',
|
|
137
|
+
'CreationDate',
|
|
138
|
+
'LastSaved',
|
|
139
|
+
'FinishDate',
|
|
140
|
+
'CurrencyCode',
|
|
141
|
+
'ScheduleFromStart',
|
|
142
|
+
'FYStartDate',
|
|
143
|
+
'CriticalSlackLimit',
|
|
144
|
+
'CurrencyDigits',
|
|
145
|
+
'CurrencySymbol',
|
|
146
|
+
'CurrencySymbolPosition',
|
|
147
|
+
'CalendarUID',
|
|
148
|
+
'DefaultStartTime',
|
|
149
|
+
'DefaultFinishTime',
|
|
150
|
+
'MinutesPerDay',
|
|
151
|
+
'MinutesPerWeek',
|
|
152
|
+
'DaysPerMonth',
|
|
153
|
+
'DefaultTaskType',
|
|
154
|
+
'DefaultFixedCostAccrual',
|
|
155
|
+
'DefaultStandardRate',
|
|
156
|
+
'DefaultOvertimeRate',
|
|
157
|
+
'DurationFormat',
|
|
158
|
+
'WorkFormat',
|
|
159
|
+
'EditableActualCosts',
|
|
160
|
+
'HonorConstraints',
|
|
161
|
+
'EarnedValueMethod',
|
|
162
|
+
'InsertedProjectsLikeSummary',
|
|
163
|
+
'MultipleCriticalPaths',
|
|
164
|
+
'NewTasksEffortDriven',
|
|
165
|
+
'NewTasksEstimated',
|
|
166
|
+
'SplitsInProgressTasks',
|
|
167
|
+
'SpreadActualCost',
|
|
168
|
+
'SpreadPercentComplete',
|
|
169
|
+
'TaskUpdatesResource',
|
|
170
|
+
'FiscalYearStart',
|
|
171
|
+
'WeekStartDay',
|
|
172
|
+
'MoveCompletedEndsBack',
|
|
173
|
+
'MoveRemainingStartsBack',
|
|
174
|
+
'MoveRemainingStartsForward',
|
|
175
|
+
'MoveCompletedEndsForward',
|
|
176
|
+
'BaselineForEarnedValue',
|
|
177
|
+
'AutoAddNewResourcesAndTasks',
|
|
178
|
+
'StatusDate',
|
|
179
|
+
'CurrentDate',
|
|
180
|
+
'MicrosoftProjectServerURL',
|
|
181
|
+
'Autolink',
|
|
182
|
+
'NewTaskStartDate',
|
|
183
|
+
'DefaultTaskEVMethod',
|
|
184
|
+
'ProjectExternallyEdited',
|
|
185
|
+
'ExtendedCreationDate',
|
|
186
|
+
'ActualsInSync',
|
|
187
|
+
'AdminProject',
|
|
188
|
+
'RemoveFileProperties',
|
|
189
|
+
'SaveVersion',
|
|
190
|
+
'UID',
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
const KNOWN_PREDECESSOR_FIELDS = new Set([
|
|
194
|
+
'PredecessorUID',
|
|
195
|
+
'Type',
|
|
196
|
+
'LinkLag',
|
|
197
|
+
'CrossProject',
|
|
198
|
+
'CrossProjectName',
|
|
199
|
+
// LagFormat is a magic number (7=minutes, 5=hours, 39=days) describing
|
|
200
|
+
// how the consumer should *display* LinkLag — we always normalize to
|
|
201
|
+
// minutes internally, so it's allowed-but-ignored.
|
|
202
|
+
'LagFormat',
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
export function parseMspdi(xml: string): MspdiParseResult {
|
|
206
|
+
const parser = new XMLParser({
|
|
207
|
+
ignoreAttributes: true,
|
|
208
|
+
isArray: (_name, jpath) =>
|
|
209
|
+
jpath === 'Project.Tasks.Task' ||
|
|
210
|
+
jpath.endsWith('.PredecessorLink') ||
|
|
211
|
+
jpath === 'Project.Calendars.Calendar' ||
|
|
212
|
+
jpath === 'Project.Resources.Resource' ||
|
|
213
|
+
jpath === 'Project.Assignments.Assignment' ||
|
|
214
|
+
jpath.endsWith('.WeekDays.WeekDay') ||
|
|
215
|
+
jpath.endsWith('.WorkingTimes.WorkingTime') ||
|
|
216
|
+
jpath === 'Project.Tasks.Task.Baseline',
|
|
217
|
+
parseTagValue: false, // keep everything as strings; we coerce per-field
|
|
218
|
+
trimValues: true,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const doc = parser.parse(xml);
|
|
222
|
+
const root = doc.Project;
|
|
223
|
+
if (!root) {
|
|
224
|
+
throw new Error('parseMspdi: <Project> root element missing');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const droppedFields: DroppedField[] = [];
|
|
228
|
+
|
|
229
|
+
// Scan the project-level fields we don't know about.
|
|
230
|
+
for (const [key, value] of Object.entries(root)) {
|
|
231
|
+
if (KNOWN_PROJECT_FIELDS.has(key)) continue;
|
|
232
|
+
droppedFields.push({
|
|
233
|
+
path: `Project.${key}`,
|
|
234
|
+
value: stringifyForDiag(value),
|
|
235
|
+
reason: 'unsupported-element',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const tasks: Task[] = [];
|
|
240
|
+
const links: Link[] = [];
|
|
241
|
+
// Per-task baseline snapshots, keyed by baseline Number (BaselineIndex).
|
|
242
|
+
// Flattened into project.baselines below.
|
|
243
|
+
const baselineAccum = new Map<BaselineIndex, Map<TaskId, BaselineTaskSnapshot>>();
|
|
244
|
+
|
|
245
|
+
const rawTasks: unknown[] = root.Tasks?.Task ?? [];
|
|
246
|
+
if (!Array.isArray(rawTasks)) {
|
|
247
|
+
throw new Error('parseMspdi: <Tasks> contained a non-array Task collection (malformed)');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
for (let i = 0; i < rawTasks.length; i++) {
|
|
251
|
+
const raw = rawTasks[i] as Record<string, unknown>;
|
|
252
|
+
const taskPath = `Project.Tasks.Task[${i}]`;
|
|
253
|
+
|
|
254
|
+
// Map the supported fields.
|
|
255
|
+
const uid = String(raw.UID ?? raw.ID ?? '');
|
|
256
|
+
if (!uid) throw new Error(`parseMspdi: ${taskPath} missing UID and ID`);
|
|
257
|
+
|
|
258
|
+
const name = String(raw.Name ?? '');
|
|
259
|
+
const start = parseMspdiDate(String(raw.Start ?? ''));
|
|
260
|
+
const end = parseMspdiDate(String(raw.Finish ?? ''));
|
|
261
|
+
const duration = parseMspdiDuration(String(raw.Duration ?? 'PT0H0M0S'));
|
|
262
|
+
|
|
263
|
+
const isMilestone = String(raw.Milestone ?? '0') === '1';
|
|
264
|
+
const isSummary = String(raw.Summary ?? '0') === '1';
|
|
265
|
+
const taskType: TaskType = isSummary ? 'summary' : isMilestone ? 'milestone' : 'task';
|
|
266
|
+
|
|
267
|
+
tasks.push({
|
|
268
|
+
id: uid,
|
|
269
|
+
text: name,
|
|
270
|
+
type: taskType,
|
|
271
|
+
scheduleMode: 'auto',
|
|
272
|
+
duration,
|
|
273
|
+
start,
|
|
274
|
+
end,
|
|
275
|
+
progress: 0,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Walk predecessor links nested inside the task.
|
|
279
|
+
const preds: unknown[] = Array.isArray(raw.PredecessorLink)
|
|
280
|
+
? (raw.PredecessorLink as unknown[])
|
|
281
|
+
: raw.PredecessorLink !== undefined
|
|
282
|
+
? [raw.PredecessorLink]
|
|
283
|
+
: [];
|
|
284
|
+
|
|
285
|
+
for (let p = 0; p < preds.length; p++) {
|
|
286
|
+
const link = preds[p] as Record<string, unknown>;
|
|
287
|
+
const srcUid = String(link.PredecessorUID ?? '');
|
|
288
|
+
if (!srcUid) continue;
|
|
289
|
+
|
|
290
|
+
const mspdiType = Number(link.Type ?? '1');
|
|
291
|
+
const linkType: DependencyType = mspdiTypeToDependencyType(mspdiType);
|
|
292
|
+
const lagTenthsOfMinute = Number(link.LinkLag ?? '0');
|
|
293
|
+
const lagMinutes = Math.round(lagTenthsOfMinute / 10);
|
|
294
|
+
|
|
295
|
+
links.push({
|
|
296
|
+
id: `${srcUid}-${uid}-${p}`,
|
|
297
|
+
source: srcUid,
|
|
298
|
+
target: uid,
|
|
299
|
+
type: linkType,
|
|
300
|
+
lag: lagMinutes,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Capture any unknown fields on the predecessor link.
|
|
304
|
+
for (const k of Object.keys(link)) {
|
|
305
|
+
if (KNOWN_PREDECESSOR_FIELDS.has(k)) continue;
|
|
306
|
+
droppedFields.push({
|
|
307
|
+
path: `${taskPath}.PredecessorLink[${p}].${k}`,
|
|
308
|
+
value: stringifyForDiag(link[k]),
|
|
309
|
+
reason: 'unsupported-element',
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Walk per-task <Baseline> children and accumulate into baselineAccum.
|
|
315
|
+
// MSPDI Baseline lives task-level (each task has up to 11 Baseline
|
|
316
|
+
// children with Number=0..10); our internal Baseline is project-level
|
|
317
|
+
// with a Map<TaskId, snapshot>. Pivot during parse.
|
|
318
|
+
const taskBaselines: unknown[] = Array.isArray(raw.Baseline)
|
|
319
|
+
? (raw.Baseline as unknown[])
|
|
320
|
+
: raw.Baseline !== undefined
|
|
321
|
+
? [raw.Baseline]
|
|
322
|
+
: [];
|
|
323
|
+
|
|
324
|
+
for (let b = 0; b < taskBaselines.length; b++) {
|
|
325
|
+
const baselineRaw = taskBaselines[b] as Record<string, unknown>;
|
|
326
|
+
const numberStr = String(baselineRaw.Number ?? '');
|
|
327
|
+
const number = Number(numberStr);
|
|
328
|
+
if (!Number.isFinite(number) || number < 0 || number > 10) {
|
|
329
|
+
droppedFields.push({
|
|
330
|
+
path: `${taskPath}.Baseline[${b}].Number`,
|
|
331
|
+
value: numberStr,
|
|
332
|
+
reason: 'unsupported-element',
|
|
333
|
+
});
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const index = number as BaselineIndex;
|
|
337
|
+
const snap: BaselineTaskSnapshot = {
|
|
338
|
+
start: parseMspdiDate(String(baselineRaw.Start ?? '')),
|
|
339
|
+
end: parseMspdiDate(String(baselineRaw.Finish ?? '')),
|
|
340
|
+
duration: parseMspdiDuration(String(baselineRaw.Duration ?? 'PT0H0M0S')),
|
|
341
|
+
};
|
|
342
|
+
let acc = baselineAccum.get(index);
|
|
343
|
+
if (!acc) {
|
|
344
|
+
acc = new Map<TaskId, BaselineTaskSnapshot>();
|
|
345
|
+
baselineAccum.set(index, acc);
|
|
346
|
+
}
|
|
347
|
+
acc.set(uid, snap);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Scan the task-level fields we don't know about.
|
|
351
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
352
|
+
if (KNOWN_TASK_FIELDS.has(key)) continue;
|
|
353
|
+
droppedFields.push({
|
|
354
|
+
path: `${taskPath}.${key}`,
|
|
355
|
+
value: stringifyForDiag(value),
|
|
356
|
+
reason: 'unsupported-element',
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const projectStart = root.StartDate
|
|
362
|
+
? parseMspdiDate(String(root.StartDate))
|
|
363
|
+
: (tasks[0]?.start ?? new Date());
|
|
364
|
+
|
|
365
|
+
// Calendars. MSPDI optionally nests <Calendars><Calendar>+. Each Calendar's
|
|
366
|
+
// <WeekDays> contains DayType 1-7 entries (the recurring pattern) and
|
|
367
|
+
// DayType=0 entries with TimePeriod (exceptions). See toMspdiCalendar in
|
|
368
|
+
// serialize.ts for the inverse mapping.
|
|
369
|
+
const calendars: Calendar[] = [];
|
|
370
|
+
const rawCalendars: unknown[] = root.Calendars?.Calendar ?? [];
|
|
371
|
+
if (Array.isArray(rawCalendars)) {
|
|
372
|
+
for (let i = 0; i < rawCalendars.length; i++) {
|
|
373
|
+
const raw = rawCalendars[i] as Record<string, unknown>;
|
|
374
|
+
calendars.push(parseMspdiCalendar(raw, `Project.Calendars.Calendar[${i}]`, droppedFields));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Resources. v0.2 first cut maps only UID + Name + CalendarUID; rates,
|
|
379
|
+
// types, units, cost, and other MS Project resource fields are
|
|
380
|
+
// intentionally ignored without dropping (we don't yet model them).
|
|
381
|
+
const resources: Resource[] = [];
|
|
382
|
+
const rawResources: unknown[] = root.Resources?.Resource ?? [];
|
|
383
|
+
if (Array.isArray(rawResources)) {
|
|
384
|
+
for (let i = 0; i < rawResources.length; i++) {
|
|
385
|
+
const raw = rawResources[i] as Record<string, unknown>;
|
|
386
|
+
resources.push(parseMspdiResource(raw, `Project.Resources.Resource[${i}]`, droppedFields));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Assignments. v0.2 first cut maps only UID + TaskUID + ResourceUID + Units
|
|
391
|
+
// (the resource-to-task allocation triple). Per-day timephased data, cost
|
|
392
|
+
// tracking, and EV fields don't enter our model — they appear in
|
|
393
|
+
// droppedFields if present.
|
|
394
|
+
const assignments: Assignment[] = [];
|
|
395
|
+
const rawAssignments: unknown[] = root.Assignments?.Assignment ?? [];
|
|
396
|
+
if (Array.isArray(rawAssignments)) {
|
|
397
|
+
for (let i = 0; i < rawAssignments.length; i++) {
|
|
398
|
+
const raw = rawAssignments[i] as Record<string, unknown>;
|
|
399
|
+
assignments.push(
|
|
400
|
+
parseMspdiAssignment(raw, `Project.Assignments.Assignment[${i}]`, droppedFields),
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Pick a sensible defaultCalendarId. Prefer the first calendar with
|
|
406
|
+
// `IsBaseCalendar` 1; fall back to the first calendar; fall back to 'std'.
|
|
407
|
+
let defaultCalendarId = 'std';
|
|
408
|
+
const firstCalendar = calendars[0];
|
|
409
|
+
if (firstCalendar) {
|
|
410
|
+
const firstBase = calendars.find((c) => c.baseCalendarId === undefined);
|
|
411
|
+
defaultCalendarId = String((firstBase ?? firstCalendar).id);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Flatten the per-task baseline accumulator into our project-level
|
|
415
|
+
// Baseline[] shape. Sort by index for stable order.
|
|
416
|
+
const baselines: Baseline[] = [];
|
|
417
|
+
for (const [index, taskMap] of baselineAccum) {
|
|
418
|
+
baselines.push({
|
|
419
|
+
index,
|
|
420
|
+
// MSPDI doesn't carry baseline name or capturedAt on individual snapshots;
|
|
421
|
+
// synthesize defaults. Consumers who want named baselines can populate
|
|
422
|
+
// these after parse.
|
|
423
|
+
capturedAt: new Date(0),
|
|
424
|
+
tasks: taskMap,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
baselines.sort((a, b) => a.index - b.index);
|
|
428
|
+
|
|
429
|
+
const project: Project = {
|
|
430
|
+
start: projectStart,
|
|
431
|
+
defaultCalendarId,
|
|
432
|
+
tasks,
|
|
433
|
+
links,
|
|
434
|
+
resources,
|
|
435
|
+
calendars,
|
|
436
|
+
baselines,
|
|
437
|
+
assignments,
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
return { project, droppedFields };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const KNOWN_CALENDAR_FIELDS = new Set([
|
|
444
|
+
'UID',
|
|
445
|
+
'Name',
|
|
446
|
+
'IsBaseCalendar',
|
|
447
|
+
'BaseCalendarUID',
|
|
448
|
+
'WeekDays',
|
|
449
|
+
]);
|
|
450
|
+
const KNOWN_WEEKDAY_FIELDS = new Set(['DayType', 'DayWorking', 'WorkingTimes', 'TimePeriod']);
|
|
451
|
+
|
|
452
|
+
// v0.2 first cut: only UID + Name + CalendarUID enter our Resource shape.
|
|
453
|
+
// Other fields exist in real MS Project exports — listed here as
|
|
454
|
+
// allowed-but-ignored so they don't noise up droppedFields.
|
|
455
|
+
const KNOWN_RESOURCE_FIELDS = new Set([
|
|
456
|
+
// Mapped
|
|
457
|
+
'UID',
|
|
458
|
+
'ID',
|
|
459
|
+
'Name',
|
|
460
|
+
'CalendarUID',
|
|
461
|
+
// Allowed but ignored (no internal model yet)
|
|
462
|
+
'IsNull',
|
|
463
|
+
'Initials',
|
|
464
|
+
'Group',
|
|
465
|
+
'Code',
|
|
466
|
+
'EmailAddress',
|
|
467
|
+
'WindowsUserAccount',
|
|
468
|
+
'Type',
|
|
469
|
+
'IsGeneric',
|
|
470
|
+
'IsInactive',
|
|
471
|
+
'IsEnterprise',
|
|
472
|
+
'BookingType',
|
|
473
|
+
'MaterialLabel',
|
|
474
|
+
'AccrueAt',
|
|
475
|
+
'MaxUnits',
|
|
476
|
+
'PeakUnits',
|
|
477
|
+
'OverAllocated',
|
|
478
|
+
'AvailableFrom',
|
|
479
|
+
'AvailableTo',
|
|
480
|
+
'StandardRate',
|
|
481
|
+
'StandardRateFormat',
|
|
482
|
+
'OvertimeRate',
|
|
483
|
+
'OvertimeRateFormat',
|
|
484
|
+
'CostPerUse',
|
|
485
|
+
'Cost',
|
|
486
|
+
'CostVariance',
|
|
487
|
+
'OvertimeCost',
|
|
488
|
+
'ActualCost',
|
|
489
|
+
'ActualOvertimeCost',
|
|
490
|
+
'RemainingCost',
|
|
491
|
+
'RemainingOvertimeCost',
|
|
492
|
+
'CostCenter',
|
|
493
|
+
'BudgetCost',
|
|
494
|
+
'BaselineCost',
|
|
495
|
+
'Work',
|
|
496
|
+
'RegularWork',
|
|
497
|
+
'OvertimeWork',
|
|
498
|
+
'ActualWork',
|
|
499
|
+
'RemainingWork',
|
|
500
|
+
'ActualOvertimeWork',
|
|
501
|
+
'RemainingOvertimeWork',
|
|
502
|
+
'PercentWorkComplete',
|
|
503
|
+
'WorkVariance',
|
|
504
|
+
'StartVariance',
|
|
505
|
+
'FinishVariance',
|
|
506
|
+
'BudgetWork',
|
|
507
|
+
'BaselineWork',
|
|
508
|
+
'ACWP',
|
|
509
|
+
'CV',
|
|
510
|
+
'BCWS',
|
|
511
|
+
'BCWP',
|
|
512
|
+
'Start',
|
|
513
|
+
'Finish',
|
|
514
|
+
'CanLevel',
|
|
515
|
+
'NotesText',
|
|
516
|
+
'NotesRTF',
|
|
517
|
+
'CreationDate',
|
|
518
|
+
'Hyperlink',
|
|
519
|
+
'HyperlinkAddress',
|
|
520
|
+
'HyperlinkSubAddress',
|
|
521
|
+
'PhoneticAlias',
|
|
522
|
+
'ExtendedAttribute',
|
|
523
|
+
'Baseline',
|
|
524
|
+
'OutlineCode',
|
|
525
|
+
'TimephasedData',
|
|
526
|
+
]);
|
|
527
|
+
|
|
528
|
+
// v0.2 first cut: only UID + TaskUID + ResourceUID + Units enter our
|
|
529
|
+
// Assignment shape. Per-day timephased data, cost tracking, EV fields,
|
|
530
|
+
// and confirmed/leveled times are all allowed-but-ignored.
|
|
531
|
+
const KNOWN_ASSIGNMENT_FIELDS = new Set([
|
|
532
|
+
// Mapped
|
|
533
|
+
'UID',
|
|
534
|
+
'TaskUID',
|
|
535
|
+
'ResourceUID',
|
|
536
|
+
'Units',
|
|
537
|
+
// Allowed but ignored (no internal model yet)
|
|
538
|
+
'PercentWorkComplete',
|
|
539
|
+
'ActualCost',
|
|
540
|
+
'ActualWork',
|
|
541
|
+
'Cost',
|
|
542
|
+
'CostVariance',
|
|
543
|
+
'Work',
|
|
544
|
+
'WorkVariance',
|
|
545
|
+
'StartVariance',
|
|
546
|
+
'FinishVariance',
|
|
547
|
+
'OvertimeCost',
|
|
548
|
+
'OvertimeWork',
|
|
549
|
+
'ActualOvertimeCost',
|
|
550
|
+
'ActualOvertimeWork',
|
|
551
|
+
'RegularWork',
|
|
552
|
+
'RemainingCost',
|
|
553
|
+
'RemainingWork',
|
|
554
|
+
'RemainingOvertimeCost',
|
|
555
|
+
'RemainingOvertimeWork',
|
|
556
|
+
'ConfirmedFinish',
|
|
557
|
+
'ConfirmedStart',
|
|
558
|
+
'Start',
|
|
559
|
+
'Finish',
|
|
560
|
+
'Stop',
|
|
561
|
+
'Resume',
|
|
562
|
+
'ResumeValid',
|
|
563
|
+
'LevelingDelay',
|
|
564
|
+
'LevelingDelayFormat',
|
|
565
|
+
'Delay',
|
|
566
|
+
'NotesText',
|
|
567
|
+
'NotesRTF',
|
|
568
|
+
'Hyperlink',
|
|
569
|
+
'HyperlinkAddress',
|
|
570
|
+
'HyperlinkSubAddress',
|
|
571
|
+
'CostRateTable',
|
|
572
|
+
'BookingType',
|
|
573
|
+
'ActualStart',
|
|
574
|
+
'ActualFinish',
|
|
575
|
+
'WorkContour',
|
|
576
|
+
'BudgetCost',
|
|
577
|
+
'BudgetWork',
|
|
578
|
+
'BaselineCost',
|
|
579
|
+
'BaselineWork',
|
|
580
|
+
'BaselineStart',
|
|
581
|
+
'BaselineFinish',
|
|
582
|
+
'BaselineBudgetCost',
|
|
583
|
+
'BaselineBudgetWork',
|
|
584
|
+
'ACWP',
|
|
585
|
+
'CV',
|
|
586
|
+
'BCWS',
|
|
587
|
+
'BCWP',
|
|
588
|
+
'Baseline',
|
|
589
|
+
'ExtendedAttribute',
|
|
590
|
+
'TimephasedData',
|
|
591
|
+
'CreationDate',
|
|
592
|
+
]);
|
|
593
|
+
|
|
594
|
+
function parseMspdiCalendar(
|
|
595
|
+
raw: Record<string, unknown>,
|
|
596
|
+
path: string,
|
|
597
|
+
droppedFields: DroppedField[],
|
|
598
|
+
): Calendar {
|
|
599
|
+
const id = String(raw.UID ?? raw.Name ?? 'std');
|
|
600
|
+
const name = String(raw.Name ?? id);
|
|
601
|
+
|
|
602
|
+
const workWeek: WorkInterval[][] = [[], [], [], [], [], [], []];
|
|
603
|
+
const exceptions: CalendarException[] = [];
|
|
604
|
+
|
|
605
|
+
const weekDays: unknown[] =
|
|
606
|
+
((raw.WeekDays as Record<string, unknown> | undefined)?.WeekDay as unknown[] | undefined) ?? [];
|
|
607
|
+
|
|
608
|
+
if (Array.isArray(weekDays)) {
|
|
609
|
+
for (let i = 0; i < weekDays.length; i++) {
|
|
610
|
+
const wd = weekDays[i] as Record<string, unknown>;
|
|
611
|
+
const wdPath = `${path}.WeekDays.WeekDay[${i}]`;
|
|
612
|
+
const dayType = Number(wd.DayType ?? '0');
|
|
613
|
+
const dayWorking = String(wd.DayWorking ?? '0') === '1';
|
|
614
|
+
|
|
615
|
+
if (dayType >= 1 && dayType <= 7) {
|
|
616
|
+
// Recurring weekday — DayType 1=Sun ... 7=Sat → DayOfWeek 0=Sun ... 6=Sat
|
|
617
|
+
const dayOfWeek = (dayType - 1) as DayOfWeek;
|
|
618
|
+
workWeek[dayOfWeek] = dayWorking ? parseWorkingTimes(wd) : [];
|
|
619
|
+
} else if (dayType === 0) {
|
|
620
|
+
// Exception entry
|
|
621
|
+
const timePeriod = wd.TimePeriod as Record<string, unknown> | undefined;
|
|
622
|
+
if (!timePeriod) continue;
|
|
623
|
+
const fromDate = parseMspdiDate(String(timePeriod.FromDate ?? ''));
|
|
624
|
+
// Treat as single-day exception, anchored on FromDate's local-day boundary.
|
|
625
|
+
const anchored = new Date(fromDate.getFullYear(), fromDate.getMonth(), fromDate.getDate());
|
|
626
|
+
const ex: CalendarException = {
|
|
627
|
+
date: anchored,
|
|
628
|
+
isWorking: dayWorking,
|
|
629
|
+
};
|
|
630
|
+
if (dayWorking) {
|
|
631
|
+
const intervals = parseWorkingTimes(wd);
|
|
632
|
+
if (intervals.length) ex.intervals = intervals;
|
|
633
|
+
}
|
|
634
|
+
exceptions.push(ex);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Scan unknown WeekDay fields
|
|
638
|
+
for (const k of Object.keys(wd)) {
|
|
639
|
+
if (KNOWN_WEEKDAY_FIELDS.has(k)) continue;
|
|
640
|
+
droppedFields.push({
|
|
641
|
+
path: `${wdPath}.${k}`,
|
|
642
|
+
value: stringifyForDiag(wd[k]),
|
|
643
|
+
reason: 'unsupported-element',
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Scan unknown Calendar fields
|
|
650
|
+
for (const k of Object.keys(raw)) {
|
|
651
|
+
if (KNOWN_CALENDAR_FIELDS.has(k)) continue;
|
|
652
|
+
droppedFields.push({
|
|
653
|
+
path: `${path}.${k}`,
|
|
654
|
+
value: stringifyForDiag(raw[k]),
|
|
655
|
+
reason: 'unsupported-element',
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const calendar: Calendar = {
|
|
660
|
+
id,
|
|
661
|
+
name,
|
|
662
|
+
workWeek,
|
|
663
|
+
exceptions,
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
// BaseCalendarUID — MS Project uses -1 for "no base". Treat -1 or absent
|
|
667
|
+
// as top-level; otherwise carry through.
|
|
668
|
+
const baseUid = raw.BaseCalendarUID !== undefined ? String(raw.BaseCalendarUID) : undefined;
|
|
669
|
+
if (baseUid !== undefined && baseUid !== '-1') {
|
|
670
|
+
calendar.baseCalendarId = baseUid;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return calendar;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function parseWorkingTimes(wd: Record<string, unknown>): WorkInterval[] {
|
|
677
|
+
const wt = wd.WorkingTimes as Record<string, unknown> | undefined;
|
|
678
|
+
if (!wt) return [];
|
|
679
|
+
const arr = wt.WorkingTime as unknown[] | undefined;
|
|
680
|
+
if (!Array.isArray(arr)) return [];
|
|
681
|
+
const intervals: WorkInterval[] = [];
|
|
682
|
+
for (const w of arr) {
|
|
683
|
+
const wRec = w as Record<string, unknown>;
|
|
684
|
+
const fromTime = String(wRec.FromTime ?? '');
|
|
685
|
+
const toTime = String(wRec.ToTime ?? '');
|
|
686
|
+
intervals.push({
|
|
687
|
+
startMinutes: parseMspdiTime(fromTime),
|
|
688
|
+
endMinutes: parseMspdiTime(toTime),
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
return intervals;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function parseMspdiTime(s: string): number {
|
|
695
|
+
// `HH:MM:SS` → minutes-from-midnight. Seconds truncated.
|
|
696
|
+
const match = s.match(/^(\d{1,2}):(\d{2}):(\d{2})$/);
|
|
697
|
+
if (!match) return 0;
|
|
698
|
+
return Number(match[1]) * 60 + Number(match[2]);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const MSPDI_TYPE_TO_DEPENDENCY: Record<number, DependencyType> = {
|
|
702
|
+
0: 'FF',
|
|
703
|
+
1: 'FS',
|
|
704
|
+
2: 'SF',
|
|
705
|
+
3: 'SS',
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
function mspdiTypeToDependencyType(t: number): DependencyType {
|
|
709
|
+
return MSPDI_TYPE_TO_DEPENDENCY[t] ?? 'FS';
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function parseMspdiDate(s: string): Date {
|
|
713
|
+
// MSPDI emits ISO 8601 like `2026-01-05T08:00:00` (no timezone in
|
|
714
|
+
// practice; MS Project writes local time). We treat it as local.
|
|
715
|
+
return new Date(s);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function parseMspdiDuration(s: string): number {
|
|
719
|
+
// MSPDI duration format: `PT{H}H{M}M{S}S` where each component is
|
|
720
|
+
// optional (e.g. `PT24H0M0S`, `PT0H30M0S`). Returns total minutes
|
|
721
|
+
// (seconds truncated).
|
|
722
|
+
const match = s.match(/^PT(\d+)H(\d+)M(\d+)S$/);
|
|
723
|
+
if (match) {
|
|
724
|
+
return Number(match[1]) * 60 + Number(match[2]);
|
|
725
|
+
}
|
|
726
|
+
// Looser fallback — support `PT{N}M` shorthand.
|
|
727
|
+
const looseMin = s.match(/^PT(\d+)M$/);
|
|
728
|
+
if (looseMin) return Number(looseMin[1]);
|
|
729
|
+
const looseHr = s.match(/^PT(\d+)H$/);
|
|
730
|
+
if (looseHr) return Number(looseHr[1]) * 60;
|
|
731
|
+
return 0;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function stringifyForDiag(v: unknown): string {
|
|
735
|
+
if (v === null || v === undefined) return '';
|
|
736
|
+
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
737
|
+
try {
|
|
738
|
+
return JSON.stringify(v).slice(0, 200);
|
|
739
|
+
} catch {
|
|
740
|
+
return '<unstringifiable>';
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// ---------------------------------------------------------------------------
|
|
745
|
+
// Resources
|
|
746
|
+
// ---------------------------------------------------------------------------
|
|
747
|
+
|
|
748
|
+
function parseMspdiResource(
|
|
749
|
+
raw: Record<string, unknown>,
|
|
750
|
+
path: string,
|
|
751
|
+
droppedFields: DroppedField[],
|
|
752
|
+
): Resource {
|
|
753
|
+
const id = String(raw.UID ?? raw.ID ?? '');
|
|
754
|
+
const name = String(raw.Name ?? '');
|
|
755
|
+
|
|
756
|
+
const resource: Resource = { id, name };
|
|
757
|
+
|
|
758
|
+
// CalendarUID — MS Project uses -1 for "no override". Treat -1, absent,
|
|
759
|
+
// or empty as "use project default"; otherwise carry through.
|
|
760
|
+
const calUid = raw.CalendarUID !== undefined ? String(raw.CalendarUID) : undefined;
|
|
761
|
+
if (calUid !== undefined && calUid !== '-1' && calUid !== '') {
|
|
762
|
+
resource.calendarId = calUid;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Scan unknown Resource fields
|
|
766
|
+
for (const k of Object.keys(raw)) {
|
|
767
|
+
if (KNOWN_RESOURCE_FIELDS.has(k)) continue;
|
|
768
|
+
droppedFields.push({
|
|
769
|
+
path: `${path}.${k}`,
|
|
770
|
+
value: stringifyForDiag(raw[k]),
|
|
771
|
+
reason: 'unsupported-element',
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return resource;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ---------------------------------------------------------------------------
|
|
779
|
+
// Assignments
|
|
780
|
+
// ---------------------------------------------------------------------------
|
|
781
|
+
|
|
782
|
+
function parseMspdiAssignment(
|
|
783
|
+
raw: Record<string, unknown>,
|
|
784
|
+
path: string,
|
|
785
|
+
droppedFields: DroppedField[],
|
|
786
|
+
): Assignment {
|
|
787
|
+
const id = String(raw.UID ?? '');
|
|
788
|
+
const taskId = String(raw.TaskUID ?? '');
|
|
789
|
+
const resourceId = String(raw.ResourceUID ?? '');
|
|
790
|
+
|
|
791
|
+
const assignment: Assignment = { id, taskId, resourceId };
|
|
792
|
+
|
|
793
|
+
// Units default 1.0. MSPDI stores as a decimal string. Treat absent/empty
|
|
794
|
+
// or unparseable as 1.0; only emit if it differs from the default.
|
|
795
|
+
if (raw.Units !== undefined) {
|
|
796
|
+
const units = Number(raw.Units);
|
|
797
|
+
if (Number.isFinite(units) && units !== 1) {
|
|
798
|
+
assignment.units = units;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Scan unknown Assignment fields. TimephasedData specifically is large +
|
|
803
|
+
// commonly present in real MS Project exports — listing it as
|
|
804
|
+
// allowed-but-ignored is the honest position until we add a per-day
|
|
805
|
+
// allocation model (v0.4+).
|
|
806
|
+
for (const k of Object.keys(raw)) {
|
|
807
|
+
if (KNOWN_ASSIGNMENT_FIELDS.has(k)) continue;
|
|
808
|
+
droppedFields.push({
|
|
809
|
+
path: `${path}.${k}`,
|
|
810
|
+
value: stringifyForDiag(raw[k]),
|
|
811
|
+
reason: 'unsupported-element',
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
return assignment;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Re-export from this entry so consumers reach it without learning the
|
|
819
|
+
// internal split.
|
|
820
|
+
export type { DroppedField, MspdiParseResult, TaskId };
|