diff --git a/README.md b/README.md index a0857ee10..67f9984f9 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,64 @@ Prefer full control over your data? Run Worklenz on your own server. - **🔄 Import & Export** - Import tasks from a CSV file and export entire projects to CSV for easy data portability.

+### 📥 CSV Task Import + +Import tasks in bulk from CSV files directly into any project. The import wizard guides you through uploading, mapping columns, previewing data, and creating tasks — all from the browser. + +#### How to Use + +1. Open a project and click the **⋮ menu** (top-right of the project header) → **Import Tasks**. +2. Select **CSV** as the import source and upload your `.csv` file (or download the sample template first). +3. **Map columns** — the wizard auto-matches common headers (e.g. *Task Name*, *Status*, *Assignee*, *Due Date*). You can adjust any mapping via the dropdown. +4. **Preview** — review the parsed data in a table before importing. Errors and warnings are highlighted. +5. Click **Import** to create the tasks inside the project. + +#### Supported Fields + +| CSV Column (auto-matched) | WorkLenz Field | +|---|---| +| Task Name, Title, Summary, Subject | **Task name** (required) | +| Status, State, Phase | **Status** | +| Assignee, Assigned To, Owner | **Assignees** | +| Due Date, Deadline, End Date | **Due date** | +| Start Date, Begin Date | **Start date** | +| Priority, Urgency, Severity | **Priority** | +| Description, Details, Notes | **Description** | +| Estimate, Story Points, Hours | **Estimation** | +| Label, Tags, Category | **Labels** | + +Columns that don't match a built-in field are imported as **custom columns**. + +#### Supported Date Formats + +| Format | Example | +|---|---| +| ISO 8601 | `2026-08-21`, `2026-08-21T14:30:00Z` | +| US (MM/DD/YYYY) | `08/21/2026` | +| EU (DD/MM/YYYY) | `21/08/2026` | +| Natural | `21 Aug 2026`, `August 21, 2026` | +| Unix Timestamp | `1787443200` (seconds or milliseconds) | + +#### Safety Limits + +- **Max file size:** 10 MB +- **Max rows:** 5 000 tasks per import +- Binary files (PDF, PNG, ZIP, etc.) are rejected at upload + +#### Delimiter Support + +Comma (`,`), semicolon (`;`), and tab (`\t`) delimited files are all auto-detected. + +#### Running Tests + +```bash +cd worklenz-backend +npm test -- --testPathPattern="src/__tests__/csv-parser.test.ts" +``` + +The test suite covers date-strategy parsing, RFC 4180 tokenization, BOM handling, delimiter detection, binary rejection, and header auto-mapping (47 tests). +
+ ## 📸 Screenshots
diff --git a/worklenz-backend/src/__tests__/csv-parser.test.ts b/worklenz-backend/src/__tests__/csv-parser.test.ts new file mode 100644 index 000000000..a83229523 --- /dev/null +++ b/worklenz-backend/src/__tests__/csv-parser.test.ts @@ -0,0 +1,342 @@ +import { + IsoDateStrategy, + UsDateStrategy, + EuDateStrategy, + NaturalDateStrategy, + UnixTimestampStrategy, + parseDate, + detectDateStrategy, + stripBom, + detectDelimiter, + tokenizeCsv, + isBinaryContent, + suggestFieldMapping, + autoMapHeaders, + MAX_CSV_ROWS, +} from "../services/import-providers/csv-parser-strategies"; + +jest.unmock("../services/import-providers/csv-parser-strategies"); + + +describe("IsoDateStrategy", () => { + const strategy = new IsoDateStrategy(); + + it("parses YYYY-MM-DD dates", () => { + expect(strategy.canParse("2026-08-21")).toBe(true); + const result = strategy.parse("2026-08-21"); + expect(result).toBeInstanceOf(Date); + expect(result!.getFullYear()).toBe(2026); + expect(result!.getMonth()).toBe(7); // 0-indexed + expect(result!.getDate()).toBe(21); + }); + + it("parses ISO 8601 with time component", () => { + expect(strategy.canParse("2026-08-21T14:30:00Z")).toBe(true); + const result = strategy.parse("2026-08-21T14:30:00Z"); + expect(result).toBeInstanceOf(Date); + }); + + it("rejects non-ISO formats", () => { + expect(strategy.canParse("08/21/2026")).toBe(false); + expect(strategy.canParse("21 Aug 2026")).toBe(false); + expect(strategy.canParse("not a date")).toBe(false); + }); +}); + +describe("UsDateStrategy", () => { + const strategy = new UsDateStrategy(); + + it("parses MM/DD/YYYY dates", () => { + expect(strategy.canParse("08/21/2026")).toBe(true); + const result = strategy.parse("08/21/2026"); + expect(result).toBeInstanceOf(Date); + expect(result!.getMonth()).toBe(7); + expect(result!.getDate()).toBe(21); + expect(result!.getFullYear()).toBe(2026); + }); + + it("parses MM-DD-YYYY dates", () => { + expect(strategy.canParse("12-25-2026")).toBe(true); + const result = strategy.parse("12-25-2026"); + expect(result).toBeInstanceOf(Date); + expect(result!.getMonth()).toBe(11); + expect(result!.getDate()).toBe(25); + }); + + it("rejects invalid months", () => { + expect(strategy.canParse("13/01/2026")).toBe(false); + expect(strategy.canParse("00/15/2026")).toBe(false); + }); +}); + +describe("EuDateStrategy", () => { + const strategy = new EuDateStrategy(); + + it("parses DD/MM/YYYY dates", () => { + expect(strategy.canParse("25/12/2026")).toBe(true); + const result = strategy.parse("25/12/2026"); + expect(result).toBeInstanceOf(Date); + expect(result!.getDate()).toBe(25); + expect(result!.getMonth()).toBe(11); + expect(result!.getFullYear()).toBe(2026); + }); + + it("parses DD-MM-YYYY dates", () => { + expect(strategy.canParse("01-06-2026")).toBe(true); + const result = strategy.parse("01-06-2026"); + expect(result).toBeInstanceOf(Date); + expect(result!.getDate()).toBe(1); + expect(result!.getMonth()).toBe(5); + }); + + it("rejects invalid months", () => { + expect(strategy.canParse("15/13/2026")).toBe(false); + }); +}); + +describe("NaturalDateStrategy", () => { + const strategy = new NaturalDateStrategy(); + + it("parses '21 Aug 2026' format", () => { + expect(strategy.canParse("21 Aug 2026")).toBe(true); + const result = strategy.parse("21 Aug 2026"); + expect(result).toBeInstanceOf(Date); + }); + + it("parses 'August 21, 2026' format", () => { + expect(strategy.canParse("August 21, 2026")).toBe(true); + const result = strategy.parse("August 21, 2026"); + expect(result).toBeInstanceOf(Date); + }); + + it("rejects plain numbers", () => { + expect(strategy.canParse("12345")).toBe(false); + }); +}); + +describe("UnixTimestampStrategy", () => { + const strategy = new UnixTimestampStrategy(); + + it("parses 10-digit unix timestamps (seconds)", () => { + expect(strategy.canParse("1787500800")).toBe(true); + const result = strategy.parse("1787500800"); + expect(result).toBeInstanceOf(Date); + expect(result!.getFullYear()).toBeGreaterThanOrEqual(2026); + }); + + it("parses 13-digit unix timestamps (milliseconds)", () => { + expect(strategy.canParse("1787500800000")).toBe(true); + const result = strategy.parse("1787500800000"); + expect(result).toBeInstanceOf(Date); + }); + + it("rejects non-numeric strings", () => { + expect(strategy.canParse("abc")).toBe(false); + expect(strategy.canParse("2026-08-21")).toBe(false); + }); +}); + +describe("parseDate (facade)", () => { + it("parses ISO dates", () => { + const result = parseDate("2026-08-21"); + expect(result).toBeInstanceOf(Date); + expect(result!.getFullYear()).toBe(2026); + }); + + it("parses US dates", () => { + const result = parseDate("08/21/2026"); + expect(result).toBeInstanceOf(Date); + }); + + it("returns null for empty strings", () => { + expect(parseDate("")).toBeNull(); + expect(parseDate(" ")).toBeNull(); + }); + + it("returns null for unparseable values", () => { + expect(parseDate("not a date")).toBeNull(); + }); +}); + +describe("detectDateStrategy", () => { + it("detects ISO strategy for ISO-formatted samples", () => { + const samples = ["2026-01-15", "2026-03-22", "2026-07-01"]; + const result = detectDateStrategy(samples); + expect(result).not.toBeNull(); + expect(result!.name).toBe("ISO 8601"); + }); + + it("returns null for non-date data", () => { + const samples = ["hello", "world", "foo"]; + const result = detectDateStrategy(samples); + expect(result).toBeNull(); + }); +}); + +describe("stripBom", () => { + it("removes UTF-8 BOM from the start of text", () => { + const withBom = "\uFEFFTitle,Status,Assignee"; + expect(stripBom(withBom)).toBe("Title,Status,Assignee"); + }); + + it("leaves text without BOM unchanged", () => { + const noBom = "Title,Status,Assignee"; + expect(stripBom(noBom)).toBe(noBom); + }); +}); + +describe("detectDelimiter", () => { + it("detects comma delimiter", () => { + const csv = "Title,Status,Assignee\nTask 1,Open,Alice\nTask 2,Done,Bob"; + expect(detectDelimiter(csv)).toBe(","); + }); + + it("detects semicolon delimiter", () => { + const csv = "Title;Status;Assignee\nTask 1;Open;Alice\nTask 2;Done;Bob"; + expect(detectDelimiter(csv)).toBe(";"); + }); + + it("detects tab delimiter", () => { + const csv = "Title\tStatus\tAssignee\nTask 1\tOpen\tAlice"; + expect(detectDelimiter(csv)).toBe("\t"); + }); + + it("defaults to comma for empty input", () => { + expect(detectDelimiter("")).toBe(","); + }); +}); + +describe("tokenizeCsv", () => { + it("parses simple comma-delimited CSV", () => { + const csv = "Title,Status\nTask 1,Open\nTask 2,Done"; + const rows = tokenizeCsv(csv); + expect(rows).toHaveLength(3); + expect(rows[0]).toEqual(["Title", "Status"]); + expect(rows[1]).toEqual(["Task 1", "Open"]); + expect(rows[2]).toEqual(["Task 2", "Done"]); + }); + + it("handles quoted fields containing commas", () => { + const csv = 'Title,Description\n"Task A","Design, implement, and test"\nTask B,Simple'; + const rows = tokenizeCsv(csv); + expect(rows).toHaveLength(3); + expect(rows[1][0]).toBe("Task A"); + expect(rows[1][1]).toBe("Design, implement, and test"); + }); + + it("handles escaped double quotes within quoted fields", () => { + const csv = 'Name,Note\n"He said ""Hello""","OK"'; + const rows = tokenizeCsv(csv); + expect(rows).toHaveLength(2); + expect(rows[1][0]).toBe('He said "Hello"'); + expect(rows[1][1]).toBe("OK"); + }); + + it("handles multiline text within quoted fields", () => { + const csv = 'Title,Description\n"Task A","Line 1\nLine 2"\nTask B,Simple'; + const rows = tokenizeCsv(csv); + expect(rows).toHaveLength(3); + expect(rows[1][1]).toContain("Line 1"); + expect(rows[1][1]).toContain("Line 2"); + }); + + it("handles CRLF line endings", () => { + const csv = "Title,Status\r\nTask 1,Open\r\nTask 2,Done"; + const rows = tokenizeCsv(csv); + expect(rows).toHaveLength(3); + expect(rows[1]).toEqual(["Task 1", "Open"]); + }); + + it("strips UTF-8 BOM", () => { + const csv = "\uFEFFTitle,Status\nTask 1,Open"; + const rows = tokenizeCsv(csv); + expect(rows[0][0]).toBe("Title"); // Not "\uFEFFTitle" + }); + + it("handles semicolon-delimited CSV", () => { + const csv = "Title;Status;Assignee\nTask 1;Open;Alice"; + const rows = tokenizeCsv(csv); + expect(rows).toHaveLength(2); + expect(rows[0]).toEqual(["Title", "Status", "Assignee"]); + expect(rows[1]).toEqual(["Task 1", "Open", "Alice"]); + }); + + it("handles empty rows gracefully", () => { + const csv = "Title,Status\n\nTask 1,Open\n\n"; + const rows = tokenizeCsv(csv); + expect(rows).toHaveLength(2); // header + 1 data row + expect(rows[0]).toEqual(["Title", "Status"]); + expect(rows[1]).toEqual(["Task 1", "Open"]); + }); +}); + + +describe("isBinaryContent", () => { + it("detects PDF files", () => { + expect(isBinaryContent("%PDF-1.4 some binary data")).toBe(true); + }); + + it("passes plain CSV text", () => { + expect(isBinaryContent("Title,Status\nTask 1,Open")).toBe(false); + }); +}); + + + +describe("suggestFieldMapping", () => { + it("maps 'Task Name' to 'key'", () => { + expect(suggestFieldMapping("Task Name")).toBe("key"); + }); + + it("maps 'title' to 'key'", () => { + expect(suggestFieldMapping("title")).toBe("key"); + }); + + it("maps 'Assigned To' to 'assignees'", () => { + expect(suggestFieldMapping("Assigned To")).toBe("assignees"); + }); + + it("maps 'Due Date' to 'dueDate'", () => { + expect(suggestFieldMapping("Due Date")).toBe("dueDate"); + }); + + it("maps 'Priority' to 'priority'", () => { + expect(suggestFieldMapping("Priority")).toBe("priority"); + }); + + it("maps 'Description' to 'description'", () => { + expect(suggestFieldMapping("Description")).toBe("description"); + }); + + it("returns null for unknown headers", () => { + expect(suggestFieldMapping("Custom Column XYZ")).toBeNull(); + }); +}); + +describe("autoMapHeaders", () => { + it("auto-maps common headers to WorkLenz fields", () => { + const headers = ["Task Name", "Status", "Assignee", "Due Date", "Priority"]; + const result = autoMapHeaders(headers); + expect(result["Task Name"]).toBe("key"); + expect(result["Status"]).toBe("status"); + expect(result["Assignee"]).toBe("assignees"); + expect(result["Due Date"]).toBe("dueDate"); + expect(result["Priority"]).toBe("priority"); + }); + + it("sets null for unrecognized headers", () => { + const headers = ["Custom Field", "Random Column"]; + const result = autoMapHeaders(headers); + expect(result["Custom Field"]).toBeNull(); + expect(result["Random Column"]).toBeNull(); + }); + + it("prevents duplicate target assignments", () => { + // Both 'Title' and 'Name' would map to 'key', but only the first should win + const headers = ["Title", "Name", "Status"]; + const result = autoMapHeaders(headers); + expect(result["Title"]).toBe("key"); + expect(result["Name"]).toBeNull(); // already taken + expect(result["Status"]).toBe("status"); + }); +}); diff --git a/worklenz-backend/src/controllers/imports-controller.ts b/worklenz-backend/src/controllers/imports-controller.ts index 618e2d5a5..590eed4ee 100644 --- a/worklenz-backend/src/controllers/imports-controller.ts +++ b/worklenz-backend/src/controllers/imports-controller.ts @@ -507,6 +507,36 @@ export default class ImportsController { }, ); } + } else if (providerKey === "csv") { + // CSV Provider: use Strategy Pattern auto-matching from csv-parser-strategies + try { + const { autoMapHeaders } = await import( + "../services/import-providers/csv-parser-strategies" + ); + const staged = await ImportsService.listStageTasks(jobId); + if (staged.length > 0 && staged[0].raw) { + const rawObj = typeof staged[0].raw === "object" ? staged[0].raw as Record : {}; + const headers = Object.keys(rawObj); + if (headers.length > 0) { + const mappings = autoMapHeaders(headers); + rows = headers.map((h) => ({ + source_field: h, + target_field: mappings[h] || h, + required: mappings[h] === "key", + include: true, + })); + } + } + } catch (err) { + await ImportsService.appendLog( + job.id, + "warn", + "CSV auto fields failed, using defaults", + { + error: (err as any)?.message, + }, + ); + } } rows = ensureRequiredTargets(rows); @@ -616,6 +646,28 @@ export default class ImportsController { // source_reference, then hand off to the background worker. This avoids // parsing tens-of-thousands of rows inside an HTTP request handler. if (job.flow_type === "csv" && body.csvText) { + // --- Validate CSV input (Strategy Pattern safety gates) --- + const csvText = body.csvText; + if (typeof csvText !== "string" || !csvText.trim()) { + throw createHttpError(400, "CSV text must be a non-empty string."); + } + const { isBinaryContent, MAX_CSV_SIZE_BYTES } = await import( + "../services/import-providers/csv-parser-strategies" + ); + const byteLength = Buffer.byteLength(csvText, "utf-8"); + if (byteLength > MAX_CSV_SIZE_BYTES) { + throw createHttpError( + 400, + `The CSV file exceeds the maximum allowed size of ${MAX_CSV_SIZE_BYTES / (1024 * 1024)} MB.` + ); + } + if (isBinaryContent(csvText)) { + throw createHttpError( + 400, + "The uploaded file does not appear to be a CSV. Please upload a valid CSV file." + ); + } + const patch: Record = { csvText: body.csvText }; if (body.fields) patch.fields = body.fields; if (body.values) patch.values = body.values; diff --git a/worklenz-backend/src/services/import-providers/csv-parser-strategies.ts b/worklenz-backend/src/services/import-providers/csv-parser-strategies.ts new file mode 100644 index 000000000..c61139763 --- /dev/null +++ b/worklenz-backend/src/services/import-providers/csv-parser-strategies.ts @@ -0,0 +1,371 @@ +export interface DateParsingStrategy { + readonly name: string; + canParse(value: string): boolean; + parse(value: string): Date | null; +} + + +function isValidDate(d: Date): boolean { + return d instanceof Date && !isNaN(d.getTime()); +} + +export class IsoDateStrategy implements DateParsingStrategy { + readonly name = "ISO 8601"; + private static readonly ISO_REGEX = + /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/; + + canParse(value: string): boolean { + return IsoDateStrategy.ISO_REGEX.test(value.trim()); + } + + parse(value: string): Date | null { + const d = new Date(value.trim()); + return isValidDate(d) ? d : null; + } +} + + +export class UsDateStrategy implements DateParsingStrategy { + readonly name = "US (MM/DD/YYYY)"; + private static readonly US_REGEX = + /^(\d{1,2})[/-](\d{1,2})[/-](\d{4})$/; + + canParse(value: string): boolean { + const match = value.trim().match(UsDateStrategy.US_REGEX); + if (!match) return false; + const month = parseInt(match[1], 10); + return month >= 1 && month <= 12; + } + + parse(value: string): Date | null { + const match = value.trim().match(UsDateStrategy.US_REGEX); + if (!match) return null; + const month = parseInt(match[1], 10); + const day = parseInt(match[2], 10); + const year = parseInt(match[3], 10); + if (month < 1 || month > 12 || day < 1 || day > 31) return null; + const d = new Date(year, month - 1, day); + return isValidDate(d) && d.getMonth() === month - 1 ? d : null; + } +} + + +export class EuDateStrategy implements DateParsingStrategy { + readonly name = "EU (DD/MM/YYYY)"; + private static readonly EU_REGEX = + /^(\d{1,2})[/-](\d{1,2})[/-](\d{4})$/; + + canParse(value: string): boolean { + const match = value.trim().match(EuDateStrategy.EU_REGEX); + if (!match) return false; + const day = parseInt(match[1], 10); + const month = parseInt(match[2], 10); + return day >= 1 && day <= 31 && month >= 1 && month <= 12; + } + + parse(value: string): Date | null { + const match = value.trim().match(EuDateStrategy.EU_REGEX); + if (!match) return null; + const day = parseInt(match[1], 10); + const month = parseInt(match[2], 10); + const year = parseInt(match[3], 10); + if (month < 1 || month > 12 || day < 1 || day > 31) return null; + const d = new Date(year, month - 1, day); + return isValidDate(d) && d.getMonth() === month - 1 ? d : null; + } +} + + +export class NaturalDateStrategy implements DateParsingStrategy { + readonly name = "Natural language"; + private static readonly NATURAL_REGEX = + /^(\d{1,2})\s+(\w{3,9})\s+(\d{4})$|^(\w{3,9})\s+(\d{1,2}),?\s+(\d{4})$/i; + + canParse(value: string): boolean { + return NaturalDateStrategy.NATURAL_REGEX.test(value.trim()); + } + + parse(value: string): Date | null { + const d = new Date(value.trim()); + return isValidDate(d) ? d : null; + } +} + + +export class UnixTimestampStrategy implements DateParsingStrategy { + readonly name = "Unix timestamp"; + private static readonly UNIX_REGEX = /^\d{10,13}$/; + + canParse(value: string): boolean { + return UnixTimestampStrategy.UNIX_REGEX.test(value.trim()); + } + + parse(value: string): Date | null { + const num = parseInt(value.trim(), 10); + if (!Number.isFinite(num)) return null; + // 10-digit = seconds, 13-digit = milliseconds + const ms = value.trim().length <= 10 ? num * 1000 : num; + const d = new Date(ms); + return isValidDate(d) ? d : null; + } +} + + +const DEFAULT_DATE_STRATEGIES: DateParsingStrategy[] = [ + new IsoDateStrategy(), + new UsDateStrategy(), + new EuDateStrategy(), + new NaturalDateStrategy(), + new UnixTimestampStrategy(), +]; + + +export function parseDate( + value: string, + strategies: DateParsingStrategy[] = DEFAULT_DATE_STRATEGIES, +): Date | null { + if (!value || !value.trim()) return null; + const trimmed = value.trim(); + + for (const strategy of strategies) { + if (strategy.canParse(trimmed)) { + const result = strategy.parse(trimmed); + if (result) return result; + } + } + return null; +} + +export function detectDateStrategy( + sampleValues: string[], + strategies: DateParsingStrategy[] = DEFAULT_DATE_STRATEGIES, +): DateParsingStrategy | null { + if (!sampleValues.length) return null; + + let bestStrategy: DateParsingStrategy | null = null; + let bestScore = 0; + + for (const strategy of strategies) { + const matches = sampleValues.filter( + (v) => v.trim() && strategy.canParse(v.trim()), + ).length; + const nonEmpty = sampleValues.filter((v) => v.trim()).length; + const score = nonEmpty > 0 ? matches / nonEmpty : 0; + if (score > bestScore) { + bestScore = score; + bestStrategy = strategy; + } + } + + return bestScore >= 0.5 ? bestStrategy : null; +} + + +export function stripBom(text: string): string { + return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; +} + + +type CsvDelimiter = "," | ";" | "\t"; + +const SUPPORTED_DELIMITERS: CsvDelimiter[] = [",", ";", "\t"]; + + +function countDelimiterOccurrences(line: string, delimiter: string): number { + let count = 0; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (char === '"') { + if (inQuotes && line[i + 1] === '"') { + i++; // skip escaped quote + } else { + inQuotes = !inQuotes; + } + } else if (char === delimiter && !inQuotes) { + count++; + } + } + return count; +} + + +export function detectDelimiter(text: string): CsvDelimiter { + const sampleLines = text.split(/\r?\n/).slice(0, 10).filter(Boolean); + if (!sampleLines.length) return ","; + + let bestDelimiter: CsvDelimiter = ","; + let bestScore = -1; + + for (const delimiter of SUPPORTED_DELIMITERS) { + const counts = sampleLines.map((line) => + countDelimiterOccurrences(line, delimiter), + ); + + if (counts[0] === 0) continue; + + const avg = counts.reduce((a, b) => a + b, 0) / counts.length; + const variance = + counts.reduce((sum, c) => sum + Math.pow(c - avg, 2), 0) / counts.length; + const consistency = variance === 0 ? 1 : 1 / (1 + variance); + const score = avg * consistency; + if (score > bestScore) { + bestScore = score; + bestDelimiter = delimiter; + } + } + + return bestDelimiter; +} + + +const BINARY_SIGNATURES = ["%PDF-", "\x89PNG", "\xFF\xD8\xFF", "PK\x03\x04"]; + +export const MAX_CSV_ROWS = 5000; + + +export const MAX_CSV_SIZE_BYTES = 10 * 1024 * 1024; + + +export function isBinaryContent(text: string): boolean { + const sample = text.slice(0, 512); + return BINARY_SIGNATURES.some((sig) => sample.includes(sig)); +} + + +export function tokenizeCsv( + rawText: string, + delimiter?: CsvDelimiter, +): string[][] { + const text = stripBom(rawText); + const delim = delimiter || detectDelimiter(text); + + const rows: string[][] = []; + let current: string[] = []; + let field = ""; + let inQuotes = false; + const len = Math.min(text.length, MAX_CSV_SIZE_BYTES); + for (let i = 0; i < len; i++) { + const char = text[i]; + const next = text[i + 1]; + + if (char === '"') { + if (inQuotes && next === '"') { + field += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + continue; + } + + if (char === delim && !inQuotes) { + current.push(field.trim()); + field = ""; + continue; + } + + if (char === "\r" && !inQuotes) { + // Consume \r\n as a single line break + if (next === "\n") i++; + if (field.length || current.length) { + current.push(field.trim()); + rows.push(current); + current = []; + field = ""; + } + continue; + } + + if (char === "\n" && !inQuotes) { + if (field.length || current.length) { + current.push(field.trim()); + rows.push(current); + current = []; + field = ""; + } + continue; + } + + field += char; + } + + + if (field.length || current.length) { + current.push(field.trim()); + rows.push(current); + } + + return rows.filter((r) => r.length > 0); +} + + +export const AUTO_FIELD_RULES: Record = { + key: [ + "title", "task name", "task", "name", "summary", "subject", + "issue", "ticket", "item", "work item", "task title", + ], + description: [ + "description", "details", "notes", "body", "task description", + "content", "remark", "remarks", + ], + status: [ + "status", "state", "stage", "progress", "section", "phase", + "workflow", "column", + ], + assignees: [ + "assignee", "assigned to", "owner", "member", "assigned", + "user email", "responsible", "team member", "user", + ], + dueDate: [ + "due date", "due_date", "due", "deadline", "end date", + "target date", "finish date", "due by", + ], + startDate: [ + "start date", "start_date", "start", "begin date", + "begin", "opened", "planned start", + ], + priority: [ + "priority", "urgency", "severity", "level", "importance", + ], + estimation: [ + "estimate", "estimated hours", "estimation", "points", + "story points", "effort", "hours", + ], + labels: [ + "label", "labels", "tag", "tags", "category", "categories", + ], +}; + + +export function suggestFieldMapping(header: string): string | null { + if (!header) return null; + const normalized = header.trim().toLowerCase().replace(/[_-]+/g, " "); + + for (const [targetField, synonyms] of Object.entries(AUTO_FIELD_RULES)) { + if (synonyms.some((s) => s === normalized)) { + return targetField; + } + } + return null; +} + + +export function autoMapHeaders( + headers: string[], +): Record { + const usedTargets = new Set(); + const result: Record = {}; + + for (const header of headers) { + const suggested = suggestFieldMapping(header); + if (suggested && !usedTargets.has(suggested)) { + result[header] = suggested; + usedTargets.add(suggested); + } else { + result[header] = null; + } + } + + return result; +} diff --git a/worklenz-backend/src/services/import-providers/csv-provider.ts b/worklenz-backend/src/services/import-providers/csv-provider.ts index da820a001..6258188dd 100644 --- a/worklenz-backend/src/services/import-providers/csv-provider.ts +++ b/worklenz-backend/src/services/import-providers/csv-provider.ts @@ -1,51 +1,14 @@ import createHttpError from "http-errors"; import { ImportProvider, ProviderResult } from "./provider-types"; import { ImportJob, StageTaskRow } from "../imports-service"; - -// Upper bound on CSV payload we will parse. Keeps the single-pass loop below -// from being driven by an attacker-controlled length (CodeQL loop-bound check). -const MAX_CSV_LENGTH = 10 * 1024 * 1024; // 10 MB - -function parseCsv(text: string): string[][] { - const rows: string[][] = []; - let current: string[] = []; - let field = ""; - let inQuotes = false; - const len = Math.min(text.length, MAX_CSV_LENGTH); - for (let i = 0; i < len; i++) { - const char = text[i]; - const next = text[i + 1]; - if (char === '"') { - if (inQuotes && next === '"') { - field += '"'; - i++; // skip escaped quote - } else { - inQuotes = !inQuotes; - } - continue; - } - if (char === "," && !inQuotes) { - current.push(field.trim()); - field = ""; - continue; - } - if ((char === "\n" || char === "\r") && !inQuotes) { - if (field.length || current.length) { - current.push(field.trim()); - rows.push(current); - current = []; - field = ""; - } - continue; - } - field += char; - } - if (field.length || current.length) { - current.push(field.trim()); - rows.push(current); - } - return rows.filter((r) => r.length > 0); -} +import { + tokenizeCsv, + isBinaryContent, + MAX_CSV_ROWS, + MAX_CSV_SIZE_BYTES, + autoMapHeaders, + parseDate, +} from "./csv-parser-strategies"; export default class CsvProvider implements ImportProvider { name = "csv"; @@ -56,45 +19,105 @@ export default class CsvProvider implements ImportProvider { ): Promise { const csvText = typeof payload?.csvText === "string" ? payload.csvText : ""; if (!csvText.trim()) return { tasks: [], fields: [] }; - if (csvText.length > MAX_CSV_LENGTH) { - throw createHttpError(400, "The CSV file is too large. Please upload a file smaller than 10 MB."); + + const byteLength = Buffer.byteLength(csvText, "utf-8"); + if (byteLength > MAX_CSV_SIZE_BYTES) { + throw createHttpError( + 400, + `The CSV file exceeds the maximum allowed size of ${MAX_CSV_SIZE_BYTES / (1024 * 1024)} MB. Please reduce the file size and try again.` + ); } - // Reject binary content (PDF, images, etc.) — check for non-text byte sequences - const sample = csvText.slice(0, 512); - const binarySignatures = ["%PDF-", "\x89PNG", "\xFF\xD8\xFF", "PK\x03\x04"]; - if (binarySignatures.some(sig => sample.includes(sig))) { - throw createHttpError(400, "The uploaded file does not appear to be a CSV. Please upload a valid CSV file."); + if (isBinaryContent(csvText)) { + throw createHttpError( + 400, + "The uploaded file does not appear to be a CSV. Please upload a valid CSV file." + ); + } + + const parsed = tokenizeCsv(csvText); + + if (!parsed.length) { + throw createHttpError( + 400, + "The CSV file is empty. Please upload a file with at least a header row and one data row." + ); } - const parsed = parseCsv(csvText); - if (!parsed.length) throw createHttpError(400, "The CSV file is empty. Please upload a file with at least a header row and one data row."); const [headerRow, ...dataRows] = parsed; const headers = headerRow.map((h) => h.trim()).filter(Boolean); - if (!headers.length) throw createHttpError(400, "The CSV file has no column headers. Please ensure the first row contains column names."); - if (!dataRows.length) throw createHttpError(400, "The CSV file has no data rows. Please ensure there is at least one row of data below the header."); + + if (!headers.length) { + throw createHttpError( + 400, + "The CSV file has no column headers. Please ensure the first row contains column names." + ); + } + + if (!dataRows.length) { + throw createHttpError( + 400, + "The CSV file has no data rows. Please ensure there is at least one row of data below the header." + ); + } + + if (dataRows.length > MAX_CSV_ROWS) { + throw createHttpError( + 400, + `The CSV file contains ${dataRows.length} rows, which exceeds the maximum of ${MAX_CSV_ROWS} tasks per import. Please split the file into smaller batches.` + ); + } + + const autoMappings = autoMapHeaders(headers); + + const titleHeader = headers.find((h) => autoMappings[h] === "key") || headers[0]; + const tasks: StageTaskRow[] = dataRows.map((row, idx) => { const record: Record = {}; headers.forEach((h, colIdx) => { record[h] = row[colIdx] || ""; }); + + const title = record[titleHeader]?.trim() || `Row ${idx + 1}`; + + const descHeader = headers.find((h) => autoMappings[h] === "description"); + const description = descHeader ? record[descHeader] || null : null; + + const statusHeader = headers.find((h) => autoMappings[h] === "status"); + const status = statusHeader ? record[statusHeader] || null : null; + + const assigneeHeader = headers.find((h) => autoMappings[h] === "assignees"); + const assignee = assigneeHeader ? record[assigneeHeader] || null : null; + + const dueHeader = headers.find((h) => autoMappings[h] === "dueDate"); + const dueRaw = dueHeader ? record[dueHeader] || null : null; + const dueParsed = dueRaw ? parseDate(dueRaw) : null; + const dueAt = dueParsed ? dueParsed.toISOString() : dueRaw; + + const startHeader = headers.find((h) => autoMappings[h] === "startDate"); + const startRaw = startHeader ? record[startHeader] || null : null; + const startParsed = startRaw ? parseDate(startRaw) : null; + const startAt = startParsed ? startParsed.toISOString() : startRaw; + return { source_task_id: `csv-${idx + 1}`, - title: record[headers[0]] || `Row ${idx + 1}`, - description: record.description || record["Description"] || null, - status: record.status || record["Status"] || null, - due_at: record.due_at || record["Due date"] || null, - start_at: record.start_at || record["Start date"] || null, - worktype: record.type || record["Type"] || null, - assignee_source_id: record.assignee || record["Assignee"] || null, + title, + description, + status, + due_at: dueAt, + start_at: startAt, + assignee_source_id: assignee, raw: record, }; }); + const fields = headers.map((h) => ({ source_field: h, - target_field: h, + target_field: autoMappings[h] || h, + required: autoMappings[h] === "key", include: true, })); + return { fields, tasks }; } } diff --git a/worklenz-backend/src/services/imports/field-mapping.ts b/worklenz-backend/src/services/imports/field-mapping.ts index 3e114cfed..8468b9c55 100644 --- a/worklenz-backend/src/services/imports/field-mapping.ts +++ b/worklenz-backend/src/services/imports/field-mapping.ts @@ -221,27 +221,64 @@ export const TARGET_FIELD_ALIASES: Record = { taskname: "key", tasktitle: "key", summary: "key", + subject: "key", + issue: "key", + ticket: "key", + item: "key", + workitem: "key", description: "description", + details: "description", + notes: "description", + body: "description", + remark: "description", + remarks: "description", progress: "progress", status: "status", + state: "status", + stage: "status", + section: "status", + workflow: "status", + column: "status", assignee: "assignees", assignees: "assignees", member: "assignees", members: "assignees", + assignedto: "assignees", + responsible: "assignees", label: "labels", labels: "labels", + tag: "labels", + tags: "labels", + category: "labels", + categories: "labels", phase: "phase", priority: "priority", + urgency: "priority", + severity: "priority", + importance: "priority", + level: "priority", timetracking: "timeTracking", estimation: "estimation", estimate: "estimation", + hours: "estimation", + storypoints: "estimation", + effort: "estimation", + points: "estimation", startdate: "startDate", start: "startDate", startat: "startDate", startatdate: "startDate", + begindate: "startDate", + opened: "startDate", + plannedstart: "startDate", duedate: "dueDate", due: "dueDate", dueat: "dueDate", + deadline: "dueDate", + finishdate: "dueDate", + enddate: "dueDate", + dueby: "dueDate", + targetdate: "dueDate", completeddate: "completedDate", completed: "completedDate", completedat: "completedDate", diff --git a/worklenz-frontend/src/lib/settings/settings-constants.ts b/worklenz-frontend/src/lib/settings/settings-constants.ts index f045a9338..e473d7627 100644 --- a/worklenz-frontend/src/lib/settings/settings-constants.ts +++ b/worklenz-frontend/src/lib/settings/settings-constants.ts @@ -19,11 +19,15 @@ import { DollarCircleOutlined, ApiOutlined, UsergroupAddOutlined, + ImportOutlined, } from '@/shared/antd-imports'; import { MobileOutlined, SwapOutlined } from '@ant-design/icons'; import React, { ReactNode, lazy } from 'react'; import { ILocalSession } from '@/types/auth/local-session.types'; import { hasBusinessFeatureAccess } from '@/ee/utils/subscription-utils'; +const ImportExportSettings = lazy( + () => import('@/pages/settings/import-export/ImportExportSettings') +); const ProfileSettings = lazy(() => import('../../pages/settings/profile/profile-settings')); const NotificationsSettings = lazy( () => import('../../pages/settings/notifications/notifications-settings') @@ -316,6 +320,17 @@ export const settingsItems: SettingMenuItem[] = [ element: React.createElement(IntegrationsSettings), adminOnly: true, }, + { + key: 'import-export', + name: 'import-export', + defaultValue: 'Import & Export', + endpoint: 'import-export', + groupKey: 'system-integrations', + groupDefaultValue: 'System & Integrations', + icon: React.createElement(ImportOutlined), + element: React.createElement(ImportExportSettings), + adminOnly: true, + }, { key: 'configuration', name: 'configuration', diff --git a/worklenz-frontend/src/pages/projects/projectView/ProjectImportModal.tsx b/worklenz-frontend/src/pages/projects/projectView/ProjectImportModal.tsx index bf521f734..879c536fa 100644 --- a/worklenz-frontend/src/pages/projects/projectView/ProjectImportModal.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/ProjectImportModal.tsx @@ -1,13 +1,28 @@ import React from 'react'; import ImportSourceModal from '@/pages/settings/import-export/ImportSourceModal'; +import { AVAILABLE_IMPORT_SOURCES } from '@/pages/settings/import-export/import-source-modal/source-icons'; +import type { ImportSource } from '@/pages/settings/import-export/import-source-modal/types'; interface ProjectImportModalProps { open: boolean; onClose: () => void; + defaultSource?: string | ImportSource | null; } -export const ProjectImportModal: React.FC = ({ open, onClose }) => { - return ; +export const ProjectImportModal: React.FC = ({ + open, + onClose, + defaultSource = null, +}) => { + const resolvedSource = React.useMemo(() => { + if (!defaultSource) return null; + if (typeof defaultSource === 'string') { + return AVAILABLE_IMPORT_SOURCES.find(s => s.key === defaultSource) || null; + } + return defaultSource; + }, [defaultSource]); + + return ; }; export default ProjectImportModal; diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx index 503ac5514..93820ee2b 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx @@ -58,6 +58,7 @@ import { getGroupIdByGroupedColumn } from '@/services/task-list/taskList.service import logger from '@/utils/errorLogger'; import ImportTaskTemplate from '@/components/task-templates/import-task-template'; import { ProjectSettingsModal } from '@/components/projects/project-settings-modal/project-settings-modal'; +import ProjectImportModal from './ProjectImportModal'; import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice'; import useIsProjectManager from '@/hooks/useIsProjectManager'; import useTabSearchParam from '@/hooks/useTabSearchParam'; @@ -96,6 +97,7 @@ const ProjectViewHeader = memo(() => { const [subscriptionLoading, setSubscriptionLoading] = useState(false); const projectTasksFetching = useAppSelector(state => state.taskManagement.loading); const [isBackButtonHovered, setIsBackButtonHovered] = useState(false); + const [csvImportModalOpen, setCsvImportModalOpen] = useState(false); const subscriptionTimeoutRef = useRef(null); @@ -257,6 +259,18 @@ const ProjectViewHeader = memo(() => { } }, [dispatch, currentSession]); + const handleImportFromCsv = useCallback(() => { + setCsvImportModalOpen(true); + }, []); + + const handleCsvImportModalClose = useCallback(() => { + setCsvImportModalOpen(false); + // Refresh project data after import + if (projectId) { + dispatch(fetchTasksV3(projectId)); + } + }, [dispatch, projectId]); + const handleNavigateToProjects = useCallback(() => { navigate('/worklenz/projects'); }, [navigate]); @@ -287,8 +301,20 @@ const ProjectViewHeader = memo(() => {
), }, + { + key: 'import-csv', + label: ( +
+ {t('importFromCsv', { defaultValue: 'Import from CSV' })} +
+ ), + }, ], - [handleImportTaskTemplate, t] + [handleImportTaskTemplate, handleImportFromCsv, t] ); const projectAttributes = useMemo(() => { @@ -586,6 +612,11 @@ const ProjectViewHeader = memo(() => { )} {createPortal(, document.body, 'import-task-template')} {createPortal(, document.body, 'save-project-as-template')} + ); }); diff --git a/worklenz-frontend/src/pages/settings/import-export/import-source-modal/components/CsvMappingStepsContent.tsx b/worklenz-frontend/src/pages/settings/import-export/import-source-modal/components/CsvMappingStepsContent.tsx index 73320baff..c9a69f377 100644 --- a/worklenz-frontend/src/pages/settings/import-export/import-source-modal/components/CsvMappingStepsContent.tsx +++ b/worklenz-frontend/src/pages/settings/import-export/import-source-modal/components/CsvMappingStepsContent.tsx @@ -444,6 +444,25 @@ export const CsvMappingStepsContent: React.FC = ({ })} + {/* Validation: warn if no column is mapped to 'key' (Task Name) */} + {!Object.values(fieldMappings).includes('key') && ( +
+ ⚠ {t('importStep.taskNameRequired', { + defaultValue: 'At least one column must be mapped to "Task Name" to proceed.', + })} +
+ )} +
= ({ {t('importStep.worklenzFields', { defaultValue: 'Worklenz fields' })} - + + {t('importStep.autoMatch', { defaultValue: 'Match' })} + + {t('importStep.includeInImport', { defaultValue: 'Include in import' })}
@@ -586,7 +608,29 @@ export const CsvMappingStepsContent: React.FC = ({ ); })()} - + + {(() => { + const mapped = fieldMappings[col] || ''; + const isAutoMatched = !!mapped && knownTargetKeys.has( + mapped.toLowerCase().replace(/[^a-z0-9]/g, '') + ); + return isAutoMatched ? ( + + ✓ Auto + + ) : null; + })()} + + setIncludeInImport(i => ({ ...i, [col]: e.target.checked }))} diff --git a/worklenz-frontend/src/pages/settings/import-export/import-source-modal/components/CsvReviewStepContent.tsx b/worklenz-frontend/src/pages/settings/import-export/import-source-modal/components/CsvReviewStepContent.tsx index cc068bc0e..43edf50f2 100644 --- a/worklenz-frontend/src/pages/settings/import-export/import-source-modal/components/CsvReviewStepContent.tsx +++ b/worklenz-frontend/src/pages/settings/import-export/import-source-modal/components/CsvReviewStepContent.tsx @@ -90,6 +90,22 @@ export const CsvReviewStepContent: React.FC = ({ ]; }, [providerKey, t]); + const handleDownloadConfig = (e: React.MouseEvent) => { + e.preventDefault(); + const config = { + spaceName: reviewSpaceName, + fieldMappings, + statusValueMapping, + }; + const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'worklenz-import-config.json'; + a.click(); + URL.revokeObjectURL(url); + }; + const cardStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', @@ -234,6 +250,97 @@ export const CsvReviewStepContent: React.FC = ({ + + {/* Preview Table: first 5 rows of CSV data with validation badges */} + {csvRows.length > 0 && ( +
+ + {t('importStep.previewTitle', { + defaultValue: 'Data preview (first {{count}} rows)', + count: Math.min(csvRows.length, 5), + })} + +
+ ✓ {t('importStep.readyToImport', { + defaultValue: '{{count}} tasks ready to import with {{mapped}} mapped fields', + count: workItems, + mapped: mappedFields, + })} +
+
+ + + + + {Object.entries(fieldMappings) + .filter(([, target]) => !!target) + .slice(0, 6) + .map(([source, target]) => ( + + ))} + + + + {csvRows.slice(0, 5).map((row, rowIdx) => ( + + + {Object.entries(fieldMappings) + .filter(([, target]) => !!target) + .slice(0, 6) + .map(([source]) => { + const cellRaw = row[source]; + const cellValue = cellRaw !== undefined && cellRaw !== null ? String(cellRaw) : ''; + const isEmpty = !cellValue.trim(); + return ( + + ); + })} + + ))} + +
# + {target} +
+ ← {source} +
+
{rowIdx + 1} + {isEmpty ? ( + — + ) : ( + cellValue + )} +
+
+ {csvRows.length > 5 && ( + + {t('importStep.previewMore', { + defaultValue: '… and {{remaining}} more rows', + remaining: csvRows.length - 5, + })} + + )} +
+ )} + +
+ + {t('importStep.downloadConfiguration', { + defaultValue: 'Download a configuration file', + })} + {' '} + {t('importStep.downloadConfigurationSuffix', { + defaultValue: 'to use the same space preferences in your next import.', + })} +
+