148 lines
4.8 KiB
TypeScript
148 lines
4.8 KiB
TypeScript
import { Exam } from "@/interfaces/exam";
|
|
import { diff, Diff } from "deep-diff";
|
|
|
|
const EXCLUDED_KEYS = new Set<string>(["_id", "id", "createdAt", "createdBy", "entities", "isDiagnostic", "private", "requiresApproval", "exerciseID", "questionID"]);
|
|
|
|
const PATH_LABELS: Record<string, string> = {
|
|
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<any, any>): 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<any, any>): 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<string>): 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<string, string>): 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 | number>): string {
|
|
return pathSegments
|
|
.map((seg) => {
|
|
if (typeof seg === "number") {
|
|
return `[#${seg + 1}]`;
|
|
}
|
|
return PATH_LABELS[seg] ?? seg;
|
|
})
|
|
.join(" → ");
|
|
}
|