@nowline/export-msproj 0.8.5 → 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/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 (Mon–Fri, 8h, fixed UIDs 1/2).
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 `inputs.today`; calendar UIDs are fixed; Tasks numbered sequentially.
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 { durationToMsProjMinutes, minutesToMsProjDuration } from './duration.js';
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
- * Anchor date (YYYY-MM-DD). MS Project needs absolute Start dates;
38
- * relative-only Nowline roadmaps anchor every task here. Falls back to
39
- * `inputs.today` (UTC midnight) if absent; and if that is also absent,
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 startDate = resolveStartDate(options.startDate, inputs.today);
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, startDate);
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
- lines.push(buildCalendarsBlock());
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(ast: NowlineFile, drops: DropCounts, startDate: string): TaskRow[] {
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
- for (const child of lane.content) {
187
- walkSwimlaneChild(child, 2, ctx, drops, tasks, startDate);
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
- function walkSwimlaneChild(
229
- child: SwimlaneContent,
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: { uid: number; id: number },
291
+ ctx: WalkCtx,
232
292
  drops: DropCounts,
233
293
  tasks: TaskRow[],
234
- startDate: string,
235
- ): void {
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
- } else if (child.$type === 'GroupBlock') {
239
- const group = child as GroupBlock;
240
- tasks.push({
241
- uid: ctx.uid++,
242
- id: ctx.id++,
243
- name: displayLabel(group),
244
- outlineLevel: outline,
245
- isSummary: true,
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
- function walkGroupChild(
277
- child: GroupContent,
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: { uid: number; id: number },
345
+ ctx: WalkCtx,
280
346
  drops: DropCounts,
281
347
  tasks: TaskRow[],
282
- startDate: string,
283
- ): void {
284
- if (child.$type === 'ItemDeclaration') {
285
- emitTaskRow(child, outline, ctx, drops, tasks);
286
- } else if (child.$type === 'GroupBlock') {
287
- const group = child as GroupBlock;
288
- tasks.push({
289
- uid: ctx.uid++,
290
- id: ctx.id++,
291
- name: displayLabel(group),
292
- outlineLevel: outline,
293
- isSummary: true,
294
- isMilestone: false,
295
- durationMinutes: 0,
296
- predecessors: [],
297
- nowlineId: group.name,
298
- ownerRefs: [],
299
- });
300
- for (const grandchild of group.content as GroupContent[]) {
301
- walkGroupChild(grandchild, outline + 1, ctx, drops, tasks, startDate);
302
- }
303
- } else if (child.$type === 'ParallelBlock') {
304
- const parallel = child as ParallelBlock;
305
- for (const grandchild of parallel.content) {
306
- if (grandchild.$type === 'ItemDeclaration') {
307
- emitTaskRow(grandchild, outline, ctx, drops, tasks);
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: { uid: number; id: number },
401
+ ctx: WalkCtx,
319
402
  drops: DropCounts,
320
403
  tasks: TaskRow[],
321
- ): void {
404
+ schedule: RoadmapSchedule,
405
+ ): number {
322
406
  countDrops(item, drops);
407
+ const uid = ctx.uid++;
323
408
  tasks.push({
324
- uid: ctx.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: durationToMsProjMinutes(
331
- getProp(item, 'duration') ?? getProp(item, 'size'),
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(t: TaskRow, idToUid: Map<string, number>, lines: string[]): void {
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
- if (uid !== undefined) {
366
- lines.push(' <PredecessorLink>');
367
- lines.push(` ${tag('PredecessorUID', uid)}`);
368
- lines.push(' <Type>1</Type>'); // FS
369
- lines.push(' </PredecessorLink>');
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, today: Date | undefined): string {
665
+ function resolveStartDate(option: string | undefined, scheduleStart: Date): string {
473
666
  if (option) {
474
- if (!/^\d{4}-\d{2}-\d{2}$/.test(option)) {
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
- if (today) {
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, '&apos;');
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
  }