add imutable ids to some exam arrays to detect and log changes between two exams.
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
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 EXCLUDED_KEYS = new Set<string>(["_id", "id", "uuid", "isDiagnostic", "owners", "entities", "createdAt", "createdBy", "access", "requiresApproval", "exerciseID", "questionID", "sectionId", "userSolutions"]);
|
||||
|
||||
const PATH_LABELS: Record<string, string> = {
|
||||
access: "Access Type",
|
||||
@@ -24,124 +23,153 @@ const PATH_LABELS: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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 = diff(oldExam, newExam) || [];
|
||||
return differences.map((change) => formatDifference(change)).filter(Boolean) as string[];
|
||||
const differences: string[] = [];
|
||||
compareObjects(oldExam, newExam, [], differences);
|
||||
return differences;
|
||||
}
|
||||
|
||||
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 isObject(val: any): val is Record<string, any> {
|
||||
return val !== null && typeof val === "object" && !Array.isArray(val);
|
||||
}
|
||||
|
||||
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";
|
||||
function formatPrimitive(value: any): string {
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (value === null) return "null";
|
||||
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(" → ");
|
||||
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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user