import { Exam } from "@/interfaces/exam"; import { diff, Diff } from "deep-diff"; const EXCLUDED_KEYS = new Set(["_id", "id", "createdAt", "createdBy", "entities", "isDiagnostic", "private", "requiresApproval", "exerciseID", "questionID"]); const PATH_LABELS: Record = { access: "Access Type", parts: "Parts", exercises: "Exercises", userSolutions: "User Solutions", words: "Words", options: "Options", prompt: "Prompt", text: "Text", audio: "Audio", script: "Script", difficulty: "Difficulty", shuffle: "Shuffle", solutions: "Solutions", variant: "Variant", prefix: "Prefix", suffix: "Suffix", topic: "Topic", allowRepetition: "Allow Repetition", maxWords: "Max Words", minTimer: "Timer", }; export function generateExamDifferences(oldExam: Exam, newExam: Exam): string[] { const differences = diff(oldExam, newExam) || []; return differences.map((change) => formatDifference(change)).filter(Boolean) as string[]; } function formatDifference(change: Diff): string | undefined { if (!change.path) return; if (change.path.some((segment) => EXCLUDED_KEYS.has(segment))) { return; } const pathString = pathToHumanReadable(change.path); switch (change.kind) { case "N": // New property/element return `• Added ${pathString} with value: ${formatValue(change.rhs)}\n`; case "D": // Deleted property/element return `• Removed ${pathString} which had value: ${formatValue(change.lhs)}\n`; case "E": // Edited property/element return `• Changed ${pathString} from ${formatValue(change.lhs)} to ${formatValue(change.rhs)}\n`; case "A": // Array change return formatArrayChange(change); default: return; } } function formatArrayChange(change: Diff): string | undefined { if (!change.path) return; if (change.path.some((segment) => EXCLUDED_KEYS.has(segment))) { return; } const pathString = pathToHumanReadable(change.path); const arrayChange = (change as any).item; const idx = (change as any).index; if (!arrayChange) return; switch (arrayChange.kind) { case "N": return `• Added an item at [#${idx + 1}] in ${pathString}: ${formatValue(arrayChange.rhs)}\n`; case "D": return `• Removed an item at [#${idx + 1}] in ${pathString}: ${formatValue(arrayChange.lhs)}\n`; case "E": return `• Edited an item at [#${idx + 1}] in ${pathString} from ${formatValue(arrayChange.lhs)} to ${formatValue(arrayChange.rhs)}\n`; case "A": return `• Complex array change at [#${idx + 1}] in ${pathString}: ${JSON.stringify(arrayChange)}\n`; default: return; } } function formatValue(value: any): string { if (value === null) return "null"; if (value === undefined) return "undefined"; if (typeof value === "object") { try { const sanitized = removeExcludedKeysDeep(value, EXCLUDED_KEYS); const renamed = renameKeysDeep(sanitized, PATH_LABELS); return JSON.stringify(renamed, null, 2); } catch { return String(value); } } return JSON.stringify(value); } function removeExcludedKeysDeep(obj: any, excludedKeys: Set): any { if (Array.isArray(obj)) { return obj.map((item) => removeExcludedKeysDeep(item, excludedKeys)); } else if (obj && typeof obj === "object") { const newObj: any = {}; for (const key of Object.keys(obj)) { if (excludedKeys.has(key)) { // Skip this key entirely continue; } newObj[key] = removeExcludedKeysDeep(obj[key], excludedKeys); } return newObj; } return obj; } function renameKeysDeep(obj: any, renameMap: Record): any { if (Array.isArray(obj)) { return obj.map((item) => renameKeysDeep(item, renameMap)); } else if (obj && typeof obj === "object") { const newObj: any = {}; for (const key of Object.keys(obj)) { const newKey = renameMap[key] ?? key; // Use friendly label if available newObj[newKey] = renameKeysDeep(obj[key], renameMap); } return newObj; } return obj; } /** * Convert an array of path segments into a user-friendly string. * e.g. ["parts", 0, "exercises", 1, "prompt"] * → "Parts → [#1] → Exercises → [#2] → Prompt" */ function pathToHumanReadable(pathSegments: Array): string { return pathSegments .map((seg) => { if (typeof seg === "number") { return `[#${seg + 1}]`; } return PATH_LABELS[seg] ?? seg; }) .join(" → "); }