429 lines
17 KiB
TypeScript
429 lines
17 KiB
TypeScript
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<JobApplication, "id" | "company" | "savedAt"> & {
|
|
company: Pick<Company, "name" | "location" | "source">;
|
|
};
|
|
|
|
type ImportFields = Record<string, string | number | null>;
|
|
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<string, unknown> {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new InvalidImport();
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function importAlias<T>(record: Record<string, unknown>, 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<number | null>(null);
|
|
const [importState, setImportState] = useState<ImportState | null>(null);
|
|
const importInputRef = useRef<HTMLInputElement>(null);
|
|
const csvInputRef = useRef<HTMLInputElement>(null);
|
|
const importBody = useRef<HTMLDivElement>(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<Company>("/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 (
|
|
<PageSection level="h3" title={t("importExportTitle")} description={helper}>
|
|
<Box ref={importBody} role="group" aria-label={t("importExportTitle")} aria-busy={importing} tabIndex={-1} sx={{ display: "grid", gap: 1.5, minWidth: 0 }}>
|
|
<Typography variant="body2" color="text.secondary">{t("jobImportScope")}</Typography>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
|
<Button variant="outlined" onClick={() => void exportAll("json")}>{t("exportJson")}</Button>
|
|
<Button variant="outlined" onClick={() => void exportAll("csv")}>{t("exportCsv")}</Button>
|
|
|
|
<Button variant="contained" disabled={importing} onClick={() => importInputRef.current?.click()}>
|
|
{importing ? t("profileUploading") : t("importJson")}
|
|
</Button>
|
|
<input ref={importInputRef} type="file" accept="application/json" hidden onChange={(e) => void onImportFile(e.target.files?.[0] ?? null)} />
|
|
|
|
<Button variant="outlined" onClick={() => csvInputRef.current?.click()}>
|
|
{t("convertCsvToImportJson")}
|
|
</Button>
|
|
<input ref={csvInputRef} type="file" accept=".csv,text/csv" hidden onChange={(e) => void onConvertCsv(e.target.files?.[0] ?? null)} />
|
|
|
|
</Box>
|
|
{importState?.kind === "checking" || importState?.kind === "running" ? <Typography role="status" variant="body2" color="text.secondary">
|
|
{importState.kind === "checking" ? t("jobImportChecking") : t("jobImportProgress", { count: importState.count, total: importState.total })}
|
|
</Typography> : null}
|
|
{importState?.kind === "invalid" ? <Alert severity="error">
|
|
{importState.row === undefined ? t("jobImportInvalidFile") : t("jobImportInvalidRow", { row: importState.row })}
|
|
</Alert> : null}
|
|
{importState?.kind === "stopped" ? <Alert severity="error">
|
|
{t("jobImportStopped", { row: importState.row, count: importState.count, total: importState.total })}
|
|
<Typography variant="body2" sx={{ mt: 0.5 }}>{t("jobImportInspectBeforeRetry")}</Typography>
|
|
</Alert> : null}
|
|
{lastImportCount !== null && (
|
|
<Typography role="status" variant="body2" color="text.secondary">
|
|
{t("lastImport", { count: lastImportCount })}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
</PageSection>
|
|
);
|
|
}
|