diff --git a/src/csv/CsvWriter.ts b/src/csv/CsvWriter.ts new file mode 100644 index 0000000..091616f --- /dev/null +++ b/src/csv/CsvWriter.ts @@ -0,0 +1,96 @@ +import type { ProjectFile } from "../model/Project.ts"; +import { formatProjectDate } from "../dateTime.ts"; + +export interface CsvWriterOptions { + /** Include summary (parent) tasks. Default: false (leaf tasks only). */ + includeSummaryTasks?: boolean; + /** Include a Resources column resolved from assignments. Default: true. */ + includeResources?: boolean; +} + +const HEADERS_BASE = [ + "ID", + "Task Name", + "WBS", + "Start", + "Finish", + "Duration", + "% Complete", + "Critical", + "Milestone", +]; + +export class CsvWriter { + write(project: ProjectFile, options?: CsvWriterOptions): string { + const includeSummary = options?.includeSummaryTasks === true; + const includeResources = options?.includeResources !== false; + + const headers = includeResources ? [...HEADERS_BASE, "Resources"] : [...HEADERS_BASE]; + + const resourceNames = new Map(); + for (const resource of project.resources) { + if (resource.uniqueId !== null && resource.name !== null) { + resourceNames.set(resource.uniqueId, resource.name); + } + } + + const taskResources = new Map(); + for (const assignment of project.assignments) { + if (assignment.taskUniqueId === null || assignment.resourceUniqueId === null) continue; + const name = resourceNames.get(assignment.resourceUniqueId); + if (!name) continue; + const existing = taskResources.get(assignment.taskUniqueId); + if (existing) { + if (!existing.includes(name)) existing.push(name); + } else { + taskResources.set(assignment.taskUniqueId, [name]); + } + } + + const tasks = includeSummary + ? project.tasks.filter((t) => t.id !== null) + : project.tasks.filter((t) => t.summary === false && t.id !== null); + + const lines = [headers.map(escapeField).join(",")]; + + for (const task of tasks) { + const fields: unknown[] = [ + task.id, + task.name, + task.wbs, + formatProjectDate(task.start), + formatProjectDate(task.finish), + task.duration?.toSimpleString() ?? null, + task.percentComplete, + task.critical ? "Yes" : "No", + task.milestone ? "Yes" : "No", + ]; + + if (includeResources) { + const names = task.uniqueId !== null ? taskResources.get(task.uniqueId) : undefined; + fields.push(names ? names.join(",") : null); + } + + lines.push(fields.map(escapeField).join(",")); + } + + return lines.join("\n") + "\n"; + } +} + +// Characters that spreadsheet apps (Excel, Google Sheets, LibreOffice Calc, +// Numbers) interpret as the start of a formula. Per OWASP, defang by +// prepending a single apostrophe so the cell renders as literal text. +const FORMULA_TRIGGERS = new Set(["=", "+", "-", "@", "\t", "\r"]); + +function escapeField(value: unknown): string { + if (value == null) return ""; + let s = String(value); + if (s.length > 0 && FORMULA_TRIGGERS.has(s[0]!)) { + s = "'" + s; + } + if (s.includes(",") || s.includes('"') || s.includes("\n") || s.includes("\r")) { + return `"${s.replace(/"/g, '""')}"`; + } + return s; +} diff --git a/test/csv.test.ts b/test/csv.test.ts new file mode 100644 index 0000000..ace7fec --- /dev/null +++ b/test/csv.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, test } from "bun:test"; +import { fileURLToPath } from "node:url"; + +import { CsvWriter } from "../src/csv/CsvWriter.ts"; +import { ResourceType } from "../src/model/types.ts"; +import { makeMinimalProject, parseCsv, fixtureExists } from "./helpers.ts"; + +const FIXTURE_MPP_PATH = resolveFixturePath("./sample-schedule.mpp"); +const FIXTURE_CSV_PATH = resolveFixturePath("./project_schedule.csv"); +const HAS_MPP_FIXTURE = fixtureExists(FIXTURE_MPP_PATH); + +async function getMppReader() { + return import("../src/mpp/MppReader.ts"); +} + +describe("CsvWriter", () => { + test.skipIf(!HAS_MPP_FIXTURE)( + "produces CSV matching the fixture baseline for leaf tasks", + async () => { + const { MppReader } = await getMppReader(); + const data = await Bun.file(FIXTURE_MPP_PATH).arrayBuffer(); + const project = new MppReader().read(data); + + const writer = new CsvWriter(); + const csv = writer.write(project); + const baselineCsv = await Bun.file(FIXTURE_CSV_PATH).text(); + + const csvRows = parseCsv(csv); + const baselineRows = parseCsv(baselineCsv); + + expect(csvRows).toHaveLength(baselineRows.length); + + for (const [index, row] of csvRows.entries()) { + const baseline = baselineRows[index]!; + expect(row["ID"]).toBe(baseline["ID"]); + expect(row["Task Name"]).toBe(baseline["Task Name"]); + expect(row["WBS"]).toBe(baseline["WBS"]); + expect(formatMinuteDate(row["Start"])).toBe(formatMinuteDate(baseline["Start"])); + expect(formatMinuteDate(row["Finish"])).toBe(formatMinuteDate(baseline["Finish"])); + expect(row["Duration"]).toBe(baseline["Duration"]); + expect(row["Critical"]).toBe(baseline["Critical"]); + expect(row["Milestone"]).toBe(baseline["Milestone"]); + } + }, + ); + + test.skipIf(!HAS_MPP_FIXTURE)("includes summary tasks when option is set", async () => { + const { MppReader } = await getMppReader(); + const data = await Bun.file(FIXTURE_MPP_PATH).arrayBuffer(); + const project = new MppReader().read(data); + + const leafCsv = new CsvWriter().write(project); + const allCsv = new CsvWriter().write(project, { includeSummaryTasks: true }); + + const leafRows = parseCsv(leafCsv); + const allRows = parseCsv(allCsv); + + expect(allRows.length).toBeGreaterThan(leafRows.length); + }); + + test("excludes Resources column when option is set", () => { + const project = makeMinimalProject(); + const csv = new CsvWriter().write(project, { includeResources: false }); + const header = csv.split("\n")[0]!; + + expect(header).not.toContain("Resources"); + expect(header).toContain("Milestone"); + }); + + test("escapes fields containing commas, quotes, and newlines", () => { + const project = makeMinimalProject(); + project.tasks[0]!.name = 'Task with "quotes" and, commas'; + + const csv = new CsvWriter().write(project); + expect(csv).toContain('"Task with ""quotes"" and, commas"'); + }); + + test("prefixes injection characters with apostrophe", () => { + const project = makeMinimalProject(); + + const injectionInputs = ["=SUM(A1)", "+1", "-1", "@SUM(A1:A10)"]; + + for (const input of injectionInputs) { + project.tasks[0]!.name = input; + const csv = new CsvWriter().write(project); + const dataLine = csv.split("\n")[1]!; + // Apostrophe prefix should appear; original value must not appear without it + expect(dataLine).toContain("'" + input); + } + }); + + test("applies both injection prefix and quote escaping together", () => { + const project = makeMinimalProject(); + project.tasks[0]!.name = '=cmd,with "quotes"'; + + const csv = new CsvWriter().write(project); + // Should get apostrophe prefix AND quote wrapping: "'=cmd,with ""quotes""" + expect(csv).toContain('"\'=cmd,with ""quotes"""'); + }); + + test("resolves resource names from assignments", () => { + const project = makeMinimalProject(); + project.resources = [ + { + id: 1, + uniqueId: 100, + name: "Alice", + type: ResourceType.Work, + email: null, + group: null, + maxUnits: null, + cost: null, + work: null, + resourcePool: null, + }, + { + id: 2, + uniqueId: 200, + name: "Bob", + type: ResourceType.Work, + email: null, + group: null, + maxUnits: null, + cost: null, + work: null, + resourcePool: null, + }, + ]; + project.assignments = [ + { + taskUniqueId: 1, + resourceUniqueId: 100, + work: null, + units: null, + start: null, + finish: null, + actualWork: null, + remainingWork: null, + }, + { + taskUniqueId: 1, + resourceUniqueId: 200, + work: null, + units: null, + start: null, + finish: null, + actualWork: null, + remainingWork: null, + }, + ]; + + const csv = new CsvWriter().write(project); + const rows = parseCsv(csv); + expect(rows[0]!["Resources"]).toContain("Alice"); + expect(rows[0]!["Resources"]).toContain("Bob"); + }); +}); + +function formatMinuteDate(value: string | null | undefined): string | null { + if (!value) return null; + return value.slice(0, 16); +} + +function resolveFixturePath(relativePath: string): string { + return fileURLToPath(new URL(relativePath, import.meta.url)); +} diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..fcedd11 --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,117 @@ +import { existsSync } from "node:fs"; +import { Duration } from "../src/model/Duration.ts"; +import { TimeUnit } from "../src/model/types.ts"; +import type { ProjectFile } from "../src/model/Project.ts"; + +export function fixtureExists(path: string): boolean { + return existsSync(path); +} + +export function makeMinimalProject(): ProjectFile { + return { + properties: { + title: "Test", + author: null, + startDate: null, + finishDate: null, + statusDate: null, + defaultCalendarUniqueId: null, + minutesPerDay: 480, + minutesPerWeek: 2400, + daysPerMonth: 20, + saveVersion: null, + }, + tasks: [ + { + id: 1, + uniqueId: 1, + name: "Test Task", + wbs: "1", + outlineLevel: 1, + start: new Date("2026-04-06T06:00:00"), + finish: new Date("2026-04-06T14:00:00"), + duration: Duration.from(8, TimeUnit.Hours), + percentComplete: 0, + summary: false, + milestone: false, + critical: false, + notes: null, + priority: null, + cost: null, + work: null, + actualStart: null, + actualFinish: null, + baselineStart: null, + baselineFinish: null, + baselineDuration: null, + actualWork: null, + constraintType: null, + freeSlack: null, + totalSlack: null, + earlyStart: null, + earlyFinish: null, + lateStart: null, + lateFinish: null, + levelingDelay: null, + deadline: null, + splits: null, + predecessors: [], + }, + ], + resources: [], + assignments: [], + calendars: [], + }; +} + +export function parseCsv(text: string): Array> { + const rows: string[][] = []; + let field = ""; + let row: string[] = []; + let inQuotes = false; + + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + if (character === '"') { + if (inQuotes && text[index + 1] === '"') { + field += '"'; + index += 1; + } else { + inQuotes = !inQuotes; + } + continue; + } + + if (character === "," && !inQuotes) { + row.push(field); + field = ""; + continue; + } + + if ((character === "\n" || character === "\r") && !inQuotes) { + if (character === "\r" && text[index + 1] === "\n") { + index += 1; + } + row.push(field); + field = ""; + if (row.some((value) => value.length > 0)) { + rows.push(row); + } + row = []; + continue; + } + + field += character; + } + + if (field.length > 0 || row.length > 0) { + row.push(field); + rows.push(row); + } + + const [header, ...body] = rows; + if (!header) return []; + return body.map((values) => + Object.fromEntries(header.map((column, index) => [column, values[index] ?? ""])), + ); +}