169 lines
6.1 KiB
TypeScript
169 lines
6.1 KiB
TypeScript
import { Exam } from "@/interfaces/exam";
|
|
|
|
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",
|
|
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<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: string[] = [];
|
|
compareObjects(oldExam, newExam, [], differences);
|
|
return differences;
|
|
}
|
|
|
|
function isObject(val: any): val is Record<string, any> {
|
|
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 | number>): 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]));
|
|
// 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];
|
|
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);
|
|
}
|
|
}
|
|
} 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`);
|
|
}
|
|
}
|
|
}
|