A functional toolkit for authoring resource-leveling algorithms — compose constraint and scoring blocks, enumerate feasible schedules
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(csv): CSV writer with RFC 4180 quoting and injection defense

Export a ProjectFile's tasks to CSV. Quoting follows RFC 4180 and triggers
on a bare CR as well as comma/quote/LF, since a lone CR can desync strict
parsers. Fields beginning with a formula trigger (=, +, -, @) are defanged
with a leading apostrophe per OWASP guidance, and the trigger set is a
named constant so the policy is auditable in one place.

Adds shared test helpers (parseCsv, makeMinimalProject) reused by the CSV,
XLSX, and library suites.

Russ T. Fugal (Mar 9, 2026, 3:30 PM -0600) 54cdae51 757fdc30

+379
+96
src/csv/CsvWriter.ts
··· 1 + import type { ProjectFile } from "../model/Project.ts"; 2 + import { formatProjectDate } from "../dateTime.ts"; 3 + 4 + export interface CsvWriterOptions { 5 + /** Include summary (parent) tasks. Default: false (leaf tasks only). */ 6 + includeSummaryTasks?: boolean; 7 + /** Include a Resources column resolved from assignments. Default: true. */ 8 + includeResources?: boolean; 9 + } 10 + 11 + const HEADERS_BASE = [ 12 + "ID", 13 + "Task Name", 14 + "WBS", 15 + "Start", 16 + "Finish", 17 + "Duration", 18 + "% Complete", 19 + "Critical", 20 + "Milestone", 21 + ]; 22 + 23 + export class CsvWriter { 24 + write(project: ProjectFile, options?: CsvWriterOptions): string { 25 + const includeSummary = options?.includeSummaryTasks === true; 26 + const includeResources = options?.includeResources !== false; 27 + 28 + const headers = includeResources ? [...HEADERS_BASE, "Resources"] : [...HEADERS_BASE]; 29 + 30 + const resourceNames = new Map<number, string>(); 31 + for (const resource of project.resources) { 32 + if (resource.uniqueId !== null && resource.name !== null) { 33 + resourceNames.set(resource.uniqueId, resource.name); 34 + } 35 + } 36 + 37 + const taskResources = new Map<number, string[]>(); 38 + for (const assignment of project.assignments) { 39 + if (assignment.taskUniqueId === null || assignment.resourceUniqueId === null) continue; 40 + const name = resourceNames.get(assignment.resourceUniqueId); 41 + if (!name) continue; 42 + const existing = taskResources.get(assignment.taskUniqueId); 43 + if (existing) { 44 + if (!existing.includes(name)) existing.push(name); 45 + } else { 46 + taskResources.set(assignment.taskUniqueId, [name]); 47 + } 48 + } 49 + 50 + const tasks = includeSummary 51 + ? project.tasks.filter((t) => t.id !== null) 52 + : project.tasks.filter((t) => t.summary === false && t.id !== null); 53 + 54 + const lines = [headers.map(escapeField).join(",")]; 55 + 56 + for (const task of tasks) { 57 + const fields: unknown[] = [ 58 + task.id, 59 + task.name, 60 + task.wbs, 61 + formatProjectDate(task.start), 62 + formatProjectDate(task.finish), 63 + task.duration?.toSimpleString() ?? null, 64 + task.percentComplete, 65 + task.critical ? "Yes" : "No", 66 + task.milestone ? "Yes" : "No", 67 + ]; 68 + 69 + if (includeResources) { 70 + const names = task.uniqueId !== null ? taskResources.get(task.uniqueId) : undefined; 71 + fields.push(names ? names.join(",") : null); 72 + } 73 + 74 + lines.push(fields.map(escapeField).join(",")); 75 + } 76 + 77 + return lines.join("\n") + "\n"; 78 + } 79 + } 80 + 81 + // Characters that spreadsheet apps (Excel, Google Sheets, LibreOffice Calc, 82 + // Numbers) interpret as the start of a formula. Per OWASP, defang by 83 + // prepending a single apostrophe so the cell renders as literal text. 84 + const FORMULA_TRIGGERS = new Set(["=", "+", "-", "@", "\t", "\r"]); 85 + 86 + function escapeField(value: unknown): string { 87 + if (value == null) return ""; 88 + let s = String(value); 89 + if (s.length > 0 && FORMULA_TRIGGERS.has(s[0]!)) { 90 + s = "'" + s; 91 + } 92 + if (s.includes(",") || s.includes('"') || s.includes("\n") || s.includes("\r")) { 93 + return `"${s.replace(/"/g, '""')}"`; 94 + } 95 + return s; 96 + }
+166
test/csv.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { fileURLToPath } from "node:url"; 3 + 4 + import { CsvWriter } from "../src/csv/CsvWriter.ts"; 5 + import { ResourceType } from "../src/model/types.ts"; 6 + import { makeMinimalProject, parseCsv, fixtureExists } from "./helpers.ts"; 7 + 8 + const FIXTURE_MPP_PATH = resolveFixturePath("./sample-schedule.mpp"); 9 + const FIXTURE_CSV_PATH = resolveFixturePath("./project_schedule.csv"); 10 + const HAS_MPP_FIXTURE = fixtureExists(FIXTURE_MPP_PATH); 11 + 12 + async function getMppReader() { 13 + return import("../src/mpp/MppReader.ts"); 14 + } 15 + 16 + describe("CsvWriter", () => { 17 + test.skipIf(!HAS_MPP_FIXTURE)( 18 + "produces CSV matching the fixture baseline for leaf tasks", 19 + async () => { 20 + const { MppReader } = await getMppReader(); 21 + const data = await Bun.file(FIXTURE_MPP_PATH).arrayBuffer(); 22 + const project = new MppReader().read(data); 23 + 24 + const writer = new CsvWriter(); 25 + const csv = writer.write(project); 26 + const baselineCsv = await Bun.file(FIXTURE_CSV_PATH).text(); 27 + 28 + const csvRows = parseCsv(csv); 29 + const baselineRows = parseCsv(baselineCsv); 30 + 31 + expect(csvRows).toHaveLength(baselineRows.length); 32 + 33 + for (const [index, row] of csvRows.entries()) { 34 + const baseline = baselineRows[index]!; 35 + expect(row["ID"]).toBe(baseline["ID"]); 36 + expect(row["Task Name"]).toBe(baseline["Task Name"]); 37 + expect(row["WBS"]).toBe(baseline["WBS"]); 38 + expect(formatMinuteDate(row["Start"])).toBe(formatMinuteDate(baseline["Start"])); 39 + expect(formatMinuteDate(row["Finish"])).toBe(formatMinuteDate(baseline["Finish"])); 40 + expect(row["Duration"]).toBe(baseline["Duration"]); 41 + expect(row["Critical"]).toBe(baseline["Critical"]); 42 + expect(row["Milestone"]).toBe(baseline["Milestone"]); 43 + } 44 + }, 45 + ); 46 + 47 + test.skipIf(!HAS_MPP_FIXTURE)("includes summary tasks when option is set", async () => { 48 + const { MppReader } = await getMppReader(); 49 + const data = await Bun.file(FIXTURE_MPP_PATH).arrayBuffer(); 50 + const project = new MppReader().read(data); 51 + 52 + const leafCsv = new CsvWriter().write(project); 53 + const allCsv = new CsvWriter().write(project, { includeSummaryTasks: true }); 54 + 55 + const leafRows = parseCsv(leafCsv); 56 + const allRows = parseCsv(allCsv); 57 + 58 + expect(allRows.length).toBeGreaterThan(leafRows.length); 59 + }); 60 + 61 + test("excludes Resources column when option is set", () => { 62 + const project = makeMinimalProject(); 63 + const csv = new CsvWriter().write(project, { includeResources: false }); 64 + const header = csv.split("\n")[0]!; 65 + 66 + expect(header).not.toContain("Resources"); 67 + expect(header).toContain("Milestone"); 68 + }); 69 + 70 + test("escapes fields containing commas, quotes, and newlines", () => { 71 + const project = makeMinimalProject(); 72 + project.tasks[0]!.name = 'Task with "quotes" and, commas'; 73 + 74 + const csv = new CsvWriter().write(project); 75 + expect(csv).toContain('"Task with ""quotes"" and, commas"'); 76 + }); 77 + 78 + test("prefixes injection characters with apostrophe", () => { 79 + const project = makeMinimalProject(); 80 + 81 + const injectionInputs = ["=SUM(A1)", "+1", "-1", "@SUM(A1:A10)"]; 82 + 83 + for (const input of injectionInputs) { 84 + project.tasks[0]!.name = input; 85 + const csv = new CsvWriter().write(project); 86 + const dataLine = csv.split("\n")[1]!; 87 + // Apostrophe prefix should appear; original value must not appear without it 88 + expect(dataLine).toContain("'" + input); 89 + } 90 + }); 91 + 92 + test("applies both injection prefix and quote escaping together", () => { 93 + const project = makeMinimalProject(); 94 + project.tasks[0]!.name = '=cmd,with "quotes"'; 95 + 96 + const csv = new CsvWriter().write(project); 97 + // Should get apostrophe prefix AND quote wrapping: "'=cmd,with ""quotes""" 98 + expect(csv).toContain('"\'=cmd,with ""quotes"""'); 99 + }); 100 + 101 + test("resolves resource names from assignments", () => { 102 + const project = makeMinimalProject(); 103 + project.resources = [ 104 + { 105 + id: 1, 106 + uniqueId: 100, 107 + name: "Alice", 108 + type: ResourceType.Work, 109 + email: null, 110 + group: null, 111 + maxUnits: null, 112 + cost: null, 113 + work: null, 114 + resourcePool: null, 115 + }, 116 + { 117 + id: 2, 118 + uniqueId: 200, 119 + name: "Bob", 120 + type: ResourceType.Work, 121 + email: null, 122 + group: null, 123 + maxUnits: null, 124 + cost: null, 125 + work: null, 126 + resourcePool: null, 127 + }, 128 + ]; 129 + project.assignments = [ 130 + { 131 + taskUniqueId: 1, 132 + resourceUniqueId: 100, 133 + work: null, 134 + units: null, 135 + start: null, 136 + finish: null, 137 + actualWork: null, 138 + remainingWork: null, 139 + }, 140 + { 141 + taskUniqueId: 1, 142 + resourceUniqueId: 200, 143 + work: null, 144 + units: null, 145 + start: null, 146 + finish: null, 147 + actualWork: null, 148 + remainingWork: null, 149 + }, 150 + ]; 151 + 152 + const csv = new CsvWriter().write(project); 153 + const rows = parseCsv(csv); 154 + expect(rows[0]!["Resources"]).toContain("Alice"); 155 + expect(rows[0]!["Resources"]).toContain("Bob"); 156 + }); 157 + }); 158 + 159 + function formatMinuteDate(value: string | null | undefined): string | null { 160 + if (!value) return null; 161 + return value.slice(0, 16); 162 + } 163 + 164 + function resolveFixturePath(relativePath: string): string { 165 + return fileURLToPath(new URL(relativePath, import.meta.url)); 166 + }
+117
test/helpers.ts
··· 1 + import { existsSync } from "node:fs"; 2 + import { Duration } from "../src/model/Duration.ts"; 3 + import { TimeUnit } from "../src/model/types.ts"; 4 + import type { ProjectFile } from "../src/model/Project.ts"; 5 + 6 + export function fixtureExists(path: string): boolean { 7 + return existsSync(path); 8 + } 9 + 10 + export function makeMinimalProject(): ProjectFile { 11 + return { 12 + properties: { 13 + title: "Test", 14 + author: null, 15 + startDate: null, 16 + finishDate: null, 17 + statusDate: null, 18 + defaultCalendarUniqueId: null, 19 + minutesPerDay: 480, 20 + minutesPerWeek: 2400, 21 + daysPerMonth: 20, 22 + saveVersion: null, 23 + }, 24 + tasks: [ 25 + { 26 + id: 1, 27 + uniqueId: 1, 28 + name: "Test Task", 29 + wbs: "1", 30 + outlineLevel: 1, 31 + start: new Date("2026-04-06T06:00:00"), 32 + finish: new Date("2026-04-06T14:00:00"), 33 + duration: Duration.from(8, TimeUnit.Hours), 34 + percentComplete: 0, 35 + summary: false, 36 + milestone: false, 37 + critical: false, 38 + notes: null, 39 + priority: null, 40 + cost: null, 41 + work: null, 42 + actualStart: null, 43 + actualFinish: null, 44 + baselineStart: null, 45 + baselineFinish: null, 46 + baselineDuration: null, 47 + actualWork: null, 48 + constraintType: null, 49 + freeSlack: null, 50 + totalSlack: null, 51 + earlyStart: null, 52 + earlyFinish: null, 53 + lateStart: null, 54 + lateFinish: null, 55 + levelingDelay: null, 56 + deadline: null, 57 + splits: null, 58 + predecessors: [], 59 + }, 60 + ], 61 + resources: [], 62 + assignments: [], 63 + calendars: [], 64 + }; 65 + } 66 + 67 + export function parseCsv(text: string): Array<Record<string, string>> { 68 + const rows: string[][] = []; 69 + let field = ""; 70 + let row: string[] = []; 71 + let inQuotes = false; 72 + 73 + for (let index = 0; index < text.length; index += 1) { 74 + const character = text[index]; 75 + if (character === '"') { 76 + if (inQuotes && text[index + 1] === '"') { 77 + field += '"'; 78 + index += 1; 79 + } else { 80 + inQuotes = !inQuotes; 81 + } 82 + continue; 83 + } 84 + 85 + if (character === "," && !inQuotes) { 86 + row.push(field); 87 + field = ""; 88 + continue; 89 + } 90 + 91 + if ((character === "\n" || character === "\r") && !inQuotes) { 92 + if (character === "\r" && text[index + 1] === "\n") { 93 + index += 1; 94 + } 95 + row.push(field); 96 + field = ""; 97 + if (row.some((value) => value.length > 0)) { 98 + rows.push(row); 99 + } 100 + row = []; 101 + continue; 102 + } 103 + 104 + field += character; 105 + } 106 + 107 + if (field.length > 0 || row.length > 0) { 108 + row.push(field); 109 + rows.push(row); 110 + } 111 + 112 + const [header, ...body] = rows; 113 + if (!header) return []; 114 + return body.map((values) => 115 + Object.fromEntries(header.map((column, index) => [column, values[index] ?? ""])), 116 + ); 117 + }