import React, { useEffect, useMemo, useRef, useState } from "react"; import { Alert, Box, Button, Typography } from "@mui/material"; import { PageSection } from "../layout/PageLayout"; import { api } from "../api"; import { Company, JobApplication } from "../types"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; import { checkSessionCookie, getSessionContext, isSessionInvalidated, SESSION_INVALIDATED_EVENT } from "../sessionBoundary"; // savedAt is omitted alongside id: both are assigned by the server on create, so an imported // row has no business supplying one. type ImportJob = Omit & { company: Pick; }; type ImportFields = Record; type PreparedImport = { company: ImportFields; job: ImportFields }; type ImportState = | { kind: "checking" } | { kind: "running"; count: number; total: number } | { kind: "invalid"; row?: number } | { kind: "stopped"; row: number; count: number; total: number }; class InvalidImport extends Error { constructor(readonly row?: number) { super("Invalid import"); } } function importRecord(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) throw new InvalidImport(); return value as Record; } function importAlias(record: Record, name: string, parse: (value: unknown) => T): T | undefined { const pascal = name[0].toUpperCase() + name.slice(1); const camelPresent = Object.prototype.hasOwnProperty.call(record, name); const pascalPresent = Object.prototype.hasOwnProperty.call(record, pascal); const camel = camelPresent ? parse(record[name]) : undefined; const exported = pascalPresent ? parse(record[pascal]) : undefined; if (camelPresent && pascalPresent && JSON.stringify(camel) !== JSON.stringify(exported)) throw new InvalidImport(); return camelPresent ? camel : exported; } function importText(value: unknown): string | null { if (value !== null && typeof value !== "string") throw new InvalidImport(); return value; } function importRequiredText(value: unknown): string { if (typeof value !== "string" || !value.trim()) throw new InvalidImport(); return value; } function importDate(value: unknown): string | null { const text = importText(value); if (text === null) return null; const match = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,16}))?)?(?:Z|([+-])(\d{2}):(\d{2}))?)?$/.exec(text); const instant = Date.parse(text); if (!match || !Number.isFinite(instant)) throw new InvalidImport(); const [, year, month, day, hour, minute, second, , offset, offsetHour, offsetMinute] = match; if (Number(year) < 1 || Number(month) < 1 || Number(month) > 12 || Number(day) < 1 || Number(day) > new Date(Date.UTC(Number(year), Number(month), 0)).getUTCDate() || Number(hour ?? 0) > 23 || Number(minute ?? 0) > 59 || Number(second ?? 0) > 59 || Number(offsetHour ?? 0) > 14 || Number(offsetMinute ?? 0) > 59 || Number(offsetHour) === 14 && Number(offsetMinute) !== 0) throw new InvalidImport(); // DateTime offsets must fit its year range; no-zone values retain unspecified local-time semantics. if (offset && (instant < Date.parse("0001-01-01T00:00:00Z") || instant > Date.parse("9999-12-31T23:59:59.999Z"))) throw new InvalidImport(); return text; } function importNumber(value: unknown): number | null { // decimal.MaxValue is 2^96 - 1; JavaScript rounds that bound up, so equality must also be rejected. if (value !== null && (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) >= 2 ** 96)) throw new InvalidImport(); return value; } function importCompany(value: unknown): ImportFields { const source = importRecord(value); const name = importAlias(source, "name", importRequiredText); if (name === undefined) throw new InvalidImport(); const company: ImportFields = { name }; for (const field of ["location", "source"]) { const text = importAlias(source, field, importText); if (text !== undefined) company[field] = text; } return company; } function prepareImport(text: string): PreparedImport[] { let parsed: unknown; try { parsed = JSON.parse(text); } catch { throw new InvalidImport(); } if (!Array.isArray(parsed)) throw new InvalidImport(); return parsed.map((value, index) => { try { const source = importRecord(value); const company = importAlias(source, "company", importCompany); const jobTitle = importAlias(source, "jobTitle", importRequiredText); if (!company || jobTitle === undefined) throw new InvalidImport(); const job: ImportFields = { jobTitle }; for (const field of ["status", "location", "salary", "salaryCurrency", "salaryPeriod", "nextAction", "notes", "description", "translatedDescription", "descriptionLanguage", "tags", "coverLetterText", "jobUrl", "source", "countryCode"]) { const text = importAlias(source, field, importText); if (text !== undefined) job[field] = text; } for (const field of ["dateApplied", "followUpAt", "feedbackRequestedAt", "deadline"]) { const date = importAlias(source, field, importDate); if (date !== undefined) job[field] = date; } for (const field of ["salaryMin", "salaryMax"]) { const number = importAlias(source, field, importNumber); if (number !== undefined) job[field] = number; } return { company, job }; } catch { throw new InvalidImport(index + 1); } }); } function downloadFile(filename: string, content: BlobPart, mime: string) { const blob = new Blob([content], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); setTimeout(() => URL.revokeObjectURL(url), 0); } function parseCsv(text: string): string[][] { const rows: string[][] = []; let row: string[] = []; let cur = ""; let i = 0; let inQuotes = false; const pushCell = () => { row.push(cur); cur = ""; }; const pushRow = () => { if (row.length === 1 && row[0] === "" && rows.length > 0) { row = []; return; } rows.push(row); row = []; }; while (i < text.length) { const ch = text[i]; if (inQuotes) { if (ch === '"') { const next = text[i + 1]; if (next === '"') { cur += '"'; i += 2; continue; } inQuotes = false; i += 1; continue; } cur += ch; i += 1; continue; } if (ch === '"') { inQuotes = true; i += 1; continue; } if (ch === ",") { pushCell(); i += 1; continue; } if (ch === "\r") { i += 1; continue; } if (ch === "\n") { pushCell(); pushRow(); i += 1; continue; } cur += ch; i += 1; } pushCell(); pushRow(); return rows; } function parseDateDMY(s: string): string | null { const v = (s || "").trim(); if (!v) return null; const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(v); if (!m) return null; const dd = Number(m[1]); const mm = Number(m[2]); const yyyy = Number(m[3]); if (!yyyy || mm < 1 || mm > 12 || dd < 1 || dd > 31) return null; return `${String(yyyy).padStart(4, "0")}-${String(mm).padStart(2, "0")}-${String(dd).padStart(2, "0")}`; } function csvToImportJobs(csvText: string): ImportJob[] { const rows = parseCsv(csvText); if (!rows.length) return []; const header = rows[0].map((h) => h.trim()); const idx = (name: string) => header.findIndex((h) => h.toLowerCase() === name.toLowerCase()); const iJobTitle = idx("Job Title"); const iCompany = idx("Company"); const iLocation = idx("Location"); const iSource = idx("Source"); const iDateApplied = idx("Date Applied"); const iMethod = idx("Application Method"); const iCover = idx("Cover Letter / Resume"); const iResp = idx("Response Received"); const iRespDate = idx("Response Date"); const iStatus = idx("Status"); const iNotes = idx("Notes"); const iInterviewDate = idx("Interview Date"); const out: ImportJob[] = []; for (let r = 1; r < rows.length; r++) { const row = rows[r]; const get = (i: number) => (i >= 0 ? (row[i] ?? "").trim() : ""); const jobTitle = get(iJobTitle); const companyName = get(iCompany) || jobTitle; if (!jobTitle && !companyName) continue; const rawStatus = get(iStatus); const status = rawStatus === "Follow-up Needed" ? "Waiting" : rawStatus || "Applied"; const dateApplied = parseDateDMY(get(iDateApplied)) ?? new Date().toISOString().slice(0, 10); const responseReceived = /^yes$/i.test(get(iResp)); const responseDate = parseDateDMY(get(iRespDate)) ?? undefined; const interviewDate = parseDateDMY(get(iInterviewDate)) ?? ""; const notesParts: string[] = []; const baseNotes = get(iNotes); if (baseNotes) notesParts.push(baseNotes); const method = get(iMethod); if (method) notesParts.push(`Application Method: ${method}`); const cover = get(iCover); if (cover) notesParts.push(`Cover Letter / Resume: ${cover}`); if (interviewDate) notesParts.push(`Interview Date: ${interviewDate}`); if (responseDate) notesParts.push(`Response Date: ${responseDate}`); out.push({ jobTitle: jobTitle || "(missing title)", status, dateApplied, responseReceived, responseDate, notes: notesParts.join("\n") || undefined, coverLetterText: undefined, jobUrl: undefined, daysSince: 0, company: { name: companyName || "(missing company)", location: get(iLocation) || undefined, source: get(iSource) || undefined, }, location: get(iLocation) || undefined, salary: undefined, nextAction: status === "Waiting" ? "Follow up" : undefined, followUpAt: undefined, isDeleted: false, deletedAt: undefined, }); } return out; } export default function ImportExportJobs() { const { toast } = useToast(); const { t } = useI18n(); const [importing, setImporting] = useState(false); const [lastImportCount, setLastImportCount] = useState(null); const [importState, setImportState] = useState(null); const importInputRef = useRef(null); const csvInputRef = useRef(null); const importBody = useRef(null); const importSession = useRef({ mounted: true, busy: false, generation: 0 }); const currentTranslation = useRef(t); useEffect(() => { currentTranslation.current = t; }, [t]); useEffect(() => { const session = importSession.current; session.mounted = true; const invalidate = () => { session.generation += 1; }; window.addEventListener(SESSION_INVALIDATED_EVENT, invalidate); return () => { session.mounted = false; invalidate(); window.removeEventListener(SESSION_INVALIDATED_EVENT, invalidate); }; }, []); const exportAll = async (format: "json" | "csv") => { try { const stamp = new Date().toISOString().slice(0, 10); const url = `/export/jobs?format=${format}&includeDeleted=false`; const res = await api.get(url, { responseType: "blob" }); // Keep the CSV's UTF-8 BOM: decoding through Blob.text() would discard it. downloadFile(`job-tracker-export-${stamp}.${format}`, res.data, format === "json" ? "application/json" : "text/csv;charset=utf-8"); toast(t("exportedJobs", { format: format.toUpperCase() }), "success"); } catch { toast(t("exportFailed"), "error"); } }; const onImportFile = async (file: File | null) => { const session = importSession.current; checkSessionCookie(); if (!file || !session.mounted || session.busy || isSessionInvalidated()) return; session.busy = true; const context = getSessionContext(); const generation = session.generation; const active = () => { checkSessionCookie(); return session.mounted && generation === session.generation && !isSessionInvalidated() && getSessionContext() === context; }; if (importBody.current?.contains(document.activeElement)) importBody.current.focus(); setImporting(true); setLastImportCount(null); setImportState({ kind: "checking" }); let total = 0; let created = 0; let row = 1; try { const text = await file.text(); if (!active()) return; const jobs = prepareImport(text); total = jobs.length; setImportState({ kind: "running", count: created, total }); for (const j of jobs) { if (!active()) return; row = created + 1; const companyRes = await api.post("/companies", j.company); if (!active()) return; const company = companyRes.data; if (!Number.isInteger(company?.id) || company.id <= 0) throw new Error("Invalid company response"); // Only the normalized create allow-list reaches this request; exported identities and history never do. await api.post("/jobapplications", { ...j.job, companyId: company.id }); if (!active()) return; created += 1; setImportState({ kind: "running", count: created, total }); } setLastImportCount(created); setImportState(null); toast(currentTranslation.current("importedJobs", { count: created }), "success"); } catch (error) { if (!active()) return; if (error instanceof InvalidImport) setImportState({ kind: "invalid", row: error.row }); else if (total === 0) setImportState({ kind: "invalid" }); else { setLastImportCount(created); setImportState({ kind: "stopped", row, count: created, total }); } toast(currentTranslation.current(total > 0 ? "jobImportStopped" : "importFailedJson", { row, count: created, total }), "error"); } finally { if (active()) { session.busy = false; setImporting(false); if (importInputRef.current) importInputRef.current.value = ""; } } }; const onConvertCsv = async (file: File | null) => { if (!file) return; try { const text = await file.text(); const jobs = csvToImportJobs(text); const stamp = new Date().toISOString().slice(0, 10); downloadFile(`job-tracker-import-${stamp}.json`, JSON.stringify(jobs, null, 2), "application/json"); toast(t("convertedRows", { count: jobs.length }), "success"); } catch { toast(t("csvConversionFailed"), "error"); } }; const helper = useMemo(() => t("importExportBody"), [t]); return ( {t("jobImportScope")} void onImportFile(e.target.files?.[0] ?? null)} /> void onConvertCsv(e.target.files?.[0] ?? null)} /> {importState?.kind === "checking" || importState?.kind === "running" ? {importState.kind === "checking" ? t("jobImportChecking") : t("jobImportProgress", { count: importState.count, total: importState.total })} : null} {importState?.kind === "invalid" ? {importState.row === undefined ? t("jobImportInvalidFile") : t("jobImportInvalidRow", { row: importState.row })} : null} {importState?.kind === "stopped" ? {t("jobImportStopped", { row: importState.row, count: importState.count, total: importState.total })} {t("jobImportInspectBeforeRetry")} : null} {lastImportCount !== null && ( {t("lastImport", { count: lastImportCount })} )} ); }