@nowline/export-msproj 0.2.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/LICENSE +190 -0
- package/README.md +116 -0
- package/dist/calendar.d.ts +7 -0
- package/dist/calendar.d.ts.map +1 -0
- package/dist/calendar.js +59 -0
- package/dist/calendar.js.map +1 -0
- package/dist/duration.d.ts +3 -0
- package/dist/duration.d.ts.map +1 -0
- package/dist/duration.js +51 -0
- package/dist/duration.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +369 -0
- package/dist/index.js.map +1 -0
- package/dist/xml.d.ts +4 -0
- package/dist/xml.d.ts.map +1 -0
- package/dist/xml.js +18 -0
- package/dist/xml.js.map +1 -0
- package/package.json +36 -0
- package/src/calendar.ts +63 -0
- package/src/duration.ts +48 -0
- package/src/index.ts +483 -0
- package/src/xml.ts +20 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
// MS Project XML exporter — lossy projection of the Nowline AST onto
|
|
2
|
+
// Microsoft Project's import schema.
|
|
3
|
+
//
|
|
4
|
+
// Spec: specs/handoffs/m2c.md § 8.
|
|
5
|
+
// Decisions:
|
|
6
|
+
// - Resolution 6: Standard calendar block (Mon–Fri, 8h, fixed UIDs 1/2).
|
|
7
|
+
// - Resolution 9: single stderr summary line on lossy drops; never an error.
|
|
8
|
+
// - Lossy export policy: `--strict` does not escalate.
|
|
9
|
+
//
|
|
10
|
+
// Determinism: no `new Date()`. Anchoring date comes from `options.startDate`
|
|
11
|
+
// or `inputs.today`; calendar UIDs are fixed; Tasks numbered sequentially.
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
AnchorDeclaration,
|
|
15
|
+
GroupBlock,
|
|
16
|
+
GroupContent,
|
|
17
|
+
ItemDeclaration,
|
|
18
|
+
MilestoneDeclaration,
|
|
19
|
+
NowlineFile,
|
|
20
|
+
ParallelBlock,
|
|
21
|
+
PersonDeclaration,
|
|
22
|
+
SwimlaneContent,
|
|
23
|
+
SwimlaneDeclaration,
|
|
24
|
+
TeamDeclaration,
|
|
25
|
+
} from '@nowline/core';
|
|
26
|
+
import type { ExportInputs } from '@nowline/export-core';
|
|
27
|
+
import { displayLabel, getProp, getProps, hasProp, roadmapTitle } from '@nowline/export-core';
|
|
28
|
+
|
|
29
|
+
import { buildCalendarsBlock, STANDARD_RESOURCE_CALENDAR_UID } from './calendar.js';
|
|
30
|
+
import { durationToMsProjMinutes, minutesToMsProjDuration } from './duration.js';
|
|
31
|
+
import { escapeXml, tag } from './xml.js';
|
|
32
|
+
|
|
33
|
+
export interface MsProjOptions {
|
|
34
|
+
/** Project name attribute. Defaults to roadmap title. */
|
|
35
|
+
projectName?: string;
|
|
36
|
+
/**
|
|
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.
|
|
41
|
+
*
|
|
42
|
+
* Documented as not round-trippable.
|
|
43
|
+
*/
|
|
44
|
+
startDate?: string;
|
|
45
|
+
/** Test seam: receives the lossy summary instead of stderr. */
|
|
46
|
+
onLossy?: (message: string) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface DropCounts {
|
|
50
|
+
labels: number;
|
|
51
|
+
footnote: number;
|
|
52
|
+
bracket: number;
|
|
53
|
+
style: number;
|
|
54
|
+
progress: number;
|
|
55
|
+
before: number;
|
|
56
|
+
description: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface TaskRow {
|
|
60
|
+
uid: number;
|
|
61
|
+
id: number;
|
|
62
|
+
name: string;
|
|
63
|
+
outlineLevel: number;
|
|
64
|
+
isSummary: boolean;
|
|
65
|
+
isMilestone: boolean;
|
|
66
|
+
durationMinutes: number;
|
|
67
|
+
predecessors: string[];
|
|
68
|
+
nowlineId?: string;
|
|
69
|
+
ownerRefs: string[];
|
|
70
|
+
startsAt?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface ResourceRow {
|
|
74
|
+
uid: number;
|
|
75
|
+
id: number;
|
|
76
|
+
name: string;
|
|
77
|
+
nowlineId?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const PROJECT_XMLNS = 'http://schemas.microsoft.com/project';
|
|
81
|
+
|
|
82
|
+
export function exportMsProjXml(inputs: ExportInputs, options: MsProjOptions = {}): string {
|
|
83
|
+
const ast = inputs.ast;
|
|
84
|
+
const drops: DropCounts = {
|
|
85
|
+
labels: 0,
|
|
86
|
+
footnote: ast.roadmapEntries.filter((e) => e.$type === 'FootnoteDeclaration').length,
|
|
87
|
+
bracket: 0,
|
|
88
|
+
style: 0,
|
|
89
|
+
progress: 0,
|
|
90
|
+
before: 0,
|
|
91
|
+
description: 0,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const projectName = escapeXml(
|
|
95
|
+
options.projectName ?? roadmapTitle(ast.roadmapDecl ?? undefined),
|
|
96
|
+
);
|
|
97
|
+
const startDate = resolveStartDate(options.startDate, inputs.today);
|
|
98
|
+
|
|
99
|
+
// Resources
|
|
100
|
+
const resources = collectResources(ast);
|
|
101
|
+
|
|
102
|
+
// Tasks (walk roadmap entries in source order)
|
|
103
|
+
const tasks = collectTasks(ast, drops, startDate);
|
|
104
|
+
|
|
105
|
+
// Predecessor lookup uses Nowline ids → task UIDs.
|
|
106
|
+
const idToUid = new Map<string, number>();
|
|
107
|
+
for (const t of tasks) {
|
|
108
|
+
if (t.nowlineId) idToUid.set(t.nowlineId, t.uid);
|
|
109
|
+
}
|
|
110
|
+
const idToUidResource = new Map<string, number>();
|
|
111
|
+
for (const r of resources) {
|
|
112
|
+
if (r.nowlineId) idToUidResource.set(r.nowlineId, r.uid);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Emit XML
|
|
116
|
+
const lines: string[] = [];
|
|
117
|
+
lines.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>');
|
|
118
|
+
lines.push(`<Project xmlns="${PROJECT_XMLNS}">`);
|
|
119
|
+
lines.push(` <Name>${projectName}</Name>`);
|
|
120
|
+
lines.push(` <Title>${projectName}</Title>`);
|
|
121
|
+
lines.push(` <StartDate>${startDate}T08:00:00</StartDate>`);
|
|
122
|
+
lines.push(' <ScheduleFromStart>1</ScheduleFromStart>');
|
|
123
|
+
lines.push(' <CalendarUID>1</CalendarUID>');
|
|
124
|
+
lines.push(buildCalendarsBlock());
|
|
125
|
+
|
|
126
|
+
// Tasks block
|
|
127
|
+
lines.push(' <Tasks>');
|
|
128
|
+
for (const t of tasks) {
|
|
129
|
+
emitTask(t, idToUid, lines);
|
|
130
|
+
}
|
|
131
|
+
lines.push(' </Tasks>');
|
|
132
|
+
|
|
133
|
+
// Resources block
|
|
134
|
+
if (resources.length > 0) {
|
|
135
|
+
lines.push(' <Resources>');
|
|
136
|
+
for (const r of resources) {
|
|
137
|
+
emitResource(r, lines);
|
|
138
|
+
}
|
|
139
|
+
lines.push(' </Resources>');
|
|
140
|
+
|
|
141
|
+
// Assignments — owners on items.
|
|
142
|
+
const assignments = collectAssignments(tasks, idToUidResource);
|
|
143
|
+
if (assignments.length > 0) {
|
|
144
|
+
lines.push(' <Assignments>');
|
|
145
|
+
for (const a of assignments) {
|
|
146
|
+
emitAssignment(a, lines);
|
|
147
|
+
}
|
|
148
|
+
lines.push(' </Assignments>');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
lines.push('</Project>');
|
|
153
|
+
|
|
154
|
+
// Lossy summary
|
|
155
|
+
const summary = formatDrops(drops);
|
|
156
|
+
if (summary) {
|
|
157
|
+
const sink = options.onLossy ?? ((msg) => process.stderr.write(`${msg}\n`));
|
|
158
|
+
sink(summary);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return lines.join('\n');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------- Tasks ----------
|
|
165
|
+
|
|
166
|
+
function collectTasks(ast: NowlineFile, drops: DropCounts, startDate: string): TaskRow[] {
|
|
167
|
+
const tasks: TaskRow[] = [];
|
|
168
|
+
const ctx = { uid: 1, id: 1 };
|
|
169
|
+
|
|
170
|
+
for (const entry of ast.roadmapEntries) {
|
|
171
|
+
if (entry.$type === 'SwimlaneDeclaration') {
|
|
172
|
+
const lane = entry as SwimlaneDeclaration;
|
|
173
|
+
const summaryUid = ctx.uid;
|
|
174
|
+
tasks.push({
|
|
175
|
+
uid: ctx.uid++,
|
|
176
|
+
id: ctx.id++,
|
|
177
|
+
name: displayLabel(lane),
|
|
178
|
+
outlineLevel: 1,
|
|
179
|
+
isSummary: true,
|
|
180
|
+
isMilestone: false,
|
|
181
|
+
durationMinutes: 0,
|
|
182
|
+
predecessors: [],
|
|
183
|
+
nowlineId: lane.name,
|
|
184
|
+
ownerRefs: getProps(lane, 'owner') as string[],
|
|
185
|
+
});
|
|
186
|
+
for (const child of lane.content) {
|
|
187
|
+
walkSwimlaneChild(child, 2, ctx, drops, tasks, startDate);
|
|
188
|
+
}
|
|
189
|
+
// Summary spans all child rows — implicit in MSProject by id ranges,
|
|
190
|
+
// but we don't bother computing FinishDate / actuals.
|
|
191
|
+
void summaryUid;
|
|
192
|
+
} else if (entry.$type === 'MilestoneDeclaration') {
|
|
193
|
+
const m = entry as MilestoneDeclaration;
|
|
194
|
+
tasks.push({
|
|
195
|
+
uid: ctx.uid++,
|
|
196
|
+
id: ctx.id++,
|
|
197
|
+
name: displayLabel(m),
|
|
198
|
+
outlineLevel: 1,
|
|
199
|
+
isSummary: false,
|
|
200
|
+
isMilestone: true,
|
|
201
|
+
durationMinutes: 0,
|
|
202
|
+
predecessors: getProps(m, 'depends') as string[],
|
|
203
|
+
nowlineId: m.name,
|
|
204
|
+
ownerRefs: [],
|
|
205
|
+
startsAt: getProp(m, 'date'),
|
|
206
|
+
});
|
|
207
|
+
if (hasProp(m, 'style')) drops.style += 1;
|
|
208
|
+
} else if (entry.$type === 'AnchorDeclaration') {
|
|
209
|
+
const a = entry as AnchorDeclaration;
|
|
210
|
+
tasks.push({
|
|
211
|
+
uid: ctx.uid++,
|
|
212
|
+
id: ctx.id++,
|
|
213
|
+
name: displayLabel(a),
|
|
214
|
+
outlineLevel: 1,
|
|
215
|
+
isSummary: false,
|
|
216
|
+
isMilestone: true, // Anchors → milestones in MS Project
|
|
217
|
+
durationMinutes: 0,
|
|
218
|
+
predecessors: [],
|
|
219
|
+
nowlineId: a.name,
|
|
220
|
+
ownerRefs: [],
|
|
221
|
+
startsAt: getProp(a, 'date'),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return tasks;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function walkSwimlaneChild(
|
|
229
|
+
child: SwimlaneContent,
|
|
230
|
+
outline: number,
|
|
231
|
+
ctx: { uid: number; id: number },
|
|
232
|
+
drops: DropCounts,
|
|
233
|
+
tasks: TaskRow[],
|
|
234
|
+
startDate: string,
|
|
235
|
+
): void {
|
|
236
|
+
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') {
|
|
272
|
+
drops.description += 1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function walkGroupChild(
|
|
277
|
+
child: GroupContent,
|
|
278
|
+
outline: number,
|
|
279
|
+
ctx: { uid: number; id: number },
|
|
280
|
+
drops: DropCounts,
|
|
281
|
+
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
|
+
}
|
|
309
|
+
}
|
|
310
|
+
} else if (child.$type === 'DescriptionDirective') {
|
|
311
|
+
drops.description += 1;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function emitTaskRow(
|
|
316
|
+
item: ItemDeclaration,
|
|
317
|
+
outline: number,
|
|
318
|
+
ctx: { uid: number; id: number },
|
|
319
|
+
drops: DropCounts,
|
|
320
|
+
tasks: TaskRow[],
|
|
321
|
+
): void {
|
|
322
|
+
countDrops(item, drops);
|
|
323
|
+
tasks.push({
|
|
324
|
+
uid: ctx.uid++,
|
|
325
|
+
id: ctx.id++,
|
|
326
|
+
name: displayLabel(item),
|
|
327
|
+
outlineLevel: outline,
|
|
328
|
+
isSummary: false,
|
|
329
|
+
isMilestone: false,
|
|
330
|
+
durationMinutes: durationToMsProjMinutes(
|
|
331
|
+
getProp(item, 'duration') ?? getProp(item, 'size'),
|
|
332
|
+
),
|
|
333
|
+
predecessors: getProps(item, 'after') as string[],
|
|
334
|
+
nowlineId: item.name,
|
|
335
|
+
ownerRefs: getProps(item, 'owner') as string[],
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function countDrops(item: ItemDeclaration, drops: DropCounts): void {
|
|
340
|
+
if (getProps(item, 'labels').length > 0) drops.labels += 1;
|
|
341
|
+
if (hasProp(item, 'style')) drops.style += 1;
|
|
342
|
+
if (hasProp(item, 'remaining')) drops.progress += 1;
|
|
343
|
+
if (getProps(item, 'before').length > 0) drops.before += 1;
|
|
344
|
+
if (item.description) drops.description += 1;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function emitTask(t: TaskRow, idToUid: Map<string, number>, lines: string[]): void {
|
|
348
|
+
lines.push(' <Task>');
|
|
349
|
+
lines.push(` ${tag('UID', t.uid)}`);
|
|
350
|
+
lines.push(` ${tag('ID', t.id)}`);
|
|
351
|
+
lines.push(` ${tag('Name', t.name)}`);
|
|
352
|
+
if (t.isSummary) lines.push(` <Summary>1</Summary>`);
|
|
353
|
+
if (t.isMilestone) {
|
|
354
|
+
lines.push(' <Milestone>1</Milestone>');
|
|
355
|
+
lines.push(' <Duration>PT0H0M0S</Duration>');
|
|
356
|
+
} else {
|
|
357
|
+
lines.push(` <Duration>${minutesToMsProjDuration(t.durationMinutes)}</Duration>`);
|
|
358
|
+
}
|
|
359
|
+
lines.push(` <OutlineLevel>${t.outlineLevel}</OutlineLevel>`);
|
|
360
|
+
if (t.startsAt) {
|
|
361
|
+
lines.push(` <Start>${t.startsAt}T08:00:00</Start>`);
|
|
362
|
+
}
|
|
363
|
+
for (const pred of t.predecessors) {
|
|
364
|
+
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>');
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
lines.push(' </Task>');
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------- Resources ----------
|
|
376
|
+
|
|
377
|
+
function collectResources(ast: NowlineFile): ResourceRow[] {
|
|
378
|
+
const out: ResourceRow[] = [];
|
|
379
|
+
const ctx = { uid: 1, id: 1 };
|
|
380
|
+
for (const entry of ast.roadmapEntries) {
|
|
381
|
+
if (entry.$type === 'PersonDeclaration') {
|
|
382
|
+
const p = entry as PersonDeclaration;
|
|
383
|
+
out.push({
|
|
384
|
+
uid: ctx.uid++,
|
|
385
|
+
id: ctx.id++,
|
|
386
|
+
name: displayLabel(p),
|
|
387
|
+
nowlineId: p.name,
|
|
388
|
+
});
|
|
389
|
+
} else if (entry.$type === 'TeamDeclaration') {
|
|
390
|
+
collectTeam(entry as TeamDeclaration, out, ctx);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function collectTeam(
|
|
397
|
+
team: TeamDeclaration,
|
|
398
|
+
out: ResourceRow[],
|
|
399
|
+
ctx: { uid: number; id: number },
|
|
400
|
+
): void {
|
|
401
|
+
out.push({
|
|
402
|
+
uid: ctx.uid++,
|
|
403
|
+
id: ctx.id++,
|
|
404
|
+
name: displayLabel(team),
|
|
405
|
+
nowlineId: team.name,
|
|
406
|
+
});
|
|
407
|
+
for (const child of team.content) {
|
|
408
|
+
if (child.$type === 'TeamDeclaration') {
|
|
409
|
+
collectTeam(child as TeamDeclaration, out, ctx);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function emitResource(r: ResourceRow, lines: string[]): void {
|
|
415
|
+
lines.push(' <Resource>');
|
|
416
|
+
lines.push(` ${tag('UID', r.uid)}`);
|
|
417
|
+
lines.push(` ${tag('ID', r.id)}`);
|
|
418
|
+
lines.push(` ${tag('Name', r.name)}`);
|
|
419
|
+
lines.push(` <CalendarUID>${STANDARD_RESOURCE_CALENDAR_UID}</CalendarUID>`);
|
|
420
|
+
lines.push(' </Resource>');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ---------- Assignments ----------
|
|
424
|
+
|
|
425
|
+
interface AssignmentRow {
|
|
426
|
+
taskUid: number;
|
|
427
|
+
resourceUid: number;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function collectAssignments(tasks: TaskRow[], idToUid: Map<string, number>): AssignmentRow[] {
|
|
431
|
+
const out: AssignmentRow[] = [];
|
|
432
|
+
const assignmentUid = 1;
|
|
433
|
+
void assignmentUid;
|
|
434
|
+
for (const t of tasks) {
|
|
435
|
+
for (const owner of t.ownerRefs) {
|
|
436
|
+
const uid = idToUid.get(owner);
|
|
437
|
+
if (uid !== undefined) {
|
|
438
|
+
out.push({ taskUid: t.uid, resourceUid: uid });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return out;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function emitAssignment(a: AssignmentRow, lines: string[]): void {
|
|
446
|
+
lines.push(' <Assignment>');
|
|
447
|
+
lines.push(` ${tag('TaskUID', a.taskUid)}`);
|
|
448
|
+
lines.push(` ${tag('ResourceUID', a.resourceUid)}`);
|
|
449
|
+
lines.push(' <Units>1</Units>');
|
|
450
|
+
lines.push(' </Assignment>');
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ---------- Lossy summary ----------
|
|
454
|
+
|
|
455
|
+
function formatDrops(drops: DropCounts): string | null {
|
|
456
|
+
const order: (keyof DropCounts)[] = [
|
|
457
|
+
'labels',
|
|
458
|
+
'footnote',
|
|
459
|
+
'bracket',
|
|
460
|
+
'style',
|
|
461
|
+
'progress',
|
|
462
|
+
'before',
|
|
463
|
+
'description',
|
|
464
|
+
];
|
|
465
|
+
const parts = order.filter((k) => drops[k] > 0).map((k) => `${k} (${drops[k]})`);
|
|
466
|
+
if (parts.length === 0) return null;
|
|
467
|
+
return `nowline: msproj export dropped ${parts.length} feature kinds: ${parts.join(', ')}`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ---------- helpers ----------
|
|
471
|
+
|
|
472
|
+
function resolveStartDate(option: string | undefined, today: Date | undefined): string {
|
|
473
|
+
if (option) {
|
|
474
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(option)) {
|
|
475
|
+
throw new Error(`invalid msproj startDate "${option}": expected YYYY-MM-DD`);
|
|
476
|
+
}
|
|
477
|
+
return option;
|
|
478
|
+
}
|
|
479
|
+
if (today) {
|
|
480
|
+
return today.toISOString().slice(0, 10);
|
|
481
|
+
}
|
|
482
|
+
return '2026-01-05'; // a deterministic Monday for tests
|
|
483
|
+
}
|
package/src/xml.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// XML escape utilities. We don't use a generic XML library because the
|
|
2
|
+
// output structure is fixed and small; manual emission keeps the package
|
|
3
|
+
// dependency-free per Resolution 1.
|
|
4
|
+
|
|
5
|
+
export function escapeXml(value: string): string {
|
|
6
|
+
return value
|
|
7
|
+
.replace(/&/g, '&')
|
|
8
|
+
.replace(/</g, '<')
|
|
9
|
+
.replace(/>/g, '>')
|
|
10
|
+
.replace(/"/g, '"')
|
|
11
|
+
.replace(/'/g, ''');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function tag(name: string, value: string | number | boolean): string {
|
|
15
|
+
return `<${name}>${escapeXml(String(value))}</${name}>`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function selfTag(name: string): string {
|
|
19
|
+
return `<${name}/>`;
|
|
20
|
+
}
|