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: (
+