import {EntityId} from "../EntityId"; export type DiffKind = 'added' | 'removed' | 'changed'; export type PathSegment = | { type: 'key'; key: string } | { type: 'index'; index: number }; export interface DiffEntry { path: string; segments: PathSegment[]; kind: DiffKind; before?: unknown; after?: unknown; groupId?: string; } export interface LabelContext { key: string | null; // назва поля (останній key) index: number | null; // індекс масиву, якщо зміна всередині масиву path: string; // items[2].price segments: PathSegment[]; entry: DiffEntry; } export type DiffFormatter = (value: unknown, ctx: LabelContext) => string; export interface DiffViewModel { kind: DiffKind; field: string; path: string; index: number | null; groupId?: string; beforeRaw?: unknown; afterRaw?: unknown; before?: string; after?: string; } export interface DiffOptions { formatter?: DiffFormatter; groupRoots?: string[]; } export function diffObjects(before: unknown, after: unknown, groupRoots: string[] = []): DiffEntry[] { const diffs: DiffEntry[] = []; walk(before, after, [], diffs, groupRoots); return diffs; } function walk( before: unknown, after: unknown, segments: PathSegment[], out: DiffEntry[], groupRoots: string[] = [], forcedKind?: DiffKind, currentGroupId?: string, ): void { if (Object.is(before, after)) return; const isPlainObject = (v: unknown) => typeof v === 'object' && v !== null && !Array.isArray(v); // determine the kind of diff let kind: DiffKind = forcedKind || 'changed'; if (!forcedKind) { if (before === undefined && after !== undefined) kind = 'added'; else if (before !== undefined && after === undefined) kind = 'removed'; } // arrays if (Array.isArray(before) || Array.isArray(after)) { const bArr = Array.isArray(before) ? before : []; const aArr = Array.isArray(after) ? after : []; const max = Math.max(bArr.length, aArr.length); const lastSegment = segments[segments.length - 1]; const isGroupRoot = lastSegment?.type === 'key' && groupRoots.includes(lastSegment.key); for (let i = 0; i < max; i++) { const nextSegments = [...segments, { type: 'index', index: i } as const]; const nextGroupId = isGroupRoot ? segmentsToPath(nextSegments) : currentGroupId; walk(bArr[i], aArr[i], nextSegments, out, groupRoots, kind, nextGroupId); } return; } // objects if (isPlainObject(before) || isPlainObject(after)) { const bObj = (isPlainObject(before) ? before : {}) as Record; const aObj = (isPlainObject(after) ? after : {}) as Record; const keys = Array.from(new Set([...Object.keys(bObj), ...Object.keys(aObj)])); for (const key of keys) { walk( bObj[key], aObj[key], [...segments, { type: 'key', key } as const], out, groupRoots, kind, currentGroupId, ); } return; } // primitives or mismatched types out.push({ path: segmentsToPath(segments), segments, kind, before, after, groupId: currentGroupId, }); } function segmentsToPath(segments: PathSegment[]): string { let path = ''; for (const s of segments) { if (s.type === 'key') { path += path ? `.${s.key}` : s.key; } else { path += `[${s.index}]`; } } return path || ''; } function buildLabelContext(entry: DiffEntry): LabelContext { let key: string | null = null; let index: number | null = null; for (let i = entry.segments.length - 1; i >= 0; i--) { const s = entry.segments[i]; if (s.type === 'key') { key = s.key; break; } } for (let i = entry.segments.length - 1; i >= 0; i--) { const s = entry.segments[i]; if (s.type === 'index') { index = s.index; break; } } return { key, index, path: entry.path, segments: entry.segments, entry, }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any function defaultStringify(value: unknown): any { if (value instanceof EntityId) return value.toJSON(); return value; } export function buildDiffViewModel(diffs: DiffEntry[], formatValue?: DiffFormatter): DiffViewModel[] { const formatter = formatValue || defaultStringify; const seen = new Set(); const result: DiffViewModel[] = []; for (const entry of diffs) { const ctx = buildLabelContext(entry); const beforeFormatted = entry.before !== undefined ? formatter(entry.before, ctx) : undefined; const afterFormatted = entry.after !== undefined ? formatter(entry.after, ctx) : undefined; // якщо форматовано однаково — не зміна if (entry.kind === 'changed' && beforeFormatted === afterFormatted) { continue; } // 👇 КЛЮЧ ДЕДУПЛІКАЦІЇ const dedupeKey = `${entry.kind}|${entry.path}`; if (seen.has(dedupeKey)) { continue; } seen.add(dedupeKey); result.push({ field: ctx.key || ctx.path, kind: entry.kind, path: entry.path, index: ctx.index, groupId: entry.groupId, beforeRaw: entry.before, afterRaw: entry.after, before: beforeFormatted, after: afterFormatted, }); } return result; } /** * Повертає diff у вигляді масиву обʼєктів (DiffViewModel), */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function getDiff(beforeObj: any, afterObj: any, options: DiffOptions = {}): DiffViewModel[] | null { const diffs = diffObjects(beforeObj, afterObj, options.groupRoots); if (diffs.length === 0) return null; return buildDiffViewModel(diffs, options.formatter); }