fix(settings): preserve safe job import compatibility and progress

This commit is contained in:
cesnimda
2026-10-20 09:30:05 +02:00
parent 5addcde1e2
commit 3028d8172c
16 changed files with 1348 additions and 39 deletions
@@ -1,12 +1,13 @@
import React, { useMemo, useRef, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Button, Typography } from "@mui/material";
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.
@@ -14,6 +15,105 @@ 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);
@@ -180,8 +280,25 @@ export default function ImportExportJobs() {
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 {
@@ -198,40 +315,61 @@ export default function ImportExportJobs() {
};
const onImportFile = async (file: File | null) => {
if (!file) return;
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();
const parsed = JSON.parse(text) as ImportJob[];
if (!Array.isArray(parsed)) throw new Error("Invalid format");
let created = 0;
for (const j of parsed) {
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;
await api.post("/jobapplications", {
jobTitle: j.jobTitle,
companyId: company.id,
status: j.status,
dateApplied: j.dateApplied,
location: j.location,
salary: j.salary,
nextAction: j.nextAction,
followUpAt: j.followUpAt,
jobUrl: j.jobUrl,
notes: j.notes,
coverLetterText: j.coverLetterText,
});
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);
toast(t("importedJobs", { count: created }), "success");
} catch {
toast(t("importFailedJson"), "error");
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 {
setImporting(false);
if (active()) {
session.busy = false;
setImporting(false);
if (importInputRef.current) importInputRef.current.value = "";
}
}
};
@@ -252,7 +390,8 @@ export default function ImportExportJobs() {
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>
@@ -267,8 +406,19 @@ export default function ImportExportJobs() {
</Button>
<input ref={csvInputRef} type="file" accept=".csv,text/csv" hidden onChange={(e) => void onConvertCsv(e.target.files?.[0] ?? null)} />
{lastImportCount !== null && (
<Typography sx={{ ml: 1, color: "text.secondary" }}>
</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>
)}