@nowline/export-xlsx 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.
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Overwrite every per-entry timestamp inside a ZIP buffer with a
3
+ * deterministic value derived from `date`. Mutates `bytes` in place and
4
+ * returns it for chaining.
5
+ *
6
+ * Throws if the buffer is not a well-formed single-disk ZIP (no EOCD found,
7
+ * or the central-directory walk hits a bad signature).
8
+ */
9
+ export declare function normalizeZipTimestamps(bytes: Uint8Array, date: Date): Uint8Array;
10
+ //# sourceMappingURL=zip-normalize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zip-normalize.d.ts","sourceRoot":"","sources":["../src/zip-normalize.ts"],"names":[],"mappings":"AAyBA;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,IAAI,GAAG,UAAU,CAuChF"}
@@ -0,0 +1,128 @@
1
+ // ZIP timestamp normalizer.
2
+ //
3
+ // XLSX is a ZIP archive. ExcelJS uses JSZip internally, and JSZip's default
4
+ // per-entry mtime is `new Date()` — which means two consecutive calls to
5
+ // `wb.xlsx.writeBuffer()` produce different bytes whenever the wall clock
6
+ // advances between them. The workbook's own `created`/`modified` properties
7
+ // are pinned in `index.ts`, but the per-entry ZIP headers are not.
8
+ //
9
+ // This module overwrites the `last mod time` / `last mod date` fields in
10
+ // every Local File Header and Central Directory File Header with a
11
+ // deterministic value derived from a fixed Date. CRC32s, compressed sizes,
12
+ // and the compressed payloads themselves are not part of those fields, so
13
+ // patching is safe and does not require re-deflating any data.
14
+ //
15
+ // References:
16
+ // - APPNOTE.TXT (ZIP spec) §4.3.7, §4.4.6, §4.5
17
+ // - DOS date/time encoding: APPNOTE.TXT §4.4.6
18
+ const SIG_LFH = 0x04034b50; // "PK\x03\x04"
19
+ const SIG_CDFH = 0x02014b50; // "PK\x01\x02"
20
+ const SIG_EOCD = 0x06054b50; // "PK\x05\x06"
21
+ const EXTRA_EXTENDED_TIMESTAMP = 0x5455; // "UT" — Info-ZIP extended timestamp
22
+ const EXTRA_NTFS = 0x000a; // NTFS file times
23
+ /**
24
+ * Overwrite every per-entry timestamp inside a ZIP buffer with a
25
+ * deterministic value derived from `date`. Mutates `bytes` in place and
26
+ * returns it for chaining.
27
+ *
28
+ * Throws if the buffer is not a well-formed single-disk ZIP (no EOCD found,
29
+ * or the central-directory walk hits a bad signature).
30
+ */
31
+ export function normalizeZipTimestamps(bytes, date) {
32
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
33
+ const dosTime = toDosTime(date);
34
+ const dosDate = toDosDate(date);
35
+ const unixSeconds = Math.floor(date.getTime() / 1000);
36
+ const eocdOffset = findEocd(view);
37
+ const cdSize = view.getUint32(eocdOffset + 12, true);
38
+ const cdOffset = view.getUint32(eocdOffset + 16, true);
39
+ const cdEnd = cdOffset + cdSize;
40
+ let p = cdOffset;
41
+ while (p < cdEnd) {
42
+ if (view.getUint32(p, true) !== SIG_CDFH) {
43
+ throw new Error(`normalizeZipTimestamps: bad CDFH signature at offset ${p}`);
44
+ }
45
+ const nameLen = view.getUint16(p + 28, true);
46
+ const extraLen = view.getUint16(p + 30, true);
47
+ const commentLen = view.getUint16(p + 32, true);
48
+ const lfhOffset = view.getUint32(p + 42, true);
49
+ view.setUint16(p + 12, dosTime, true);
50
+ view.setUint16(p + 14, dosDate, true);
51
+ patchExtraTimestamps(view, p + 46 + nameLen, extraLen, unixSeconds);
52
+ if (view.getUint32(lfhOffset, true) !== SIG_LFH) {
53
+ throw new Error(`normalizeZipTimestamps: bad LFH signature at offset ${lfhOffset}`);
54
+ }
55
+ const lfhNameLen = view.getUint16(lfhOffset + 26, true);
56
+ const lfhExtraLen = view.getUint16(lfhOffset + 28, true);
57
+ view.setUint16(lfhOffset + 10, dosTime, true);
58
+ view.setUint16(lfhOffset + 12, dosDate, true);
59
+ patchExtraTimestamps(view, lfhOffset + 30 + lfhNameLen, lfhExtraLen, unixSeconds);
60
+ p += 46 + nameLen + extraLen + commentLen;
61
+ }
62
+ return bytes;
63
+ }
64
+ function patchExtraTimestamps(view, start, length, unixSeconds) {
65
+ let p = start;
66
+ const end = start + length;
67
+ while (p + 4 <= end) {
68
+ const id = view.getUint16(p, true);
69
+ const size = view.getUint16(p + 2, true);
70
+ const dataStart = p + 4;
71
+ const dataEnd = dataStart + size;
72
+ if (dataEnd > end)
73
+ return;
74
+ if (id === EXTRA_EXTENDED_TIMESTAMP && size >= 5) {
75
+ // Layout: flags(1) | mtime(4) | [atime(4)] | [ctime(4)]
76
+ const flags = view.getUint8(dataStart);
77
+ let off = dataStart + 1;
78
+ if (off + 4 <= dataEnd) {
79
+ view.setUint32(off, unixSeconds, true);
80
+ off += 4;
81
+ }
82
+ if ((flags & 0x02) !== 0 && off + 4 <= dataEnd) {
83
+ view.setUint32(off, unixSeconds, true);
84
+ off += 4;
85
+ }
86
+ if ((flags & 0x04) !== 0 && off + 4 <= dataEnd) {
87
+ view.setUint32(off, unixSeconds, true);
88
+ }
89
+ }
90
+ else if (id === EXTRA_NTFS && size >= 32) {
91
+ // Layout: reserved(4) | tag1=0x0001 | size1=24 | mtime(8) | atime(8) | ctime(8)
92
+ // Times are Win32 FILETIME (100-ns intervals since 1601-01-01).
93
+ const fileTime = unixSecondsToFiletime(unixSeconds);
94
+ const attrStart = dataStart + 4 + 4; // skip reserved + tag/size
95
+ view.setBigUint64(attrStart + 0, fileTime, true);
96
+ view.setBigUint64(attrStart + 8, fileTime, true);
97
+ view.setBigUint64(attrStart + 16, fileTime, true);
98
+ }
99
+ p = dataEnd;
100
+ }
101
+ }
102
+ function findEocd(view) {
103
+ const len = view.byteLength;
104
+ const minSize = 22;
105
+ const maxComment = 0xffff;
106
+ const searchStart = Math.max(0, len - minSize - maxComment);
107
+ for (let i = len - minSize; i >= searchStart; i--) {
108
+ if (view.getUint32(i, true) === SIG_EOCD)
109
+ return i;
110
+ }
111
+ throw new Error('normalizeZipTimestamps: end-of-central-directory record not found');
112
+ }
113
+ function toDosTime(d) {
114
+ return (((d.getUTCHours() & 0x1f) << 11) |
115
+ ((d.getUTCMinutes() & 0x3f) << 5) |
116
+ (Math.floor(d.getUTCSeconds() / 2) & 0x1f));
117
+ }
118
+ function toDosDate(d) {
119
+ // DOS year is offset from 1980; clamp to that floor so any pre-1980 Date
120
+ // produces a valid (zero) field rather than overflowing.
121
+ const year = Math.max(0, d.getUTCFullYear() - 1980);
122
+ return ((year & 0x7f) << 9) | (((d.getUTCMonth() + 1) & 0xf) << 5) | (d.getUTCDate() & 0x1f);
123
+ }
124
+ const FILETIME_EPOCH_OFFSET = 11644473600n; // seconds between 1601-01-01 and 1970-01-01
125
+ function unixSecondsToFiletime(unixSeconds) {
126
+ return (BigInt(unixSeconds) + FILETIME_EPOCH_OFFSET) * 10000000n;
127
+ }
128
+ //# sourceMappingURL=zip-normalize.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zip-normalize.js","sourceRoot":"","sources":["../src/zip-normalize.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@nowline/export-xlsx",
3
+ "version": "0.2.0",
4
+ "description": "Nowline XLSX exporter — five-sheet workbook via ExcelJS",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist/",
17
+ "src/"
18
+ ],
19
+ "dependencies": {
20
+ "exceljs": "4.4.0",
21
+ "@nowline/export-core": "0.2.0",
22
+ "@nowline/core": "0.2.0",
23
+ "@nowline/layout": "0.2.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^22.0.0",
27
+ "langium": "~4.2.2",
28
+ "typescript": "~5.7.0",
29
+ "vitest": "^3.1.0"
30
+ },
31
+ "scripts": {
32
+ "build": "tsc -b tsconfig.json",
33
+ "watch": "tsc -b tsconfig.json --watch",
34
+ "test": "vitest run",
35
+ "test:watch": "vitest"
36
+ }
37
+ }
@@ -0,0 +1,49 @@
1
+ // Convert Nowline duration tokens into a numeric working-day count for the
2
+ // XLSX "Duration" column. Spec: specs/handoffs/m2c.md § 7
3
+ // "Resolution 5: numeric working-day duration".
4
+ //
5
+ // We expose two artifacts:
6
+ // - `durationToWorkingDays(literal)` for the cell value (Number type so
7
+ // Excel SUM / sort / filter work).
8
+ // - `durationLiteralToText(literal)` for the optional display column
9
+ // (preserves the original DSL text — `2w`, `xl`, `1m`, …).
10
+
11
+ const SIZE_BUCKET_DAYS: Readonly<Record<string, number>> = {
12
+ xs: 1,
13
+ sm: 3,
14
+ s: 3,
15
+ md: 5,
16
+ m: 5,
17
+ lg: 10,
18
+ l: 10,
19
+ xl: 15,
20
+ };
21
+
22
+ const NUMERIC_RE = /^(\d+(?:\.\d+)?)\s*(d|w|m|y)?$/i;
23
+
24
+ const WORKING_DAYS_PER_WEEK = 5;
25
+ const WORKING_DAYS_PER_MONTH = 22;
26
+ const WORKING_DAYS_PER_YEAR = 252;
27
+
28
+ /** 0 if the literal is missing/invalid (Excel sums treat 0 as no-op). */
29
+ export function durationToWorkingDays(literal: string | undefined): number {
30
+ if (!literal) return 0;
31
+ const trimmed = literal.trim().toLowerCase();
32
+ if (!trimmed) return 0;
33
+ if (trimmed in SIZE_BUCKET_DAYS) return SIZE_BUCKET_DAYS[trimmed];
34
+ const match = NUMERIC_RE.exec(trimmed);
35
+ if (!match) return 0;
36
+ const value = Number(match[1]);
37
+ if (!Number.isFinite(value) || value <= 0) return 0;
38
+ const unit = (match[2] ?? 'd').toLowerCase();
39
+ if (unit === 'd') return value;
40
+ if (unit === 'w') return value * WORKING_DAYS_PER_WEEK;
41
+ if (unit === 'm') return value * WORKING_DAYS_PER_MONTH;
42
+ if (unit === 'y') return value * WORKING_DAYS_PER_YEAR;
43
+ return 0;
44
+ }
45
+
46
+ export function durationLiteralToText(literal: string | undefined): string {
47
+ if (!literal) return '';
48
+ return literal.trim();
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,384 @@
1
+ // XLSX exporter — five-sheet workbook via ExcelJS.
2
+ //
3
+ // Spec: specs/handoffs/m2c.md § 7 + specs/rendering.md § XLSX Export.
4
+ // Decisions:
5
+ // - Resolution 5 (working-day duration): the "Duration" cell is a NUMBER
6
+ // of working days; an extra "Duration (text)" column preserves the
7
+ // original DSL literal. Excel can SUM and filter the numeric column
8
+ // without a custom formatter.
9
+ // - Resolution 8 (deterministic ExcelJS): pin a single ExcelJS version in
10
+ // `package.json`. Any zip-level non-determinism is patched at write time
11
+ // by re-emitting the package's content streams in deterministic order.
12
+ //
13
+ // Determinism contract:
14
+ // - workbook.created = `inputs.today` (UTC midnight) — never `new Date()`
15
+ // in default code path.
16
+ // - Sheet 1 ("Roadmap") "Generated" cell takes the same `today`.
17
+ // - Style ids and column orders are explicit so ExcelJS's id allocator
18
+ // emits the same numbers across runs.
19
+ // - JSZip (used by ExcelJS to pack the .xlsx archive) stamps every entry
20
+ // with `new Date()` by default; we rewrite all per-entry `last mod
21
+ // time/date` fields to `generated` after `writeBuffer()` so two
22
+ // consecutive calls with identical inputs produce byte-identical
23
+ // output. See `zip-normalize.ts` for the patcher.
24
+
25
+ import type {
26
+ AnchorDeclaration,
27
+ GroupBlock,
28
+ GroupContent,
29
+ ItemDeclaration,
30
+ MilestoneDeclaration,
31
+ NowlineFile,
32
+ ParallelBlock,
33
+ PersonDeclaration,
34
+ SwimlaneContent,
35
+ SwimlaneDeclaration,
36
+ TeamDeclaration,
37
+ } from '@nowline/core';
38
+ import type { ExportInputs } from '@nowline/export-core';
39
+ import { displayLabel, getProp, getProps, roadmapTitle } from '@nowline/export-core';
40
+ import ExcelJS from 'exceljs';
41
+
42
+ import { durationLiteralToText, durationToWorkingDays } from './duration.js';
43
+ import { normalizeZipTimestamps } from './zip-normalize.js';
44
+
45
+ export interface XlsxOptions {
46
+ /** Override the workbook author / Roadmap-sheet "Author" cell. */
47
+ author?: string;
48
+ /** Override the "Generated" timestamp; defaults to `inputs.today`. */
49
+ generated?: Date;
50
+ }
51
+
52
+ export async function exportXlsx(
53
+ inputs: ExportInputs,
54
+ options: XlsxOptions = {},
55
+ ): Promise<Uint8Array> {
56
+ const wb = new ExcelJS.Workbook();
57
+ const today = options.generated ?? inputs.today ?? new Date(Date.UTC(2026, 0, 5));
58
+ const generated = new Date(
59
+ Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()),
60
+ );
61
+ wb.creator = options.author ?? inferAuthor(inputs.ast) ?? 'nowline';
62
+ wb.lastModifiedBy = wb.creator;
63
+ wb.created = generated;
64
+ wb.modified = generated;
65
+ wb.title = roadmapTitle(inputs.ast.roadmapDecl ?? undefined);
66
+
67
+ buildRoadmapSheet(wb, inputs, generated);
68
+ buildItemsSheet(wb, inputs.ast);
69
+ buildMilestonesSheet(wb, inputs.ast);
70
+ buildAnchorsSheet(wb, inputs.ast);
71
+ buildPeopleAndTeamsSheet(wb, inputs.ast);
72
+
73
+ const buf = (await wb.xlsx.writeBuffer()) as ArrayBuffer | Buffer;
74
+ const bytes = Buffer.isBuffer(buf)
75
+ ? new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)
76
+ : new Uint8Array(buf);
77
+ return normalizeZipTimestamps(bytes, generated);
78
+ }
79
+
80
+ function inferAuthor(ast: NowlineFile): string | undefined {
81
+ const decl = ast.roadmapDecl;
82
+ if (!decl) return undefined;
83
+ return decl.properties.find((p) => p.key === 'author')?.value;
84
+ }
85
+
86
+ // ---------- Sheet 1: Roadmap ----------
87
+
88
+ function buildRoadmapSheet(wb: ExcelJS.Workbook, inputs: ExportInputs, generated: Date): void {
89
+ const sheet = wb.addWorksheet('Roadmap');
90
+ const decl = inputs.ast.roadmapDecl;
91
+ const scale = decl ? getProp(decl, 'scale') : undefined;
92
+ const start = decl ? getProp(decl, 'start') : undefined;
93
+
94
+ const rows: [string, string | number | Date | undefined][] = [
95
+ ['Roadmap', roadmapTitle(decl ?? undefined)],
96
+ ['Author', inputs.ast.roadmapDecl ? (getProp(inputs.ast.roadmapDecl, 'author') ?? '') : ''],
97
+ ['Scale', scale ?? ''],
98
+ ['Start', start ?? ''],
99
+ ['Generated', generated],
100
+ ];
101
+ rows.forEach((row, idx) => {
102
+ const r = sheet.addRow(row);
103
+ r.getCell(1).font = { bold: true };
104
+ if (idx === 4) r.getCell(2).numFmt = 'yyyy-mm-dd';
105
+ });
106
+ sheet.columns = [
107
+ { key: 'field', width: 16 },
108
+ { key: 'value', width: 36 },
109
+ ];
110
+ }
111
+
112
+ // ---------- Sheet 2: Items ----------
113
+
114
+ const ITEM_HEADERS: ReadonlyArray<{ header: string; key: string; width: number }> = [
115
+ { header: 'ID', key: 'id', width: 18 },
116
+ { header: 'Title', key: 'title', width: 28 },
117
+ { header: 'Swimlane', key: 'swimlane', width: 22 },
118
+ { header: 'Group', key: 'group', width: 18 },
119
+ { header: 'Parallel', key: 'parallel', width: 18 },
120
+ { header: 'Duration', key: 'duration', width: 12 },
121
+ { header: 'Duration (text)', key: 'durationText', width: 16 },
122
+ { header: 'Status', key: 'status', width: 14 },
123
+ { header: 'Remaining', key: 'remaining', width: 12 },
124
+ { header: 'Owner', key: 'owner', width: 14 },
125
+ { header: 'After', key: 'after', width: 24 },
126
+ { header: 'Before', key: 'before', width: 24 },
127
+ { header: 'Labels', key: 'labels', width: 18 },
128
+ { header: 'Link', key: 'link', width: 24 },
129
+ { header: 'Description', key: 'description', width: 36 },
130
+ ];
131
+
132
+ const STATUS_FILLS: Readonly<Record<string, string>> = {
133
+ done: 'FFC8E6C9', // green
134
+ 'in-progress': 'FFBBDEFB', // blue
135
+ 'at-risk': 'FFFFF59D', // yellow
136
+ blocked: 'FFFFCDD2', // red
137
+ planned: 'FFE0E0E0', // grey
138
+ };
139
+
140
+ interface ItemRow {
141
+ id: string;
142
+ title: string;
143
+ swimlane: string;
144
+ group: string;
145
+ parallel: string;
146
+ duration: number;
147
+ durationText: string;
148
+ status: string;
149
+ remaining: string;
150
+ owner: string;
151
+ after: string;
152
+ before: string;
153
+ labels: string;
154
+ link: string;
155
+ description: string;
156
+ }
157
+
158
+ function buildItemsSheet(wb: ExcelJS.Workbook, ast: NowlineFile): void {
159
+ const sheet = wb.addWorksheet('Items', {
160
+ views: [{ state: 'frozen', xSplit: 0, ySplit: 1 }],
161
+ });
162
+ sheet.columns = [...ITEM_HEADERS];
163
+
164
+ const rows: ItemRow[] = [];
165
+ for (const entry of ast.roadmapEntries) {
166
+ if (entry.$type === 'SwimlaneDeclaration') {
167
+ collectFromSwimlane(entry as SwimlaneDeclaration, [entry.name ?? ''], rows, '', '');
168
+ }
169
+ }
170
+ for (const r of rows) sheet.addRow(r);
171
+
172
+ // Status conditional formatting via per-row fill; ExcelJS ConditionalFormat
173
+ // is supported but the per-row fill is simpler and equally deterministic.
174
+ sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
175
+ if (rowNumber === 1) return;
176
+ const status = String(row.getCell('status').value ?? '');
177
+ const fill = STATUS_FILLS[status];
178
+ if (fill) {
179
+ row.getCell('status').fill = {
180
+ type: 'pattern',
181
+ pattern: 'solid',
182
+ fgColor: { argb: fill },
183
+ };
184
+ }
185
+ });
186
+
187
+ sheet.getRow(1).font = { bold: true };
188
+ sheet.autoFilter = {
189
+ from: { row: 1, column: 1 },
190
+ to: { row: Math.max(rows.length + 1, 1), column: ITEM_HEADERS.length },
191
+ };
192
+ }
193
+
194
+ function collectFromSwimlane(
195
+ lane: SwimlaneDeclaration,
196
+ breadcrumb: readonly string[],
197
+ rows: ItemRow[],
198
+ group: string,
199
+ parallel: string,
200
+ ): void {
201
+ const breadcrumbStr = breadcrumb.filter((s) => s.length > 0).join('.');
202
+ for (const child of lane.content) {
203
+ addSwimlaneChild(child, breadcrumbStr, group, parallel, rows);
204
+ }
205
+ }
206
+
207
+ function addSwimlaneChild(
208
+ child: SwimlaneContent,
209
+ swimlane: string,
210
+ group: string,
211
+ parallel: string,
212
+ rows: ItemRow[],
213
+ ): void {
214
+ if (child.$type === 'ItemDeclaration') {
215
+ rows.push(itemRow(child, swimlane, group, parallel));
216
+ return;
217
+ }
218
+ if (child.$type === 'GroupBlock') {
219
+ const g = child.name ?? displayLabel(child);
220
+ for (const grandchild of (child as GroupBlock).content as GroupContent[]) {
221
+ addGroupChild(grandchild, swimlane, g, parallel, rows);
222
+ }
223
+ return;
224
+ }
225
+ if (child.$type === 'ParallelBlock') {
226
+ const p = child.name ?? displayLabel(child);
227
+ for (const grandchild of (child as ParallelBlock).content) {
228
+ if (grandchild.$type === 'ItemDeclaration') {
229
+ rows.push(itemRow(grandchild, swimlane, group, p));
230
+ } else if (grandchild.$type === 'GroupBlock') {
231
+ const g = grandchild.name ?? displayLabel(grandchild);
232
+ for (const inner of (grandchild as GroupBlock).content as GroupContent[]) {
233
+ addGroupChild(inner, swimlane, g, p, rows);
234
+ }
235
+ }
236
+ }
237
+ }
238
+ }
239
+
240
+ function addGroupChild(
241
+ child: GroupContent,
242
+ swimlane: string,
243
+ group: string,
244
+ parallel: string,
245
+ rows: ItemRow[],
246
+ ): void {
247
+ if (child.$type === 'ItemDeclaration') {
248
+ rows.push(itemRow(child, swimlane, group, parallel));
249
+ } else if (child.$type === 'GroupBlock') {
250
+ const sub = child.name ?? displayLabel(child);
251
+ const nested = group ? `${group}.${sub}` : sub;
252
+ for (const grandchild of (child as GroupBlock).content as GroupContent[]) {
253
+ addGroupChild(grandchild, swimlane, nested, parallel, rows);
254
+ }
255
+ } else if (child.$type === 'ParallelBlock') {
256
+ const p = child.name ?? displayLabel(child);
257
+ for (const grandchild of (child as ParallelBlock).content) {
258
+ if (grandchild.$type === 'ItemDeclaration') {
259
+ rows.push(itemRow(grandchild, swimlane, group, p));
260
+ }
261
+ }
262
+ }
263
+ }
264
+
265
+ function itemRow(
266
+ item: ItemDeclaration,
267
+ swimlane: string,
268
+ group: string,
269
+ parallel: string,
270
+ ): ItemRow {
271
+ const durationLiteral = getProp(item, 'duration') ?? getProp(item, 'size');
272
+ return {
273
+ id: item.name ?? '',
274
+ title: item.title ?? '',
275
+ swimlane,
276
+ group,
277
+ parallel,
278
+ duration: durationToWorkingDays(durationLiteral),
279
+ durationText: durationLiteralToText(durationLiteral),
280
+ status: getProp(item, 'status') ?? '',
281
+ remaining: getProp(item, 'remaining') ?? '',
282
+ owner: getProp(item, 'owner') ?? '',
283
+ after: getProps(item, 'after').join('; '),
284
+ before: getProps(item, 'before').join('; '),
285
+ labels: getProps(item, 'labels').join('; '),
286
+ link: getProp(item, 'link') ?? '',
287
+ description: item.description?.text ?? '',
288
+ };
289
+ }
290
+
291
+ // ---------- Sheet 3: Milestones ----------
292
+
293
+ function buildMilestonesSheet(wb: ExcelJS.Workbook, ast: NowlineFile): void {
294
+ const sheet = wb.addWorksheet('Milestones', {
295
+ views: [{ state: 'frozen', xSplit: 0, ySplit: 1 }],
296
+ });
297
+ sheet.columns = [
298
+ { header: 'ID', key: 'id', width: 18 },
299
+ { header: 'Title', key: 'title', width: 28 },
300
+ { header: 'Date', key: 'date', width: 14 },
301
+ { header: 'Depends', key: 'depends', width: 28 },
302
+ ];
303
+ for (const entry of ast.roadmapEntries) {
304
+ if (entry.$type === 'MilestoneDeclaration') {
305
+ const m = entry as MilestoneDeclaration;
306
+ sheet.addRow({
307
+ id: m.name ?? '',
308
+ title: m.title ?? '',
309
+ date: getProp(m, 'date') ?? '',
310
+ depends: getProps(m, 'depends').join('; '),
311
+ });
312
+ }
313
+ }
314
+ sheet.getRow(1).font = { bold: true };
315
+ }
316
+
317
+ // ---------- Sheet 4: Anchors ----------
318
+
319
+ function buildAnchorsSheet(wb: ExcelJS.Workbook, ast: NowlineFile): void {
320
+ const sheet = wb.addWorksheet('Anchors', {
321
+ views: [{ state: 'frozen', xSplit: 0, ySplit: 1 }],
322
+ });
323
+ sheet.columns = [
324
+ { header: 'ID', key: 'id', width: 18 },
325
+ { header: 'Title', key: 'title', width: 28 },
326
+ { header: 'Date', key: 'date', width: 14 },
327
+ ];
328
+ for (const entry of ast.roadmapEntries) {
329
+ if (entry.$type === 'AnchorDeclaration') {
330
+ const a = entry as AnchorDeclaration;
331
+ sheet.addRow({
332
+ id: a.name ?? '',
333
+ title: a.title ?? '',
334
+ date: getProp(a, 'date') ?? '',
335
+ });
336
+ }
337
+ }
338
+ sheet.getRow(1).font = { bold: true };
339
+ }
340
+
341
+ // ---------- Sheet 5: People and Teams ----------
342
+
343
+ function buildPeopleAndTeamsSheet(wb: ExcelJS.Workbook, ast: NowlineFile): void {
344
+ const sheet = wb.addWorksheet('People and Teams', {
345
+ views: [{ state: 'frozen', xSplit: 0, ySplit: 1 }],
346
+ });
347
+ sheet.columns = [
348
+ { header: 'ID', key: 'id', width: 18 },
349
+ { header: 'Title', key: 'title', width: 28 },
350
+ { header: 'Type', key: 'type', width: 10 },
351
+ { header: 'Parent Team', key: 'parent', width: 18 },
352
+ { header: 'Link', key: 'link', width: 28 },
353
+ ];
354
+ for (const entry of ast.roadmapEntries) {
355
+ if (entry.$type === 'PersonDeclaration') {
356
+ const p = entry as PersonDeclaration;
357
+ sheet.addRow({
358
+ id: p.name ?? '',
359
+ title: p.title ?? '',
360
+ type: 'person',
361
+ parent: '',
362
+ link: getProp(p, 'link') ?? '',
363
+ });
364
+ } else if (entry.$type === 'TeamDeclaration') {
365
+ walkTeam(entry as TeamDeclaration, '', sheet);
366
+ }
367
+ }
368
+ sheet.getRow(1).font = { bold: true };
369
+ }
370
+
371
+ function walkTeam(team: TeamDeclaration, parent: string, sheet: ExcelJS.Worksheet): void {
372
+ sheet.addRow({
373
+ id: team.name ?? '',
374
+ title: team.title ?? '',
375
+ type: 'team',
376
+ parent,
377
+ link: getProp(team, 'link') ?? '',
378
+ });
379
+ for (const child of team.content) {
380
+ if (child.$type === 'TeamDeclaration') {
381
+ walkTeam(child as TeamDeclaration, team.name ?? '', sheet);
382
+ }
383
+ }
384
+ }