This commit is contained in:
@@ -31,3 +31,6 @@ yarn-error.log*
|
|||||||
.vercel
|
.vercel
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Local tooling
|
||||||
|
RenderWorker/
|
||||||
|
|||||||
@@ -951,6 +951,86 @@ export async function updateShotsSeqTimecodes(
|
|||||||
return { updated, skipped, errors };
|
return { updated, skipped, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Simple CSV shot import ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SimpleCsvRow {
|
||||||
|
shotCode: string;
|
||||||
|
seqTimecodeStart: string;
|
||||||
|
seqTimecodeEnd: string;
|
||||||
|
description: string;
|
||||||
|
action: "create" | "update" | "skip";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importShotsFromSimpleCsv(
|
||||||
|
projectId: string,
|
||||||
|
rows: SimpleCsvRow[]
|
||||||
|
): Promise<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] }> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||||||
|
throw new Error("Insufficient permissions");
|
||||||
|
}
|
||||||
|
|
||||||
|
const created: string[] = [];
|
||||||
|
const updated: string[] = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.action === "skip") { skipped.push(row.shotCode); continue; }
|
||||||
|
try {
|
||||||
|
const existing = await db.shot.findFirst({
|
||||||
|
where: { projectId, shotCode: row.shotCode },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db.shot.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
description: row.description || null,
|
||||||
|
seqTimecodeStart: row.seqTimecodeStart || null,
|
||||||
|
seqTimecodeEnd: row.seqTimecodeEnd || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
updated.push(row.shotCode);
|
||||||
|
} else if (row.action === "create") {
|
||||||
|
const parts = row.shotCode.split("_");
|
||||||
|
const scene = parts.length >= 3 ? parts[2] : (parts[1] ?? "000");
|
||||||
|
const episode = parts.length >= 4 ? parts[1] : null;
|
||||||
|
const maxNum = await db.shot.findFirst({
|
||||||
|
where: { projectId, scene, episode },
|
||||||
|
orderBy: { shotNumber: "desc" },
|
||||||
|
select: { shotNumber: true },
|
||||||
|
});
|
||||||
|
const shotNumber = (maxNum?.shotNumber ?? 0) + 10;
|
||||||
|
|
||||||
|
await db.shot.create({
|
||||||
|
data: {
|
||||||
|
projectId,
|
||||||
|
shotCode: row.shotCode,
|
||||||
|
scene,
|
||||||
|
episode,
|
||||||
|
shotNumber,
|
||||||
|
description: row.description || null,
|
||||||
|
seqTimecodeStart: row.seqTimecodeStart || null,
|
||||||
|
seqTimecodeEnd: row.seqTimecodeEnd || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
created.push(row.shotCode);
|
||||||
|
} else {
|
||||||
|
// action === "update" but shot not found
|
||||||
|
skipped.push(row.shotCode);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`${row.shotCode}: ${e instanceof Error ? e.message : "Unknown error"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/projects/${projectId}`);
|
||||||
|
return { created, updated, skipped, errors };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal: handle client requesting changes on a shot.
|
* Internal: handle client requesting changes on a shot.
|
||||||
* Resets shotApprovalStatus = PENDING, sharedWithClient = false.
|
* Resets shotApprovalStatus = PENDING, sharedWithClient = false.
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ import {
|
|||||||
FilePlus2,
|
FilePlus2,
|
||||||
Pencil,
|
Pencil,
|
||||||
Film,
|
Film,
|
||||||
|
FileSpreadsheet,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { parseEdlCsv, parsePictureTrackerCsv } from "@/lib/edl-utils";
|
import { parseEdlCsv, parsePictureTrackerCsv, parseSimpleCsv } from "@/lib/edl-utils";
|
||||||
import type { EdlImportRow, PictureTrackerRow } from "@/lib/edl-utils";
|
import type { EdlImportRow, PictureTrackerRow, SimpleCsvRow } from "@/lib/edl-utils";
|
||||||
import { importShotsFromEdl, updateShotsSeqTimecodes } from "@/actions/shots";
|
import { importShotsFromEdl, updateShotsSeqTimecodes, importShotsFromSimpleCsv } from "@/actions/shots";
|
||||||
|
|
||||||
interface EdlImportClientProps {
|
interface EdlImportClientProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -54,6 +55,15 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
|
|||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const [result, setResult] = useState<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] } | null>(null);
|
const [result, setResult] = useState<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] } | null>(null);
|
||||||
|
|
||||||
|
// ── Simple CSV state ────────────────────────────────────────────────────────
|
||||||
|
const scFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [scStep, setScStep] = useState<"input" | "preview" | "result">("input");
|
||||||
|
const [scCsvText, setScCsvText] = useState("");
|
||||||
|
const [scRows, setScRows] = useState<SimpleCsvRow[]>([]);
|
||||||
|
const [scErrors, setScErrors] = useState<string[]>([]);
|
||||||
|
const [scImporting, setScImporting] = useState(false);
|
||||||
|
const [scResult, setScResult] = useState<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] } | null>(null);
|
||||||
|
|
||||||
// ── Picture Tracker state ─────────────────────────────────────────────────
|
// ── Picture Tracker state ─────────────────────────────────────────────────
|
||||||
const ptFileInputRef = useRef<HTMLInputElement>(null);
|
const ptFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [ptStep, setPtStep] = useState<"input" | "preview" | "result">("input");
|
const [ptStep, setPtStep] = useState<"input" | "preview" | "result">("input");
|
||||||
@@ -128,6 +138,61 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
|
|||||||
setResult(null);
|
setResult(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Simple CSV handlers ─────────────────────────────────────────────────────
|
||||||
|
const handleScFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => setScCsvText(ev.target?.result as string ?? "");
|
||||||
|
reader.readAsText(file);
|
||||||
|
e.target.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScParse = useCallback(() => {
|
||||||
|
const { rows: parsed, errors } = parseSimpleCsv(scCsvText, existingShotCodes);
|
||||||
|
setScErrors(errors);
|
||||||
|
setScRows(parsed);
|
||||||
|
if (parsed.length > 0) setScStep("preview");
|
||||||
|
}, [scCsvText, existingShotCodes]);
|
||||||
|
|
||||||
|
const toggleScAction = (idx: number) => {
|
||||||
|
setScRows((prev) =>
|
||||||
|
prev.map((r, i) => {
|
||||||
|
if (i !== idx) return r;
|
||||||
|
const cycle: SimpleCsvRow["action"][] = ["create", "update", "skip"];
|
||||||
|
const next = cycle[(cycle.indexOf(r.action) + 1) % cycle.length];
|
||||||
|
return { ...r, action: next };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScImport = async () => {
|
||||||
|
setScImporting(true);
|
||||||
|
try {
|
||||||
|
const res = await importShotsFromSimpleCsv(projectId, scRows);
|
||||||
|
setScResult(res);
|
||||||
|
setScStep("result");
|
||||||
|
if (res.created.length + res.updated.length > 0) {
|
||||||
|
toast({
|
||||||
|
title: "Import complete",
|
||||||
|
description: `${res.created.length} created, ${res.updated.length} updated`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({ title: "Import failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setScImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScReset = () => {
|
||||||
|
setScStep("input");
|
||||||
|
setScCsvText("");
|
||||||
|
setScRows([]);
|
||||||
|
setScErrors([]);
|
||||||
|
setScResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
// ── Picture Tracker handlers ──────────────────────────────────────────────
|
// ── Picture Tracker handlers ──────────────────────────────────────────────
|
||||||
const handlePtFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handlePtFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -426,6 +491,193 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── SIMPLE CSV PANEL ──────────────────────────────────────────── */}
|
||||||
|
<div className="pt-6 border-t border-zinc-800 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-white">Import Shots from Simple CSV</h2>
|
||||||
|
<p className="text-sm text-zinc-500 mt-0.5">
|
||||||
|
Create or update shots from a CSV with <span className="font-mono text-zinc-300">Shot Name</span>, <span className="font-mono text-zinc-300">Time Code In</span>, <span className="font-mono text-zinc-300">Time Code Out</span>, <span className="font-mono text-zinc-300">Description</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scStep === "input" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium text-zinc-300">Paste CSV or upload file</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input ref={scFileInputRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleScFileUpload} />
|
||||||
|
<Button variant="outline" size="sm" className="gap-1.5 h-7" onClick={() => scFileInputRef.current?.click()}>
|
||||||
|
<Upload className="h-3.5 w-3.5" />
|
||||||
|
Upload .csv
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
value={scCsvText}
|
||||||
|
onChange={(e) => setScCsvText(e.target.value)}
|
||||||
|
placeholder={`Shot Name,Time Code In,Time Code Out,Description\nUNG_108_001_010,01:00:10:00,01:00:20:00,Hero wide shot`}
|
||||||
|
className="font-mono text-xs min-h-[200px] bg-zinc-950 border-zinc-700 resize-y"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-zinc-950 border border-zinc-800 p-3 text-xs text-zinc-400 space-y-1">
|
||||||
|
<p className="font-medium text-zinc-300">Expected format</p>
|
||||||
|
<p>Required: <span className="font-mono text-amber-400">Shot Name</span></p>
|
||||||
|
<p>Optional: <span className="font-mono text-zinc-400">Time Code In, Time Code Out, Description</span></p>
|
||||||
|
<p>Existing shots will be set to <span className="font-mono text-blue-400">update</span>; new shots to <span className="font-mono text-emerald-400">create</span>. Toggle per row in the preview.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleScParse} disabled={!scCsvText.trim()} className="gap-2">
|
||||||
|
<FileSpreadsheet className="h-4 w-4" />
|
||||||
|
Parse & Preview
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scStep === "preview" && (() => {
|
||||||
|
const scCreate = scRows.filter((r) => r.action === "create").length;
|
||||||
|
const scUpdate = scRows.filter((r) => r.action === "update").length;
|
||||||
|
const scSkip = scRows.filter((r) => r.action === "skip").length;
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-sm">
|
||||||
|
<FilePlus2 className="h-3.5 w-3.5" />
|
||||||
|
{scCreate} to create
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-blue-500/10 border border-blue-500/20 text-blue-400 text-sm">
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
{scUpdate} to update
|
||||||
|
</div>
|
||||||
|
{scSkip > 0 && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-zinc-500/10 border border-zinc-500/20 text-zinc-400 text-sm">
|
||||||
|
<SkipForward className="h-3.5 w-3.5" />
|
||||||
|
{scSkip} to skip
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scErrors.length > 0 && (
|
||||||
|
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-3 space-y-1">
|
||||||
|
<p className="text-xs font-medium text-red-400 flex items-center gap-1.5">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5" /> {scErrors.length} parse warning{scErrors.length !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
{scErrors.map((e, i) => <p key={i} className="text-xs text-red-300 pl-5">{e}</p>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-zinc-800 overflow-hidden">
|
||||||
|
<div className="grid grid-cols-[auto_1fr_1fr_1fr_2fr] text-xs font-medium text-zinc-500 uppercase tracking-wider bg-zinc-900 px-4 py-2.5 gap-4 border-b border-zinc-800">
|
||||||
|
<span>Action</span>
|
||||||
|
<span>Shot Code</span>
|
||||||
|
<span>Seq TC In</span>
|
||||||
|
<span>Seq TC Out</span>
|
||||||
|
<span>Description</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40 max-h-[400px] overflow-y-auto">
|
||||||
|
{scRows.map((row, i) => {
|
||||||
|
const ActionIcon = ACTION_ICONS[row.action];
|
||||||
|
return (
|
||||||
|
<div key={i} className="grid grid-cols-[auto_1fr_1fr_1fr_2fr] items-center gap-4 px-4 py-3 text-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleScAction(i)}
|
||||||
|
title="Click to cycle: create → update → skip"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded border text-xs font-medium transition-colors shrink-0",
|
||||||
|
ACTION_STYLES[row.action]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ActionIcon className="h-3 w-3" />
|
||||||
|
{row.action}
|
||||||
|
</button>
|
||||||
|
<span className="font-mono text-xs text-zinc-200 truncate">{row.shotCode}</span>
|
||||||
|
<span className="font-mono text-xs text-zinc-400">{row.seqTimecodeStart || "—"}</span>
|
||||||
|
<span className="font-mono text-xs text-zinc-400">{row.seqTimecodeEnd || "—"}</span>
|
||||||
|
<span className="text-xs text-zinc-500 truncate">{row.description || "—"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button variant="outline" onClick={handleScReset}>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1.5" /> Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleScImport}
|
||||||
|
disabled={scImporting || (scCreate + scUpdate === 0)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{scImporting ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||||
|
{scImporting ? "Importing…" : `Import ${scCreate + scUpdate} shot${scCreate + scUpdate !== 1 ? "s" : ""}`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{scStep === "result" && scResult && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-6 space-y-5">
|
||||||
|
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-emerald-400" />
|
||||||
|
Import complete
|
||||||
|
</h2>
|
||||||
|
{scResult.created.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-emerald-400 uppercase tracking-wide">Created ({scResult.created.length})</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{scResult.created.map((c) => (
|
||||||
|
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-emerald-300">{c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{scResult.updated.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-blue-400 uppercase tracking-wide">Updated ({scResult.updated.length})</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{scResult.updated.map((c) => (
|
||||||
|
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-blue-500/10 border border-blue-500/20 text-blue-300">{c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{scResult.skipped.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">Skipped ({scResult.skipped.length})</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{scResult.skipped.map((c) => (
|
||||||
|
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-zinc-800 border border-zinc-700 text-zinc-400">{c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{scResult.errors.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-red-400 uppercase tracking-wide flex items-center gap-1.5">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5" /> Errors ({scResult.errors.length})
|
||||||
|
</p>
|
||||||
|
{scResult.errors.map((e, i) => (
|
||||||
|
<p key={i} className="text-xs text-red-300 pl-5">{e}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" onClick={handleScReset}>Import Another</Button>
|
||||||
|
<Button onClick={() => router.push(`/projects/${projectId}`)}>Back to Project</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── PICTURE TRACKER PANEL ─────────────────────────────────────── */}
|
{/* ── PICTURE TRACKER PANEL ─────────────────────────────────────── */}
|
||||||
<div className="pt-6 border-t border-zinc-800 space-y-4">
|
<div className="pt-6 border-t border-zinc-800 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -184,3 +184,80 @@ export function parsePictureTrackerCsv(
|
|||||||
|
|
||||||
return { rows, errors };
|
return { rows, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Simple Shot CSV ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SimpleCsvRow {
|
||||||
|
shotCode: string;
|
||||||
|
seqTimecodeStart: string;
|
||||||
|
seqTimecodeEnd: string;
|
||||||
|
description: string;
|
||||||
|
action: "create" | "update" | "skip";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a simple shot CSV: Shot Name, Time Code In, Time Code Out, Description.
|
||||||
|
* Column names are matched case-insensitively with common aliases.
|
||||||
|
* Timecodes are validated but not required.
|
||||||
|
*/
|
||||||
|
export function parseSimpleCsv(
|
||||||
|
csvText: string,
|
||||||
|
existingCodes: Set<string>
|
||||||
|
): { rows: SimpleCsvRow[]; errors: string[] } {
|
||||||
|
const lines = csvText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||||
|
if (lines.length < 2) {
|
||||||
|
return { rows: [], errors: ["CSV must have a header row and at least one data row"] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawHeaders = parseCsvLine(lines[0]).map((h) => h.toLowerCase().trim());
|
||||||
|
const col = (names: string[]) => {
|
||||||
|
for (const name of names) {
|
||||||
|
const idx = rawHeaders.indexOf(name);
|
||||||
|
if (idx !== -1) return idx;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shotNameIdx = col(["shot name", "shot code", "shotcode", "shot", "name"]);
|
||||||
|
const tcInIdx = col(["time code in", "timecode in", "tc in", "tcin", "timecode start", "tc start"]);
|
||||||
|
const tcOutIdx = col(["time code out", "timecode out", "tc out", "tcout", "timecode end", "tc end"]);
|
||||||
|
const descIdx = col(["description", "desc", "notes", "note"]);
|
||||||
|
|
||||||
|
if (shotNameIdx === -1) {
|
||||||
|
return { rows: [], errors: ['CSV must contain a "Shot Name" (or "Shot Code") column'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: SimpleCsvRow[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const cells = parseCsvLine(lines[i]);
|
||||||
|
const get = (idx: number) =>
|
||||||
|
idx !== -1 && idx < cells.length ? (cells[idx] ?? "").trim() : "";
|
||||||
|
|
||||||
|
const shotCode = get(shotNameIdx);
|
||||||
|
if (!shotCode) { errors.push(`Row ${i + 1}: empty Shot Name — skipped`); continue; }
|
||||||
|
|
||||||
|
const tcIn = get(tcInIdx);
|
||||||
|
const tcOut = get(tcOutIdx);
|
||||||
|
|
||||||
|
if (tcIn && !isValidTimecode(tcIn)) {
|
||||||
|
errors.push(`Row ${i + 1} (${shotCode}): invalid TC In "${tcIn}" — skipped`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tcOut && !isValidTimecode(tcOut)) {
|
||||||
|
errors.push(`Row ${i + 1} (${shotCode}): invalid TC Out "${tcOut}" — skipped`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
shotCode,
|
||||||
|
seqTimecodeStart: tcIn,
|
||||||
|
seqTimecodeEnd: tcOut,
|
||||||
|
description: get(descIdx),
|
||||||
|
action: existingCodes.has(shotCode) ? "update" : "create",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rows, errors };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user