adding packages
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
enum ChangeOp { insert, delete, modify, equal }
|
||||
|
||||
class DiffNode {
|
||||
final ChangeOp op;
|
||||
final String key;
|
||||
final Object? oldValue;
|
||||
final Object? newValue;
|
||||
final List<DiffNode>? children;
|
||||
final TextSplice? splice;
|
||||
|
||||
DiffNode.insert(this.key, this.newValue)
|
||||
: op = ChangeOp.insert,
|
||||
oldValue = null,
|
||||
children = null,
|
||||
splice = null;
|
||||
|
||||
DiffNode.delete(this.key, this.oldValue)
|
||||
: op = ChangeOp.delete,
|
||||
newValue = null,
|
||||
children = null,
|
||||
splice = null;
|
||||
|
||||
DiffNode.equal(this.key, this.newValue)
|
||||
: op = ChangeOp.equal,
|
||||
oldValue = newValue,
|
||||
children = null,
|
||||
splice = null;
|
||||
|
||||
DiffNode.modify(
|
||||
this.key,
|
||||
this.oldValue,
|
||||
this.newValue, {
|
||||
this.children,
|
||||
this.splice,
|
||||
}) : op = ChangeOp.modify;
|
||||
|
||||
@override
|
||||
String toString() => '$op: $key';
|
||||
}
|
||||
|
||||
class TextSplice {
|
||||
final int index;
|
||||
final int deleteCount;
|
||||
final String insertText;
|
||||
|
||||
TextSplice(this.index, this.deleteCount, this.insertText);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'i': index,
|
||||
'd': deleteCount,
|
||||
't': insertText,
|
||||
};
|
||||
}
|
||||
|
||||
class HybridDiffer {
|
||||
static List<DiffNode> diff(
|
||||
List<Map<String, Object?>> oldList,
|
||||
List<Map<String, Object?>> newList, {
|
||||
required String idField,
|
||||
}) {
|
||||
final oldIds = oldList.map((e) => e[idField]).toList();
|
||||
final newIds = newList.map((e) => e[idField]).toList();
|
||||
|
||||
final structuralOps = _myersDiff(oldIds, newIds);
|
||||
final results = <DiffNode>[];
|
||||
|
||||
for (final op in structuralOps) {
|
||||
if (op.type == _MyersOpType.delete) {
|
||||
final item = oldList[op.oldIndex!];
|
||||
results.add(DiffNode.delete(item[idField].toString(), item));
|
||||
} else if (op.type == _MyersOpType.insert) {
|
||||
final item = newList[op.newIndex!];
|
||||
results.add(DiffNode.insert(item[idField].toString(), item));
|
||||
} else {
|
||||
// Equal ID: Check Content
|
||||
final oldItem = oldList[op.oldIndex!];
|
||||
final newItem = newList[op.newIndex!];
|
||||
final key = newItem[idField].toString();
|
||||
|
||||
if (_areDeepEqual(oldItem, newItem)) {
|
||||
results.add(DiffNode.equal(key, newItem));
|
||||
} else {
|
||||
final fieldChanges = _generateObjectDiff(oldItem, newItem);
|
||||
results.add(
|
||||
DiffNode.modify(key, oldItem, newItem, children: fieldChanges),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
static List<DiffNode> _generateObjectDiff(
|
||||
Map<String, Object?> oldObj,
|
||||
Map<String, Object?> newObj,
|
||||
) {
|
||||
final diffs = <DiffNode>[];
|
||||
final allKeys = {...oldObj.keys, ...newObj.keys}.toList()..sort();
|
||||
|
||||
for (final key in allKeys) {
|
||||
final oldVal = oldObj[key];
|
||||
final newVal = newObj[key];
|
||||
|
||||
if (!oldObj.containsKey(key)) {
|
||||
diffs.add(DiffNode.insert(key, newVal));
|
||||
} else if (!newObj.containsKey(key)) {
|
||||
diffs.add(DiffNode.delete(key, oldVal));
|
||||
} else if (oldVal is Map<String, Object?> &&
|
||||
newVal is Map<String, Object?>) {
|
||||
final nested = _generateObjectDiff(oldVal, newVal);
|
||||
if (nested.isNotEmpty) {
|
||||
diffs.add(DiffNode.modify(key, oldVal, newVal, children: nested));
|
||||
}
|
||||
} else if (oldVal is String && newVal is String) {
|
||||
if (oldVal != newVal) {
|
||||
final splice = _calculateStringSplice(oldVal, newVal);
|
||||
diffs.add(DiffNode.modify(key, oldVal, newVal, splice: splice));
|
||||
}
|
||||
} else {
|
||||
if (!_areDeepEqual(oldVal, newVal)) {
|
||||
diffs.add(DiffNode.modify(key, oldVal, newVal));
|
||||
}
|
||||
}
|
||||
}
|
||||
return diffs;
|
||||
}
|
||||
|
||||
static bool _areDeepEqual(Object? a, Object? b) {
|
||||
if (a == b) return true;
|
||||
if (a is List && b is List) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (!_areDeepEqual(a[i], b[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (a is Map && b is Map) {
|
||||
if (a.length != b.length) return false;
|
||||
for (final key in a.keys) {
|
||||
if (!b.containsKey(key) || !_areDeepEqual(a[key], b[key])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static TextSplice? _calculateStringSplice(String oldText, String newText) {
|
||||
if (oldText == newText) return null;
|
||||
int start = 0;
|
||||
final minLen = min(oldText.length, newText.length);
|
||||
while (start < minLen &&
|
||||
oldText.codeUnitAt(start) == newText.codeUnitAt(start)) {
|
||||
start++;
|
||||
}
|
||||
|
||||
if (start > 0 &&
|
||||
start < oldText.length &&
|
||||
_isSurrogate(oldText.codeUnitAt(start - 1))) {
|
||||
start--;
|
||||
}
|
||||
|
||||
int oldEnd = oldText.length;
|
||||
int newEnd = newText.length;
|
||||
while (oldEnd > start &&
|
||||
newEnd > start &&
|
||||
oldText.codeUnitAt(oldEnd - 1) == newText.codeUnitAt(newEnd - 1)) {
|
||||
oldEnd--;
|
||||
newEnd--;
|
||||
}
|
||||
|
||||
return TextSplice(start, oldEnd - start, newText.substring(start, newEnd));
|
||||
}
|
||||
|
||||
static bool _isSurrogate(int code) => (code >= 0xD800 && code <= 0xDFFF);
|
||||
|
||||
// --- MYERS ALGORITHM ---
|
||||
|
||||
static List<_MyersOp> _myersDiff(List oldIds, List newIds) {
|
||||
final n = oldIds.length;
|
||||
final m = newIds.length;
|
||||
final max = n + m;
|
||||
final v = Int32List(2 * max + 1)..fillRange(0, 2 * max + 1, -1);
|
||||
final trace = <Int32List>[];
|
||||
|
||||
v[max] = 0;
|
||||
|
||||
for (var d = 0; d <= max; d++) {
|
||||
trace.add(Int32List.fromList(v));
|
||||
for (var k = -d; k <= d; k += 2) {
|
||||
final indexK = max + k;
|
||||
int x;
|
||||
if (d == 0) {
|
||||
x = 0;
|
||||
} else if (k == -d || (k != d && v[indexK - 1] < v[indexK + 1])) {
|
||||
x = v[indexK + 1];
|
||||
} else {
|
||||
x = v[indexK - 1] + 1;
|
||||
}
|
||||
int y = x - k;
|
||||
while (x < n && y < m && oldIds[x] == newIds[y]) {
|
||||
x++;
|
||||
y++;
|
||||
}
|
||||
v[indexK] = x;
|
||||
if (x >= n && y >= m) {
|
||||
return _buildMyersScript(oldIds, newIds, trace, d, max);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
static List<_MyersOp> _buildMyersScript(
|
||||
List oldIds,
|
||||
List newIds,
|
||||
List<Int32List> trace,
|
||||
int d,
|
||||
int maxOffset,
|
||||
) {
|
||||
var script = <_MyersOp>[];
|
||||
var x = oldIds.length;
|
||||
var y = newIds.length;
|
||||
|
||||
for (var i = d; i > 0; i--) {
|
||||
final k = x - y;
|
||||
final kIndex = maxOffset + k;
|
||||
final prevV = trace[i];
|
||||
final kMinus1 = kIndex - 1;
|
||||
final kPlus1 = kIndex - 1 + 2; // kIndex + 1
|
||||
|
||||
int prevKIndex;
|
||||
final bool pickInsert;
|
||||
|
||||
if (k == -i) {
|
||||
pickInsert = true;
|
||||
} else if (k == i) {
|
||||
pickInsert = false;
|
||||
} else if (prevV[kMinus1] == -1) {
|
||||
pickInsert = true;
|
||||
} else if (prevV[kPlus1] == -1) {
|
||||
pickInsert = false;
|
||||
} else {
|
||||
pickInsert = prevV[kMinus1] < prevV[kPlus1];
|
||||
}
|
||||
|
||||
if (pickInsert) {
|
||||
prevKIndex = kPlus1;
|
||||
} else {
|
||||
prevKIndex = kMinus1;
|
||||
}
|
||||
|
||||
final prevX = prevV[prevKIndex];
|
||||
final isInsert = prevKIndex == kPlus1;
|
||||
final startX = isInsert ? prevX : prevX + 1;
|
||||
|
||||
while (x > startX) {
|
||||
// Snake loop logic
|
||||
script.add(
|
||||
_MyersOp(_MyersOpType.equal, oldIndex: x - 1, newIndex: y - 1),
|
||||
);
|
||||
x--;
|
||||
y--;
|
||||
}
|
||||
|
||||
if (isInsert) {
|
||||
// Moved down (Insert)
|
||||
script.add(_MyersOp(_MyersOpType.insert, newIndex: y - 1));
|
||||
y--;
|
||||
} else {
|
||||
// Moved right (Delete)
|
||||
script.add(_MyersOp(_MyersOpType.delete, oldIndex: x - 1));
|
||||
x--;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining snakes
|
||||
while (x > 0 && y > 0) {
|
||||
script.add(
|
||||
_MyersOp(_MyersOpType.equal, oldIndex: x - 1, newIndex: y - 1),
|
||||
);
|
||||
x--;
|
||||
y--;
|
||||
}
|
||||
return script.reversed.toList();
|
||||
}
|
||||
}
|
||||
|
||||
enum _MyersOpType { insert, delete, equal }
|
||||
|
||||
class _MyersOp {
|
||||
final _MyersOpType type;
|
||||
final int? oldIndex;
|
||||
final int? newIndex;
|
||||
_MyersOp(this.type, {this.oldIndex, this.newIndex});
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
enum DiffType { insert, delete, equal }
|
||||
|
||||
class DiffOperation<T> {
|
||||
final DiffType type;
|
||||
final T data;
|
||||
final int
|
||||
index; // Index in newList (for insert) or oldList (for delete/equal)
|
||||
|
||||
DiffOperation(this.type, this.data, this.index);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'$type: ${data.toString().substring(0, 20)}... (@$index)';
|
||||
}
|
||||
|
||||
/// A generic-free typedef for the custom hasher to maximize performance
|
||||
typedef JsonHasher = int Function(Map<String, dynamic> item);
|
||||
|
||||
class FastJsonDiffer {
|
||||
static const _deepEquality = DeepCollectionEquality();
|
||||
|
||||
// POOLING: Reusable buffers to prevent GC thrashing during high-freq updates
|
||||
Int32List _vBuffer = Int32List(1024);
|
||||
|
||||
// Storing history for the traceback
|
||||
// We reuse the list container, but we must allocate new Int32Lists for snapshots
|
||||
final List<Int32List> _traceBuffer = [];
|
||||
|
||||
/// Main Diff Method.
|
||||
///
|
||||
/// [keyGenerator] (Optional): Provide this for maximum speed.
|
||||
/// instead of comparing every field, return a hash of specific fields
|
||||
/// (e.g., `(i) => Object.hash(i['id'], i['version'])`).
|
||||
List<DiffOperation<Map<String, dynamic>>> diff(
|
||||
List<Map<String, dynamic>> oldList,
|
||||
List<Map<String, dynamic>> newList, {
|
||||
JsonHasher? keyGenerator,
|
||||
}) {
|
||||
// 1. Identity Check (Optimization)
|
||||
if (identical(oldList, newList)) return [];
|
||||
if (oldList.isEmpty && newList.isEmpty) return [];
|
||||
|
||||
// 2. Generate Proxies (Hashes)
|
||||
// If no keyGenerator is provided, we default to expensive Deep Equality
|
||||
final hasher = keyGenerator ?? (item) => _deepEquality.hash(item);
|
||||
|
||||
final oldHashes = _generateHashes(oldList, hasher);
|
||||
final newHashes = _generateHashes(newList, hasher);
|
||||
|
||||
// 3. Run Myers Algorithm on Ints
|
||||
final rawOps = _diffInts(oldHashes, newHashes);
|
||||
|
||||
// 4. Rehydrate (Map indices back to actual Objects)
|
||||
return rawOps.map((op) {
|
||||
switch (op.type) {
|
||||
case DiffType.insert:
|
||||
return DiffOperation(DiffType.insert, newList[op.index], op.index);
|
||||
case DiffType.delete:
|
||||
return DiffOperation(DiffType.delete, oldList[op.index], op.index);
|
||||
case DiffType.equal:
|
||||
return DiffOperation(DiffType.equal, oldList[op.index], op.index);
|
||||
}
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Int32List _generateHashes(
|
||||
List<Map<String, dynamic>> list,
|
||||
JsonHasher hasher,
|
||||
) {
|
||||
final int len = list.length;
|
||||
final buffer = Int32List(len);
|
||||
for (var i = 0; i < len; i++) {
|
||||
buffer[i] = hasher(list[i]);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// The Core Myers Logic specialized for Int32List.
|
||||
/// This runs purely on stack primitives and typed arrays.
|
||||
List<DiffOperation<int>> _diffInts(Int32List oldList, Int32List newList) {
|
||||
final n = oldList.length;
|
||||
final m = newList.length;
|
||||
final max = n + m;
|
||||
final requiredSize = 2 * max + 1;
|
||||
|
||||
// Grow buffer if necessary
|
||||
if (_vBuffer.length < requiredSize) {
|
||||
_vBuffer = Int32List(requiredSize);
|
||||
}
|
||||
|
||||
_vBuffer.fillRange(0, requiredSize, -1);
|
||||
_traceBuffer.clear();
|
||||
|
||||
_vBuffer[max] = 0;
|
||||
|
||||
for (var d = 0; d <= max; d++) {
|
||||
// Snapshot current V state for traceback.
|
||||
_traceBuffer.add(Int32List.fromList(_vBuffer.sublist(0, requiredSize)));
|
||||
|
||||
for (var k = -d; k <= d; k += 2) {
|
||||
final kIndex = max + k;
|
||||
int x;
|
||||
|
||||
// Choose move: Down (Insertion) or Right (Deletion)
|
||||
if (d == 0) {
|
||||
x = 0;
|
||||
} else if (k == -d ||
|
||||
(k != d && _vBuffer[kIndex - 1] < _vBuffer[kIndex + 1])) {
|
||||
x = _vBuffer[kIndex + 1];
|
||||
} else {
|
||||
x = _vBuffer[kIndex - 1] + 1;
|
||||
}
|
||||
|
||||
int y = x - k;
|
||||
|
||||
// Snake: Move diagonal as long as hashes match
|
||||
while (x < n && y < m && oldList[x] == newList[y]) {
|
||||
x++;
|
||||
y++;
|
||||
}
|
||||
|
||||
_vBuffer[kIndex] = x;
|
||||
|
||||
// Check for completion
|
||||
if (x >= n && y >= m) {
|
||||
return _buildScript(oldList, newList, d, max);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
List<DiffOperation<int>> _buildScript(
|
||||
Int32List oldList,
|
||||
Int32List newList,
|
||||
int d,
|
||||
int maxOffset,
|
||||
) {
|
||||
var script = <DiffOperation<int>>[];
|
||||
var x = oldList.length;
|
||||
var y = newList.length;
|
||||
|
||||
for (var i = d; i > 0; i--) {
|
||||
final k = x - y;
|
||||
final kIndex = maxOffset + k;
|
||||
final prevV = _traceBuffer[i]; // FIXED: Use trace[i]
|
||||
|
||||
final kMinus1 = kIndex - 1;
|
||||
final kPlus1 = kIndex + 1;
|
||||
|
||||
int prevKIndex;
|
||||
final bool pickInsert;
|
||||
|
||||
// Robust selection logic
|
||||
if (k == -i) {
|
||||
pickInsert = true;
|
||||
} else if (k == i) {
|
||||
pickInsert = false;
|
||||
} else if (prevV[kMinus1] == -1) {
|
||||
pickInsert = true;
|
||||
} else if (prevV[kPlus1] == -1) {
|
||||
pickInsert = false;
|
||||
} else {
|
||||
pickInsert = prevV[kMinus1] < prevV[kPlus1];
|
||||
}
|
||||
|
||||
if (pickInsert) {
|
||||
prevKIndex = kPlus1;
|
||||
} else {
|
||||
prevKIndex = kMinus1;
|
||||
}
|
||||
|
||||
final prevX = prevV[prevKIndex];
|
||||
// Assert specific invariants
|
||||
assert(
|
||||
prevX >= 0,
|
||||
"Myers invariant violated: prevX should effectively never be -1 (sentinel) when accessed from valid path.",
|
||||
);
|
||||
assert(
|
||||
x >= prevX,
|
||||
"Myers invariant violated: x ($x) cannot be less than prevX ($prevX)",
|
||||
);
|
||||
|
||||
final isInsert = prevKIndex == kPlus1;
|
||||
final startX = isInsert ? prevX : prevX + 1;
|
||||
|
||||
// Add Snakes (Equal)
|
||||
while (x > startX) {
|
||||
assert(x > 0, "Backtracking snake cannot go below 0 for x");
|
||||
script.add(DiffOperation(DiffType.equal, 0, x - 1));
|
||||
x--;
|
||||
y--;
|
||||
}
|
||||
|
||||
// Add Edit
|
||||
if (isInsert) {
|
||||
assert(y > 0, "Insert operation must have y index > 0 (y=$y)");
|
||||
script.add(DiffOperation(DiffType.insert, 0, y - 1));
|
||||
y--;
|
||||
} else {
|
||||
assert(x > 0, "Delete operation must have x index > 0 (x=$x)");
|
||||
script.add(DiffOperation(DiffType.delete, 0, x - 1));
|
||||
x--;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining snakes
|
||||
while (x > 0 && y > 0) {
|
||||
script.add(DiffOperation(DiffType.equal, 0, x - 1));
|
||||
x--;
|
||||
y--;
|
||||
}
|
||||
|
||||
assert(
|
||||
x == 0 && y == 0,
|
||||
"Backtrack should end at 0,0. Ended at x=$x, y=$y. Traceback logic is flowed.",
|
||||
);
|
||||
|
||||
return script.reversed.toList();
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
final differ = FastJsonDiffer();
|
||||
|
||||
// Scenario: API Response or Game State
|
||||
final oldData = [
|
||||
{'id': 1, 'val': 'A', 'meta': 'complex_obj'},
|
||||
{'id': 2, 'val': 'B', 'meta': 'complex_obj'},
|
||||
{'id': 3, 'val': 'C', 'meta': 'complex_obj'},
|
||||
];
|
||||
|
||||
final newData = [
|
||||
{'id': 1, 'val': 'A', 'meta': 'complex_obj'},
|
||||
{'id': 3, 'val': 'Z', 'meta': 'complex_obj'}, // Modified (val C -> Z)
|
||||
{'id': 4, 'val': 'D', 'meta': 'complex_obj'}, // Inserted
|
||||
];
|
||||
|
||||
print('--- 1. Default (Deep Equality) ---');
|
||||
// Good for correctness, slower for large lists.
|
||||
final opsDefault = differ.diff(oldData, newData);
|
||||
|
||||
for (var op in opsDefault) {
|
||||
if (op.type != DiffType.equal) print(op);
|
||||
}
|
||||
// Expected:
|
||||
// Delete: {id: 2...}
|
||||
// Delete: {id: 3, val: C...} (Because val changed, hash changed)
|
||||
// Insert: {id: 3, val: Z...}
|
||||
// Insert: {id: 4...}
|
||||
|
||||
print('\n--- 2. Optimized (ID + Val Check) ---');
|
||||
// Great for high-frequency. We tell the differ EXACTLY what constitutes a change.
|
||||
// Here we ignore the 'meta' field entirely.
|
||||
final opsFast = differ.diff(
|
||||
oldData,
|
||||
newData,
|
||||
keyGenerator: (map) => Object.hash(map['id'], map['val']),
|
||||
);
|
||||
|
||||
for (var op in opsFast) {
|
||||
if (op.type != DiffType.equal) print(op);
|
||||
}
|
||||
|
||||
print('\n--- 3. Ultra Fast (ID Only - Move Detection) ---');
|
||||
// If we only care if the ID exists (e.g. for list animations where content updates later)
|
||||
final opsIdOnly = differ.diff(
|
||||
oldData,
|
||||
newData,
|
||||
keyGenerator: (map) => map['id'] as int, // Direct int cast is fastest
|
||||
);
|
||||
|
||||
for (var op in opsIdOnly) {
|
||||
if (op.type != DiffType.equal) print(op);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'dart:convert';
|
||||
|
||||
// =============================================================================
|
||||
// --- CORE ALGORITHM (The Diff Engine) ---
|
||||
// =============================================================================
|
||||
|
||||
/// Sentinel value for deleted keys
|
||||
class Deleted {
|
||||
const Deleted();
|
||||
Map<String, dynamic> toJson() => {'_op': 'd'};
|
||||
@override
|
||||
String toString() => '<Deleted>';
|
||||
}
|
||||
|
||||
const deleted = Deleted();
|
||||
|
||||
/// Represents a precise text edit (Splice)
|
||||
class TextSplice {
|
||||
final int index;
|
||||
final int deleteCount;
|
||||
final String insertText;
|
||||
|
||||
TextSplice(this.index, this.deleteCount, this.insertText);
|
||||
|
||||
/// Applies the splice to a string
|
||||
String apply(String original) {
|
||||
if (index > original.length) return original + insertText;
|
||||
final start = original.substring(0, index);
|
||||
final end = original.substring(index + deleteCount);
|
||||
return '$start$insertText$end';
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'_op': 's',
|
||||
'i': index,
|
||||
'd': deleteCount,
|
||||
't': insertText,
|
||||
};
|
||||
}
|
||||
|
||||
class DiffEngine {
|
||||
/// Generates a recursive diff between two JSON-like maps
|
||||
static Map<String, dynamic> generateDiff(
|
||||
Map<String, dynamic> oldObj,
|
||||
Map<String, dynamic> newObj,
|
||||
) {
|
||||
final diff = <String, dynamic>{};
|
||||
|
||||
// 1. Walk new keys
|
||||
for (final key in newObj.keys) {
|
||||
final oldVal = oldObj[key];
|
||||
final newVal = newObj[key];
|
||||
|
||||
// New Key Added
|
||||
if (!oldObj.containsKey(key)) {
|
||||
diff[key] = newVal;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recursive Map Diff
|
||||
if (oldVal is Map<String, dynamic> && newVal is Map<String, dynamic>) {
|
||||
final nested = generateDiff(oldVal, newVal);
|
||||
if (nested.isNotEmpty) diff[key] = nested;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Smart String Diff (Splice)
|
||||
if (oldVal is String && newVal is String) {
|
||||
final splice = _calculateStringSplice(oldVal, newVal);
|
||||
if (splice != null) diff[key] = splice;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Standard Value Replacement (Primitives or Lists)
|
||||
if (!_areValuesEqual(oldVal, newVal)) {
|
||||
diff[key] = newVal;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Walk old keys to find Deletions
|
||||
for (final key in oldObj.keys) {
|
||||
if (!newObj.containsKey(key)) diff[key] = deleted;
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
/// Calculates the minimal edit (splice) with EMOJI SAFETY
|
||||
static TextSplice? _calculateStringSplice(String oldText, String newText) {
|
||||
if (oldText == newText) return null;
|
||||
|
||||
// A. Find Common Prefix
|
||||
int start = 0;
|
||||
final minLen = (oldText.length < newText.length)
|
||||
? oldText.length
|
||||
: newText.length;
|
||||
|
||||
while (start < minLen &&
|
||||
oldText.codeUnitAt(start) == newText.codeUnitAt(start)) {
|
||||
start++;
|
||||
}
|
||||
|
||||
// SAFETY: If we stopped inside a Surrogate Pair (High Surrogate), backtrack.
|
||||
if (start > 0 && start < oldText.length && start < newText.length) {
|
||||
final prevCode = oldText.codeUnitAt(start - 1);
|
||||
if (prevCode >= 0xD800 && prevCode <= 0xDBFF) {
|
||||
start--;
|
||||
}
|
||||
}
|
||||
|
||||
// B. Find Common Suffix
|
||||
int oldEnd = oldText.length;
|
||||
int newEnd = newText.length;
|
||||
|
||||
while (oldEnd > start &&
|
||||
newEnd > start &&
|
||||
oldText.codeUnitAt(oldEnd - 1) == newText.codeUnitAt(newEnd - 1)) {
|
||||
oldEnd--;
|
||||
newEnd--;
|
||||
}
|
||||
|
||||
// SAFETY: If we stopped inside a Surrogate Pair (Low Surrogate), expand the change area.
|
||||
if (oldEnd < oldText.length && newEnd < newText.length) {
|
||||
final code = oldText.codeUnitAt(oldEnd);
|
||||
if (code >= 0xDC00 && code <= 0xDFFF) {
|
||||
oldEnd++;
|
||||
newEnd++;
|
||||
}
|
||||
}
|
||||
|
||||
final deleteCount = oldEnd - start;
|
||||
final insertText = newText.substring(start, newEnd);
|
||||
|
||||
return TextSplice(start, deleteCount, insertText);
|
||||
}
|
||||
|
||||
static bool _areValuesEqual(dynamic a, dynamic b) {
|
||||
if (a == b) return true;
|
||||
if (a is List && b is List) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) if (a[i] != b[i]) return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to serialize the diff for the UI/Network
|
||||
String serializeDiff(Map<String, dynamic> diff) {
|
||||
// We use a custom encoder to handle our TextSplice and Deleted objects
|
||||
return const JsonEncoder.withIndent(' ').convert(
|
||||
jsonDecode(
|
||||
jsonEncode(
|
||||
diff,
|
||||
toEncodable: (obj) {
|
||||
if (obj is TextSplice) return obj.toJson();
|
||||
if (obj is Deleted) return obj.toJson();
|
||||
return obj;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user