@nowline/export-msproj 0.8.6 → 0.9.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/README.md +21 -9
- package/dist/calendar.d.ts +6 -1
- package/dist/calendar.d.ts.map +1 -1
- package/dist/calendar.js +54 -10
- package/dist/calendar.js.map +1 -1
- package/dist/duration.d.ts +5 -1
- package/dist/duration.d.ts.map +1 -1
- package/dist/duration.js +12 -43
- package/dist/duration.js.map +1 -1
- package/dist/index.d.ts +3 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +227 -94
- package/dist/index.js.map +1 -1
- package/dist/xml.d.ts +7 -0
- package/dist/xml.d.ts.map +1 -1
- package/dist/xml.js +9 -0
- package/dist/xml.js.map +1 -1
- package/package.json +6 -6
- package/src/calendar.ts +72 -10
- package/src/duration.ts +12 -39
- package/src/index.ts +302 -112
- package/src/xml.ts +10 -0
package/src/index.ts
CHANGED
|
@@ -3,12 +3,21 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Spec: specs/handoffs/m2c.md § 8.
|
|
5
5
|
// Decisions:
|
|
6
|
-
// - Resolution 6: Standard calendar block (
|
|
6
|
+
// - Resolution 6: Standard calendar block (8h days, fixed UIDs 1/2). Its
|
|
7
|
+
// working weekdays and every task Duration come from engine C
|
|
8
|
+
// (`scheduleRoadmap`) under the file's calendar, so they match the chart
|
|
9
|
+
// (specs/working-calendar.md §8).
|
|
7
10
|
// - Resolution 9: single stderr summary line on lossy drops; never an error.
|
|
8
11
|
// - Lossy export policy: `--strict` does not escalate.
|
|
12
|
+
// - Waves (specs/waves.md §10): one wave-end milestone task per wave,
|
|
13
|
+
// appended after every other task so existing UIDs never renumber.
|
|
14
|
+
// - Lane sequencing: each node links finish-to-start to the node before
|
|
15
|
+
// it in its lane or group, so MS Project's forward pass starts every
|
|
16
|
+
// task on engine C's day (specs/rendering.md § MS Project XML Export).
|
|
9
17
|
//
|
|
10
18
|
// Determinism: no `new Date()`. Anchoring date comes from `options.startDate`
|
|
11
|
-
// or
|
|
19
|
+
// or the schedule's start (the roadmap's `start:`, else `inputs.today`);
|
|
20
|
+
// calendar UIDs are fixed; Tasks numbered sequentially.
|
|
12
21
|
|
|
13
22
|
import type {
|
|
14
23
|
AnchorDeclaration,
|
|
@@ -22,22 +31,24 @@ import type {
|
|
|
22
31
|
SwimlaneContent,
|
|
23
32
|
SwimlaneDeclaration,
|
|
24
33
|
TeamDeclaration,
|
|
34
|
+
WavePlan,
|
|
25
35
|
} from '@nowline/core';
|
|
36
|
+
import { ISO_DATE_RE } from '@nowline/core/internal';
|
|
26
37
|
import type { ExportInputs } from '@nowline/export-core';
|
|
27
38
|
import { displayLabel, getProp, getProps, hasProp, roadmapTitle } from '@nowline/export-core';
|
|
39
|
+
import { calendarExceptions, type RoadmapSchedule, scheduleRoadmap } from '@nowline/layout';
|
|
28
40
|
|
|
29
41
|
import { buildCalendarsBlock, STANDARD_RESOURCE_CALENDAR_UID } from './calendar.js';
|
|
30
|
-
import {
|
|
31
|
-
import { escapeXml, tag } from './xml.js';
|
|
42
|
+
import { minutesToMsProjDuration, workingDaysToMinutes } from './duration.js';
|
|
43
|
+
import { escapeXml, singleLine, tag } from './xml.js';
|
|
32
44
|
|
|
33
45
|
export interface MsProjOptions {
|
|
34
46
|
/** Project name attribute. Defaults to roadmap title. */
|
|
35
47
|
projectName?: string;
|
|
36
48
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* `inputs.today` (UTC midnight)
|
|
40
|
-
* to `2026-01-05` (a deterministic Monday) so tests are stable.
|
|
49
|
+
* Project start date (YYYY-MM-DD) written to `<StartDate>`. Defaults to
|
|
50
|
+
* the schedule's start, the same anchor every task date uses: the
|
|
51
|
+
* roadmap's `start:`, else `inputs.today` (UTC midnight).
|
|
41
52
|
*
|
|
42
53
|
* Documented as not round-trippable.
|
|
43
54
|
*/
|
|
@@ -54,6 +65,10 @@ interface DropCounts {
|
|
|
54
65
|
progress: number;
|
|
55
66
|
before: number;
|
|
56
67
|
description: number;
|
|
68
|
+
/** Wave members without an id: no link to their wave-end task. */
|
|
69
|
+
waveNoId: number;
|
|
70
|
+
/** Inline-date wave floors: MS Project links tasks, not dates. */
|
|
71
|
+
waveFloor: number;
|
|
57
72
|
}
|
|
58
73
|
|
|
59
74
|
interface TaskRow {
|
|
@@ -68,6 +83,12 @@ interface TaskRow {
|
|
|
68
83
|
nowlineId?: string;
|
|
69
84
|
ownerRefs: string[];
|
|
70
85
|
startsAt?: string;
|
|
86
|
+
/** The source item, for wave membership. */
|
|
87
|
+
node?: ItemDeclaration;
|
|
88
|
+
/** The previous node in the lane or group: its task, or every track end of a parallel. */
|
|
89
|
+
chainUids: number[];
|
|
90
|
+
/** Links by UID, added after `chainUids`, skipping UIDs already linked. */
|
|
91
|
+
extraPredecessorUids?: number[];
|
|
71
92
|
}
|
|
72
93
|
|
|
73
94
|
interface ResourceRow {
|
|
@@ -77,6 +98,17 @@ interface ResourceRow {
|
|
|
77
98
|
nowlineId?: string;
|
|
78
99
|
}
|
|
79
100
|
|
|
101
|
+
/**
|
|
102
|
+
* UID and ID counters for the task walk, plus each named parallel's track
|
|
103
|
+
* ends: the UID of every track's task (an item, or a group's summary task),
|
|
104
|
+
* which `after:<parallel>` links to.
|
|
105
|
+
*/
|
|
106
|
+
interface WalkCtx {
|
|
107
|
+
uid: number;
|
|
108
|
+
id: number;
|
|
109
|
+
parallelEnds: Map<string, number[]>;
|
|
110
|
+
}
|
|
111
|
+
|
|
80
112
|
const PROJECT_XMLNS = 'http://schemas.microsoft.com/project';
|
|
81
113
|
|
|
82
114
|
export function exportMsProjXml(inputs: ExportInputs, options: MsProjOptions = {}): string {
|
|
@@ -89,24 +121,33 @@ export function exportMsProjXml(inputs: ExportInputs, options: MsProjOptions = {
|
|
|
89
121
|
progress: 0,
|
|
90
122
|
before: 0,
|
|
91
123
|
description: 0,
|
|
124
|
+
waveNoId: 0,
|
|
125
|
+
waveFloor: 0,
|
|
92
126
|
};
|
|
93
127
|
|
|
94
128
|
const projectName = escapeXml(
|
|
95
129
|
options.projectName ?? roadmapTitle(ast.roadmapDecl ?? undefined),
|
|
96
130
|
);
|
|
97
|
-
const
|
|
131
|
+
const schedule =
|
|
132
|
+
inputs.schedule ?? scheduleRoadmap(ast, inputs.resolved, { today: inputs.today });
|
|
133
|
+
const startDate = resolveStartDate(options.startDate, schedule.startDate);
|
|
98
134
|
|
|
99
135
|
// Resources
|
|
100
136
|
const resources = collectResources(ast);
|
|
101
137
|
|
|
102
138
|
// Tasks (walk roadmap entries in source order)
|
|
103
|
-
const tasks = collectTasks(ast, drops,
|
|
139
|
+
const { tasks, parallelEnds } = collectTasks(ast, drops, schedule);
|
|
104
140
|
|
|
105
141
|
// Predecessor lookup uses Nowline ids → task UIDs.
|
|
106
142
|
const idToUid = new Map<string, number>();
|
|
107
143
|
for (const t of tasks) {
|
|
108
144
|
if (t.nowlineId) idToUid.set(t.nowlineId, t.uid);
|
|
109
145
|
}
|
|
146
|
+
// Undefined when the roadmap declares no waves: nothing below changes.
|
|
147
|
+
const wavePlan = schedule.plan;
|
|
148
|
+
if (wavePlan) {
|
|
149
|
+
addWaveTasks(ast, wavePlan, tasks, idToUid, drops);
|
|
150
|
+
}
|
|
110
151
|
const idToUidResource = new Map<string, number>();
|
|
111
152
|
for (const r of resources) {
|
|
112
153
|
if (r.nowlineId) idToUidResource.set(r.nowlineId, r.uid);
|
|
@@ -121,12 +162,19 @@ export function exportMsProjXml(inputs: ExportInputs, options: MsProjOptions = {
|
|
|
121
162
|
lines.push(` <StartDate>${startDate}T08:00:00</StartDate>`);
|
|
122
163
|
lines.push(' <ScheduleFromStart>1</ScheduleFromStart>');
|
|
123
164
|
lines.push(' <CalendarUID>1</CalendarUID>');
|
|
124
|
-
|
|
165
|
+
const { working } = schedule.calendar;
|
|
166
|
+
const { startDate: windowStart, endDate: windowEnd } = inputs.model.timeline;
|
|
167
|
+
lines.push(
|
|
168
|
+
buildCalendarsBlock(
|
|
169
|
+
working.workingWeekdays,
|
|
170
|
+
calendarExceptions(working, windowStart, windowEnd),
|
|
171
|
+
),
|
|
172
|
+
);
|
|
125
173
|
|
|
126
174
|
// Tasks block
|
|
127
175
|
lines.push(' <Tasks>');
|
|
128
176
|
for (const t of tasks) {
|
|
129
|
-
emitTask(t, idToUid, lines);
|
|
177
|
+
emitTask(t, idToUid, parallelEnds, lines);
|
|
130
178
|
}
|
|
131
179
|
lines.push(' </Tasks>');
|
|
132
180
|
|
|
@@ -163,9 +211,13 @@ export function exportMsProjXml(inputs: ExportInputs, options: MsProjOptions = {
|
|
|
163
211
|
|
|
164
212
|
// ---------- Tasks ----------
|
|
165
213
|
|
|
166
|
-
function collectTasks(
|
|
214
|
+
function collectTasks(
|
|
215
|
+
ast: NowlineFile,
|
|
216
|
+
drops: DropCounts,
|
|
217
|
+
schedule: RoadmapSchedule,
|
|
218
|
+
): { tasks: TaskRow[]; parallelEnds: Map<string, number[]> } {
|
|
167
219
|
const tasks: TaskRow[] = [];
|
|
168
|
-
const ctx = { uid: 1, id: 1 };
|
|
220
|
+
const ctx: WalkCtx = { uid: 1, id: 1, parallelEnds: new Map() };
|
|
169
221
|
|
|
170
222
|
for (const entry of ast.roadmapEntries) {
|
|
171
223
|
if (entry.$type === 'SwimlaneDeclaration') {
|
|
@@ -180,12 +232,12 @@ function collectTasks(ast: NowlineFile, drops: DropCounts, startDate: string): T
|
|
|
180
232
|
isMilestone: false,
|
|
181
233
|
durationMinutes: 0,
|
|
182
234
|
predecessors: [],
|
|
235
|
+
chainUids: [],
|
|
183
236
|
nowlineId: lane.name,
|
|
184
237
|
ownerRefs: getProps(lane, 'owner') as string[],
|
|
185
238
|
});
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
239
|
+
// A lane's first node links to nothing: it starts on the project start.
|
|
240
|
+
walkSequence(lane.content, [], 2, ctx, drops, tasks, schedule);
|
|
189
241
|
// Summary spans all child rows — implicit in MSProject by id ranges,
|
|
190
242
|
// but we don't bother computing FinishDate / actuals.
|
|
191
243
|
void summaryUid;
|
|
@@ -200,6 +252,7 @@ function collectTasks(ast: NowlineFile, drops: DropCounts, startDate: string): T
|
|
|
200
252
|
isMilestone: true,
|
|
201
253
|
durationMinutes: 0,
|
|
202
254
|
predecessors: getProps(m, 'after') as string[],
|
|
255
|
+
chainUids: [],
|
|
203
256
|
nowlineId: m.name,
|
|
204
257
|
ownerRefs: [],
|
|
205
258
|
startsAt: getProp(m, 'date'),
|
|
@@ -216,124 +269,157 @@ function collectTasks(ast: NowlineFile, drops: DropCounts, startDate: string): T
|
|
|
216
269
|
isMilestone: true, // Anchors → milestones in MS Project
|
|
217
270
|
durationMinutes: 0,
|
|
218
271
|
predecessors: [],
|
|
272
|
+
chainUids: [],
|
|
219
273
|
nowlineId: a.name,
|
|
220
274
|
ownerRefs: [],
|
|
221
275
|
startsAt: getProp(a, 'date'),
|
|
222
276
|
});
|
|
223
277
|
}
|
|
224
278
|
}
|
|
225
|
-
return tasks;
|
|
279
|
+
return { tasks, parallelEnds: ctx.parallelEnds };
|
|
226
280
|
}
|
|
227
281
|
|
|
228
|
-
|
|
229
|
-
|
|
282
|
+
/**
|
|
283
|
+
* Engine C's sequential rule (`walkLane` in schedule.ts): each node starts
|
|
284
|
+
* after the one before it. `entry` is what the first node links to; each
|
|
285
|
+
* node's ends become the next node's entry. Returns the last node's ends.
|
|
286
|
+
*/
|
|
287
|
+
function walkSequence(
|
|
288
|
+
children: readonly (SwimlaneContent | GroupContent)[],
|
|
289
|
+
entry: number[],
|
|
230
290
|
outline: number,
|
|
231
|
-
ctx:
|
|
291
|
+
ctx: WalkCtx,
|
|
232
292
|
drops: DropCounts,
|
|
233
293
|
tasks: TaskRow[],
|
|
234
|
-
|
|
235
|
-
):
|
|
294
|
+
schedule: RoadmapSchedule,
|
|
295
|
+
): number[] {
|
|
296
|
+
let prev = entry;
|
|
297
|
+
for (const child of children) {
|
|
298
|
+
prev = walkNode(child, prev, [], outline, ctx, drops, tasks, schedule);
|
|
299
|
+
}
|
|
300
|
+
return prev;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Writes one lane or group child and returns the UIDs that end it. `entry`
|
|
305
|
+
* links to the previous node; `inheritedAfter` is a parallel's own `after:`,
|
|
306
|
+
* which each of its tracks carries since the parallel has no task.
|
|
307
|
+
*/
|
|
308
|
+
function walkNode(
|
|
309
|
+
child: SwimlaneContent | GroupContent,
|
|
310
|
+
entry: number[],
|
|
311
|
+
inheritedAfter: string[],
|
|
312
|
+
outline: number,
|
|
313
|
+
ctx: WalkCtx,
|
|
314
|
+
drops: DropCounts,
|
|
315
|
+
tasks: TaskRow[],
|
|
316
|
+
schedule: RoadmapSchedule,
|
|
317
|
+
): number[] {
|
|
236
318
|
if (child.$type === 'ItemDeclaration') {
|
|
237
|
-
emitTaskRow(child, outline, ctx, drops, tasks);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
tasks
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
isMilestone: false,
|
|
247
|
-
durationMinutes: 0,
|
|
248
|
-
predecessors: [],
|
|
249
|
-
nowlineId: group.name,
|
|
250
|
-
ownerRefs: [],
|
|
251
|
-
});
|
|
252
|
-
for (const grandchild of group.content as GroupContent[]) {
|
|
253
|
-
walkGroupChild(grandchild, outline + 1, ctx, drops, tasks, startDate);
|
|
254
|
-
}
|
|
255
|
-
} else if (child.$type === 'ParallelBlock') {
|
|
256
|
-
const parallel = child as ParallelBlock;
|
|
257
|
-
for (const grandchild of parallel.content) {
|
|
258
|
-
if (grandchild.$type === 'ItemDeclaration') {
|
|
259
|
-
emitTaskRow(grandchild, outline, ctx, drops, tasks);
|
|
260
|
-
} else if (grandchild.$type === 'GroupBlock') {
|
|
261
|
-
walkSwimlaneChild(
|
|
262
|
-
grandchild as unknown as SwimlaneContent,
|
|
263
|
-
outline,
|
|
264
|
-
ctx,
|
|
265
|
-
drops,
|
|
266
|
-
tasks,
|
|
267
|
-
startDate,
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
} else if (child.$type === 'DescriptionDirective') {
|
|
319
|
+
return [emitTaskRow(child, entry, inheritedAfter, outline, ctx, drops, tasks, schedule)];
|
|
320
|
+
}
|
|
321
|
+
if (child.$type === 'GroupBlock') {
|
|
322
|
+
return [emitGroup(child, entry, inheritedAfter, outline, ctx, drops, tasks, schedule)];
|
|
323
|
+
}
|
|
324
|
+
if (child.$type === 'ParallelBlock') {
|
|
325
|
+
return walkParallel(child, entry, outline, ctx, drops, tasks, schedule);
|
|
326
|
+
}
|
|
327
|
+
if (child.$type === 'DescriptionDirective') {
|
|
272
328
|
drops.description += 1;
|
|
273
329
|
}
|
|
330
|
+
return entry;
|
|
274
331
|
}
|
|
275
332
|
|
|
276
|
-
|
|
277
|
-
|
|
333
|
+
/**
|
|
334
|
+
* A group is a summary task over its children, one outline level down. Its
|
|
335
|
+
* entry link and its `after:` go on the summary task: MS Project starts no
|
|
336
|
+
* subtask before its summary task's predecessors finish, so the first child
|
|
337
|
+
* needs no link of its own. Returns the summary task's UID, which MS Project
|
|
338
|
+
* finishes with the group's last child.
|
|
339
|
+
*/
|
|
340
|
+
function emitGroup(
|
|
341
|
+
group: GroupBlock,
|
|
342
|
+
entry: number[],
|
|
343
|
+
inheritedAfter: string[],
|
|
278
344
|
outline: number,
|
|
279
|
-
ctx:
|
|
345
|
+
ctx: WalkCtx,
|
|
280
346
|
drops: DropCounts,
|
|
281
347
|
tasks: TaskRow[],
|
|
282
|
-
|
|
283
|
-
):
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
348
|
+
schedule: RoadmapSchedule,
|
|
349
|
+
): number {
|
|
350
|
+
const uid = ctx.uid++;
|
|
351
|
+
tasks.push({
|
|
352
|
+
uid,
|
|
353
|
+
id: ctx.id++,
|
|
354
|
+
name: displayLabel(group),
|
|
355
|
+
outlineLevel: outline,
|
|
356
|
+
isSummary: true,
|
|
357
|
+
isMilestone: false,
|
|
358
|
+
durationMinutes: 0,
|
|
359
|
+
predecessors: [...(getProps(group, 'after') as string[]), ...inheritedAfter],
|
|
360
|
+
chainUids: entry,
|
|
361
|
+
nowlineId: group.name,
|
|
362
|
+
ownerRefs: [],
|
|
363
|
+
});
|
|
364
|
+
walkSequence(group.content, [], outline + 1, ctx, drops, tasks, schedule);
|
|
365
|
+
return uid;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* A parallel has no task: its tracks sit at its own outline level, and each
|
|
370
|
+
* track's task (an item, or a group's summary task) takes the links the
|
|
371
|
+
* parallel would have had, its entry and its `after:`. Engine C ends a
|
|
372
|
+
* parallel at its latest track end, so the next node in the lane and
|
|
373
|
+
* `after:<parallel>` link to every track's task and MS Project starts the
|
|
374
|
+
* successor at the latest of them. Returns those track ends.
|
|
375
|
+
*/
|
|
376
|
+
function walkParallel(
|
|
377
|
+
parallel: ParallelBlock,
|
|
378
|
+
entry: number[],
|
|
379
|
+
outline: number,
|
|
380
|
+
ctx: WalkCtx,
|
|
381
|
+
drops: DropCounts,
|
|
382
|
+
tasks: TaskRow[],
|
|
383
|
+
schedule: RoadmapSchedule,
|
|
384
|
+
): number[] {
|
|
385
|
+
const after = getProps(parallel, 'after') as string[];
|
|
386
|
+
const ends: number[] = [];
|
|
387
|
+
for (const track of parallel.content) {
|
|
388
|
+
if (track.$type === 'ItemDeclaration' || track.$type === 'GroupBlock') {
|
|
389
|
+
ends.push(...walkNode(track, entry, after, outline, ctx, drops, tasks, schedule));
|
|
309
390
|
}
|
|
310
|
-
} else if (child.$type === 'DescriptionDirective') {
|
|
311
|
-
drops.description += 1;
|
|
312
391
|
}
|
|
392
|
+
if (parallel.name) ctx.parallelEnds.set(parallel.name, ends);
|
|
393
|
+
return ends.length > 0 ? ends : entry;
|
|
313
394
|
}
|
|
314
395
|
|
|
315
396
|
function emitTaskRow(
|
|
316
397
|
item: ItemDeclaration,
|
|
398
|
+
entry: number[],
|
|
399
|
+
inheritedAfter: string[],
|
|
317
400
|
outline: number,
|
|
318
|
-
ctx:
|
|
401
|
+
ctx: WalkCtx,
|
|
319
402
|
drops: DropCounts,
|
|
320
403
|
tasks: TaskRow[],
|
|
321
|
-
|
|
404
|
+
schedule: RoadmapSchedule,
|
|
405
|
+
): number {
|
|
322
406
|
countDrops(item, drops);
|
|
407
|
+
const uid = ctx.uid++;
|
|
323
408
|
tasks.push({
|
|
324
|
-
uid
|
|
409
|
+
uid,
|
|
325
410
|
id: ctx.id++,
|
|
326
411
|
name: displayLabel(item),
|
|
327
412
|
outlineLevel: outline,
|
|
328
413
|
isSummary: false,
|
|
329
414
|
isMilestone: false,
|
|
330
|
-
durationMinutes:
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
predecessors: getProps(item, 'after') as string[],
|
|
415
|
+
durationMinutes: workingDaysToMinutes(schedule.byNode.get(item)?.days),
|
|
416
|
+
predecessors: [...(getProps(item, 'after') as string[]), ...inheritedAfter],
|
|
417
|
+
chainUids: entry,
|
|
334
418
|
nowlineId: item.name,
|
|
335
419
|
ownerRefs: getProps(item, 'owner') as string[],
|
|
420
|
+
node: item,
|
|
336
421
|
});
|
|
422
|
+
return uid;
|
|
337
423
|
}
|
|
338
424
|
|
|
339
425
|
function countDrops(item: ItemDeclaration, drops: DropCounts): void {
|
|
@@ -344,11 +430,16 @@ function countDrops(item: ItemDeclaration, drops: DropCounts): void {
|
|
|
344
430
|
if (item.description) drops.description += 1;
|
|
345
431
|
}
|
|
346
432
|
|
|
347
|
-
function emitTask(
|
|
433
|
+
function emitTask(
|
|
434
|
+
t: TaskRow,
|
|
435
|
+
idToUid: Map<string, number>,
|
|
436
|
+
parallelEnds: Map<string, number[]>,
|
|
437
|
+
lines: string[],
|
|
438
|
+
): void {
|
|
348
439
|
lines.push(' <Task>');
|
|
349
440
|
lines.push(` ${tag('UID', t.uid)}`);
|
|
350
441
|
lines.push(` ${tag('ID', t.id)}`);
|
|
351
|
-
lines.push(` ${tag('Name', t.name)}`);
|
|
442
|
+
lines.push(` ${tag('Name', singleLine(t.name))}`);
|
|
352
443
|
if (t.isSummary) lines.push(` <Summary>1</Summary>`);
|
|
353
444
|
if (t.isMilestone) {
|
|
354
445
|
lines.push(' <Milestone>1</Milestone>');
|
|
@@ -360,18 +451,112 @@ function emitTask(t: TaskRow, idToUid: Map<string, number>, lines: string[]): vo
|
|
|
360
451
|
if (t.startsAt) {
|
|
361
452
|
lines.push(` <Start>${t.startsAt}T08:00:00</Start>`);
|
|
362
453
|
}
|
|
454
|
+
const linked = new Set<number>();
|
|
363
455
|
for (const pred of t.predecessors) {
|
|
364
456
|
const uid = idToUid.get(pred);
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
lines
|
|
369
|
-
|
|
457
|
+
// A parallel has no task of its own: link to each track's end.
|
|
458
|
+
for (const end of uid !== undefined ? [uid] : (parallelEnds.get(pred) ?? [])) {
|
|
459
|
+
if (linked.has(end)) continue;
|
|
460
|
+
emitPredecessorLink(end, lines);
|
|
461
|
+
linked.add(end);
|
|
370
462
|
}
|
|
371
463
|
}
|
|
464
|
+
for (const uid of [...t.chainUids, ...(t.extraPredecessorUids ?? [])]) {
|
|
465
|
+
if (linked.has(uid)) continue;
|
|
466
|
+
emitPredecessorLink(uid, lines);
|
|
467
|
+
linked.add(uid);
|
|
468
|
+
}
|
|
372
469
|
lines.push(' </Task>');
|
|
373
470
|
}
|
|
374
471
|
|
|
472
|
+
function emitPredecessorLink(uid: number, lines: string[]): void {
|
|
473
|
+
lines.push(' <PredecessorLink>');
|
|
474
|
+
lines.push(` ${tag('PredecessorUID', uid)}`);
|
|
475
|
+
lines.push(' <Type>1</Type>'); // FS
|
|
476
|
+
lines.push(' </PredecessorLink>');
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ---------- Waves ----------
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Waves (specs/waves.md §10). Appends one zero-duration milestone task per
|
|
483
|
+
* wave, `{title} (wave end)`, after every other task (so existing UIDs and
|
|
484
|
+
* IDs never renumber), and wires the barrier as FS links:
|
|
485
|
+
* - wave-end task k <- every member of wave k with an id, and wave k-1's end;
|
|
486
|
+
* - every member of wave k >= 2 <- wave k-1's end task;
|
|
487
|
+
* - a floor (`after:` on the wave naming an anchor or a dated milestone)
|
|
488
|
+
* <- every member of wave k and wave k's end task (the end task covers an
|
|
489
|
+
* empty wave); an inline-date floor is dropped and counted.
|
|
490
|
+
* Wave ids join `idToUid`, so `after:<wave>` links to the wave-end task.
|
|
491
|
+
* Background work gets nothing.
|
|
492
|
+
*/
|
|
493
|
+
function addWaveTasks(
|
|
494
|
+
ast: NowlineFile,
|
|
495
|
+
plan: WavePlan,
|
|
496
|
+
tasks: TaskRow[],
|
|
497
|
+
idToUid: Map<string, number>,
|
|
498
|
+
drops: DropCounts,
|
|
499
|
+
): void {
|
|
500
|
+
const datedTargets = new Set<string>();
|
|
501
|
+
for (const entry of ast.roadmapEntries) {
|
|
502
|
+
if (entry.$type === 'AnchorDeclaration') {
|
|
503
|
+
const a = entry as AnchorDeclaration;
|
|
504
|
+
if (a.name) datedTargets.add(a.name);
|
|
505
|
+
} else if (entry.$type === 'MilestoneDeclaration') {
|
|
506
|
+
const m = entry as MilestoneDeclaration;
|
|
507
|
+
if (m.name && getProp(m, 'date') !== undefined) datedTargets.add(m.name);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const last = tasks[tasks.length - 1];
|
|
512
|
+
let uid = (last?.uid ?? 0) + 1;
|
|
513
|
+
let id = (last?.id ?? 0) + 1;
|
|
514
|
+
const endUids = plan.waves.map(() => uid++);
|
|
515
|
+
|
|
516
|
+
const floorUids = plan.waves.map((wave) => {
|
|
517
|
+
const out: number[] = [];
|
|
518
|
+
for (const ref of getProps(wave, 'after') as string[]) {
|
|
519
|
+
if (ISO_DATE_RE.test(ref)) {
|
|
520
|
+
drops.waveFloor += 1;
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
const target = datedTargets.has(ref) ? idToUid.get(ref) : undefined;
|
|
524
|
+
if (target !== undefined) out.push(target);
|
|
525
|
+
}
|
|
526
|
+
return out;
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
const memberUids: number[][] = plan.waves.map(() => []);
|
|
530
|
+
for (const t of tasks) {
|
|
531
|
+
const k = t.node ? plan.memberWave(t.node) : undefined;
|
|
532
|
+
if (k === undefined) continue;
|
|
533
|
+
const extra = [...floorUids[k - 1]];
|
|
534
|
+
if (k >= 2) extra.unshift(endUids[k - 2]);
|
|
535
|
+
if (extra.length > 0) t.extraPredecessorUids = extra;
|
|
536
|
+
if (t.nowlineId) memberUids[k - 1].push(t.uid);
|
|
537
|
+
else drops.waveNoId += 1;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
plan.waves.forEach((wave, i) => {
|
|
541
|
+
const preds = [...memberUids[i], ...floorUids[i]];
|
|
542
|
+
if (i > 0) preds.push(endUids[i - 1]);
|
|
543
|
+
tasks.push({
|
|
544
|
+
uid: endUids[i],
|
|
545
|
+
id: id++,
|
|
546
|
+
name: `${displayLabel(wave)} (wave end)`,
|
|
547
|
+
outlineLevel: 1,
|
|
548
|
+
isSummary: false,
|
|
549
|
+
isMilestone: true,
|
|
550
|
+
durationMinutes: 0,
|
|
551
|
+
predecessors: [],
|
|
552
|
+
chainUids: [],
|
|
553
|
+
ownerRefs: [],
|
|
554
|
+
extraPredecessorUids: preds,
|
|
555
|
+
});
|
|
556
|
+
if (wave.name && !idToUid.has(wave.name)) idToUid.set(wave.name, endUids[i]);
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
375
560
|
// ---------- Resources ----------
|
|
376
561
|
|
|
377
562
|
function collectResources(ast: NowlineFile): ResourceRow[] {
|
|
@@ -415,7 +600,7 @@ function emitResource(r: ResourceRow, lines: string[]): void {
|
|
|
415
600
|
lines.push(' <Resource>');
|
|
416
601
|
lines.push(` ${tag('UID', r.uid)}`);
|
|
417
602
|
lines.push(` ${tag('ID', r.id)}`);
|
|
418
|
-
lines.push(` ${tag('Name', r.name)}`);
|
|
603
|
+
lines.push(` ${tag('Name', singleLine(r.name))}`);
|
|
419
604
|
lines.push(` <CalendarUID>${STANDARD_RESOURCE_CALENDAR_UID}</CalendarUID>`);
|
|
420
605
|
lines.push(' </Resource>');
|
|
421
606
|
}
|
|
@@ -461,23 +646,28 @@ function formatDrops(drops: DropCounts): string | null {
|
|
|
461
646
|
'progress',
|
|
462
647
|
'before',
|
|
463
648
|
'description',
|
|
649
|
+
'waveNoId',
|
|
650
|
+
'waveFloor',
|
|
464
651
|
];
|
|
465
|
-
const parts = order.filter((k) => drops[k] > 0).map((k) => `${k} (${drops[k]})`);
|
|
652
|
+
const parts = order.filter((k) => drops[k] > 0).map((k) => `${formatDropKey(k)} (${drops[k]})`);
|
|
466
653
|
if (parts.length === 0) return null;
|
|
467
654
|
return `nowline: msproj export dropped ${parts.length} feature kinds: ${parts.join(', ')}`;
|
|
468
655
|
}
|
|
469
656
|
|
|
657
|
+
function formatDropKey(key: keyof DropCounts): string {
|
|
658
|
+
if (key === 'waveNoId') return 'wave-member-no-id';
|
|
659
|
+
if (key === 'waveFloor') return 'wave-floor';
|
|
660
|
+
return key;
|
|
661
|
+
}
|
|
662
|
+
|
|
470
663
|
// ---------- helpers ----------
|
|
471
664
|
|
|
472
|
-
function resolveStartDate(option: string | undefined,
|
|
665
|
+
function resolveStartDate(option: string | undefined, scheduleStart: Date): string {
|
|
473
666
|
if (option) {
|
|
474
|
-
if (
|
|
667
|
+
if (!ISO_DATE_RE.test(option)) {
|
|
475
668
|
throw new Error(`invalid msproj startDate "${option}": expected YYYY-MM-DD`);
|
|
476
669
|
}
|
|
477
670
|
return option;
|
|
478
671
|
}
|
|
479
|
-
|
|
480
|
-
return today.toISOString().slice(0, 10);
|
|
481
|
-
}
|
|
482
|
-
return '2026-01-05'; // a deterministic Monday for tests
|
|
672
|
+
return scheduleStart.toISOString().slice(0, 10);
|
|
483
673
|
}
|
package/src/xml.ts
CHANGED
|
@@ -11,6 +11,16 @@ export function escapeXml(value: string): string {
|
|
|
11
11
|
.replace(/'/g, ''');
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Collapse explicit line breaks (a `\n` in an item title) to a single
|
|
16
|
+
* space. MS Project task and resource names are single-line fields, so a
|
|
17
|
+
* raw line feed in `<Name>` would import as a stray control character
|
|
18
|
+
* instead of a break; a space keeps the words apart.
|
|
19
|
+
*/
|
|
20
|
+
export function singleLine(value: string): string {
|
|
21
|
+
return /[\r\n]/.test(value) ? value.replace(/\s*[\r\n]+\s*/g, ' ').trim() : value;
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
export function tag(name: string, value: string | number | boolean): string {
|
|
15
25
|
return `<${name}>${escapeXml(String(value))}</${name}>`;
|
|
16
26
|
}
|