import { Exam } from "@/interfaces/exam"; const EXCLUDED_KEYS = new Set(["_id", "id", "uuid", "isDiagnostic", "owners", "entities", "createdAt", "createdBy", "access", "requiresApproval", "exerciseID", "questionID", "sectionId", "userSolutions"]); 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", section: "Section", module: "Module", type: "Type", intro: "Intro", category: "Category", context: "Context", instructions: "Instructions", name: "Name", gender: "Gender", voice: "Voice", enableNavigation: "Enable Navigation", limit: "Limit", instructorGender: "Instructor Gender", wordCounter: "Word Counter", attachment: "Attachment", first_title: "First Title", second_title: "Second Title", first_topic: "First Topic", second_topic: "Second Topic", questions: "Questions", sentences: "Sentences", sentence: "Sentence", solution: "Solution", passage: "Passage", }; const ARRAY_ITEM_LABELS: Record = { exercises: "Exercise", paths: "Path", difficulties: "Difficulty", solutions: "Solution", options: "Option", words: "Word", questions: "Question", userSolutions: "User Solution", sentences: "Sentence", parts: "Part", }; export function generateExamDifferences(oldExam: Exam, newExam: Exam): string[] { const differences: string[] = []; compareObjects(oldExam, newExam, [], differences); return differences; } function isObject(val: any): val is Record { return val !== null && typeof val === "object" && !Array.isArray(val); } function formatPrimitive(value: any): string { if (value === undefined) return "undefined"; if (value === null) return "null"; return JSON.stringify(value); } function pathToHumanReadable(pathSegments: Array): string { const mapped = pathSegments.map((seg) => { if (typeof seg === "number") { return `#${seg + 1}`; } return PATH_LABELS[seg] ?? seg; }); let result = ""; for (let i = 0; i < mapped.length; i++) { result += mapped[i]; if (mapped[i].startsWith("#") && i < mapped.length - 1) { result += " - "; } else if (i < mapped.length - 1) { result += " "; } } return result.trim(); } function getArrayItemLabel(path: (string | number)[]): string { if (path.length === 0) return "item"; const lastSegment = path[path.length - 1]; if (typeof lastSegment === "string" && ARRAY_ITEM_LABELS[lastSegment]) { return ARRAY_ITEM_LABELS[lastSegment]; } return "item"; } function getIdentifier(item: any): string | number | undefined { if (item?.uuid !== undefined) return item.uuid; if (item?.id !== undefined) return item.id; return undefined; } function compareObjects(oldObj: any, newObj: any, path: (string | number)[], differences: string[]) { if (Array.isArray(oldObj) && Array.isArray(newObj)) { // Check if array elements are objects with an identifier (uuid or id). if (oldObj.length > 0 && typeof oldObj[0] === "object" && getIdentifier(oldObj[0]) !== undefined) { // Process removed items const newIds = new Set(newObj.map((item: any) => getIdentifier(item))); for (let i = 0; i < oldObj.length; i++) { const oldItem = oldObj[i]; const identifier = getIdentifier(oldItem); if (identifier !== undefined && !newIds.has(identifier)) { differences.push(`• Removed ${getArrayItemLabel(path)} #${i + 1} from ${pathToHumanReadable(path)}\n`); } } const oldIndexMap = new Map(oldObj.map((item: any, index: number) => [getIdentifier(item), index])); let reorderDetected = false; // Process items in the new array using their order. for (let i = 0; i < newObj.length; i++) { const newItem = newObj[i]; const identifier = getIdentifier(newItem); if (identifier !== undefined) { if (oldIndexMap.has(identifier)) { const oldIndex = oldIndexMap.get(identifier)!; const oldItem = oldObj[oldIndex]; if (oldIndex !== i) { reorderDetected = true; } compareObjects(oldItem, newItem, path.concat(`#${i + 1}`), differences); } else { differences.push(`• Added new ${getArrayItemLabel(path)} #${i + 1} at ${pathToHumanReadable(path)}\n`); } } else { // Fallback: if item does not have an identifier, compare by index. compareObjects(oldObj[i], newItem, path.concat(`#${i + 1}`), differences); } } if (reorderDetected) { differences.push(`• Reordered Items at ${pathToHumanReadable(path)}\n`); } } else { // For arrays that are not identifier-based, compare element by element. const maxLength = Math.max(oldObj.length, newObj.length); for (let i = 0; i < maxLength; i++) { compareObjects(oldObj[i], newObj[i], path.concat(`#${i + 1}`), differences); } } } else if (isObject(oldObj) && isObject(newObj)) { // Compare objects by keys (ignoring excluded keys). const keys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]); for (const key of keys) { if (EXCLUDED_KEYS.has(key)) continue; compareObjects(oldObj[key], newObj[key], path.concat(key), differences); } } else { if (oldObj !== newObj) { differences.push(`• Changed ${pathToHumanReadable(path)} from:\n ${formatPrimitive(oldObj)}\n To:\n ${formatPrimitive(newObj)}\n`); } } }